diff --git a/.dockerignore b/.dockerignore index 84f15d7..5ef06d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,24 +1,59 @@ +# Shared .dockerignore — applies to BOTH the v1 dashboard build +# (Dockerfile) and the v2 Action build (Dockerfile.action). +# +# Rule of thumb: only list paths neither image needs in its build +# context. If a path is needed by exactly one of the two, leave it +# in and let the COPY in the other Dockerfile decide. Splitting this +# file per-Dockerfile (e.g. via Dockerfile..dockerignore) is +# avoided so we have one source of truth. + +# --- Local dev / VCS / IDE noise --- .git/ +.github/ .idea/ .claude/ +# --- Build artefacts that should never enter an image --- *.exe *.test *.out *.pem +oracle +oracle.exe +probe-pricing +probe-pricing.exe +cloudoracle +cloudoracle.exe +# --- Secrets --- .env .env.* +# --- v1 dashboard frontend artefacts --- +# (rebuilt inside the multi-stage v1 Dockerfile; ignored from the host) web/node_modules/ web/dist/ internal/api/dist/ -report.pdf +# --- Test fixtures and tests (not needed at runtime) --- +**/testdata/ +**/*_test.go + +# --- Other cmd/ binaries that are dev-only, not for shipping --- +cmd/probe-pricing/ +cmd/checkpoint*/ +cmd/narrative-preview/ + +# --- Project meta / sample artefacts --- +checkpoint/ +docker-compose.yml +example.png +example_report.pdf +examplepdf.png cloudoracle-report.pdf -cloudoracle -cloudoracle.exe +README.md +# --- Docker plumbing --- Dockerfile +Dockerfile.action .dockerignore -README.md diff --git a/.github/examples/README.md b/.github/examples/README.md new file mode 100644 index 0000000..768d3f1 --- /dev/null +++ b/.github/examples/README.md @@ -0,0 +1,107 @@ +# CloudOracle Action — Workflow Examples + +These two YAML files are runnable references for wiring the CloudOracle +Action into a Terraform repository. Pick whichever matches your auth +posture and LLM appetite, copy it under `.github/workflows/` in your +target repo, and adjust the paths and IAM ARN. + +## Which example do I want? + +| File | AWS auth | LLM narrative | Best for | +|------|----------|---------------|----------| +| [`terraform-plan.yml`](terraform-plan.yml) | OIDC (recommended) | Yes (Anthropic / Gemini / OpenAI) | Production setups, security-conscious orgs | +| [`terraform-plan-no-llm.yml`](terraform-plan-no-llm.yml) | Static access keys | No (templated text) | Quick start, no LLM procurement, air-gapped CI | + +Both produce a single PR comment that updates in place across pushes +(via the `cloudoracle-pr-v1` HTML marker), so the conversation thread +stays clean. + +## Required permissions + +The workflow needs the following at minimum: + +```yaml +permissions: + pull-requests: write # to post/update the comment + contents: read # to checkout the repo + id-token: write # ONLY when using OIDC (omit for static keys) +``` + +GitHub's default workflow permissions vary by org policy; declaring +them explicitly makes the workflow portable. + +## AWS IAM setup for OIDC + +The OIDC example assumes a role trust policy of the form: + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:YOUR_ORG/YOUR_REPO:pull_request" + } + } + }] +} +``` + +The role only needs `pricing:GetProducts` on `*` — CloudOracle does not +read or modify any AWS resources beyond Pricing API metadata: + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "pricing:GetProducts", + "Resource": "*" + }] +} +``` + +GitHub's OIDC setup guide: +https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services + +## LLM key as a secret + +Set one of the following as a repository or organisation secret: + +- `ANTHROPIC_API_KEY` (Claude — used by default in v2) +- `GEMINI_API_KEY` +- `OPENAI_API_KEY` + +Pass it via `env:` in the step that uses the Action (see +`terraform-plan.yml`). Without any key the Action degrades silently to +the templated narrative — the PR comment is still posted, just less +narrated. + +## Action inputs + +| Input | Required | Default | What it does | +|-------|----------|---------|--------------| +| `plan-file` | yes | — | Path to `terraform show -json` output | +| `region` | no | `us-east-2` | AWS region for pricing | +| `output-file` | no | `` (empty) | Also write the Markdown to this file (e.g. for artefact upload) | +| `marker` | no | `cloudoracle-pr-v1` | HTML comment marker for upsert | +| `no-llm` | no | `false` | Force templated narrative | +| `github-token` | no | `${{ github.token }}` | Token for the comment POST/PATCH | + +## Behaviour notes + +- The Action only **posts** when `GITHUB_EVENT_NAME` is `pull_request` + or `pull_request_target`. Other events render the Markdown and exit; + use `output-file` to capture it elsewhere. +- The Action exits with differentiated codes: + - `0`: success + - `1`: input error (missing plan file, bad flags) + - `2`: pricing error (AWS API failure) + - `3`: output error (file write failed) + - `4`: GitHub error (post/update failed) diff --git a/.github/examples/terraform-plan-no-llm.yml b/.github/examples/terraform-plan-no-llm.yml new file mode 100644 index 0000000..9561c43 --- /dev/null +++ b/.github/examples/terraform-plan-no-llm.yml @@ -0,0 +1,42 @@ +name: Terraform Plan with Cost Comment (No LLM) + +# Minimal example for teams that: +# - cannot or do not want to send plan data to an LLM provider +# - haven't set up OIDC and prefer long-lived AWS access keys +# - want the smallest possible permissions surface +# +# The output is the deterministic templated narrative ("This plan adds +# N resources..."), which is functional but less informative than the +# LLM-narrated version in terraform-plan.yml. +on: + pull_request: + paths: ['**.tf'] + +permissions: + pull-requests: write + contents: read + +jobs: + cost-impact: + runs-on: ubuntu-latest + 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 + + - run: terraform init + + - run: terraform plan -out=tf.plan + + - run: terraform show -json tf.plan > tf-plan.json + + - uses: Cro22/CloudOracle@v2.0.0 + with: + plan-file: tf-plan.json + no-llm: 'true' diff --git a/.github/examples/terraform-plan.yml b/.github/examples/terraform-plan.yml new file mode 100644 index 0000000..974cbb4 --- /dev/null +++ b/.github/examples/terraform-plan.yml @@ -0,0 +1,64 @@ +name: Terraform Plan with Cost Comment + +# Triggers on PRs that touch Terraform files. The path filter is a soft +# optimisation: jobs are skipped when no .tf-shaped files changed, so a +# PR editing only README.md doesn't burn a runner minute on a no-op cost +# comment. Tighten the patterns to your monorepo's layout if needed. +on: + pull_request: + paths: + - '**.tf' + - '**.tfvars' + - '.terraform.lock.hcl' + +permissions: + pull-requests: write # CloudOracle needs this to post/update the comment. + id-token: write # Required by aws-actions/configure-aws-credentials when using OIDC. + contents: read + +jobs: + cost-impact: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # OIDC role assumption — the recommended path for cloud auth in + # CI. No long-lived AWS credentials sitting in repo secrets. See + # README.md in this directory for a sample IAM trust policy. + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsCloudOracle + aws-region: us-east-2 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.6.0 + + - name: Terraform init + run: terraform init + + - name: Terraform plan + run: terraform plan -out=tf.plan + + - name: Convert plan to JSON + run: terraform show -json tf.plan > tf-plan.json + + # CloudOracle reads tf-plan.json, queries the AWS Pricing API for + # 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 + with: + plan-file: tf-plan.json + region: us-east-2 + env: + # The LLM auto-detects which provider to use based on which key + # is set; you only need one of these. Pick whichever provider + # your org has procurement for. Without any key, CloudOracle + # falls back silently to the templated narrative. + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.github/workflows/cost-self-test.yml b/.github/workflows/cost-self-test.yml new file mode 100644 index 0000000..61ae99a --- /dev/null +++ b/.github/workflows/cost-self-test.yml @@ -0,0 +1,67 @@ +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. + +on: + pull_request: + branches: [main] + paths: + - 'action.yml' + - 'Dockerfile.action' + - 'entrypoint.sh' + - 'cmd/oracle/**' + - 'internal/iac/**' + - 'internal/pricing/**' + - 'internal/diff/**' + - 'internal/github/**' + - 'e2e-test/**' + - '.github/workflows/cost-self-test.yml' + workflow_dispatch: + +permissions: + pull-requests: write + contents: read + +jobs: + cost-impact: + runs-on: ubuntu-latest + 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. + - 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 }} diff --git a/Dockerfile.action b/Dockerfile.action new file mode 100644 index 0000000..0a2202c --- /dev/null +++ b/Dockerfile.action @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1.6 +# +# CloudOracle Action image (Hito 16.4). +# +# Builds the `oracle` CLI as a static binary and packages it with the +# entrypoint shim that adapts GitHub Action inputs to `pr-check` flags. +# This Dockerfile is independent from the root Dockerfile (which still +# builds the v1 dashboard image); action.yml references this file +# explicitly via `image: 'Dockerfile.action'`. + +# --- Build stage ---------------------------------------------------------- +FROM golang:1.25-alpine AS build +WORKDIR /src + +# Module cache layer first so dep changes don't bust the source layer. +COPY go.mod go.sum ./ +RUN go mod download + +# Copy only what `go build ./cmd/oracle` needs. The web/ frontend and +# probe-pricing helper are excluded via .dockerignore at the project +# root, but a narrower COPY here keeps the build resilient even if +# .dockerignore drifts. +COPY cmd ./cmd +COPY internal ./internal + +# internal/api uses `//go:embed all:dist` to bundle the v1 dashboard +# frontend. The Action build doesn't include the React frontend (the +# `serve` subcommand still compiles in but isn't reachable through the +# Action entrypoint), and `go:embed` requires the matched directory to +# contain at least one file. Drop a placeholder so the embed directive +# resolves; serving the dashboard from this image would render the +# "dashboard bundle not found" fallback HTML — fine, since the Action +# never invokes `serve`. +RUN mkdir -p internal/api/dist && echo "stub" > internal/api/dist/.placeholder + +# CGO disabled → fully static binary, no glibc/musl runtime needed. +# -ldflags "-s -w" strips debug + symbol tables (~25MB → ~15MB). +# -trimpath removes local build paths from the binary, useful for +# reproducibility and to avoid leaking developer paths into stack traces. +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/oracle \ + ./cmd/oracle + +# --- Final stage ---------------------------------------------------------- +FROM alpine:3.19 + +# ca-certificates is required for HTTPS to AWS Pricing API and GitHub +# REST API. tzdata is omitted: the binary uses UTC for slog timestamps +# and we don't render local time anywhere. +RUN apk add --no-cache ca-certificates + +COPY --from=build /out/oracle /usr/local/bin/oracle +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index af0bd06..4d196da 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,173 @@ # CloudOracle -![Tests](https://img.shields.io/badge/tests-171%20unit%20%2B%2012%20integration-brightgreen) +![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) -A CLI tool built in Go that analyzes cloud infrastructure resources and detects cost optimization opportunities. It simulates a real-world FinOps workflow: ingesting cloud resource data, storing it in PostgreSQL, and running deterministic rules to surface waste such as idle EC2 instances, orphaned EBS volumes, oversized RDS databases, and over-provisioned Lambda functions. +A Go FinOps toolkit that ships in two modes from the same `oracle` binary: + +- **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. The classic "what waste is already in our cloud bill?" workflow. +- **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](#v2--terraform-pr-cost-analysis-current-focus) and as the `oracle pr-check` subcommand. + +The v2 mode is the current focus — it's documented immediately below. The v1 audit mode is documented further down (["v1 — Cloud cost audit"](#v1--cloud-cost-audit)) and is fully functional. + +## v2 — Terraform PR cost analysis (current focus) + +CloudOracle parses a Terraform plan, prices every changing resource against the live AWS Pricing API, and renders a PR comment that looks like this: + +> ## 💰 Cloud Cost Impact +> **Net monthly change: +$389.35** 🔴 +> +> The Aurora cluster instance dominates this change at ~$204/month — over half the total. If this is intended for a non-production environment, an `aws_db_instance` running `db.t3.medium` would land around $60/mo for similar functional coverage. Note that data-processing charges for the NAT gateway are not modeled in this estimate. +> +> ### Top movers by cost impact +> | Resource | Action | Δ Monthly | Confidence | +> | -------- | ------ | --------- | ---------- | +> | `aws_rds_cluster_instance.aurora` | 🆕 create | +$204.40 | low | +> | `aws_db_instance.db` | 🆕 create | +$71.36 | low | +> | `aws_instance.web` | 🆕 create | +$64.74 | low | +> +> _
Full breakdown · Assumptions and caveats
_ +> +> Generated by [CloudOracle](...) · Confidence: **low** +> +> `` + +The HTML marker at the end is what makes re-renders safe: subsequent pushes update that comment in place instead of stacking new ones. + +### Quick start: GitHub Action + +Drop this into `.github/workflows/cost-comment.yml` in any repo with Terraform: + +```yaml +name: Terraform Plan Cost Comment +on: + pull_request: + paths: ['**.tf'] + +permissions: + pull-requests: write + id-token: write + contents: read + +jobs: + cost: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsCloudOracle + aws-region: us-east-2 + - 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 + with: + plan-file: tf-plan.json + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} +``` + +Two reference workflows live under [`.github/examples/`](.github/examples) — one with OIDC + LLM, one with static AWS access keys + no-LLM fallback. The `.github/examples/README.md` covers IAM trust policies, the minimum permission set, and how to wire the LLM secret. + +### Action inputs + +| 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. | +| `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. | +| `github-token` | no | `${{ github.token }}` | Used to post the comment; needs `pull-requests: write`. | + +The Action only posts when `GITHUB_EVENT_NAME` is `pull_request` or `pull_request_target`; on other triggers it renders to stdout (or `output-file`) and exits, with a `::notice::` log line explaining why. + +### Quick start: CLI + +The same workflow runs locally without any GitHub plumbing — useful for testing, debugging, or iterating on the prompt: + +```bash +# Just render to stdout (no AWS creds needed for the templated narrative) +go run ./cmd/oracle pr-check \ + --plan-file=internal/iac/testdata/plan_simple_create.json \ + --no-llm + +# Render against a real plan + AWS Pricing API +terraform show -json my.tfplan > plan.json +go run ./cmd/oracle pr-check --plan-file=plan.json --region=us-east-2 + +# Render and post (or update) the comment on PR #11 +go run ./cmd/oracle pr-check \ + --plan-file=plan.json \ + --post --repo=Cro22/CloudOracle --pr=11 \ + --token=$GITHUB_TOKEN +``` + +Full flag listing: + +| Flag | Default | Notes | +|------|---------|-------| +| `--plan-file` | — | Required. Path to JSON plan. | +| `--region` | `us-east-2` | AWS region for pricing. | +| `--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`. | +| `--repo` | — | `owner/name` form. Required with `--post`. | +| `--pr` | `0` | PR number. Required with `--post`. | +| `--token` | _(env)_ | Falls back to `$GITHUB_TOKEN` when empty. | +| `--marker` | `cloudoracle-pr-v1` | HTML comment marker for upsert. | + +Exit codes are differentiated so the Action wrapper can produce sensible CI error messages: + +| Code | Meaning | +|------|---------| +| 0 | Success. | +| 1 | Input error (missing/invalid flag, plan file unreadable). | +| 2 | Pricing error (AWS Pricing API rejected the request). | +| 3 | Output error (couldn't write `--output` path). | +| 4 | GitHub error (post/update failed). | + +### LLM narrative behavior + +The PR narrative is generated by the same provider layer as v1 (Gemini / Claude / OpenAI), so the same env-var conventions apply: set `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY`, optionally pin one with `LLM_PROVIDER`. With no key configured, the comment falls back silently to a deterministic templated narrative — the comment still posts, just less narrated. + +The v2 prompt (in `internal/diff/narrative.go`) is purpose-built for PR review tone: 1–3 sentences, identifies the dominant cost driver, optionally suggests an architectural alternative (never a billing-model swap), avoids cheerleading. Caveats are grouped by resource so the model can't accidentally attribute one resource's note to another (e.g. mistakenly claiming the database carries the NAT gateway's data-processing charges — a real bug observed during prompt development that the grouping prevents). + +### v2 architecture + +``` +internal/iac/ # Terraform plan parser + terraform.go # ParsePlan / ParsePlanFile + the canonical Plan model + aws/ # AWS-specific resource shape decoders (after_unknown handling, attr extraction) +internal/pricing/ # AWS Pricing API client + per-service estimators + aws.go # *pricing.Client wrapping the AWS SDK + cache.go # 7-day disk cache (best-effort) keyed by service+filters + ec2.go / ebs.go / rds.go / lambda.go / nat.go # one estimator per supported resource type + estimator.go # EstimateChange entry point — dispatches to the right estimator +internal/diff/ # CostDiff aggregation + Markdown rendering + engine.go # Analyze: per-resource estimates -> CostDiff (Created/Deleted/Updated/Replaced/Skipped) + markdown.go # template-based PR comment renderer (header / table / breakdown / caveats / footer) + narrative.go # LLM narrative + grouped caveats + silent fallback to templated text +internal/github/ # Thin GitHub REST client (issue comments only) + client.go / comments.go # listComments (paginated, capped) + postComment + updateComment + PostOrUpdateComment +cmd/oracle/ # pr-check subcommand wires it all together + main.go # runPRCheck: ParsePlan -> Analyze -> Render -> [Post] +Dockerfile.action # Multi-stage golang:1.25-alpine -> alpine:3.19, ENTRYPOINT entrypoint.sh +entrypoint.sh # POSIX shim: INPUT_* env vars -> oracle pr-check flags +action.yml # GitHub Action manifest (runs: docker, image: Dockerfile.action) +``` + +The v1 dashboard `Dockerfile` at the repo root is **untouched** — `Dockerfile.action` is a separate, leaner image just for the Action. They share a single `.dockerignore`. + +### Supported resources (v2) + +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`. + +--- + +## v1 — Cloud cost audit ## Why this project? @@ -39,10 +202,12 @@ Unlike policy engines like **Cloud Custodian** that focus on automated enforceme - **Export findings to JSON or CSV** - Pipe analyzer output into downstream tooling (dashboards, spreadsheets, ticket systems) via `oracle export --format=json|csv`, writing to stdout or a file - **Single-binary web dashboard** - React + Recharts UI embedded into the Go binary via `go:embed`; `oracle serve` boots API and dashboard on one port with no external assets required -## Architecture +## Architecture (v1) + +> The v2 packages (`internal/iac`, `internal/pricing`, `internal/diff`, `internal/github`) are documented in the [v2 architecture](#v2-architecture) section above. The tree below is the v1 audit-mode layout. ``` -cmd/oracle/main.go # CLI entry point (seed, list, analyze, report, trend) +cmd/oracle/main.go # CLI entry point (seed, list, analyze, report, trend, pr-check) internal/ config/ config.go # Central Config + Load(): reads every env var up front @@ -628,9 +793,20 @@ Building this project surfaced a subtle but important bug that would have gone u ## Roadmap +### 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) and `after_unknown` handling +- [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] 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 +- [x] GitHub REST client — `internal/github.PostOrUpdateComment` lists, finds-by-marker, and PATCHes / POSTs; paginated with cap, body truncation guard at 60KB, multi-match resolution to most-recently-updated +- [x] `oracle pr-check` subcommand — orchestrates the whole pipeline, with differentiated exit codes (1 input / 2 pricing / 3 output / 4 github) and `--no-llm` / `--post` switches +- [x] GitHub Action packaging — `Dockerfile.action`, `action.yml`, POSIX `entrypoint.sh` that auto-extracts the PR number from `GITHUB_REF` on `pull_request[_target]` events; reference workflows under `.github/examples/` + +### v1 — Cloud cost audit - [x] LLM-powered analysis: executive summaries generated by Gemini / Claude / OpenAI - [x] PDF report generation with executive summary and severity-coded tables -- [x] Test suite: 103 unit tests across analyzer, generator, LLM providers, PDF, export, config, and cloud mapping - [x] Real AWS integration via SDK (EC2, RDS, EBS, Lambda with STS validation and graceful degradation) - [x] Multi-cloud support (GCP, Azure) with Compute, SQL, Disks, and Functions for each provider - [x] Cost trend tracking over time (automatic snapshots on seed + `trend` command) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..eee5cee --- /dev/null +++ b/action.yml @@ -0,0 +1,36 @@ +name: 'CloudOracle PR Cost Comment' +description: 'Posts a cost-impact analysis comment on Terraform pull requests, with an LLM-generated narrative.' +author: 'Cro22' + +branding: + icon: 'dollar-sign' + color: 'green' + +inputs: + plan-file: + description: 'Path to the JSON output of `terraform show -json plan.tfplan`. Required.' + required: true + region: + description: 'AWS region for Pricing API queries.' + required: false + default: 'us-east-2' + output-file: + description: 'Optional path to also write the rendered Markdown to a file (in addition to posting). Useful for uploading the comment as an artefact.' + required: false + default: '' + marker: + description: 'HTML comment marker used to find the existing CloudOracle comment for upsert. Bump if you change the comment template format.' + required: false + default: 'cloudoracle-pr-v1' + no-llm: + description: 'If "true", disables the LLM narrative and falls back to the templated text. Use when LLM keys are unavailable or disallowed.' + required: false + default: 'false' + github-token: + description: 'GitHub token used to post the comment. Defaults to the workflow token. Requires `pull-requests: write` permission.' + required: false + default: ${{ github.token }} + +runs: + using: 'docker' + image: 'Dockerfile.action' diff --git a/cmd/oracle/cmd_pr_check_test.go b/cmd/oracle/cmd_pr_check_test.go new file mode 100644 index 0000000..dbc8603 --- /dev/null +++ b/cmd/oracle/cmd_pr_check_test.go @@ -0,0 +1,633 @@ +package main + +import ( + "bytes" + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "CloudOracle/internal/config" + "CloudOracle/internal/diff" + "CloudOracle/internal/github" +) + +// erroringSource satisfies diff.Source by always returning an error. +// Pricing engine reacts by marking every resource as Skipped: estimation +// failed, which is enough to exercise the orchestration paths in +// runPRCheck without standing up real pricing fixtures or an AWS SDK. +// We test the rest of the CostDiff machinery in internal/diff and +// internal/pricing with their own fixtures — repeating those tests at +// the cmd layer would only retest mocks. +type erroringSource struct{} + +func (erroringSource) GetProducts(_ context.Context, _ string, _ map[string]string) ([]string, error) { + return nil, errors.New("erroringSource: pricing not wired in tests") +} + +// withFakeSource swaps the package-level newPRCheckSource for one that +// returns the given diff.Source. The original factory is restored on +// test cleanup so tests stay independent. +func withFakeSource(t *testing.T, src diff.Source) { + t.Helper() + prev := newPRCheckSource + newPRCheckSource = func(_ context.Context) (diff.Source, error) { + return src, nil + } + t.Cleanup(func() { newPRCheckSource = prev }) +} + +// captureLogs replaces slog's default logger with one that writes to a +// buffer. The buffer is returned so individual tests can assert on log +// content (e.g. that the "no LLM provider configured" line appears or +// is suppressed). Logger is restored on test cleanup. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// emptyConfig returns a config with no LLM keys set, matching how the +// CI environment will look until 16.2 wires real secrets through. +func emptyConfig() config.Config { + return config.Config{} +} + +const ( + headerMarker = "## 💰 Cloud Cost Impact" + footerMarker = "" +) + +func TestPRCheck_HappyPath_Stdout(t *testing.T) { + withFakeSource(t, erroringSource{}) + + var stdout, stderr bytes.Buffer + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-no-llm", + } + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, headerMarker) { + t.Errorf("output missing header %q", headerMarker) + } + if !strings.Contains(out, footerMarker) { + t.Errorf("output missing footer %q", footerMarker) + } +} + +func TestPRCheck_HappyPath_OutputFile(t *testing.T) { + withFakeSource(t, erroringSource{}) + + tmp := filepath.Join(t.TempDir(), "comment.md") + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-output=" + tmp, + "-no-llm", + } + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("--output set but stdout still received %d bytes", stdout.Len()) + } + body, err := os.ReadFile(tmp) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + bodyStr := string(body) + if !strings.Contains(bodyStr, headerMarker) { + t.Errorf("file missing header marker") + } + if !strings.Contains(bodyStr, footerMarker) { + t.Errorf("file missing footer marker") + } +} + +// TestPRCheck_NoLLMFlag exercises the --no-llm short-circuit. With the +// flag set, runPRCheck must not even attempt to construct an LLM +// provider — so the "no LLM provider configured" info-log produced by +// the auto-fallback path must NOT appear. Without the flag (and no +// keys configured) the same log line MUST appear, confirming we ran +// the LLM construction attempt. +func TestPRCheck_NoLLMFlag(t *testing.T) { + withFakeSource(t, erroringSource{}) + planArg := "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json") + + t.Run("with -no-llm: skips LLM provider construction", func(t *testing.T) { + logs := captureLogs(t) + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{planArg, "-no-llm"}, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if strings.Contains(logs.String(), "no LLM provider configured") { + t.Errorf("--no-llm should short-circuit before LLM construction; log:\n%s", logs.String()) + } + if strings.Contains(logs.String(), "rendering with LLM narrative") { + t.Errorf("--no-llm should not produce LLM-rendering log; log:\n%s", logs.String()) + } + }) + + t.Run("without -no-llm: attempts LLM and falls back", func(t *testing.T) { + logs := captureLogs(t) + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{planArg}, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if !strings.Contains(logs.String(), "no LLM provider configured") { + t.Errorf("expected slog.Info 'no LLM provider configured' when no keys + no --no-llm; log:\n%s", logs.String()) + } + }) +} + +func TestPRCheck_PlanFileMissing(t *testing.T) { + withFakeSource(t, erroringSource{}) + + missing := filepath.Join(t.TempDir(), "does_not_exist.json") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-plan-file=" + missing, "-no-llm"}, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("expected exit %d for missing plan file, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "--plan-file") { + t.Errorf("stderr should mention the failing flag; got: %s", stderr.String()) + } +} + +func TestPRCheck_PlanFileMissingFlag(t *testing.T) { + withFakeSource(t, erroringSource{}) + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-no-llm"}, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("expected exit %d when --plan-file is omitted, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "--plan-file is required") { + t.Errorf("expected 'is required' message; got: %s", stderr.String()) + } +} + +func TestPRCheck_PlanFileEmpty(t *testing.T) { + withFakeSource(t, erroringSource{}) + + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_empty.json"), + "-no-llm", + } + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("empty plan should still produce a valid comment; got exit %d (stderr: %s)", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, headerMarker) { + t.Errorf("empty-plan output missing header marker") + } + if !strings.Contains(out, "No priceable resources") { + t.Errorf("empty-plan output should say 'No priceable resources'; got:\n%s", out) + } +} + +func TestPRCheck_OutputDirNotWritable(t *testing.T) { + withFakeSource(t, erroringSource{}) + + // A path under a non-existent parent directory: os.WriteFile will + // reject it with a "no such file or directory"-style error. We use + // t.TempDir() then join a nonexistent subdir — robust on Windows + // and POSIX without depending on hardcoded /nonexistent paths. + bad := filepath.Join(t.TempDir(), "does_not_exist_dir", "comment.md") + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-output=" + bad, + "-no-llm", + } + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOutputErr { + t.Errorf("expected exit %d for unwritable output path, got %d", exitPRCheckOutputErr, code) + } + if !strings.Contains(stderr.String(), "--output") { + t.Errorf("stderr should mention the --output flag on write failure; got: %s", stderr.String()) + } +} + +func TestPRCheck_HelpExitsZero(t *testing.T) { + // flag.ErrHelp is not a malformed invocation; --help is a deliberate + // user action and should exit 0 so CI-style scripts that do + // `oracle pr-check --help` to verify a binary works don't fail. + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-h"}, &stdout, &stderr) + if code != exitPRCheckOK { + t.Errorf("--help / -h should exit 0, got %d", code) + } +} + +func TestPRCheck_FlagParseFailureExitsOne(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-not-a-real-flag=true"}, &stdout, &stderr) + if code != exitPRCheckInputErr { + t.Errorf("unknown flag should exit %d, got %d", exitPRCheckInputErr, code) + } +} + +// --- --post flag tests --- + +// postCall captures one PostOrUpdateComment invocation so a test can +// assert on what runPRCheck handed to the github layer. +type postCall struct { + repo github.Repo + pr int + body string + marker string +} + +// recordingGithubPoster is the test double for githubPoster: it logs +// every call and lets each test program a return value or error. +type recordingGithubPoster struct { + calls []postCall + id int64 + created bool + err error +} + +func (r *recordingGithubPoster) PostOrUpdateComment(_ context.Context, repo github.Repo, prNumber int, body, marker string) (int64, bool, error) { + r.calls = append(r.calls, postCall{repo: repo, pr: prNumber, body: body, marker: marker}) + if r.err != nil { + return 0, false, r.err + } + id := r.id + if id == 0 { + id = 12345 + } + return id, r.created, nil +} + +// withFakeGithubClient swaps the package-level newPRCheckGithubClient +// for one that returns the supplied poster, capturing the token the +// factory was called with so tests can assert on env-derived values. +// The original factory is restored on test cleanup. +func withFakeGithubClient(t *testing.T, poster githubPoster) *string { + t.Helper() + var capturedToken string + prev := newPRCheckGithubClient + newPRCheckGithubClient = func(token string) githubPoster { + capturedToken = token + return poster + } + t.Cleanup(func() { newPRCheckGithubClient = prev }) + return &capturedToken +} + +// commonArgs returns the boilerplate flags shared by post-flag tests. +func commonArgs(extra ...string) []string { + base := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-no-llm", + } + return append(base, extra...) +} + +func TestPRCheck_PostFlag_HappyPath(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 555, created: true} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=Cro22/CloudOracle", "-pr=11", "-token=test-token") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if len(rec.calls) != 1 { + t.Fatalf("expected 1 post call, got %d", len(rec.calls)) + } + c := rec.calls[0] + if c.repo != (github.Repo{Owner: "Cro22", Name: "CloudOracle"}) { + t.Errorf("repo = %+v, want Cro22/CloudOracle", c.repo) + } + if c.pr != 11 { + t.Errorf("pr = %d, want 11", c.pr) + } + if !strings.Contains(c.body, footerMarker) { + t.Errorf("post body missing footer marker") + } + if c.marker != defaultMarker { + t.Errorf("marker = %q, want default %q", c.marker, defaultMarker) + } + // stdout still received the markdown — --post is additive, not exclusive. + if !strings.Contains(stdout.String(), headerMarker) { + t.Errorf("stdout should still contain markdown when --post is set") + } +} + +func TestPRCheck_PostFlag_TokenFromEnv(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + captured := withFakeGithubClient(t, rec) + + t.Setenv("GITHUB_TOKEN", "env-token") + args := commonArgs("-post", "-repo=Cro22/CloudOracle", "-pr=11") // no -token + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if *captured != "env-token" { + t.Errorf("token captured = %q, want %q (from env)", *captured, "env-token") + } +} + +func TestPRCheck_PostFlag_NoToken(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + t.Setenv("GITHUB_TOKEN", "") // make sure the env doesn't satisfy the requirement + args := commonArgs("-post", "-repo=Cro22/CloudOracle", "-pr=11") + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("expected exit %d (input error), got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "GITHUB_TOKEN") { + t.Errorf("stderr should mention GITHUB_TOKEN; got: %s", stderr.String()) + } + if len(rec.calls) != 0 { + t.Errorf("github should not be called when token is missing") + } +} + +func TestPRCheck_PostFlag_BadRepoFormat(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=invalid-no-slash", "-pr=11", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("bad repo format should exit %d, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "owner/name") { + t.Errorf("stderr should mention 'owner/name'; got: %s", stderr.String()) + } + if len(rec.calls) != 0 { + t.Errorf("github should not be called on bad repo format") + } +} + +func TestPRCheck_PostFlag_NegativePR(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=-1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("negative PR should exit %d, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "--pr") { + t.Errorf("stderr should mention --pr; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_AuthError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: authentication failed (check GITHUB_TOKEN): 401 Bad credentials"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("auth error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "authentication") { + t.Errorf("stderr should mention 'authentication'; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_NotFoundError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: repo o/n or PR #1 not found"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("not-found error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "not found") { + t.Errorf("stderr should mention 'not found'; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_ValidationError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: validation failed: body too long"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("validation error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "validation") { + t.Errorf("stderr should mention 'validation'; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_GenericError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: something unexpected happened"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("generic github error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "something unexpected happened") { + t.Errorf("stderr should contain the underlying error message; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_LogsCreateVsUpdate(t *testing.T) { + t.Run("created=true logs 'created'", func(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 100, created: true} + withFakeGithubClient(t, rec) + logs := captureLogs(t) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if !strings.Contains(logs.String(), "created") { + t.Errorf("logs should contain 'created' for created=true; got:\n%s", logs.String()) + } + if strings.Contains(logs.String(), "comment updated") { + t.Errorf("logs should NOT say 'updated' when created=true; got:\n%s", logs.String()) + } + }) + + t.Run("created=false logs 'updated'", func(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 100, created: false} + withFakeGithubClient(t, rec) + logs := captureLogs(t) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if !strings.Contains(logs.String(), "updated") { + t.Errorf("logs should contain 'updated' for created=false; got:\n%s", logs.String()) + } + }) +} + +func TestPRCheck_NoPostFlag_DoesNotCallGithub(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs() // no --post + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if len(rec.calls) != 0 { + t.Errorf("github must not be called without --post; saw %d calls", len(rec.calls)) + } +} + +func TestPRCheck_PostFlag_WritesOutputFile(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 7} + withFakeGithubClient(t, rec) + + tmp := filepath.Join(t.TempDir(), "comment.md") + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t", "-output="+tmp) + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + body, err := os.ReadFile(tmp) + if err != nil { + t.Fatalf("output file missing: %v", err) + } + if !strings.Contains(string(body), headerMarker) { + t.Errorf("output file does not contain markdown header") + } + if len(rec.calls) != 1 { + t.Errorf("expected 1 post call alongside the file write, got %d", len(rec.calls)) + } +} + +func TestPRCheck_PostFlag_CustomMarker(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t", "-marker=v2-prefix") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if len(rec.calls) != 1 { + t.Fatalf("expected 1 post call, got %d", len(rec.calls)) + } + if rec.calls[0].marker != "v2-prefix" { + t.Errorf("marker = %q, want %q", rec.calls[0].marker, "v2-prefix") + } +} + +// TestParseRepo covers the small helper directly. All paths through +// parseRepo are exercised indirectly by the post-flag tests above; the +// dedicated table here makes regressions in the validation rule +// (e.g. accepting "owner/" or "/name") surface with a sharper failure. +func TestParseRepo(t *testing.T) { + cases := []struct { + in string + wantErr bool + wantRepo github.Repo + }{ + {"Cro22/CloudOracle", false, github.Repo{Owner: "Cro22", Name: "CloudOracle"}}, + {"a/b", false, github.Repo{Owner: "a", Name: "b"}}, + {"", true, github.Repo{}}, + {"no-slash", true, github.Repo{}}, + {"/missing-owner", true, github.Repo{}}, + {"missing-name/", true, github.Repo{}}, + {"a/b/c", false, github.Repo{Owner: "a", Name: "b/c"}}, // SplitN keeps trailing '/c' as Name; documented behaviour + } + for _, c := range cases { + got, err := parseRepo(c.in) + if (err != nil) != c.wantErr { + t.Errorf("parseRepo(%q) err = %v, wantErr %v", c.in, err, c.wantErr) + continue + } + if !c.wantErr && got != c.wantRepo { + t.Errorf("parseRepo(%q) = %+v, want %+v", c.in, got, c.wantRepo) + } + } +} diff --git a/cmd/oracle/main.go b/cmd/oracle/main.go index 80dff49..3b70e3d 100644 --- a/cmd/oracle/main.go +++ b/cmd/oracle/main.go @@ -6,9 +6,13 @@ import ( "CloudOracle/internal/cloud" "CloudOracle/internal/config" "CloudOracle/internal/db" + "CloudOracle/internal/diff" + "CloudOracle/internal/github" + "CloudOracle/internal/iac" "CloudOracle/internal/llm" "CloudOracle/internal/logging" "CloudOracle/internal/migrations" + "CloudOracle/internal/pricing" "CloudOracle/internal/report" "CloudOracle/internal/shared" "context" @@ -20,6 +24,7 @@ import ( "os" "sort" "strings" + "time" ) func main() { @@ -39,6 +44,16 @@ func main() { logging.Setup(cfg.LogLevel, cfg.LogFormat) ctx := context.Background() + + // pr-check is a stateless plan→markdown transform — no database + // involved. The GitHub Action that wraps this binary in Hito 16.3 + // runs in environments where Postgres is not available, so we + // dispatch this subcommand before db.Connect to avoid a spurious + // connection failure. + if os.Args[1] == "pr-check" { + os.Exit(runPRCheck(ctx, cfg, os.Args[2:], os.Stdout, os.Stderr)) + } + pool, err := db.Connect(ctx, cfg.DB) if err != nil { slog.Error("failed to connect to database", "error", err) @@ -88,6 +103,10 @@ func printUsage() { fmt.Println(" oracle trend [--days N] - Show cost trends over time") fmt.Println(" oracle export --format=json|csv [--output file] - Export findings to JSON or CSV (stdout by default)") fmt.Println(" oracle serve [--port 8080] - Start the HTTP API for the dashboard") + fmt.Println(" oracle pr-check --plan-file=plan.json [--region=us-east-2] [--output=comment.md] [--no-llm]") + fmt.Println(" [--post --repo=owner/name --pr=N [--token=TOK] [--marker=cloudoracle-pr-v1]]") + fmt.Println(" - Render a Terraform plan as a PR-comment Markdown,") + fmt.Println(" optionally posting/updating it on GitHub") } func runSeed(ctx context.Context, pool *db.Pool, cfg config.Config, args []string) { @@ -422,6 +441,240 @@ func runExport(ctx context.Context, pool *db.Pool, args []string) { } } +// pr-check exit codes. Differentiated so the GitHub Action wrapper in +// Hito 16.3 can distinguish "the developer's plan is broken" (1) from +// "our pricing dependency failed" (2) from "we can't write the output" +// (3) — different remediations for each. The other oracle subcommands +// use exit 1 uniformly; pr-check is the first to be CI-targeted. +const ( + exitPRCheckOK = 0 + exitPRCheckInputErr = 1 + exitPRCheckPricingErr = 2 + exitPRCheckOutputErr = 3 + exitPRCheckGitHubErr = 4 +) + +// defaultMarker matches the HTML marker that diff.RenderMarkdown emits at +// the bottom of every CloudOracle PR comment. The --marker flag exists so +// users can override (e.g. for a future v2 marker without breaking +// existing v1 comments) but the default is the canonical one. +const defaultMarker = "cloudoracle-pr-v1" + +// githubPoster is the subset of *github.Client behaviour runPRCheck +// actually exercises. Defining it here (rather than exporting one from +// internal/github) keeps the test fake's surface tiny: a single method +// instead of the whole Client. +type githubPoster interface { + PostOrUpdateComment(ctx context.Context, repo github.Repo, prNumber int, body, marker string) (int64, bool, error) +} + +// newPRCheckGithubClient is the factory used by runPRCheck to obtain a +// githubPoster. Wrapped as a package-level var so tests can swap it for +// a recording fake. Production wraps github.NewClient — *github.Client +// already satisfies githubPoster by structural typing. +var newPRCheckGithubClient = func(token string) githubPoster { + return github.NewClient(token) +} + +// newPRCheckSource builds the pricing.Source used by `pr-check`. +// Wrapped as a package-level var so tests can swap it for a fake +// without spinning up the AWS SDK or hitting the network. Production +// builds get a 7-day disk cache wrapping a real AWS Pricing client; +// the cache is best-effort — a directory-creation failure logs WARN +// and falls back to the uncached client rather than aborting. +var newPRCheckSource = func(ctx context.Context) (diff.Source, error) { + client, err := pricing.NewClient(ctx) + if err != nil { + return nil, err + } + dir, dirErr := pricing.DefaultCacheDir() + if dirErr != nil || dir == "" { + slog.Warn("pricing cache disabled, using direct client", "error", dirErr) + return client, nil + } + cache, err := pricing.NewCache(client, dir, 7*24*time.Hour) + if err != nil { + slog.Warn("pricing cache disabled, using direct client", "error", err) + return client, nil + } + return cache, nil +} + +// runPRCheck is the orchestrator for the `pr-check` subcommand. It +// returns the process exit code instead of calling os.Exit directly so +// it is testable in-process; main() does the os.Exit at the dispatch +// site. stdout receives the rendered Markdown when --output is empty +// or "-"; stderr receives flag-parse and error messages. +func runPRCheck(ctx context.Context, cfg config.Config, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("pr-check", flag.ContinueOnError) + fs.SetOutput(stderr) + planFile := fs.String("plan-file", "", "path to `terraform show -json` output (required)") + region := fs.String("region", "us-east-2", "AWS region for pricing lookups") + output := fs.String("output", "", "file to write the Markdown to; empty or \"-\" means stdout") + noLLM := fs.Bool("no-llm", false, "force the templated narrative even if an LLM provider is configured") + post := fs.Bool("post", false, "post the rendered Markdown as a PR comment (upserts via marker)") + repoFlag := fs.String("repo", "", "target repo in `owner/name` form (required when --post is set)") + prNumber := fs.Int("pr", 0, "target PR number (required when --post is set)") + tokenFlag := fs.String("token", "", "GitHub token; falls back to the GITHUB_TOKEN env var when empty") + marker := fs.String("marker", defaultMarker, "HTML marker substring used to find the existing CloudOracle comment for upsert") + + if err := fs.Parse(args); err != nil { + // flag.Parse already wrote a usage message to stderr. -h / --help + // is a deliberate user action, not a malformed invocation, so it + // exits 0; everything else is a flag-parse failure (exit 1). + if errors.Is(err, flag.ErrHelp) { + return exitPRCheckOK + } + return exitPRCheckInputErr + } + + if *planFile == "" { + fmt.Fprintln(stderr, "oracle pr-check: --plan-file is required") + return exitPRCheckInputErr + } + + plan, err := iac.ParsePlanFile(*planFile) + if err != nil { + fmt.Fprintf(stderr, "oracle pr-check: --plan-file %q: %v\n", *planFile, err) + return exitPRCheckInputErr + } + + source, err := newPRCheckSource(ctx) + if err != nil { + slog.Error("pr-check: pricing source unavailable", "error", err) + return exitPRCheckPricingErr + } + + costDiff, err := diff.Analyze(ctx, source, plan, *region) + if err != nil { + slog.Error("pr-check: diff analysis failed", "region", *region, "error", err) + return exitPRCheckPricingErr + } + + md := renderPRCheckMarkdown(ctx, cfg, costDiff, *noLLM) + + // Output first (stdout when unset or "-"; file otherwise). --output + // and --post are independent: a CI run that sets both gets the file + // for artefact upload AND the comment posted. + if *output == "" || *output == "-" { + if _, err := io.WriteString(stdout, md); err != nil { + slog.Error("pr-check: writing to stdout failed", "error", err) + return exitPRCheckOutputErr + } + } else { + if err := os.WriteFile(*output, []byte(md), 0o644); err != nil { + fmt.Fprintf(stderr, "oracle pr-check: --output %q: %v\n", *output, err) + return exitPRCheckOutputErr + } + slog.Info("pr-check: wrote markdown", "path", *output, "bytes", len(md)) + } + + if !*post { + return exitPRCheckOK + } + return runPRCheckPost(ctx, *repoFlag, *prNumber, *tokenFlag, md, *marker, stderr) +} + +// runPRCheckPost validates the post-related flags and dispatches to the +// configured githubPoster. Split from runPRCheck so the post path's +// flag-validation, token resolution, and error mapping have one home — +// the orchestrator stays linear and the unit tests can target each +// failure mode without rebuilding the analyze/render plumbing. +func runPRCheckPost(ctx context.Context, repoFlag string, prNumber int, tokenFlag, body, marker string, stderr io.Writer) int { + if prNumber <= 0 { + fmt.Fprintln(stderr, "oracle pr-check: --pr must be a positive integer when --post is set") + return exitPRCheckInputErr + } + repo, err := parseRepo(repoFlag) + if err != nil { + fmt.Fprintf(stderr, "oracle pr-check: %v\n", err) + return exitPRCheckInputErr + } + token := tokenFlag + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + if token == "" { + fmt.Fprintln(stderr, "oracle pr-check: --token or GITHUB_TOKEN env required when --post is set") + return exitPRCheckInputErr + } + + client := newPRCheckGithubClient(token) + id, created, err := client.PostOrUpdateComment(ctx, repo, prNumber, body, marker) + if err != nil { + return classifyGithubError(err, stderr) + } + + action := "updated" + if created { + action = "created" + } + slog.Info("pr-check: github comment "+action, + "comment_id", id, + "repo", repoFlag, + "pr", prNumber, + "marker", marker) + return exitPRCheckOK +} + +// parseRepo accepts the "owner/name" form used by GitHub URLs and +// returns a github.Repo. Both halves must be non-empty; we use SplitN +// with n=2 so a name containing a slash (which GitHub forbids anyway) +// would be caught by the empty-half check rather than silently keeping +// the trailing portion. +func parseRepo(s string) (github.Repo, error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return github.Repo{}, fmt.Errorf("--repo must be in 'owner/name' format, got %q", s) + } + return github.Repo{Owner: parts[0], Name: parts[1]}, nil +} + +// classifyGithubError maps an error string from internal/github to a +// user-facing stderr line and the exit-4 code. Matching is by stable +// substring rather than wrapped sentinel errors because internal/github +// uses fmt.Errorf with prefix conventions (documented at the +// PostOrUpdateComment godoc); converting to typed errors would couple +// the two packages tighter than necessary. +func classifyGithubError(err error, stderr io.Writer) int { + msg := err.Error() + switch { + case strings.Contains(msg, "authentication failed"): + fmt.Fprintln(stderr, "oracle pr-check: github post failed: authentication; check token permissions") + case strings.Contains(msg, "not found"): + fmt.Fprintln(stderr, "oracle pr-check: github post failed: repo or PR not found") + case strings.Contains(msg, "validation failed"): + fmt.Fprintf(stderr, "oracle pr-check: github post failed: validation: %s\n", + strings.TrimPrefix(msg, "github: validation failed: ")) + default: + fmt.Fprintf(stderr, "oracle pr-check: github post failed: %s\n", msg) + } + return exitPRCheckGitHubErr +} + +// renderPRCheckMarkdown picks between LLM-narrated and templated render. +// Splitting it from runPRCheck keeps the orchestrator's branching +// cyclomatic-low and makes the LLM-fallback path easy to reason about +// in isolation: LLM disabled (--no-llm), no provider keys configured, +// or provider construction error all converge on the same templated +// path. +func renderPRCheckMarkdown(ctx context.Context, cfg config.Config, d diff.CostDiff, noLLM bool) string { + if noLLM { + return diff.RenderMarkdown(d) + } + provider, err := llm.NewProvider(cfg.LLM) + if err != nil { + if errors.Is(err, llm.ErrNoProvider) { + slog.Info("pr-check: no LLM provider configured, using templated narrative") + } else { + slog.Warn("pr-check: LLM provider error, using templated narrative", "error", err) + } + return diff.RenderMarkdown(d) + } + slog.Info("pr-check: rendering with LLM narrative", "provider", provider.Name()) + return diff.RenderMarkdownWithLLM(ctx, d, provider) +} + func runServe(pool *db.Pool, args []string) { fs := flag.NewFlagSet("serve", flag.ExitOnError) port := fs.String("port", "8080", "Port to listen on") diff --git a/cmd/probe-pricing/main.go b/cmd/probe-pricing/main.go new file mode 100644 index 0000000..0f4fd00 --- /dev/null +++ b/cmd/probe-pricing/main.go @@ -0,0 +1,80 @@ +// Command probe-pricing inspects raw AWS Pricing API responses for a given +// service code and filter set. It exists for two reasons: +// +// 1. Diagnosing "multiple products" warnings: when an EstimateXxx mapper +// warns that a query returned >1 product, run the same filters through +// the probe to see exactly which attributes differ between the products +// and pick a tighter filter to add. +// 2. Ad-hoc exploration of new resource types before writing a mapper — +// dumping a known-good filter set is the fastest way to learn the +// attribute vocabulary AWS uses for that productFamily. +// +// Usage: +// +// go run ./cmd/probe-pricing '' +// +// Example: +// +// go run ./cmd/probe-pricing AmazonRDS \ +// '{"productFamily":"Database Storage","volumeType":"General Purpose","deploymentOption":"Single-AZ","regionCode":"us-east-2"}' +// +// Requires AWS credentials in the standard chain (env vars, shared config, +// instance metadata). The Pricing API endpoint is forced to us-east-1 by +// pricing.NewClient regardless of the resource's region — the resource +// region is a filter value, not the endpoint region. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "sort" + + "CloudOracle/internal/pricing" +) + +func main() { + if len(os.Args) < 3 { + log.Fatal("usage: probe-pricing ") + } + + var filters map[string]string + if err := json.Unmarshal([]byte(os.Args[2]), &filters); err != nil { + log.Fatalf("invalid filters JSON: %v", err) + } + + ctx := context.Background() + client, err := pricing.NewClient(ctx) + if err != nil { + log.Fatalf("NewClient: %v", err) + } + + products, err := client.GetProducts(ctx, os.Args[1], filters) + if err != nil { + log.Fatalf("GetProducts: %v", err) + } + + fmt.Printf("Got %d products for %s with filters %v\n\n", len(products), os.Args[1], filters) + for i, p := range products { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(p), &parsed); err != nil { + fmt.Printf("[%d] parse error: %v\n", i, err) + continue + } + product, _ := parsed["product"].(map[string]interface{}) + attrs, _ := product["attributes"].(map[string]interface{}) + + fmt.Printf("[%d] sku=%v\n", i, product["sku"]) + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Printf(" %-30s = %v\n", k, attrs[k]) + } + fmt.Println() + } +} diff --git a/e2e-test/main.tf b/e2e-test/main.tf new file mode 100644 index 0000000..0918b45 --- /dev/null +++ b/e2e-test/main.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "us-east-2" +} + +# Single t3.micro on Linux on-demand — a small but non-zero cost diff +# (~$7-8/month) so the rendered comment exercises the Top-movers table +# and the LLM narrative path. The AMI is a well-known Amazon Linux 2 +# image in us-east-2; terraform plan does not resolve it (no data +# sources), so the value flows straight into the plan JSON for the +# CloudOracle parser to read. +resource "aws_instance" "self_test" { + ami = "ami-0c55b159cbfafe1f0" + instance_type = "t3.micro" + + tags = { + Name = "cloudoracle-self-test" + } +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..de60944 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# CloudOracle Action entrypoint (Hito 16.4). +# +# Translates GitHub Action `inputs:` (delivered as INPUT_* env vars) into +# `oracle pr-check` flags, then exec's the binary so its exit code is the +# Action's exit code (1=input, 2=pricing, 3=output, 4=github). +# +# POSIX-only — no bashisms — because the alpine base ships /bin/sh as +# busybox ash. Run shellcheck under -s sh to catch regressions. +set -eu + +# --- Required input ------------------------------------------------------- +if [ -z "${INPUT_PLAN_FILE:-}" ]; then + echo "::error::plan-file input is required" >&2 + exit 1 +fi + +# --- Build the argv incrementally ---------------------------------------- +# `set -- ...` rewrites positional parameters; each `set -- "$@" ...` line +# appends to the existing argv. This is the POSIX-portable way to build +# a list when arrays aren't available. +set -- --plan-file="${INPUT_PLAN_FILE}" +set -- "$@" --region="${INPUT_REGION:-us-east-2}" +set -- "$@" --marker="${INPUT_MARKER:-cloudoracle-pr-v1}" + +if [ "${INPUT_NO_LLM:-false}" = "true" ]; then + set -- "$@" --no-llm +fi + +if [ -n "${INPUT_OUTPUT_FILE:-}" ]; then + set -- "$@" --output="${INPUT_OUTPUT_FILE}" +fi + +# --- Auto-post on pull_request[_target] events --------------------------- +# GitHub sets GITHUB_EVENT_NAME and GITHUB_REF for us. For PR events, +# GITHUB_REF takes the form `refs/pull/{N}/merge` (default) or +# `refs/pull/{N}/head` (when checkout-merge-commit:false is configured). +# We strip the `refs/pull/` prefix, then strip the trailing `/merge` or +# `/head` to get just the PR number. If the ref doesn't match that +# shape, the substitution is a no-op (`PR_REF == GITHUB_REF`) and we +# skip posting with a warning rather than blindly POSTing with a bad +# value. +event="${GITHUB_EVENT_NAME:-}" +if [ "$event" = "pull_request" ] || [ "$event" = "pull_request_target" ]; then + PR_REF="${GITHUB_REF#refs/pull/}" + PR_NUMBER="${PR_REF%/*}" + + if [ -n "$PR_NUMBER" ] && [ "$PR_REF" != "${GITHUB_REF:-}" ]; then + set -- "$@" --post + set -- "$@" --repo="${GITHUB_REPOSITORY}" + set -- "$@" --pr="${PR_NUMBER}" + if [ -n "${INPUT_GITHUB_TOKEN:-}" ]; then + set -- "$@" --token="${INPUT_GITHUB_TOKEN}" + fi + else + echo "::warning::Could not extract PR number from GITHUB_REF=${GITHUB_REF:-}; rendering only, not posting." >&2 + fi +else + echo "::notice::Not a pull_request event (event=${event:-}); rendering only, not posting." >&2 +fi + +exec /usr/local/bin/oracle pr-check "$@" diff --git a/example_report.pdf b/example_report.pdf new file mode 100644 index 0000000..626b0c9 Binary files /dev/null and b/example_report.pdf differ diff --git a/go.mod b/go.mod index 69d5d77..dd9b7a9 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,18 @@ 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/config v1.32.14 + github.com/aws/aws-sdk-go-v2/config v1.32.17 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 github.com/aws/aws-sdk-go-v2/service/rds v1.118.1 - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 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 + google.golang.org/protobuf v1.36.11 ) require ( @@ -33,20 +37,19 @@ require ( 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.6 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.7 // 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.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect - github.com/aws/smithy-go v1.25.0 // 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.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // 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 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -92,8 +95,6 @@ require ( github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/testcontainers/testcontainers-go v0.42.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect 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 @@ -113,6 +114,5 @@ require ( 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/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 56053c2..2dba1e7 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ 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= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= @@ -44,44 +46,44 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= 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.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= -github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +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/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= -github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= -github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +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/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/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0 h1:A+7NViqbMUCoTQFWjbSXdbzE4K5Ziu2zWJtZzAusm+A= github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0/go.mod h1:R+2BNtUfTfhPY0RH18oL02q116bakeBWjanrbnVBqkM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1 h1:JxHLwNK5mIKsh2Q0APTSijdzkk5ccI4gyvYdar1JU/0= github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1/go.mod h1:7qoh/MlWG5QCnZwq9bvdXomEAkmumayXcjEjIemIV7U= +github.com/aws/aws-sdk-go-v2/service/pricing v1.41.2 h1:Ujj2QuBZCrQRek/VmnPwjz6LRGdKoxldpp8fNXwLUUg= +github.com/aws/aws-sdk-go-v2/service/pricing v1.41.2/go.mod h1:zXv2YjVkSugNoBHG8WrHHNqCyTFplsE8B8hCAV9riRA= github.com/aws/aws-sdk-go-v2/service/rds v1.118.1 h1:cywOPYUFOSOAjrovJNxuBXd6SV3osiP3KJ5p412IEJQ= github.com/aws/aws-sdk-go-v2/service/rds v1.118.1/go.mod h1:BaS59j6evm68pt9EaJnb7tnTOaT0MY4rJeESKh8RKKY= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= -github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +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/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= @@ -98,6 +100,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +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= @@ -150,12 +154,20 @@ 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/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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -186,11 +198,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/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= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= 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/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= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -235,6 +251,8 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc 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/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -255,6 +273,12 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 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= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/diff/engine.go b/internal/diff/engine.go new file mode 100644 index 0000000..67f79db --- /dev/null +++ b/internal/diff/engine.go @@ -0,0 +1,264 @@ +package diff + +import ( + "context" + "fmt" + "log/slog" + "math" + "sort" + "strings" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// TopMoversCount is the default number of top-by-absolute-delta items +// surfaced in CostDiff.TopMovers. Five fits in a PR comment header +// without crowding it; the renderer in 14.2 may show more on demand. +const TopMoversCount = 5 + +// Analyze runs pricing.EstimateChange on every resource change in the +// plan and aggregates the results into a CostDiff. +// +// Failures on individual changes are NOT fatal: the orchestrator logs +// them at slog.Warn and the change is added to Skipped with +// SkipReason="estimation failed: ". This way callers can always +// render a CostDiff — even one where every resource failed to price — +// rather than refusing to produce a comment because of a single +// transient API hiccup. +// +// Analyze returns a non-nil error only when the input itself is +// malformed: nil plan, nil src, empty region. Multi-region plans are +// out of scope; all changes are priced against `region`. +func Analyze(ctx context.Context, src Source, plan *iac.Plan, region string) (CostDiff, error) { + if src == nil { + return CostDiff{}, fmt.Errorf("Analyze: nil src") + } + return analyzeWithEstimator(ctx, src, plan, region, defaultEstimator) +} + +// estimator is the function shape Analyze uses internally to produce +// per-change estimates. Defaulted to pricing.EstimateChange via +// defaultEstimator; tests inject their own to avoid building a fake +// Source plus fixture JSON for every assertion. +type estimator func(ctx context.Context, src Source, rc iac.ResourceChange, region string) (pricing.ChangeEstimate, error) + +// defaultEstimator is the production wiring: a thin pass-through to +// pricing.EstimateChange. Lives as a named function rather than a +// closure so the test seam (analyzeWithEstimator) is easy to read. +func defaultEstimator(ctx context.Context, src Source, rc iac.ResourceChange, region string) (pricing.ChangeEstimate, error) { + return pricing.EstimateChange(ctx, src, rc, region) +} + +// analyzeWithEstimator is the test seam. Public Analyze always supplies +// defaultEstimator; tests pass a fake to avoid simulating Pricing API +// JSON. Mirrors the pattern pricing.newClientWithAPI uses. +func analyzeWithEstimator(ctx context.Context, src Source, plan *iac.Plan, region string, est estimator) (CostDiff, error) { + if plan == nil { + return CostDiff{}, fmt.Errorf("Analyze: nil plan") + } + if region == "" { + return CostDiff{}, fmt.Errorf("Analyze: empty region") + } + + out := CostDiff{ + Currency: "USD", + Changes: make([]pricing.ChangeEstimate, 0, len(plan.ResourceChanges)), + } + + for _, rc := range plan.ResourceChanges { + ce, err := est(ctx, src, rc, region) + if err != nil { + slog.Warn("diff: estimation failed; recording as skipped", + "address", rc.Address, + "type", rc.Type, + "error", err, + ) + ce = pricing.ChangeEstimate{ + ResourceAddress: rc.Address, + ResourceType: rc.Type, + Action: rc.Action(), + Currency: "USD", + Confidence: pricing.ConfidenceHigh, + Skipped: true, + SkipReason: "estimation failed: " + err.Error(), + } + } + out.Changes = append(out.Changes, ce) + } + + // Sort by absolute MonthlyDelta descending. Skipped items have + // delta=0 and naturally trail the priced ones; ties keep input order + // (sort.SliceStable would matter only if we cared about secondary + // ordering, which we don't here). + sort.SliceStable(out.Changes, func(i, j int) bool { + return math.Abs(out.Changes[i].MonthlyDelta) > math.Abs(out.Changes[j].MonthlyDelta) + }) + + out.Confidence = pricing.ConfidenceHigh + for i := range out.Changes { + ce := &out.Changes[i] + out.TotalMonthlyDelta += ce.MonthlyDelta + categorize(ce, &out) + if !ce.Skipped { + out.Confidence = weakestConfidence(out.Confidence, ce.Confidence) + } + } + + out.TopMovers = topMovers(out.Changes, TopMoversCount) + out.Stats = computeStats(&out) + out.Notes = buildNotes(&out) + + return out, nil +} + +// categorize files a change into the appropriate action-keyed slice on +// out, falling back to Skipped when ce.Skipped is set regardless of +// action. The action-keyed slices intentionally exclude skipped items +// so a renderer iterating "Created" sees only successfully-priced +// creates. +func categorize(ce *pricing.ChangeEstimate, out *CostDiff) { + if ce.Skipped { + out.Skipped = append(out.Skipped, *ce) + return + } + switch classifyAction(ce.Action) { + case "create": + out.Created = append(out.Created, *ce) + case "delete": + out.Deleted = append(out.Deleted, *ce) + case "update": + out.Updated = append(out.Updated, *ce) + case "replace": + out.Replaced = append(out.Replaced, *ce) + } +} + +// classifyAction returns the canonical category string for an action, +// or "" for actions that don't get their own slice (no-op, read, +// unknown). The empty string is the cue to NOT add the change to any +// action-specific slice — those changes are still represented in +// Changes, and if Skipped=true they show up in Skipped. +func classifyAction(a iac.Action) string { + switch a { + case iac.ActionCreate: + return "create" + case iac.ActionDelete: + return "delete" + case iac.ActionUpdate: + return "update" + case iac.ActionReplace: + return "replace" + } + return "" +} + +// topMovers returns the first n non-skipped entries from changes. +// Skipped items have delta=0 and would dilute the "biggest changes" +// summary, so they are filtered out. n is clamped against the +// available non-skipped count. +func topMovers(changes []pricing.ChangeEstimate, n int) []pricing.ChangeEstimate { + if n <= 0 || len(changes) == 0 { + return nil + } + out := make([]pricing.ChangeEstimate, 0, n) + for _, c := range changes { + if c.Skipped { + continue + } + out = append(out, c) + if len(out) == n { + break + } + } + if len(out) == 0 { + return nil + } + return out +} + +// computeStats counts each disjoint category. The partition rule is in +// the Stats godoc: NoOp captures no-op/read actions; Skipped captures +// items with Skipped=true that AREN'T no-op/read; Priced is the four +// action slices summed. +func computeStats(out *CostDiff) Stats { + s := Stats{ + Total: len(out.Changes), + Created: len(out.Created), + Deleted: len(out.Deleted), + Updated: len(out.Updated), + Replaced: len(out.Replaced), + } + s.Priced = s.Created + s.Deleted + s.Updated + s.Replaced + for _, c := range out.Changes { + switch c.Action { + case iac.ActionNoop, iac.ActionRead: + s.NoOp++ + default: + if c.Skipped { + s.Skipped++ + } + } + } + return s +} + +// buildNotes produces the plan-wide notes appended to CostDiff.Notes. +// The order is fixed: skip-count breakdown first (if any skips), then +// either the "no priceable" diagnostic or the net-direction note. +// Calling code can rely on this order when rendering. +func buildNotes(out *CostDiff) []string { + var notes []string + + if len(out.Skipped) > 0 { + var unsupported, estFail int + for _, c := range out.Skipped { + switch { + case strings.Contains(c.SkipReason, "unsupported"): + unsupported++ + case strings.Contains(c.SkipReason, "estimation failed"): + estFail++ + } + } + notes = append(notes, + fmt.Sprintf("%d resources skipped (%d unsupported types, %d estimation failures)", + len(out.Skipped), unsupported, estFail)) + } + + allSkipped := len(out.Changes) == 0 || len(out.Skipped) == len(out.Changes) + switch { + case allSkipped: + notes = append(notes, "No priceable resources in plan") + case out.TotalMonthlyDelta > 0: + notes = append(notes, "Net cost increase this plan") + case out.TotalMonthlyDelta < 0: + notes = append(notes, "Net cost reduction this plan") + default: + notes = append(notes, "Net zero cost change") + } + + return notes +} + +// weakestConfidence returns whichever of a/b is "weaker": low dominates +// medium dominates high. Duplicated from pricing/change.go intentionally +// — the function is five lines and importing it would couple the diff +// package to pricing's confidenceRank, which is unexported and could +// change shape independently. +func weakestConfidence(a, b pricing.Confidence) pricing.Confidence { + rank := func(c pricing.Confidence) int { + switch c { + case pricing.ConfidenceHigh: + return 0 + case pricing.ConfidenceMedium: + return 1 + case pricing.ConfidenceLow: + return 2 + } + return 3 + } + if rank(a) >= rank(b) { + return a + } + return b +} diff --git a/internal/diff/engine_test.go b/internal/diff/engine_test.go new file mode 100644 index 0000000..8555d0d --- /dev/null +++ b/internal/diff/engine_test.go @@ -0,0 +1,505 @@ +package diff + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// fakeEstimator is the test seam for analyzeWithEstimator. It looks up +// the response by rc.Address; tests build a results map and (optionally) +// an errors map keyed by address. nilSrc satisfies Source for callers +// that don't exercise the underlying call path. +type fakeEstimator struct { + results map[string]pricing.ChangeEstimate + errors map[string]error +} + +func (f *fakeEstimator) estimate(_ context.Context, _ Source, rc iac.ResourceChange, _ string) (pricing.ChangeEstimate, error) { + if err, ok := f.errors[rc.Address]; ok { + return pricing.ChangeEstimate{}, err + } + if r, ok := f.results[rc.Address]; ok { + return r, nil + } + // Default: a no-op-ish skipped result so tests don't crash on + // missing entries — they'll see the missing address in the diff. + return pricing.ChangeEstimate{ + ResourceAddress: rc.Address, + ResourceType: rc.Type, + Action: rc.Action(), + Skipped: true, + SkipReason: "no result programmed for " + rc.Address, + }, nil +} + +// nilSource is a Source that should never be called (tests inject a +// fake estimator that ignores src). Calling GetProducts panics so a +// regression in the wiring is loud. +type nilSource struct{} + +func (nilSource) GetProducts(_ context.Context, _ string, _ map[string]string) ([]string, error) { + panic("nilSource.GetProducts called — fake estimator should bypass src entirely") +} + +// rc is a small constructor for ResourceChange test fixtures. +func rc(addr, typ string, action string) iac.ResourceChange { + return iac.ResourceChange{ + Address: addr, + Mode: "managed", + Type: typ, + Change: iac.Change{Actions: []string{action}}, + } +} + +func plan(rcs ...iac.ResourceChange) *iac.Plan { + return &iac.Plan{ + FormatVersion: "1.2", + ResourceChanges: rcs, + } +} + +func TestAnalyze_HappyPath(t *testing.T) { + rcs := []iac.ResourceChange{ + rc("aws_instance.web", "aws_instance", "create"), + rc("aws_ebs_volume.vol", "aws_ebs_volume", "delete"), + rc("aws_db_instance.db", "aws_db_instance", "update"), + rc("aws_lambda_function.fn", "aws_lambda_function", "create"), + } + // Replace = ["delete","create"] + rcs = append(rcs, iac.ResourceChange{ + Address: "aws_lambda_function.replaced", + Mode: "managed", + Type: "aws_lambda_function", + Change: iac.Change{Actions: []string{"delete", "create"}}, + }) + p := plan(rcs...) + + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", ResourceType: "aws_instance", + Action: iac.ActionCreate, Currency: "USD", + AfterMonthly: 100, MonthlyDelta: 100, Confidence: pricing.ConfidenceLow, + }, + "aws_ebs_volume.vol": { + ResourceAddress: "aws_ebs_volume.vol", ResourceType: "aws_ebs_volume", + Action: iac.ActionDelete, Currency: "USD", + BeforeMonthly: 5, MonthlyDelta: -5, Confidence: pricing.ConfidenceMedium, + }, + "aws_db_instance.db": { + ResourceAddress: "aws_db_instance.db", ResourceType: "aws_db_instance", + Action: iac.ActionUpdate, Currency: "USD", + BeforeMonthly: 50, AfterMonthly: 75, MonthlyDelta: 25, Confidence: pricing.ConfidenceLow, + }, + "aws_lambda_function.fn": { + ResourceAddress: "aws_lambda_function.fn", ResourceType: "aws_lambda_function", + Action: iac.ActionCreate, Currency: "USD", + AfterMonthly: 0, MonthlyDelta: 0, Confidence: pricing.ConfidenceLow, + }, + "aws_lambda_function.replaced": { + ResourceAddress: "aws_lambda_function.replaced", ResourceType: "aws_lambda_function", + Action: iac.ActionReplace, Currency: "USD", + BeforeMonthly: 10, AfterMonthly: 30, MonthlyDelta: 20, Confidence: pricing.ConfidenceLow, + }, + }} + + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("analyzeWithEstimator: %v", err) + } + + wantTotal := 100.0 - 5 + 25 + 0 + 20 + if math.Abs(d.TotalMonthlyDelta-wantTotal) > 1e-9 { + t.Errorf("TotalMonthlyDelta = %v, want %v", d.TotalMonthlyDelta, wantTotal) + } + if d.Currency != "USD" { + t.Errorf("Currency = %q", d.Currency) + } + + // Sort: 100, 25, 20, -5, 0 (by abs) + wantOrder := []string{ + "aws_instance.web", "aws_db_instance.db", "aws_lambda_function.replaced", + "aws_ebs_volume.vol", "aws_lambda_function.fn", + } + for i, want := range wantOrder { + if d.Changes[i].ResourceAddress != want { + t.Errorf("Changes[%d] = %q, want %q", i, d.Changes[i].ResourceAddress, want) + } + } + + if len(d.Created) != 2 { + t.Errorf("Created len = %d, want 2", len(d.Created)) + } + if len(d.Deleted) != 1 { + t.Errorf("Deleted len = %d, want 1", len(d.Deleted)) + } + if len(d.Updated) != 1 { + t.Errorf("Updated len = %d, want 1", len(d.Updated)) + } + if len(d.Replaced) != 1 { + t.Errorf("Replaced len = %d, want 1", len(d.Replaced)) + } + if len(d.Skipped) != 0 { + t.Errorf("Skipped len = %d, want 0", len(d.Skipped)) + } + + // TopMovers: first 5 non-skipped, in delta order. + if len(d.TopMovers) != 5 { + t.Errorf("TopMovers len = %d, want 5", len(d.TopMovers)) + } + + // Confidence is the weakest non-skipped: Low (4 Low + 1 Medium). + if d.Confidence != pricing.ConfidenceLow { + t.Errorf("Confidence = %q, want low", d.Confidence) + } +} + +func TestAnalyze_AllSkipped(t *testing.T) { + p := plan( + rc("aws_iam_role.r1", "aws_iam_role", "create"), + rc("aws_iam_role.r2", "aws_iam_role", "create"), + rc("aws_iam_role.r3", "aws_iam_role", "delete"), + ) + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, ResourceType: "aws_iam_role", Currency: "USD", + Action: iac.ActionCreate, Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + Confidence: pricing.ConfidenceHigh, + } + } + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_iam_role.r1": mk("aws_iam_role.r1"), + "aws_iam_role.r2": mk("aws_iam_role.r2"), + "aws_iam_role.r3": mk("aws_iam_role.r3"), + }} + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if d.TotalMonthlyDelta != 0 { + t.Errorf("TotalMonthlyDelta = %v, want 0", d.TotalMonthlyDelta) + } + if d.Confidence != pricing.ConfidenceHigh { + t.Errorf("Confidence = %q, want high (no priceable changes)", d.Confidence) + } + if len(d.Skipped) != 3 { + t.Errorf("Skipped len = %d, want 3", len(d.Skipped)) + } + if len(d.TopMovers) != 0 { + t.Errorf("TopMovers len = %d, want 0", len(d.TopMovers)) + } + foundNote := false + for _, n := range d.Notes { + if strings.Contains(n, "No priceable resources") { + foundNote = true + } + } + if !foundNote { + t.Errorf("Notes missing 'No priceable resources': %v", d.Notes) + } +} + +func TestAnalyze_EstimationError(t *testing.T) { + p := plan( + rc("aws_instance.web", "aws_instance", "create"), + rc("aws_instance.broken", "aws_instance", "create"), + ) + fake := &fakeEstimator{ + results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", ResourceType: "aws_instance", + Action: iac.ActionCreate, AfterMonthly: 50, MonthlyDelta: 50, + Confidence: pricing.ConfidenceLow, Currency: "USD", + }, + }, + errors: map[string]error{ + "aws_instance.broken": errors.New("AccessDenied"), + }, + } + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + // The successful one is in Created; the broken one is in Skipped. + if len(d.Created) != 1 { + t.Errorf("Created len = %d, want 1", len(d.Created)) + } + if len(d.Skipped) != 1 { + t.Errorf("Skipped len = %d, want 1", len(d.Skipped)) + } + if got := d.Skipped[0].SkipReason; !strings.Contains(got, "estimation failed") || !strings.Contains(got, "AccessDenied") { + t.Errorf("SkipReason = %q, missing 'estimation failed'/'AccessDenied'", got) + } + if d.Skipped[0].ResourceAddress != "aws_instance.broken" { + t.Errorf("Skipped[0].Address = %q", d.Skipped[0].ResourceAddress) + } + + // Plan-wide note breaks down the skip reasons. + foundBreakdown := false + for _, n := range d.Notes { + if strings.Contains(n, "estimation failures") && strings.Contains(n, "1 estimation failures") { + foundBreakdown = true + } + } + if !foundBreakdown { + t.Errorf("Notes missing skip-breakdown: %v", d.Notes) + } +} + +func TestAnalyze_NoOp(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "no-op")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", ResourceType: "aws_instance", + Action: iac.ActionNoop, Currency: "USD", + Skipped: true, SkipReason: "action has no cost impact", + Confidence: pricing.ConfidenceHigh, + }, + }} + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(d.Created) != 0 || len(d.Updated) != 0 { + t.Errorf("no-op should not appear in priced slices: created=%d updated=%d", len(d.Created), len(d.Updated)) + } + if len(d.Skipped) != 1 { + t.Errorf("Skipped len = %d, want 1", len(d.Skipped)) + } + if d.Stats.NoOp != 1 { + t.Errorf("Stats.NoOp = %d, want 1", d.Stats.NoOp) + } + if d.Stats.Skipped != 0 { + t.Errorf("Stats.Skipped = %d, want 0 (no-op is counted under NoOp, not Skipped)", d.Stats.Skipped) + } +} + +func TestAnalyze_NetIncrease(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "create")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", Action: iac.ActionCreate, + AfterMonthly: 50, MonthlyDelta: 50, Confidence: pricing.ConfidenceLow, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if !containsNote(d.Notes, "Net cost increase") { + t.Errorf("expected 'Net cost increase' note, got: %v", d.Notes) + } +} + +func TestAnalyze_NetDecrease(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "delete")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", Action: iac.ActionDelete, + BeforeMonthly: 50, MonthlyDelta: -50, Confidence: pricing.ConfidenceLow, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if !containsNote(d.Notes, "Net cost reduction") { + t.Errorf("expected 'Net cost reduction' note, got: %v", d.Notes) + } +} + +func TestAnalyze_NetZero(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "update")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", Action: iac.ActionUpdate, + BeforeMonthly: 50, AfterMonthly: 50, MonthlyDelta: 0, Confidence: pricing.ConfidenceLow, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if !containsNote(d.Notes, "Net zero cost change") { + t.Errorf("expected 'Net zero cost change' note, got: %v", d.Notes) + } +} + +func TestAnalyze_TopMoversFiltersSkipped(t *testing.T) { + rcs := make([]iac.ResourceChange, 0, 10) + for i := 1; i <= 7; i++ { + rcs = append(rcs, rc("aws_instance."+string(rune('a'+i-1)), "aws_instance", "create")) + } + rcs = append(rcs, rc("aws_iam_role.r1", "aws_iam_role", "create")) + rcs = append(rcs, rc("aws_iam_role.r2", "aws_iam_role", "create")) + rcs = append(rcs, rc("aws_iam_role.r3", "aws_iam_role", "create")) + + results := map[string]pricing.ChangeEstimate{} + for i, r := range rcs { + isSkipped := r.Type == "aws_iam_role" + ce := pricing.ChangeEstimate{ + ResourceAddress: r.Address, ResourceType: r.Type, + Action: iac.ActionCreate, Currency: "USD", + Confidence: pricing.ConfidenceLow, + } + if isSkipped { + ce.Skipped = true + ce.SkipReason = "unsupported resource type: aws_iam_role" + } else { + ce.AfterMonthly = float64(i+1) * 10 + ce.MonthlyDelta = float64(i+1) * 10 + } + results[r.Address] = ce + } + fake := &fakeEstimator{results: results} + p := plan(rcs...) + + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(d.TopMovers) != TopMoversCount { + t.Errorf("TopMovers len = %d, want %d", len(d.TopMovers), TopMoversCount) + } + for _, m := range d.TopMovers { + if m.Skipped { + t.Errorf("TopMovers contains skipped entry: %+v", m) + } + } + // Ordered by abs delta descending: 70, 60, 50, 40, 30 + wantFirst := 70.0 + if math.Abs(d.TopMovers[0].MonthlyDelta-wantFirst) > 1e-9 { + t.Errorf("TopMovers[0].MonthlyDelta = %v, want %v", d.TopMovers[0].MonthlyDelta, wantFirst) + } +} + +func TestAnalyze_TopMoversCountClampedDown(t *testing.T) { + p := plan( + rc("aws_instance.a", "aws_instance", "create"), + rc("aws_instance.b", "aws_instance", "create"), + rc("aws_instance.c", "aws_instance", "create"), + ) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.a": {ResourceAddress: "aws_instance.a", Action: iac.ActionCreate, MonthlyDelta: 10, Confidence: pricing.ConfidenceLow}, + "aws_instance.b": {ResourceAddress: "aws_instance.b", Action: iac.ActionCreate, MonthlyDelta: 20, Confidence: pricing.ConfidenceLow}, + "aws_instance.c": {ResourceAddress: "aws_instance.c", Action: iac.ActionCreate, MonthlyDelta: 30, Confidence: pricing.ConfidenceLow}, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if len(d.TopMovers) != 3 { + t.Errorf("TopMovers len = %d, want 3 (only 3 changes exist)", len(d.TopMovers)) + } +} + +func TestAnalyze_NilPlan(t *testing.T) { + _, err := analyzeWithEstimator(context.Background(), nilSource{}, nil, "us-east-2", (&fakeEstimator{}).estimate) + if err == nil || !strings.Contains(err.Error(), "nil plan") { + t.Fatalf("err = %v", err) + } +} + +func TestAnalyze_EmptyRegion(t *testing.T) { + _, err := analyzeWithEstimator(context.Background(), nilSource{}, &iac.Plan{FormatVersion: "1.2"}, "", (&fakeEstimator{}).estimate) + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestAnalyze_NilSrc(t *testing.T) { + _, err := Analyze(context.Background(), nil, &iac.Plan{FormatVersion: "1.2"}, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil src") { + t.Fatalf("err = %v", err) + } +} + +func TestAnalyze_EmptyPlan(t *testing.T) { + d, err := analyzeWithEstimator(context.Background(), nilSource{}, &iac.Plan{FormatVersion: "1.2"}, "us-east-2", (&fakeEstimator{}).estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if d.Stats.Total != 0 { + t.Errorf("Stats.Total = %d, want 0", d.Stats.Total) + } + if d.Confidence != pricing.ConfidenceHigh { + t.Errorf("Confidence = %q, want high (no analysis to doubt)", d.Confidence) + } + for _, slice := range [][]pricing.ChangeEstimate{ + d.Changes, d.Created, d.Deleted, d.Updated, d.Replaced, d.Skipped, d.TopMovers, + } { + if len(slice) != 0 { + t.Errorf("expected empty slice, got %v", slice) + } + } + if !containsNote(d.Notes, "No priceable resources") { + t.Errorf("expected 'No priceable resources' note, got: %v", d.Notes) + } +} + +func TestStats_Counts(t *testing.T) { + p := plan( + rc("aws_instance.a", "aws_instance", "create"), + rc("aws_instance.b", "aws_instance", "delete"), + rc("aws_instance.c", "aws_instance", "update"), + iac.ResourceChange{ + Address: "aws_instance.d", Mode: "managed", Type: "aws_instance", + Change: iac.Change{Actions: []string{"delete", "create"}}, + }, + rc("aws_iam_role.r", "aws_iam_role", "create"), + rc("aws_instance.noop", "aws_instance", "no-op"), + ) + mkPriced := func(addr string, action iac.Action, delta float64) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, ResourceType: "aws_instance", Action: action, + MonthlyDelta: delta, Confidence: pricing.ConfidenceLow, + } + } + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.a": mkPriced("aws_instance.a", iac.ActionCreate, 100), + "aws_instance.b": mkPriced("aws_instance.b", iac.ActionDelete, -50), + "aws_instance.c": mkPriced("aws_instance.c", iac.ActionUpdate, 25), + "aws_instance.d": mkPriced("aws_instance.d", iac.ActionReplace, 10), + "aws_iam_role.r": { + ResourceAddress: "aws_iam_role.r", ResourceType: "aws_iam_role", + Action: iac.ActionCreate, Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + Confidence: pricing.ConfidenceHigh, + }, + "aws_instance.noop": { + ResourceAddress: "aws_instance.noop", ResourceType: "aws_instance", + Action: iac.ActionNoop, Skipped: true, + SkipReason: "action has no cost impact", Confidence: pricing.ConfidenceHigh, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + + want := Stats{Total: 6, Created: 1, Deleted: 1, Updated: 1, Replaced: 1, NoOp: 1, Skipped: 1, Priced: 4} + if d.Stats != want { + t.Errorf("Stats = %+v, want %+v", d.Stats, want) + } + // Disjoint partition invariant. + if d.Stats.Priced+d.Stats.NoOp+d.Stats.Skipped != d.Stats.Total { + t.Errorf("partition broken: priced=%d noop=%d skipped=%d total=%d", + d.Stats.Priced, d.Stats.NoOp, d.Stats.Skipped, d.Stats.Total) + } +} + +func TestClassifyAction(t *testing.T) { + cases := map[iac.Action]string{ + iac.ActionCreate: "create", + iac.ActionDelete: "delete", + iac.ActionUpdate: "update", + iac.ActionReplace: "replace", + iac.ActionNoop: "", + iac.ActionRead: "", + } + for a, want := range cases { + if got := classifyAction(a); got != want { + t.Errorf("classifyAction(%q) = %q, want %q", a, got, want) + } + } +} + +func containsNote(notes []string, sub string) bool { + for _, n := range notes { + if strings.Contains(n, sub) { + return true + } + } + return false +} diff --git a/internal/diff/markdown.go b/internal/diff/markdown.go new file mode 100644 index 0000000..bb803f4 --- /dev/null +++ b/internal/diff/markdown.go @@ -0,0 +1,497 @@ +package diff + +import ( + "bytes" + "fmt" + "math" + "strings" + "text/template" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// MarkdownConfig customizes the Markdown rendering. The zero value is +// valid and produces the default presentation: full breakdown shown, +// caveats shown, the canonical comment marker, the project repo URL, +// and Analyze-supplied TopMovers. +// +// Polarity note on Hide* booleans. Spec called for "Show*" flags with +// a true default, but Go cannot distinguish an unset bool from an +// explicit `false`, so a Show* design would force every caller that +// constructs MarkdownConfig{} to also remember to set Show* = true. We +// inverted to Hide* so the zero value naturally means "show +// everything". Setting HideFullBreakdown / HideCaveats to true at the +// call site has the same effect a hypothetical Show*=false would. +type MarkdownConfig struct { + CommentMarker string + RepoURL string + HideFullBreakdown bool + HideCaveats bool + TopMoversCount int +} + +const ( + defaultCommentMarker = "cloudoracle-pr-v1" + defaultRepoURL = "https://github.com/Cro22/CloudOracle" + + // centavoTolerance is the threshold below which a delta is treated + // as zero for sign-and-emoji purposes. Pricing deltas accumulate + // floating-point noise — a "zero" change can land at $0.0000001 — + // and a 🔴 emoji on a fraction-of-a-cent rounding error makes the + // PR comment look broken to humans. Half a cent is well below + // "anything a human cares about" without false-zeroing genuine + // pennies. + centavoTolerance = 0.005 +) + +// RenderMarkdown returns the CostDiff rendered as a self-contained +// Markdown comment suitable for posting on a GitHub pull request. Uses +// the default MarkdownConfig. +func RenderMarkdown(d CostDiff) string { + return RenderMarkdownWithConfig(d, MarkdownConfig{}) +} + +// RenderMarkdownWithConfig is RenderMarkdown with explicit configuration. +// See MarkdownConfig for the supported knobs. +func RenderMarkdownWithConfig(d CostDiff, cfg MarkdownConfig) string { + cfg = applyDefaults(cfg) + data := buildTemplateData(d, cfg) + var buf bytes.Buffer + if err := mdTemplate.Execute(&buf, data); err != nil { + // Should never trigger — the template is a hard-coded constant + // vetted at init() via template.Must. If a future edit slips a + // regression past tests, surface the failure inline instead of + // silently emitting a half-rendered comment. + return fmt.Sprintf("CloudOracle render error: %v", err) + } + return buf.String() +} + +func applyDefaults(cfg MarkdownConfig) MarkdownConfig { + if cfg.CommentMarker == "" { + cfg.CommentMarker = defaultCommentMarker + } + if cfg.RepoURL == "" { + cfg.RepoURL = defaultRepoURL + } + return cfg +} + +// templateData is the precomputed shape passed to the Markdown +// template. Every dynamic value is preformatted here so the template +// body stays free of formatting logic — easier to reason about +// whitespace and easier to swap when milestone 15 introduces an +// LLM-generated narrative. +type templateData struct { + NetChange string + TrendEmoji string + Narrative string + HasTopMovers bool + TopMovers []tplRow + ShowFullBreakdown bool + StatsPriced int + StatsSkippedTotal int + HasCreated bool + Created []tplRow + HasDeleted bool + Deleted []tplRow + HasUpdated bool + Updated []tplRow + HasReplaced bool + Replaced []tplRow + HasSkipped bool + Skipped []tplSkipRow + ShowCaveats bool + GlobalNotes []string + PerResourceNotes []tplResourceNote + AggregateConfidence string + RepoURL string + CommentMarker string +} + +type tplRow struct { + Address string + Action string + Delta string + Confidence string + Breakdown []tplLine +} + +type tplLine struct { + Component string + Cost string +} + +type tplSkipRow struct { + Address string + Type string + SkipReason string +} + +type tplResourceNote struct { + Note string + Addresses []string +} + +func buildTemplateData(d CostDiff, cfg MarkdownConfig) templateData { + data := templateData{ + NetChange: formatDelta(d.TotalMonthlyDelta), + TrendEmoji: trendEmoji(d.TotalMonthlyDelta), + Narrative: renderNarrative(d), + ShowFullBreakdown: !cfg.HideFullBreakdown, + ShowCaveats: !cfg.HideCaveats, + StatsPriced: d.Stats.Priced, + StatsSkippedTotal: len(d.Skipped), + AggregateConfidence: string(d.Confidence), + RepoURL: cfg.RepoURL, + CommentMarker: cfg.CommentMarker, + } + + movers := selectTopMovers(d.TopMovers, cfg.TopMoversCount) + for _, m := range movers { + data.TopMovers = append(data.TopMovers, makeRow(m)) + } + data.HasTopMovers = len(data.TopMovers) > 0 + + for _, c := range d.Created { + data.Created = append(data.Created, makeRow(c)) + } + data.HasCreated = len(data.Created) > 0 + for _, c := range d.Deleted { + data.Deleted = append(data.Deleted, makeRow(c)) + } + data.HasDeleted = len(data.Deleted) > 0 + for _, c := range d.Updated { + data.Updated = append(data.Updated, makeRow(c)) + } + data.HasUpdated = len(data.Updated) > 0 + for _, c := range d.Replaced { + data.Replaced = append(data.Replaced, makeRow(c)) + } + data.HasReplaced = len(data.Replaced) > 0 + + for _, c := range d.Skipped { + data.Skipped = append(data.Skipped, tplSkipRow{ + Address: c.ResourceAddress, + Type: c.ResourceType, + SkipReason: c.SkipReason, + }) + } + data.HasSkipped = len(data.Skipped) > 0 + + data.GlobalNotes = append([]string(nil), d.Notes...) + data.PerResourceNotes = deduplicateNotes(d.Changes) + + return data +} + +// selectTopMovers honours cfg.TopMoversCount: 0 (default) means "use +// what Analyze gave us", positive caps the count, negative selects +// nothing (a foot-gun guard rather than a meaningful feature). +func selectTopMovers(in []pricing.ChangeEstimate, n int) []pricing.ChangeEstimate { + switch { + case n < 0: + return nil + case n == 0: + return in + case n > len(in): + return in + default: + return in[:n] + } +} + +func makeRow(c pricing.ChangeEstimate) tplRow { + row := tplRow{ + Address: c.ResourceAddress, + Action: actionDisplay(c.Action), + Delta: formatDelta(c.MonthlyDelta), + Confidence: string(c.Confidence), + } + for _, li := range c.Breakdown { + row.Breakdown = append(row.Breakdown, tplLine{ + Component: li.Component, + Cost: formatDelta(li.MonthlyUSD), + }) + } + return row +} + +// deduplicateNotes groups identical Notes texts across resources so a +// caveat list shared by N resources renders as one bullet instead of N. +// The order of unique notes follows first-seen-in-Changes; addresses +// inside each note follow first-seen order; both matter for stable +// golden tests. +func deduplicateNotes(changes []pricing.ChangeEstimate) []tplResourceNote { + byNote := map[string][]string{} + var order []string + for _, c := range changes { + for _, n := range c.Notes { + if _, ok := byNote[n]; !ok { + order = append(order, n) + } + // Addresses are deduped too — repeating the same address + // for the same note (which only happens via parser bugs) + // would clutter the rendered list silently. + addrs := byNote[n] + seen := false + for _, a := range addrs { + if a == c.ResourceAddress { + seen = true + break + } + } + if !seen { + byNote[n] = append(addrs, c.ResourceAddress) + } + } + } + out := make([]tplResourceNote, 0, len(order)) + for _, n := range order { + out = append(out, tplResourceNote{Note: n, Addresses: byNote[n]}) + } + return out +} + +// formatDelta renders a monetary value with explicit sign for a PR +// comment. Conventions: +// +// - positive → "+$60.74" +// - negative → "-$50.00" +// - near-zero → "$0.00" (no sign — applied within centavoTolerance to +// avoid surfacing floating-point noise as a real change) +// +// Always two decimals, always a dollar sign, never scientific notation. +func formatDelta(d float64) string { + if math.Abs(d) < centavoTolerance { + return "$0.00" + } + if d > 0 { + return fmt.Sprintf("+$%.2f", d) + } + return fmt.Sprintf("-$%.2f", math.Abs(d)) +} + +// trendEmoji returns the colored circle that pairs with the net delta +// in the comment header. Within centavoTolerance the change is treated +// as zero (⚪) — a sub-cent fluctuation should not draw a red dot. +func trendEmoji(d float64) string { + if math.Abs(d) < centavoTolerance { + return "⚪" + } + if d > 0 { + return "🔴" + } + return "🟢" +} + +// actionDisplay returns the label shown in the Top movers table for an +// action. The emojis are deliberately verbose — a PR reviewer scanning +// a long comment recognises 🔄 replace much faster than the bare word. +// Unknown action strings pass through verbatim with no emoji rather +// than masking a parser issue with a generic icon. +func actionDisplay(a iac.Action) string { + switch a { + case iac.ActionCreate: + return "🆕 create" + case iac.ActionDelete: + return "❌ delete" + case iac.ActionUpdate: + return "♻️ update" + case iac.ActionReplace: + return "🔄 replace" + case iac.ActionNoop: + return "⏭️ no-op" + case iac.ActionRead: + return "⏭️ read" + } + return string(a) +} + +// renderNarrative produces the prose paragraph between the header and +// the top-movers table. This is the function milestone 15 will replace +// with an LLM call: the contract is "input CostDiff, output 1-3 +// sentences in Markdown, no headings or lists". +// +// Today's templated logic walks the action stats and the delta sign to +// build a single sentence, optionally appended with an estimation- +// failure count when relevant. +func renderNarrative(d CostDiff) string { + if d.Stats.Priced == 0 { + return "No priceable resources in this plan." + } + + var verbs []string + if d.Stats.Created > 0 { + verbs = append(verbs, fmt.Sprintf("adds %d %s", d.Stats.Created, plural("resource", d.Stats.Created))) + } + if d.Stats.Deleted > 0 { + verbs = append(verbs, fmt.Sprintf("removes %d", d.Stats.Deleted)) + } + if d.Stats.Updated > 0 { + verbs = append(verbs, fmt.Sprintf("updates %d", d.Stats.Updated)) + } + if d.Stats.Replaced > 0 { + verbs = append(verbs, fmt.Sprintf("replaces %d", d.Stats.Replaced)) + } + actions := joinClauses(verbs) + + var direction string + switch { + case math.Abs(d.TotalMonthlyDelta) < centavoTolerance: + direction = "no net cost change" + case d.TotalMonthlyDelta > 0: + direction = "a net monthly cost increase of " + formatDelta(d.TotalMonthlyDelta) + default: + direction = "a net monthly cost decrease of " + formatDelta(d.TotalMonthlyDelta) + } + + sentence := fmt.Sprintf("This plan %s, with %s.", actions, direction) + + estFail := 0 + for _, s := range d.Skipped { + if strings.Contains(s.SkipReason, "estimation failed") { + estFail++ + } + } + if estFail > 0 { + sentence += fmt.Sprintf(" %d %s could not be priced.", estFail, plural("resource", estFail)) + } + + return sentence +} + +func plural(word string, n int) string { + if n == 1 { + return word + } + return word + "s" +} + +// joinClauses produces an Oxford-comma list. Empty input is an empty +// string; one element returns itself; two are joined by " and "; three +// or more by ", " with " and " before the last. +func joinClauses(parts []string) string { + switch len(parts) { + case 0: + return "" + case 1: + return parts[0] + case 2: + return parts[0] + " and " + parts[1] + } + return strings.Join(parts[:len(parts)-1], ", ") + ", and " + parts[len(parts)-1] +} + +// addressList formats a list of resource addresses inline as +// `addr1`, `addr2`, ... — used inside the per-resource caveat bullets. +func addressList(addrs []string) string { + var sb strings.Builder + for i, a := range addrs { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteByte('`') + sb.WriteString(a) + sb.WriteByte('`') + } + return sb.String() +} + +var mdTemplate = template.Must(template.New("md").Funcs(template.FuncMap{ + "addressList": addressList, +}).Parse(markdownTemplate)) + +// markdownTemplate is the canonical layout for a CostDiff rendered as a +// PR comment. Whitespace is fiddly: blank lines are needed between +// Markdown sections (otherwise GitHub merges paragraphs), and trim +// markers on `{{- }}` actions are used to keep the template readable +// without injecting spurious indentation. If you change this, run +// `go test -update ./internal/diff/...` to refresh the goldens and +// eyeball the diff. +const markdownTemplate = "## 💰 Cloud Cost Impact\n" + + "\n" + + "**Net monthly change: {{.NetChange}}** {{.TrendEmoji}}\n" + + "\n" + + "{{.Narrative}}\n" + + "{{- if .HasTopMovers}}\n" + + "\n" + + "### Top movers by cost impact\n" + + "\n" + + "| Resource | Action | Δ Monthly | Confidence |\n" + + "|----------|--------|-----------|------------|\n" + + "{{- range .TopMovers}}\n" + + "| `{{.Address}}` | {{.Action}} | {{.Delta}} | {{.Confidence}} |\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .ShowFullBreakdown}}\n" + + "\n" + + "
\n" + + "📋 Full breakdown ({{.StatsPriced}} priced, {{.StatsSkippedTotal}} skipped)\n" + + "{{- if .HasCreated}}\n" + + "\n" + + "#### Created ({{len .Created}})\n" + + "{{range .Created}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasDeleted}}\n" + + "\n" + + "#### Deleted ({{len .Deleted}})\n" + + "{{range .Deleted}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasUpdated}}\n" + + "\n" + + "#### Updated ({{len .Updated}})\n" + + "{{range .Updated}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasReplaced}}\n" + + "\n" + + "#### Replaced ({{len .Replaced}})\n" + + "{{range .Replaced}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasSkipped}}\n" + + "\n" + + "#### Skipped ({{len .Skipped}})\n" + + "{{range .Skipped}}\n" + + "- `{{.Address}}` ({{.Type}}) — {{.SkipReason}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "\n" + + "
\n" + + "{{- end}}\n" + + "{{- if .ShowCaveats}}\n" + + "\n" + + "
\n" + + "⚠️ Assumptions and caveats\n" + + "\n" + + "{{range .GlobalNotes}}- {{.}}\n{{end}}" + + "{{range .PerResourceNotes}}- {{.Note}} _(applies to: {{addressList .Addresses}})_\n{{end}}" + + "\n" + + "
\n" + + "{{- end}}\n" + + "\n" + + "---\n" + + "\n" + + "Generated by [CloudOracle]({{.RepoURL}}) · Confidence: **{{.AggregateConfidence}}**\n" + + "\n" + + "\n" diff --git a/internal/diff/markdown_test.go b/internal/diff/markdown_test.go new file mode 100644 index 0000000..5a926c4 --- /dev/null +++ b/internal/diff/markdown_test.go @@ -0,0 +1,553 @@ +package diff + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// updateGoldens regenerates the testdata/markdown_*.md fixtures from +// the current renderer output. Run with `go test -update +// ./internal/diff/...` after an intentional template change. Without +// the flag, tests compare against the existing files. +var updateGoldens = flag.Bool("update", false, "update golden Markdown files") + +// goldenCheck renders the diff with default config and compares against +// or rewrites the golden file. The comparison is byte-exact — Markdown +// whitespace matters because GitHub renders subtle differences (extra +// blank line collapses paragraphs into one). +func goldenCheck(t *testing.T, name string, d CostDiff) { + t.Helper() + got := RenderMarkdown(d) + path := filepath.Join("testdata", name) + if *updateGoldens { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("writing golden %q: %v", path, err) + } + return + } + wantBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading golden %q: %v (run with -update to create it)", path, err) + } + want := string(wantBytes) + if got != want { + t.Errorf("output differs from %s\n--- got ---\n%s\n--- want ---\n%s", + path, got, want) + } +} + +// ce builds a ChangeEstimate test fixture. Keeps the test cases short. +func ce(addr, typ string, action iac.Action, delta float64, conf pricing.Confidence) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: typ, + Action: action, + MonthlyDelta: delta, + Currency: "USD", + Confidence: conf, + } +} + +// withBreakdown wraps a ChangeEstimate adding line items so the full +// breakdown section has something interesting to render. +func withBreakdown(c pricing.ChangeEstimate, items ...pricing.LineItem) pricing.ChangeEstimate { + c.Breakdown = append([]pricing.LineItem(nil), items...) + return c +} + +func withNotes(c pricing.ChangeEstimate, notes ...string) pricing.ChangeEstimate { + c.Notes = append([]string(nil), notes...) + return c +} + +// happyPathDiff mimics the checkpoint-13.5 output: 6 resources, all +// creates, $389.35 total. This is the canonical PR-comment shape. +func happyPathDiff() CostDiff { + web := withNotes( + withBreakdown( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 64.74, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 60.74}, + pricing.LineItem{Component: "RootEBS", MonthlyUSD: 4.00}, + ), + "Operating system assumed Linux (plan does not specify)", + "Pricing assumes On-Demand (no Reserved Instances or Savings Plans)", + ) + web.AfterMonthly = 64.74 + + db := withNotes( + withBreakdown( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 71.36, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 59.86}, + pricing.LineItem{Component: "Storage", MonthlyUSD: 11.50}, + ), + "License: No license required (postgres/mysql/mariadb)", + ) + db.AfterMonthly = 71.36 + + disk := withNotes( + withBreakdown( + ce("aws_ebs_volume.disk", "aws_ebs_volume", iac.ActionCreate, 16.00, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Storage", MonthlyUSD: 16.00}, + ), + "IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s)", + ) + disk.AfterMonthly = 16.00 + + fn := withNotes( + ce("aws_lambda_function.fn", "aws_lambda_function", iac.ActionCreate, 0, pricing.ConfidenceLow), + "Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled", + ) + + nat := withNotes( + withBreakdown( + ce("aws_nat_gateway.nat", "aws_nat_gateway", iac.ActionCreate, 32.85, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Gateway", MonthlyUSD: 32.85}, + ), + "Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled", + ) + nat.AfterMonthly = 32.85 + + aurora := withNotes( + withBreakdown( + ce("aws_rds_cluster_instance.aurora", "aws_rds_cluster_instance", iac.ActionCreate, 204.40, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 204.40}, + ), + "Cluster-level storage and I/O charges not included (priced at aws_rds_cluster)", + "Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag", + "Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled", + ) + aurora.AfterMonthly = 204.40 + + all := []pricing.ChangeEstimate{aurora, db, web, nat, disk, fn} // sorted by abs delta desc + + return CostDiff{ + TotalMonthlyDelta: 389.35, + Currency: "USD", + Changes: all, + Created: all, + TopMovers: all[:5], + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost increase this plan"}, + Stats: Stats{ + Total: 6, Created: 6, Priced: 6, + }, + } +} + +func TestRenderMarkdown_HappyPath(t *testing.T) { + goldenCheck(t, "markdown_happy_path.md", happyPathDiff()) +} + +func TestRenderMarkdown_NetDecrease(t *testing.T) { + web := withBreakdown( + ce("aws_instance.web", "aws_instance", iac.ActionDelete, -64.74, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: -60.74}, + pricing.LineItem{Component: "RootEBS", MonthlyUSD: -4.00}, + ) + web.BeforeMonthly = 64.74 + disk := withBreakdown( + ce("aws_ebs_volume.disk", "aws_ebs_volume", iac.ActionDelete, -16.00, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Storage", MonthlyUSD: -16.00}, + ) + disk.BeforeMonthly = 16.00 + + all := []pricing.ChangeEstimate{web, disk} + d := CostDiff{ + TotalMonthlyDelta: -80.74, + Currency: "USD", + Changes: all, + Deleted: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost reduction this plan"}, + Stats: Stats{Total: 2, Deleted: 2, Priced: 2}, + } + goldenCheck(t, "markdown_net_decrease.md", d) +} + +func TestRenderMarkdown_NetZero(t *testing.T) { + // Replace where before == after price. Common when changing tags + // or other non-cost attributes. + a := withBreakdown( + ce("aws_instance.a", "aws_instance", iac.ActionReplace, 0, pricing.ConfidenceLow), + ) + a.BeforeMonthly = 50 + a.AfterMonthly = 50 + + all := []pricing.ChangeEstimate{a} + d := CostDiff{ + TotalMonthlyDelta: 0, + Currency: "USD", + Changes: all, + Replaced: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net zero cost change"}, + Stats: Stats{Total: 1, Replaced: 1, Priced: 1}, + } + goldenCheck(t, "markdown_net_zero.md", d) +} + +func TestRenderMarkdown_EmptyPlan(t *testing.T) { + d := CostDiff{ + Currency: "USD", + Confidence: pricing.ConfidenceHigh, + Notes: []string{"No priceable resources in plan"}, + Stats: Stats{}, + } + goldenCheck(t, "markdown_empty_plan.md", d) +} + +func TestRenderMarkdown_AllSkipped(t *testing.T) { + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: "aws_iam_role", + Action: iac.ActionCreate, + Currency: "USD", + Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + Confidence: pricing.ConfidenceHigh, + } + } + all := []pricing.ChangeEstimate{ + mk("aws_iam_role.r1"), mk("aws_iam_role.r2"), mk("aws_iam_role.r3"), + } + d := CostDiff{ + Currency: "USD", + Changes: all, + Skipped: all, + Confidence: pricing.ConfidenceHigh, + Notes: []string{ + "3 resources skipped (3 unsupported types, 0 estimation failures)", + "No priceable resources in plan", + }, + Stats: Stats{Total: 3, Skipped: 3}, + } + goldenCheck(t, "markdown_all_skipped.md", d) +} + +func TestRenderMarkdown_WithEstimationErrors(t *testing.T) { + web := withBreakdown( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 64.74, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 60.74}, + pricing.LineItem{Component: "RootEBS", MonthlyUSD: 4.00}, + ) + web.AfterMonthly = 64.74 + + broken := pricing.ChangeEstimate{ + ResourceAddress: "aws_instance.broken", + ResourceType: "aws_instance", + Action: iac.ActionCreate, + Currency: "USD", + Skipped: true, + SkipReason: "estimation failed: AccessDenied calling pricing API", + Confidence: pricing.ConfidenceHigh, + } + d := CostDiff{ + TotalMonthlyDelta: 64.74, + Currency: "USD", + Changes: []pricing.ChangeEstimate{web, broken}, + Created: []pricing.ChangeEstimate{web}, + Skipped: []pricing.ChangeEstimate{broken}, + TopMovers: []pricing.ChangeEstimate{web}, + Confidence: pricing.ConfidenceLow, + Notes: []string{ + "1 resources skipped (0 unsupported types, 1 estimation failures)", + "Net cost increase this plan", + }, + Stats: Stats{Total: 2, Created: 1, Skipped: 1, Priced: 1}, + } + goldenCheck(t, "markdown_with_estimation_errors.md", d) +} + +func TestRenderMarkdown_MixedActions(t *testing.T) { + cre := withBreakdown( + ce("aws_instance.new", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 100}, + ) + del := withBreakdown( + ce("aws_ebs_volume.old", "aws_ebs_volume", iac.ActionDelete, -16, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Storage", MonthlyUSD: -16}, + ) + upd := withBreakdown( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionUpdate, 25, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 25}, + pricing.LineItem{Component: "Storage", MonthlyUSD: 0}, + ) + rep := withBreakdown( + ce("aws_lambda_function.fn", "aws_lambda_function", iac.ActionReplace, 5, pricing.ConfidenceLow), + pricing.LineItem{Component: "ProvisionedConcurrency", MonthlyUSD: 5}, + ) + // Sorted by abs delta: 100, 25, -16, 5 + sorted := []pricing.ChangeEstimate{cre, upd, del, rep} + d := CostDiff{ + TotalMonthlyDelta: 114, + Currency: "USD", + Changes: sorted, + Created: []pricing.ChangeEstimate{cre}, + Deleted: []pricing.ChangeEstimate{del}, + Updated: []pricing.ChangeEstimate{upd}, + Replaced: []pricing.ChangeEstimate{rep}, + TopMovers: sorted, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost increase this plan"}, + Stats: Stats{ + Total: 4, Created: 1, Deleted: 1, Updated: 1, Replaced: 1, Priced: 4, + }, + } + goldenCheck(t, "markdown_mixed_actions.md", d) +} + +func TestFormatDelta(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "$0.00"}, + {0.001, "$0.00"}, + {-0.001, "$0.00"}, + {0.004, "$0.00"}, + {0.005, "+$0.01"}, + {-0.005, "-$0.01"}, + {60.74, "+$60.74"}, + {-50.0, "-$50.00"}, + {1234.567, "+$1234.57"}, + } + for _, c := range cases { + if got := formatDelta(c.in); got != c.want { + t.Errorf("formatDelta(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestTrendEmoji(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "⚪"}, + {0.001, "⚪"}, + {-0.001, "⚪"}, + {0.005, "🔴"}, + {50, "🔴"}, + {-50, "🟢"}, + } + for _, c := range cases { + if got := trendEmoji(c.in); got != c.want { + t.Errorf("trendEmoji(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestActionDisplay(t *testing.T) { + cases := map[iac.Action]string{ + iac.ActionCreate: "🆕 create", + iac.ActionDelete: "❌ delete", + iac.ActionUpdate: "♻️ update", + iac.ActionReplace: "🔄 replace", + iac.ActionNoop: "⏭️ no-op", + iac.ActionRead: "⏭️ read", + } + for a, want := range cases { + if got := actionDisplay(a); got != want { + t.Errorf("actionDisplay(%q) = %q, want %q", a, got, want) + } + } + // Unknown action passes through. + if got := actionDisplay(iac.Action("import")); got != "import" { + t.Errorf("actionDisplay(import) = %q, want literal pass-through", got) + } +} + +func TestMarkdownConfig_Defaults(t *testing.T) { + d := happyPathDiff() + a := RenderMarkdown(d) + b := RenderMarkdownWithConfig(d, MarkdownConfig{}) + if a != b { + t.Errorf("zero MarkdownConfig should produce same output as RenderMarkdown") + } +} + +func TestMarkdownConfig_CustomMarker(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{CommentMarker: "my-custom-marker"}) + if !strings.Contains(out, "") { + t.Errorf("custom marker not in output") + } + if strings.Contains(out, "") { + t.Errorf("default marker leaked into output") + } +} + +func TestMarkdownConfig_HideBreakdown(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{HideFullBreakdown: true}) + if strings.Contains(out, "Full breakdown") { + t.Errorf("HideFullBreakdown=true did not omit the section") + } + // Caveats still present (only Breakdown was hidden). + if !strings.Contains(out, "Assumptions and caveats") { + t.Errorf("Caveats section unexpectedly missing") + } +} + +func TestMarkdownConfig_HideCaveats(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{HideCaveats: true}) + if strings.Contains(out, "Assumptions and caveats") { + t.Errorf("HideCaveats=true did not omit the section") + } + if !strings.Contains(out, "Full breakdown") { + t.Errorf("Breakdown section unexpectedly missing") + } +} + +func TestMarkdownConfig_TopMoversCountClampsDown(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{TopMoversCount: 2}) + // Count rows in the top-movers table by counting the table-row prefix. + rows := strings.Count(out, "\n| `aws_") + if rows != 2 { + t.Errorf("got %d top-movers rows, want 2", rows) + } +} + +func TestRenderNarrative_AllShapes(t *testing.T) { + cases := []struct { + name string + d CostDiff + want string + }{ + { + name: "no priceable", + d: CostDiff{Stats: Stats{Total: 3, Skipped: 3}}, + want: "No priceable resources in this plan.", + }, + { + name: "increase one create", + d: CostDiff{ + TotalMonthlyDelta: 50, + Stats: Stats{Created: 1, Priced: 1}, + }, + want: "This plan adds 1 resource, with a net monthly cost increase of +$50.00.", + }, + { + name: "decrease one delete", + d: CostDiff{ + TotalMonthlyDelta: -50, + Stats: Stats{Deleted: 1, Priced: 1}, + }, + want: "This plan removes 1, with a net monthly cost decrease of -$50.00.", + }, + { + name: "zero with replace", + d: CostDiff{ + TotalMonthlyDelta: 0, + Stats: Stats{Replaced: 1, Priced: 1}, + }, + want: "This plan replaces 1, with no net cost change.", + }, + { + name: "with estimation failures", + d: CostDiff{ + TotalMonthlyDelta: 50, + Stats: Stats{Created: 1, Priced: 1, Skipped: 1}, + Skipped: []pricing.ChangeEstimate{ + {SkipReason: "estimation failed: timeout"}, + }, + }, + want: "This plan adds 1 resource, with a net monthly cost increase of +$50.00. 1 resource could not be priced.", + }, + { + name: "mixed verbs all four", + d: CostDiff{ + TotalMonthlyDelta: 100, + Stats: Stats{Created: 2, Deleted: 1, Updated: 3, Replaced: 1, Priced: 7}, + }, + want: "This plan adds 2 resources, removes 1, updates 3, and replaces 1, with a net monthly cost increase of +$100.00.", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := renderNarrative(c.d); got != c.want { + t.Errorf("got:\n %q\nwant:\n %q", got, c.want) + } + }) + } +} + +// TestRenderMarkdown_BlocksAreSeparatedByBlankLines guards against a +// past regression where text/template's `{{- range}}` trim swallowed +// the newline after section headings, causing GitHub Markdown to +// coalesce blocks (the table absorbed the preceding header,
+// rendered as literal HTML, etc). Each marker below is the start of a +// distinct Markdown block; in a well-formed comment, each one must be +// preceded by a blank line ("\n\n") so GitHub renders them separately. +func TestRenderMarkdown_BlocksAreSeparatedByBlankLines(t *testing.T) { + out := RenderMarkdown(happyPathDiff()) + + // "---\n" (rule line ending) avoids colliding with the table-separator + // row "|----------|...|" which embeds "---" but never "---\n". + markers := []string{ + "**Net monthly change", + "### Top movers", + "| Resource | Action", + "
", + "---\n", + "", + "") { + t.Errorf("custom CommentMarker missing") + } + if !strings.Contains(out, "Aurora drives this.") { + t.Errorf("LLM narrative missing under custom config") + } +} + +// --- Compile-time check that fakeProvider satisfies llm.Provider --- + +var _ llm.Provider = (*fakeProvider)(nil) + +// --- cleanNarrative direct unit tests --- + +func TestCleanNarrative(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {" ", ""}, + {"\n\n\t \n", ""}, + {"clean text", "clean text"}, + {" padded ", "padded"}, + {"Here is the narrative: ACTUAL", "ACTUAL"}, + {"here's the narrative: ACTUAL", "ACTUAL"}, + {"Sure, ACTUAL", "ACTUAL"}, + {"Of course. ACTUAL", "ACTUAL"}, + // Preamble matching is anchored at the start; an inline "Sure" doesn't strip. + {"Pricing is sure to surprise.", "Pricing is sure to surprise."}, + } + for _, c := range cases { + if got := cleanNarrative(c.in); got != c.want { + t.Errorf("cleanNarrative(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDirectionWord(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "neutral"}, + {0.001, "neutral"}, + {-0.001, "neutral"}, + {50, "increase"}, + {-50, "decrease (savings)"}, + } + for _, c := range cases { + if got := directionWord(c.in); got != c.want { + t.Errorf("directionWord(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestStatsSummary(t *testing.T) { + cases := []struct { + in Stats + want string + }{ + {Stats{}, "no changes"}, + {Stats{Created: 1}, "1 created"}, + {Stats{Created: 2, Deleted: 1, Skipped: 3}, "2 created, 1 deleted, 3 skipped"}, + {Stats{Updated: 5, Replaced: 2}, "5 updated, 2 replaced"}, + } + for _, c := range cases { + if got := statsSummary(c.in); got != c.want { + t.Errorf("statsSummary(%+v) = %q, want %q", c.in, got, c.want) + } + } +} + +// --- Caveat grouping (hotfix to prevent caveat hallucination) --- + +// caveatGroupedDiff returns a deterministic CostDiff used by the +// grouping tests below. Primary driver = aws_instance.web (top mover); +// "other" = aws_db_instance.db with one note; one plan-wide note. +func caveatGroupedDiff() CostDiff { + web := withNotes( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow), + "Operating system assumed Linux", + "Pricing assumes On-Demand", + ) + db := withNotes( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "License: No license required", + ) + all := []pricing.ChangeEstimate{web, db} + return CostDiff{ + TotalMonthlyDelta: 150, + Currency: "USD", + Changes: all, + Created: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost increase this plan"}, + Stats: Stats{Total: 2, Created: 2, Priced: 2}, + } +} + +func TestBuildPRNarrativePrompt_CaveatsAreGroupedByResource(t *testing.T) { + prompt := BuildPRNarrativePrompt(caveatGroupedDiff()) + + // Sub-block 1: primary header names the address explicitly. + if !strings.Contains(prompt, "# Notable assumptions for the primary driver (aws_instance.web)") { + t.Errorf("primary-driver heading missing or wrong; got:\n%s", prompt) + } + // Primary's own notes appear without an address prefix (the heading + // already binds them). + for _, want := range []string{"- Operating system assumed Linux", "- Pricing assumes On-Demand"} { + if !strings.Contains(prompt, want) { + t.Errorf("primary note %q missing", want) + } + } + + // Sub-block 2: other resources are explicitly attributed via prefix. + if !strings.Contains(prompt, "# Other notable assumptions (do NOT attribute to the primary driver)") { + t.Errorf("'other' heading missing") + } + if !strings.Contains(prompt, "- aws_db_instance.db: License: No license required") { + t.Errorf("'other' note missing its address prefix; got:\n%s", prompt) + } + + // Critical anti-hallucination check: the primary driver's address + // must not appear inside the "Other" sub-block (would defeat the + // purpose of the split). + otherIdx := strings.Index(prompt, "# Other notable assumptions") + planIdx := strings.Index(prompt, "# Plan-wide notes") + if otherIdx < 0 || planIdx < 0 || planIdx < otherIdx { + t.Fatalf("section ordering wrong (other=%d plan=%d)", otherIdx, planIdx) + } + otherBlock := prompt[otherIdx:planIdx] + if strings.Contains(otherBlock, "aws_instance.web:") { + t.Errorf("primary driver leaked into 'Other' block:\n%s", otherBlock) + } + + // Sub-block 3: plan-wide. + if !strings.Contains(prompt, "# Plan-wide notes\n- Net cost increase this plan") { + t.Errorf("plan-wide note missing or in wrong format") + } +} + +func TestBuildPRNarrativePrompt_NoNotesOmitsSection(t *testing.T) { + web := ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow) + all := []pricing.ChangeEstimate{web} + d := CostDiff{ + TotalMonthlyDelta: 100, + Changes: all, + Created: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Stats: Stats{Total: 1, Created: 1, Priced: 1}, + } + prompt := BuildPRNarrativePrompt(d) + for _, marker := range []string{ + "# Notable assumptions for the primary driver", + "# Other notable assumptions", + "# Plan-wide notes", + } { + if strings.Contains(prompt, marker) { + t.Errorf("expected %q to be omitted when there are no notes; got:\n%s", marker, prompt) + } + } +} + +func TestBuildPRNarrativePrompt_OnlyPrimaryHasNotes(t *testing.T) { + web := withNotes( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow), + "Linux assumed", + ) + db := ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 50, pricing.ConfidenceLow) + all := []pricing.ChangeEstimate{web, db} + d := CostDiff{ + TotalMonthlyDelta: 150, + Changes: all, + Created: all, + TopMovers: all, + Stats: Stats{Total: 2, Created: 2, Priced: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if !strings.Contains(prompt, "# Notable assumptions for the primary driver (aws_instance.web)") { + t.Errorf("primary heading missing") + } + if strings.Contains(prompt, "# Other notable assumptions") { + t.Errorf("'other' block should be omitted when no other resource has notes") + } + if strings.Contains(prompt, "# Plan-wide notes") { + t.Errorf("plan-wide block should be omitted when d.Notes is empty") + } +} + +func TestBuildPRNarrativePrompt_OnlyOthersHaveNotes(t *testing.T) { + web := ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow) + db := withNotes( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "License assumed", + ) + all := []pricing.ChangeEstimate{web, db} + d := CostDiff{ + TotalMonthlyDelta: 150, + Changes: all, + Created: all, + TopMovers: all, + Stats: Stats{Total: 2, Created: 2, Priced: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if strings.Contains(prompt, "# Notable assumptions for the primary driver") { + t.Errorf("primary block should be omitted when primary has no notes") + } + if !strings.Contains(prompt, "# Other notable assumptions (do NOT attribute to the primary driver)") { + t.Errorf("'other' heading missing") + } + if !strings.Contains(prompt, "- aws_db_instance.db: License assumed") { + t.Errorf("'other' note missing its address prefix") + } +} + +func TestBuildPRNarrativePrompt_NoTopMovers(t *testing.T) { + // All-skipped plan: no TopMovers, so no primary driver. Only + // plan-wide notes should render. + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: "aws_iam_role", + Action: iac.ActionCreate, + Skipped: true, + SkipReason: "unsupported", + } + } + all := []pricing.ChangeEstimate{mk("aws_iam_role.r1"), mk("aws_iam_role.r2")} + d := CostDiff{ + Changes: all, + Skipped: all, + Notes: []string{"2 resources skipped (2 unsupported types, 0 estimation failures)"}, + Stats: Stats{Total: 2, Skipped: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if strings.Contains(prompt, "# Notable assumptions for the primary driver") { + t.Errorf("primary block should be omitted when there is no primary") + } + if strings.Contains(prompt, "# Other notable assumptions") { + t.Errorf("'other' block should be omitted when no resource carries per-resource notes") + } + if !strings.Contains(prompt, "# Plan-wide notes\n- 2 resources skipped") { + t.Errorf("plan-wide notes missing or malformed; got:\n%s", prompt) + } +} + +func TestBuildPRNarrativePrompt_ContainsBillingModelDoNot(t *testing.T) { + prompt := BuildPRNarrativePrompt(happyPathDiff()) + if !strings.Contains(prompt, "Suggest billing-model alternatives") { + t.Errorf("new billing-model DO NOT rule missing from prompt") + } + if !strings.Contains(prompt, "Reserved Instances") { + t.Errorf("billing-model rule should name the things it forbids (RI/SP/Spot)") + } +} diff --git a/internal/diff/testdata/markdown_all_skipped.md b/internal/diff/testdata/markdown_all_skipped.md new file mode 100644 index 0000000..05579be --- /dev/null +++ b/internal/diff/testdata/markdown_all_skipped.md @@ -0,0 +1,30 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: $0.00** ⚪ + +No priceable resources in this plan. + +
+📋 Full breakdown (0 priced, 3 skipped) + +#### Skipped (3) + +- `aws_iam_role.r1` (aws_iam_role) — unsupported resource type: aws_iam_role +- `aws_iam_role.r2` (aws_iam_role) — unsupported resource type: aws_iam_role +- `aws_iam_role.r3` (aws_iam_role) — unsupported resource type: aws_iam_role + +
+ +
+⚠️ Assumptions and caveats + +- 3 resources skipped (3 unsupported types, 0 estimation failures) +- No priceable resources in plan + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **high** + + diff --git a/internal/diff/testdata/markdown_empty_plan.md b/internal/diff/testdata/markdown_empty_plan.md new file mode 100644 index 0000000..7c4f5f1 --- /dev/null +++ b/internal/diff/testdata/markdown_empty_plan.md @@ -0,0 +1,23 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: $0.00** ⚪ + +No priceable resources in this plan. + +
+📋 Full breakdown (0 priced, 0 skipped) + +
+ +
+⚠️ Assumptions and caveats + +- No priceable resources in plan + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **high** + + diff --git a/internal/diff/testdata/markdown_happy_path.md b/internal/diff/testdata/markdown_happy_path.md new file mode 100644 index 0000000..8240fa3 --- /dev/null +++ b/internal/diff/testdata/markdown_happy_path.md @@ -0,0 +1,58 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: +$389.35** 🔴 + +This plan adds 6 resources, with a net monthly cost increase of +$389.35. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_rds_cluster_instance.aurora` | 🆕 create | +$204.40 | low | +| `aws_db_instance.db` | 🆕 create | +$71.36 | low | +| `aws_instance.web` | 🆕 create | +$64.74 | low | +| `aws_nat_gateway.nat` | 🆕 create | +$32.85 | medium | +| `aws_ebs_volume.disk` | 🆕 create | +$16.00 | medium | + +
+📋 Full breakdown (6 priced, 0 skipped) + +#### Created (6) + +- `aws_rds_cluster_instance.aurora` — +$204.40 + - Compute: +$204.40 +- `aws_db_instance.db` — +$71.36 + - Compute: +$59.86 + - Storage: +$11.50 +- `aws_instance.web` — +$64.74 + - Compute: +$60.74 + - RootEBS: +$4.00 +- `aws_nat_gateway.nat` — +$32.85 + - Gateway: +$32.85 +- `aws_ebs_volume.disk` — +$16.00 + - Storage: +$16.00 +- `aws_lambda_function.fn` — $0.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net cost increase this plan +- Cluster-level storage and I/O charges not included (priced at aws_rds_cluster) _(applies to: `aws_rds_cluster_instance.aurora`)_ +- Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag _(applies to: `aws_rds_cluster_instance.aurora`)_ +- Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled _(applies to: `aws_rds_cluster_instance.aurora`)_ +- License: No license required (postgres/mysql/mariadb) _(applies to: `aws_db_instance.db`)_ +- Operating system assumed Linux (plan does not specify) _(applies to: `aws_instance.web`)_ +- Pricing assumes On-Demand (no Reserved Instances or Savings Plans) _(applies to: `aws_instance.web`)_ +- Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled _(applies to: `aws_nat_gateway.nat`)_ +- IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s) _(applies to: `aws_ebs_volume.disk`)_ +- Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled _(applies to: `aws_lambda_function.fn`)_ + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + + diff --git a/internal/diff/testdata/markdown_mixed_actions.md b/internal/diff/testdata/markdown_mixed_actions.md new file mode 100644 index 0000000..2cc150d --- /dev/null +++ b/internal/diff/testdata/markdown_mixed_actions.md @@ -0,0 +1,53 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: +$114.00** 🔴 + +This plan adds 1 resource, removes 1, updates 1, and replaces 1, with a net monthly cost increase of +$114.00. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.new` | 🆕 create | +$100.00 | low | +| `aws_db_instance.db` | ♻️ update | +$25.00 | low | +| `aws_ebs_volume.old` | ❌ delete | -$16.00 | medium | +| `aws_lambda_function.fn` | 🔄 replace | +$5.00 | low | + +
+📋 Full breakdown (4 priced, 0 skipped) + +#### Created (1) + +- `aws_instance.new` — +$100.00 + - Compute: +$100.00 + +#### Deleted (1) + +- `aws_ebs_volume.old` — -$16.00 + - Storage: -$16.00 + +#### Updated (1) + +- `aws_db_instance.db` — +$25.00 + - Compute: +$25.00 + - Storage: $0.00 + +#### Replaced (1) + +- `aws_lambda_function.fn` — +$5.00 + - ProvisionedConcurrency: +$5.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net cost increase this plan + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + + diff --git a/internal/diff/testdata/markdown_net_decrease.md b/internal/diff/testdata/markdown_net_decrease.md new file mode 100644 index 0000000..df9659e --- /dev/null +++ b/internal/diff/testdata/markdown_net_decrease.md @@ -0,0 +1,38 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: -$80.74** 🟢 + +This plan removes 2, with a net monthly cost decrease of -$80.74. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.web` | ❌ delete | -$64.74 | low | +| `aws_ebs_volume.disk` | ❌ delete | -$16.00 | medium | + +
+📋 Full breakdown (2 priced, 0 skipped) + +#### Deleted (2) + +- `aws_instance.web` — -$64.74 + - Compute: -$60.74 + - RootEBS: -$4.00 +- `aws_ebs_volume.disk` — -$16.00 + - Storage: -$16.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net cost reduction this plan + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + + diff --git a/internal/diff/testdata/markdown_net_zero.md b/internal/diff/testdata/markdown_net_zero.md new file mode 100644 index 0000000..8badefc --- /dev/null +++ b/internal/diff/testdata/markdown_net_zero.md @@ -0,0 +1,33 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: $0.00** ⚪ + +This plan replaces 1, with no net cost change. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.a` | 🔄 replace | $0.00 | low | + +
+📋 Full breakdown (1 priced, 0 skipped) + +#### Replaced (1) + +- `aws_instance.a` — $0.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net zero cost change + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + + diff --git a/internal/diff/testdata/markdown_with_estimation_errors.md b/internal/diff/testdata/markdown_with_estimation_errors.md new file mode 100644 index 0000000..17d5458 --- /dev/null +++ b/internal/diff/testdata/markdown_with_estimation_errors.md @@ -0,0 +1,40 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: +$64.74** 🔴 + +This plan adds 1 resource, with a net monthly cost increase of +$64.74. 1 resource could not be priced. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.web` | 🆕 create | +$64.74 | low | + +
+📋 Full breakdown (1 priced, 1 skipped) + +#### Created (1) + +- `aws_instance.web` — +$64.74 + - Compute: +$60.74 + - RootEBS: +$4.00 + +#### Skipped (1) + +- `aws_instance.broken` (aws_instance) — estimation failed: AccessDenied calling pricing API + +
+ +
+⚠️ Assumptions and caveats + +- 1 resources skipped (0 unsupported types, 1 estimation failures) +- Net cost increase this plan + +
+ +--- + +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + + diff --git a/internal/diff/testdata/narrative_prompts/happy_path.txt b/internal/diff/testdata/narrative_prompts/happy_path.txt new file mode 100644 index 0000000..dda2222 --- /dev/null +++ b/internal/diff/testdata/narrative_prompts/happy_path.txt @@ -0,0 +1,43 @@ +You are reviewing a Terraform pull request as a senior cloud engineer. Your output is a 1-3 sentence narrative that will appear at the top of a PR comment, above a table that already shows the per-resource cost breakdown. + +# Cost change summary +Total monthly delta: +$389.35 +Direction: increase +Stats: 6 created + +# Top resources by impact +- aws_rds_cluster_instance.aurora (aws_rds_cluster_instance, action=create): +$204.40 per month — components: Compute +$204.40 +- aws_db_instance.db (aws_db_instance, action=create): +$71.36 per month — components: Compute +$59.86, Storage +$11.50 +- aws_instance.web (aws_instance, action=create): +$64.74 per month — components: Compute +$60.74, RootEBS +$4.00 + +# Notable assumptions for the primary driver (aws_rds_cluster_instance.aurora) +- Cluster-level storage and I/O charges not included (priced at aws_rds_cluster) +- Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag +- Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled + +# Other notable assumptions (do NOT attribute to the primary driver) +- aws_db_instance.db: License: No license required (postgres/mysql/mariadb) +- aws_instance.web: Operating system assumed Linux (plan does not specify) +- aws_instance.web: Pricing assumes On-Demand (no Reserved Instances or Savings Plans) +- aws_nat_gateway.nat: Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled +- aws_ebs_volume.disk: IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s) +- aws_lambda_function.fn: Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled + +# Plan-wide notes +- Net cost increase this plan + +# Your task +Write 1-3 sentences that: +1. Identify the PRIMARY DRIVER of cost change (do not summarize the table; pick the dominant resource and explain its weight). +2. If a clear lower-cost alternative exists for the primary driver (smaller instance class, different storage type, etc.), mention it as an "if X, consider Y" — never as a prescription. +3. Optionally note one risk if applicable (e.g., uncovered cost like data processing, license assumption that may not hold). + +DO NOT: +- Repeat the total monthly delta (it's already in the bold above your output). +- List resources by name unless they are the primary driver. +- Use cheerleading language ("great", "looks good", "concerning"). +- Use markdown headings or lists (your output is inline prose only). +- Suggest IaC changes ("you should add..."); only point out cost properties. +- Suggest billing-model alternatives (Reserved Instances, Savings Plans, Spot) — those are pricing levers, not cost-shape alternatives. Limit suggestions to architectural/sizing changes (different instance class, storage type, deployment shape). + +Output only the prose. No preamble. No "Here is the narrative:". Just the 1-3 sentences. \ No newline at end of file diff --git a/internal/diff/types.go b/internal/diff/types.go new file mode 100644 index 0000000..39774d6 --- /dev/null +++ b/internal/diff/types.go @@ -0,0 +1,92 @@ +// Package diff aggregates per-resource cost estimates from internal/pricing +// into a plan-wide picture. The output (CostDiff) is consumed by the Markdown +// renderer in milestone 14.2 and the LLM narrative layer in milestone 15. +// +// This package does NOT call the AWS Pricing API directly — it delegates to +// pricing.EstimateChange via an injected estimator. The split lets analysis +// logic (sorting, categorising, top-movers, notes) be tested without a Source +// or fixture JSON. +package diff + +import ( + "context" + + "CloudOracle/internal/pricing" +) + +// CostDiff is the aggregate cost impact of a single Terraform plan. +// +// Changes contains one entry per resource_change in the plan, including +// changes that could not be priced (those carry Skipped=true). Changes is +// sorted by absolute MonthlyDelta descending, so the most impactful items +// surface first; Skipped items have delta=0 and naturally sort last. +// +// Created, Deleted, Updated, Replaced, and Skipped are non-overlapping +// subsets of Changes (with one nuance: items with Skipped=true never +// appear in the action-keyed slices regardless of their Action — even an +// unsupported aws_iam_role with action=create lands in Skipped only). +// All five subsets share the same descending-by-abs-delta order as Changes. +// +// TopMovers is the leading slice of Changes (length min(TopMoversCount, +// non-skipped count)) used by the renderer for the "biggest changes" +// summary at the top of a PR comment. Skipped items are excluded from +// TopMovers because their delta of 0 carries no signal. +// +// Confidence is the weakest non-skipped confidence across Changes (low +// dominates medium dominates high). With no non-skipped changes, the +// default is ConfidenceHigh — there is nothing to be unsure about. +// +// Notes are plan-wide observations generated by Analyze itself, not +// inherited from individual ChangeEstimates: skip-count summaries, net +// direction of the plan, "no priceable resources" diagnostics, etc. +type CostDiff struct { + TotalMonthlyDelta float64 + Currency string + + Changes []pricing.ChangeEstimate + + Created []pricing.ChangeEstimate + Deleted []pricing.ChangeEstimate + Updated []pricing.ChangeEstimate + Replaced []pricing.ChangeEstimate + Skipped []pricing.ChangeEstimate + + TopMovers []pricing.ChangeEstimate + + Confidence pricing.Confidence + + Notes []string + + Stats Stats +} + +// Stats holds counts by category for quick summary rendering. The values +// form a disjoint partition of the plan: every change is counted in +// exactly one of {Created, Deleted, Updated, Replaced, NoOp, Skipped}, +// so Total = Created + Deleted + Updated + Replaced + NoOp + Skipped. +// +// NoOp counts no-op AND read actions (both have zero cost impact and no +// real "change" to price). Skipped counts the items that ended up in the +// Skipped slice MINUS those NoOp items — i.e. only the changes we +// genuinely could not price (unsupported types, data sources, +// estimation failures). Priced is a convenience alias for Created + +// Deleted + Updated + Replaced. +type Stats struct { + Total int + Created int + Deleted int + Updated int + Replaced int + NoOp int + Skipped int + Priced int +} + +// Source is anything that can serve raw Pricing API products. Both +// *pricing.Client (live calls) and *pricing.Cache (disk-cached) satisfy +// it. Defining the interface here — rather than importing pricing's +// unexported productGetter — keeps the diff package decoupled from the +// pricing package's internal types. +type Source interface { + GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) +} diff --git a/internal/diff/types_test.go b/internal/diff/types_test.go new file mode 100644 index 0000000..98ee756 --- /dev/null +++ b/internal/diff/types_test.go @@ -0,0 +1,28 @@ +package diff + +import ( + "testing" + + "CloudOracle/internal/pricing" +) + +func TestWeakestConfidence(t *testing.T) { + cases := []struct { + a, b, want pricing.Confidence + }{ + {pricing.ConfidenceHigh, pricing.ConfidenceHigh, pricing.ConfidenceHigh}, + {pricing.ConfidenceHigh, pricing.ConfidenceMedium, pricing.ConfidenceMedium}, + {pricing.ConfidenceMedium, pricing.ConfidenceHigh, pricing.ConfidenceMedium}, + {pricing.ConfidenceHigh, pricing.ConfidenceLow, pricing.ConfidenceLow}, + {pricing.ConfidenceLow, pricing.ConfidenceHigh, pricing.ConfidenceLow}, + {pricing.ConfidenceMedium, pricing.ConfidenceMedium, pricing.ConfidenceMedium}, + {pricing.ConfidenceMedium, pricing.ConfidenceLow, pricing.ConfidenceLow}, + {pricing.ConfidenceLow, pricing.ConfidenceMedium, pricing.ConfidenceLow}, + {pricing.ConfidenceLow, pricing.ConfidenceLow, pricing.ConfidenceLow}, + } + for _, c := range cases { + if got := weakestConfidence(c.a, c.b); got != c.want { + t.Errorf("weakestConfidence(%q, %q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..4a3caca --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,84 @@ +package github + +import ( + "net/http" + "time" +) + +const ( + defaultBaseURL = "https://api.github.com" + defaultUserAgent = "CloudOracle/v2" + defaultTimeout = 30 * time.Second + + // apiVersion is the GitHub REST API version pinned via the + // X-GitHub-Api-Version header. GitHub recommends setting this + // explicitly so behaviour does not change under us when they ship + // a new API version. + apiVersion = "2022-11-28" + + // acceptHeader is the "modern" media type GitHub asks REST clients + // to send. application/vnd.github+json picks the latest stable + // representation for whatever endpoint we hit. + acceptHeader = "application/vnd.github+json" +) + +// Client is a thin GitHub REST API client scoped to the operations +// CloudOracle needs (PR comment list / post / update). It is not a +// general-purpose SDK and intentionally does not retry, throttle, or +// cache — those belong to the caller (the Hito 16.3 Action wrapper). +type Client struct { + token string + baseURL string + httpClient *http.Client + userAgent string +} + +// NewClient creates a Client with the production defaults: GitHub's +// public API host, a 30s HTTP timeout, and the "CloudOracle/v2" +// User-Agent. token must be a personal access token or a workflow +// GITHUB_TOKEN with the correct PR-write scope; passing an empty +// string is allowed (calls will fail with 401), so the caller can +// surface auth errors uniformly with a real-but-bad token. +func NewClient(token string) *Client { + return &Client{ + token: token, + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: defaultTimeout}, + userAgent: defaultUserAgent, + } +} + +// NewClientWithConfig is the explicit constructor used by tests +// (httptest.NewServer URL via baseURL) and by callers targeting GitHub +// Enterprise Server. Empty strings on baseURL or userAgent fall back +// to the package defaults; httpClient may be nil to use the default +// 30s-timeout client. +func NewClientWithConfig(token, baseURL string, httpClient *http.Client, userAgent string) *Client { + if baseURL == "" { + baseURL = defaultBaseURL + } + if userAgent == "" { + userAgent = defaultUserAgent + } + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultTimeout} + } + return &Client{ + token: token, + baseURL: baseURL, + httpClient: httpClient, + userAgent: userAgent, + } +} + +// setHeaders applies the standard set of GitHub REST headers (auth, +// accept, version, user-agent) to a request built by this package. +// Centralised so a single point owns the auth contract. +func (c *Client) setHeaders(req *http.Request) { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + req.Header.Set("Accept", acceptHeader) + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", c.userAgent) +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..4e181cf --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,114 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewClient_Defaults(t *testing.T) { + c := NewClient("token-abc") + if c.token != "token-abc" { + t.Errorf("token not stored: got %q", c.token) + } + if c.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL) + } + if c.userAgent != defaultUserAgent { + t.Errorf("userAgent = %q, want %q", c.userAgent, defaultUserAgent) + } + if c.httpClient == nil { + t.Fatal("httpClient is nil") + } + if c.httpClient.Timeout != defaultTimeout { + t.Errorf("httpClient.Timeout = %v, want %v", c.httpClient.Timeout, defaultTimeout) + } +} + +func TestNewClientWithConfig_OverridesAndFallbacks(t *testing.T) { + custom := &http.Client{Timeout: 5 * time.Second} + c := NewClientWithConfig("tok", "https://ghe.example.com", custom, "Test/1.0") + if c.baseURL != "https://ghe.example.com" { + t.Errorf("baseURL not overridden") + } + if c.userAgent != "Test/1.0" { + t.Errorf("userAgent not overridden") + } + if c.httpClient != custom { + t.Errorf("httpClient not overridden") + } + + // Empty / nil arguments fall back to defaults. + def := NewClientWithConfig("tok", "", nil, "") + if def.baseURL != defaultBaseURL { + t.Errorf("empty baseURL did not fall back to default") + } + if def.userAgent != defaultUserAgent { + t.Errorf("empty userAgent did not fall back to default") + } + if def.httpClient == nil { + t.Errorf("nil httpClient did not fall back to default") + } +} + +// TestClient_AuthHeaderSet confirms every outbound request from this +// package includes the GitHub-required header bundle. We exercise the +// listComments path since it's the simplest GET; the other verbs share +// the same setHeaders helper, so a regression there would surface +// here too. +func TestClient_AuthHeaderSet(t *testing.T) { + var ( + gotAuth string + gotAccept string + gotVersion string + gotUA string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + gotVersion = r.Header.Get("X-GitHub-Api-Version") + gotUA = r.Header.Get("User-Agent") + _ = json.NewEncoder(w).Encode([]Comment{}) + })) + defer srv.Close() + + c := NewClientWithConfig("the-token", srv.URL, srv.Client(), "") + if _, err := c.listComments(context.Background(), Repo{Owner: "o", Name: "r"}, 1); err != nil { + t.Fatalf("listComments: %v", err) + } + + if gotAuth != "Bearer the-token" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer the-token") + } + if gotAccept != acceptHeader { + t.Errorf("Accept = %q, want %q", gotAccept, acceptHeader) + } + if gotVersion != apiVersion { + t.Errorf("X-GitHub-Api-Version = %q, want %q", gotVersion, apiVersion) + } + if gotUA != defaultUserAgent { + t.Errorf("User-Agent = %q, want %q", gotUA, defaultUserAgent) + } +} + +// TestClient_AuthHeaderOmittedWhenTokenEmpty: an empty token must NOT +// produce a "Bearer " header (auth header absent), so a misconfigured +// caller fails with a clean GitHub 401 rather than an oddly-formed +// header that some servers reject earlier. +func TestClient_AuthHeaderOmittedWhenTokenEmpty(t *testing.T) { + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadAuth = r.Header["Authorization"] + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := NewClientWithConfig("", srv.URL, srv.Client(), "") + _, _ = c.listComments(context.Background(), Repo{Owner: "o", Name: "r"}, 1) + if hadAuth { + t.Error("Authorization header set despite empty token") + } +} diff --git a/internal/github/comments.go b/internal/github/comments.go new file mode 100644 index 0000000..b4d9df8 --- /dev/null +++ b/internal/github/comments.go @@ -0,0 +1,310 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +const ( + // perPage is GitHub's max page size for list endpoints. Using the + // max minimises round-trips for repos with many comments. + perPage = 100 + + // maxPagesGitHub caps pagination at 5000 comments. Real PRs almost + // never exceed a few dozen; the cap exists so a buggy server or + // pagination loop cannot spin forever. Hitting the cap is logged + // as a warning so it is visible in the Action output. + maxPagesGitHub = 50 + + // maxBodyChars is a defensive cap below GitHub's documented + // ~65,536-char comment body limit. CloudOracle's rendered Markdown + // is typically <5KB, but the truncation guard prevents a 422 + // response if a future change makes the comment unexpectedly + // large. Truncation is best-effort: it may strip the trailing + // HTML marker, in which case the next push posts a fresh comment + // rather than updating the truncated one. + maxBodyChars = 60000 + + // truncationSuffix is appended to a body that was cropped to fit + // maxBodyChars. The visible "[truncated]" tells the reviewer the + // comment was incomplete; engineering can investigate via the + // Action logs. + truncationSuffix = "...\n[truncated]" +) + +// PostOrUpdateComment finds the most recent comment in the given PR +// whose body contains marker, and updates it; if no such comment +// exists, posts a new one. Returns the resulting comment ID, a +// "created" flag (true for a new comment, false for an update), and +// the first error encountered. +// +// The marker is a substring guaranteed to appear in CloudOracle- +// generated comments — typically an HTML comment like +// "" placed at the end of the rendered +// Markdown. The marker contract is symmetric: every CloudOracle +// comment must include it, and any comment containing it is +// considered "ours" for the purpose of update-vs-post. +// +// If multiple comments match the marker (rare; possible if a previous +// integration left duplicates or a manual paste happened), this +// function picks the one with the most recent UpdatedAt and emits a +// slog.Warn — it does not delete the others, since deletion is +// destructive and out of scope. +// +// Body length is capped at 60,000 characters; oversize bodies are +// truncated with a "...[truncated]" suffix and a slog.Warn. Note +// that truncation can remove the trailing marker, in which case the +// next pr-check posts a fresh comment instead of updating the +// truncated one. +// +// Errors are wrapped with stable prefixes the caller can match on: +// "github: authentication failed", "github: ... not found", +// "github: validation failed", "github: server error", and +// "github: request failed". No retries are performed — the caller +// (Action wrapper in Hito 16.3) owns retry policy. +// +// PRs and issues share the same numbering on GitHub; the "issues" +// comments endpoint serves both, which is why the parameter is named +// prNumber even though we hit /issues/{n}/comments. +func (c *Client) PostOrUpdateComment(ctx context.Context, repo Repo, prNumber int, body, marker string) (int64, bool, error) { + body = capBody(body) + + existing, err := c.listComments(ctx, repo, prNumber) + if err != nil { + return 0, false, err + } + + if match, ok := pickMostRecentMatch(existing, marker); ok { + if err := c.updateComment(ctx, repo, match.ID, body); err != nil { + return 0, false, err + } + return match.ID, false, nil + } + + id, err := c.postComment(ctx, repo, prNumber, body) + if err != nil { + return 0, false, err + } + return id, true, nil +} + +// pickMostRecentMatch scans comments for ones whose body contains the +// marker and returns the one with the latest UpdatedAt. If more than +// one matches, a warning is logged: a single match is the steady +// state, multiple matches indicate either a manual duplication or +// drift in the marker convention. +func pickMostRecentMatch(comments []Comment, marker string) (Comment, bool) { + var matches []Comment + for _, c := range comments { + if strings.Contains(c.Body, marker) { + matches = append(matches, c) + } + } + if len(matches) == 0 { + return Comment{}, false + } + winner := matches[0] + for _, m := range matches[1:] { + if m.UpdatedAt.After(winner.UpdatedAt) { + winner = m + } + } + if len(matches) > 1 { + slog.Warn("github: multiple comments match marker, updating most recent", + "matches", len(matches), + "winner_id", winner.ID, + "marker", marker) + } + return winner, true +} + +// capBody truncates a comment body that exceeds maxBodyChars, +// appending truncationSuffix so the reader knows the comment is +// incomplete. The combined length is exactly maxBodyChars (suffix +// included) — i.e. we crop the original to maxBodyChars - len(suffix) +// before appending. A warning is logged so operators can investigate. +func capBody(body string) string { + if len(body) <= maxBodyChars { + return body + } + keep := max(maxBodyChars-len(truncationSuffix), 0) + slog.Warn("github: comment body exceeds size cap, truncating", + "original_len", len(body), + "max", maxBodyChars, + "kept", keep) + return body[:keep] + truncationSuffix +} + +// listComments fetches every comment on the given issue/PR. GitHub +// uses cursor-style pagination with Link headers, but a per_page=100 +// request also reports the count via the JSON array length: a short +// final page (or an empty one) signals end-of-results. Parsing only +// the array length keeps the implementation simple and avoids a Link +// header parser. +// +// The loop is hard-capped at maxPagesGitHub iterations (5000 comments) +// so a buggy server cannot spin forever. Hitting the cap is logged +// and the comments collected so far are returned — the caller still +// gets useful data, and the warning is visible in the Action output. +func (c *Client) listComments(ctx context.Context, repo Repo, prNumber int) ([]Comment, error) { + var all []Comment + subject := fmt.Sprintf("repo %s/%s or PR #%d", repo.Owner, repo.Name, prNumber) + + for page := 1; page <= maxPagesGitHub; page++ { + batch, err := c.fetchCommentsPage(ctx, repo, prNumber, page, subject) + if err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < perPage { + return all, nil + } + } + + slog.Warn("github: pagination cap hit, returning collected comments", + "max_pages", maxPagesGitHub, + "comments", len(all)) + return all, nil +} + +// fetchCommentsPage pulls a single page of issue comments. Split out +// of listComments so the request body close happens at function exit +// instead of being deferred inside a loop (which would leak HTTP +// connections until listComments itself returned). +func (c *Client) fetchCommentsPage(ctx context.Context, repo Repo, prNumber, page int, subject string) ([]Comment, error) { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=%d&page=%d", + c.baseURL, repo.Owner, repo.Name, prNumber, perPage, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("github: request failed: %w", err) + } + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("github: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("github: request failed: reading body: %w", err) + } + if err := mapHTTPError(resp.StatusCode, body, subject); err != nil { + return nil, err + } + + var batch []Comment + if err := json.Unmarshal(body, &batch); err != nil { + return nil, fmt.Errorf("github: parsing list response: %w", err) + } + return batch, nil +} + +// postComment creates a new comment under the given issue/PR and +// returns its ID. GitHub responds with 201 Created and the full +// comment object on success; we only need the ID for downstream +// referencing. +func (c *Client) postComment(ctx context.Context, repo Repo, prNumber int, body string) (int64, error) { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", + c.baseURL, repo.Owner, repo.Name, prNumber) + subject := fmt.Sprintf("repo %s/%s or PR #%d", repo.Owner, repo.Name, prNumber) + + payload, err := json.Marshal(map[string]string{"body": body}) + if err != nil { + return 0, fmt.Errorf("github: marshalling comment: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return 0, fmt.Errorf("github: request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("github: request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("github: request failed: reading body: %w", err) + } + if err := mapHTTPError(resp.StatusCode, respBody, subject); err != nil { + return 0, err + } + + var created Comment + if err := json.Unmarshal(respBody, &created); err != nil { + return 0, fmt.Errorf("github: parsing post response: %w", err) + } + return created.ID, nil +} + +// updateComment replaces the body of an existing comment. The endpoint +// is /repos/{owner}/{repo}/issues/comments/{commentID} — note that +// the PR number is not part of the path: GitHub identifies comments +// globally by ID once you have one. We still pass repo so a 404 can +// be reported with the same shape as listComments and postComment. +func (c *Client) updateComment(ctx context.Context, repo Repo, commentID int64, body string) error { + url := fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d", + c.baseURL, repo.Owner, repo.Name, commentID) + subject := fmt.Sprintf("comment #%d on repo %s/%s", commentID, repo.Owner, repo.Name) + + payload, err := json.Marshal(map[string]string{"body": body}) + if err != nil { + return fmt.Errorf("github: marshalling comment update: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("github: request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("github: request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("github: request failed: reading body: %w", err) + } + return mapHTTPError(resp.StatusCode, respBody, subject) +} + +// mapHTTPError translates a GitHub response status into a wrapped +// error with a stable prefix the caller can match on. 2xx returns +// nil. The mapping is deliberately coarse — callers that need the +// raw status or body can wrap a transport that captures them; for +// CloudOracle's purposes the prefix is what matters because the +// Action wrapper renders it to stderr. +func mapHTTPError(status int, body []byte, subject string) error { + if status >= 200 && status < 300 { + return nil + } + switch { + case status == http.StatusUnauthorized, status == http.StatusForbidden: + return fmt.Errorf("github: authentication failed (check GITHUB_TOKEN): %d %s", status, string(body)) + case status == http.StatusNotFound: + return fmt.Errorf("github: %s not found", subject) + case status == http.StatusUnprocessableEntity: + return fmt.Errorf("github: validation failed: %s", string(body)) + case status >= 500: + return fmt.Errorf("github: server error: %d %s", status, string(body)) + default: + return fmt.Errorf("github: unexpected status %d: %s", status, string(body)) + } +} diff --git a/internal/github/comments_test.go b/internal/github/comments_test.go new file mode 100644 index 0000000..256b064 --- /dev/null +++ b/internal/github/comments_test.go @@ -0,0 +1,565 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// captureLogs swaps slog's default logger for one that writes to a +// buffer, so tests can assert on warnings emitted by the silent +// fallbacks (multi-match, body truncation, pagination cap). Restored +// on test cleanup. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// makeComment is a small constructor used to keep test fixtures terse. +func makeComment(id int64, body string, updated time.Time) Comment { + return Comment{ID: id, Body: body, UpdatedAt: updated} +} + +func mustParse(t *testing.T, ts string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Fatalf("bad timestamp %q: %v", ts, err) + } + return v +} + +// repo is the canonical test repo. The values are unimportant; the +// server validates them so a bug that swaps owner/name is loud. +var testRepo = Repo{Owner: "Cro22", Name: "CloudOracle"} + +// --- listComments --- + +func TestListComments_SinglePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/repos/Cro22/CloudOracle/issues/42/comments") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode([]Comment{ + makeComment(1, "first", mustParse(t, "2026-05-01T10:00:00Z")), + makeComment(2, "second", mustParse(t, "2026-05-02T10:00:00Z")), + }) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + got, err := c.listComments(context.Background(), testRepo, 42) + if err != nil { + t.Fatalf("listComments: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d comments, want 2", len(got)) + } +} + +func TestListComments_TwoPages(t *testing.T) { + var pagesSeen []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pagesSeen = append(pagesSeen, page) + switch page { + case "1": + _ = json.NewEncoder(w).Encode(makeBatch(100, 1)) + case "2": + _ = json.NewEncoder(w).Encode(makeBatch(50, 101)) + default: + t.Errorf("unexpected page request: %s", page) + } + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + got, err := c.listComments(context.Background(), testRepo, 1) + if err != nil { + t.Fatalf("listComments: %v", err) + } + if len(got) != 150 { + t.Errorf("got %d comments, want 150", len(got)) + } + if len(pagesSeen) != 2 || pagesSeen[0] != "1" || pagesSeen[1] != "2" { + t.Errorf("pages seen = %v, want [1 2]", pagesSeen) + } +} + +func TestListComments_PaginationCap(t *testing.T) { + logs := captureLogs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always return a full page so the function never sees a + // short page (which would naturally terminate the loop). + _ = json.NewEncoder(w).Encode(makeBatch(100, 1)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + got, err := c.listComments(context.Background(), testRepo, 1) + if err != nil { + t.Fatalf("listComments: %v", err) + } + if len(got) != maxPagesGitHub*perPage { + t.Errorf("collected %d comments, want %d (cap)", len(got), maxPagesGitHub*perPage) + } + if !strings.Contains(logs.String(), "pagination cap hit") { + t.Errorf("expected pagination-cap warn; logs:\n%s", logs.String()) + } +} + +func TestListComments_404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 99) + if err == nil { + t.Fatal("expected error on 404") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want substring 'not found'", err) + } + if !strings.Contains(err.Error(), "PR #99") { + t.Errorf("error should reference the PR number; got %q", err) + } +} + +func TestListComments_401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 1) + if err == nil { + t.Fatal("expected error on 401") + } + if !strings.Contains(err.Error(), "authentication failed") { + t.Errorf("error = %q, want substring 'authentication failed'", err) + } +} + +func TestListComments_403WrappedAsAuth(t *testing.T) { + // 403 (rate-limited / forbidden) shares the auth-error mapping + // because there's no useful user-facing distinction at the call + // site — both mean "GitHub rejected your token". + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"forbidden"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 1) + if err == nil || !strings.Contains(err.Error(), "authentication failed") { + t.Errorf("403 should map to authentication failed; got %v", err) + } +} + +func TestListComments_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() // closed before any request arrives — connection refused + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 1) + if err == nil { + t.Fatal("expected network error") + } + if !strings.Contains(err.Error(), "request failed") { + t.Errorf("error = %q, want substring 'request failed'", err) + } +} + +func TestListComments_ContextCancelled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(2 * time.Second) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(ctx, testRepo, 1) + if err == nil { + t.Fatal("expected context cancellation error") + } +} + +// --- postComment / updateComment --- + +func TestPostComment_Success(t *testing.T) { + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Comment{ID: 12345, Body: "x", UpdatedAt: time.Now()}) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, err := c.postComment(context.Background(), testRepo, 7, "hello body") + if err != nil { + t.Fatalf("postComment: %v", err) + } + if id != 12345 { + t.Errorf("id = %d, want 12345", id) + } + if !strings.Contains(string(gotBody), `"body":"hello body"`) { + t.Errorf("server didn't see expected body; got %s", string(gotBody)) + } +} + +func TestPostComment_422(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation Failed","errors":[{"code":"missing"}]}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.postComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "validation failed") { + t.Errorf("422 should map to validation failed; got %v", err) + } +} + +func TestPostComment_500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`upstream broken`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.postComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "server error") { + t.Errorf("500 should map to server error; got %v", err) + } +} + +func TestPostComment_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.postComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "request failed") { + t.Errorf("network error should map to request failed; got %v", err) + } +} + +func TestUpdateComment_Success(t *testing.T) { + var gotMethod, gotPath string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + _ = json.NewEncoder(w).Encode(Comment{ID: 12345, Body: "updated"}) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + if err := c.updateComment(context.Background(), testRepo, 12345, "updated body"); err != nil { + t.Fatalf("updateComment: %v", err) + } + if gotMethod != http.MethodPatch { + t.Errorf("method = %s, want PATCH", gotMethod) + } + if !strings.HasSuffix(gotPath, "/issues/comments/12345") { + t.Errorf("path = %s, want suffix '/issues/comments/12345'", gotPath) + } + if !strings.Contains(string(gotBody), `"body":"updated body"`) { + t.Errorf("server didn't see expected body; got %s", string(gotBody)) + } +} + +func TestUpdateComment_422(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"validation"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + err := c.updateComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "validation failed") { + t.Errorf("422 should map to validation failed; got %v", err) + } +} + +func TestUpdateComment_500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + err := c.updateComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "server error") { + t.Errorf("502 should map to server error; got %v", err) + } +} + +// --- PostOrUpdateComment integration --- + +const testMarker = "" + +// scriptedServer is a tiny mux that lets each test wire its own +// list / post / update behaviour. Every request increments calls +// counters so tests can assert on which verbs were exercised. +type scriptedServer struct { + t *testing.T + listComments func(page string) ([]Comment, int) + postBody string + postID int64 + postStatus int + updateBody string + updateID int64 + updateStatus int + calls struct { + list, post, update int + } +} + +func (s *scriptedServer) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/") && strings.HasSuffix(r.URL.Path, "/comments"): + s.calls.list++ + batch, status := s.listComments(r.URL.Query().Get("page")) + if status != 0 { + w.WriteHeader(status) + return + } + _ = json.NewEncoder(w).Encode(batch) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/comments"): + s.calls.post++ + body, _ := io.ReadAll(r.Body) + s.postBody = string(body) + if s.postStatus != 0 { + w.WriteHeader(s.postStatus) + _, _ = w.Write([]byte(`{"message":"forced failure"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Comment{ID: s.postID, Body: "x"}) + case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/comments/"): + s.calls.update++ + body, _ := io.ReadAll(r.Body) + s.updateBody = string(body) + parts := strings.Split(r.URL.Path, "/") + s.updateID, _ = parseInt64(parts[len(parts)-1]) + if s.updateStatus != 0 { + w.WriteHeader(s.updateStatus) + return + } + _ = json.NewEncoder(w).Encode(Comment{ID: s.updateID, Body: "updated"}) + default: + s.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + } +} + +func TestPostOrUpdateComment_Update(t *testing.T) { + existing := []Comment{ + makeComment(101, "## 💰 Cloud Cost Impact\nbody A\n"+testMarker, mustParse(t, "2026-05-01T10:00:00Z")), + makeComment(102, "an unrelated review comment", mustParse(t, "2026-05-02T10:00:00Z")), + } + scr := &scriptedServer{ + t: t, + listComments: func(page string) ([]Comment, int) { + if page == "1" { + return existing, 0 + } + return nil, 0 + }, + updateID: 101, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 5, "new body "+testMarker, testMarker) + if err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + if created { + t.Errorf("expected created=false (update path)") + } + if id != 101 { + t.Errorf("id = %d, want 101", id) + } + if scr.calls.update != 1 { + t.Errorf("expected 1 update call, got %d", scr.calls.update) + } + if scr.calls.post != 0 { + t.Errorf("expected 0 post calls on update path, got %d", scr.calls.post) + } + if scr.updateID != 101 { + t.Errorf("PATCHed wrong comment ID: %d", scr.updateID) + } + if !strings.Contains(scr.updateBody, "new body") { + t.Errorf("update body did not contain new content; got %s", scr.updateBody) + } +} + +func TestPostOrUpdateComment_PostNew(t *testing.T) { + scr := &scriptedServer{ + t: t, + listComments: func(_ string) ([]Comment, int) { return []Comment{}, 0 }, + postID: 999, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 5, "fresh "+testMarker, testMarker) + if err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + if !created { + t.Errorf("expected created=true (new comment path)") + } + if id != 999 { + t.Errorf("id = %d, want 999", id) + } + if scr.calls.post != 1 { + t.Errorf("expected 1 post call, got %d", scr.calls.post) + } + if scr.calls.update != 0 { + t.Errorf("expected 0 update calls on post path, got %d", scr.calls.update) + } +} + +func TestPostOrUpdateComment_MultipleMatches(t *testing.T) { + logs := captureLogs(t) + existing := []Comment{ + makeComment(10, "old "+testMarker, mustParse(t, "2026-04-01T10:00:00Z")), + makeComment(20, "newest "+testMarker, mustParse(t, "2026-05-10T10:00:00Z")), + makeComment(30, "middle "+testMarker, mustParse(t, "2026-04-15T10:00:00Z")), + } + scr := &scriptedServer{ + t: t, + listComments: func(page string) ([]Comment, int) { + if page == "1" { + return existing, 0 + } + return nil, 0 + }, + updateID: 20, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 5, "newer "+testMarker, testMarker) + if err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + if created { + t.Errorf("expected update path, got created=true") + } + if id != 20 { + t.Errorf("id = %d, want 20 (most recent updated_at)", id) + } + if scr.updateID != 20 { + t.Errorf("PATCHed comment ID = %d, want 20", scr.updateID) + } + if !strings.Contains(logs.String(), "multiple comments match marker") { + t.Errorf("expected multi-match warn; logs:\n%s", logs.String()) + } +} + +func TestPostOrUpdateComment_BodyTooLong(t *testing.T) { + logs := captureLogs(t) + scr := &scriptedServer{ + t: t, + listComments: func(_ string) ([]Comment, int) { return []Comment{}, 0 }, + postID: 1, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + bigBody := strings.Repeat("x", 70000) + testMarker + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + if _, _, err := c.PostOrUpdateComment(context.Background(), testRepo, 1, bigBody, testMarker); err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + + if !strings.Contains(logs.String(), "exceeds size cap") { + t.Errorf("expected truncation warn; logs:\n%s", logs.String()) + } + // The server should have observed a body shorter than the original. + if len(scr.postBody) >= 70000 { + t.Errorf("post body not truncated: len=%d", len(scr.postBody)) + } + if !strings.Contains(scr.postBody, "[truncated]") { + t.Errorf("post body missing truncation suffix; got tail: %q", + scr.postBody[max(0, len(scr.postBody)-200):]) + } +} + +func TestPostOrUpdateComment_PostFails(t *testing.T) { + scr := &scriptedServer{ + t: t, + listComments: func(_ string) ([]Comment, int) { return []Comment{}, 0 }, + postStatus: http.StatusUnprocessableEntity, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 1, "body "+testMarker, testMarker) + if err == nil { + t.Fatal("expected post failure to surface") + } + if id != 0 { + t.Errorf("expected id=0 on failure, got %d", id) + } + if created { + t.Errorf("expected created=false on failure") + } + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("error = %v, want validation failed", err) + } +} + +// --- helpers --- + +func makeBatch(n, startID int) []Comment { + out := make([]Comment, n) + for i := range n { + out[i] = Comment{ID: int64(startID + i), Body: "x"} + } + return out +} + +func parseInt64(s string) (int64, error) { + var n int64 + _, err := fmt.Sscan(s, &n) + return n, err +} diff --git a/internal/github/types.go b/internal/github/types.go new file mode 100644 index 0000000..014d999 --- /dev/null +++ b/internal/github/types.go @@ -0,0 +1,29 @@ +// Package github is a thin GitHub REST API client scoped to the +// operations CloudOracle's PR-comment integration needs: listing, +// posting, and updating issue comments. It is deliberately not a full +// SDK — webhooks, branches, releases, reactions, and review comments +// are out of scope. +// +// The package only deserialises the fields it actually uses; unknown +// JSON keys in GitHub's responses are ignored. This keeps the package +// resilient to API additions without forcing dependency churn. +package github + +import "time" + +// Repo identifies a GitHub repository by its owner login and repo +// name. Both are required by every endpoint this package calls. +type Repo struct { + Owner string // e.g. "Cro22" + Name string // e.g. "CloudOracle" +} + +// Comment is a minimal projection of GitHub's issue/PR comment payload. +// We only deserialise the fields the marker-matching and update flow +// need; GitHub's full comment object is much larger and would tie us +// to fields we don't read. +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/iac/aws/aws.go b/internal/iac/aws/aws.go new file mode 100644 index 0000000..816d012 --- /dev/null +++ b/internal/iac/aws/aws.go @@ -0,0 +1,101 @@ +// Package aws extracts strongly-typed cost-impacting attributes from +// Terraform plan resource changes for AWS resources. +// +// Each ExtractXxx function takes a map[string]interface{} (the shape of +// terraform-iac.Change.Before or .After) rather than the full ResourceChange +// from the parser package. That separation is deliberate: extractors don't +// need to know about actions, addresses, or before/after distinctions — +// the diff engine in the next milestone decides which state to extract. +// +// Currently supported: aws_instance, aws_db_instance, aws_ebs_volume. +// Other types (Lambda, NAT, RDS Cluster, EKS, ElastiCache, S3) are added +// in subsequent milestones. +package aws + +// ResourceAttributes is a discriminated union over the AWS resource types +// this package supports. Exactly one of EC2, RDS, EBS is non-nil; the +// Type field identifies which. +// +// We use this shape rather than a bare interface{} for two reasons: +// +// 1. Type safety at call sites — pricing code can write `if r.EC2 != nil` +// and get nil-deref protection from the compiler. +// 2. Exhaustive switches feasible — adding a new type forces every +// consumer to add a case (or explicitly default), instead of silently +// dispatching a runtime type assertion that no-ops on the new type. +type ResourceAttributes struct { + Type string + EC2 *EC2Attributes + RDS *RDSAttributes + EBS *EBSAttributes + Lambda *LambdaAttributes + NATGateway *NATGatewayAttributes + RDSClusterInstance *RDSClusterInstanceAttributes +} + +// Extract dispatches to the type-specific extractor for resourceType. +// +// Unsupported resource types return (nil, nil) — the caller treats +// "no data" as "no cost impact for this resource". This is intentional: +// a real Terraform plan contains many resources we don't price (IAM roles, +// VPCs, Route53 records, etc.). Forcing every caller to distinguish +// "unsupported" from "extraction failed" would push complexity outward +// instead of containing it here. +// +// Extraction failures on supported types still return (nil, error). +func Extract(resourceType string, attrs map[string]interface{}) (*ResourceAttributes, error) { + switch resourceType { + case "aws_instance": + ec2, err := ExtractEC2(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, EC2: ec2}, nil + case "aws_db_instance": + rds, err := ExtractRDS(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, RDS: rds}, nil + case "aws_ebs_volume": + ebs, err := ExtractEBS(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, EBS: ebs}, nil + case "aws_lambda_function": + fn, err := ExtractLambda(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, Lambda: fn}, nil + case "aws_nat_gateway": + nat, err := ExtractNATGateway(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, NATGateway: nat}, nil + case "aws_rds_cluster_instance": + rci, err := ExtractRDSClusterInstance(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, RDSClusterInstance: rci}, nil + } + return nil, nil +} + +// SupportedTypes returns the AWS resource types this package can extract +// attributes for. The returned slice is freshly allocated on each call so +// callers can mutate it without affecting future returns. Order is stable +// across calls so it can drive menus or docs without sorting. +func SupportedTypes() []string { + return []string{ + "aws_instance", + "aws_db_instance", + "aws_ebs_volume", + "aws_lambda_function", + "aws_nat_gateway", + "aws_rds_cluster_instance", + } +} diff --git a/internal/iac/aws/aws_test.go b/internal/iac/aws/aws_test.go new file mode 100644 index 0000000..10df522 --- /dev/null +++ b/internal/iac/aws/aws_test.go @@ -0,0 +1,225 @@ +package aws + +import ( + "fmt" + "reflect" + "sort" + "testing" +) + +func TestExtract_DispatchesEC2(t *testing.T) { + r, err := Extract("aws_instance", map[string]interface{}{ + "instance_type": "t3.large", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_instance" { + t.Errorf("Type = %q", r.Type) + } + if r.EC2 == nil { + t.Fatal("EC2 is nil — dispatch failed") + } + if r.EC2.InstanceType != "t3.large" { + t.Errorf("InstanceType = %q", r.EC2.InstanceType) + } + if r.RDS != nil || r.EBS != nil { + t.Errorf("non-EC2 fields should be nil: RDS=%v EBS=%v", r.RDS, r.EBS) + } +} + +func TestExtract_DispatchesRDS(t *testing.T) { + r, err := Extract("aws_db_instance", map[string]interface{}{ + "engine": "postgres", + "instance_class": "db.t3.micro", + "allocated_storage": float64(20), + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.RDS == nil || r.RDS.Engine != "postgres" { + t.Errorf("RDS not populated: %+v", r) + } + if r.EC2 != nil || r.EBS != nil { + t.Errorf("non-RDS fields should be nil") + } +} + +func TestExtract_DispatchesEBS(t *testing.T) { + r, err := Extract("aws_ebs_volume", map[string]interface{}{ + "type": "gp3", + "size": float64(100), + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.EBS == nil || r.EBS.Type != "gp3" { + t.Errorf("EBS not populated: %+v", r) + } + if r.EC2 != nil || r.RDS != nil { + t.Errorf("non-EBS fields should be nil") + } +} + +func TestExtract_DispatchesLambda(t *testing.T) { + r, err := Extract("aws_lambda_function", map[string]interface{}{ + "function_name": "checkout", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_lambda_function" { + t.Errorf("Type = %q", r.Type) + } + if r.Lambda == nil || r.Lambda.FunctionName != "checkout" { + t.Errorf("Lambda not populated: %+v", r) + } + if r.EC2 != nil || r.RDS != nil || r.EBS != nil || + r.NATGateway != nil || r.RDSClusterInstance != nil { + t.Error("non-Lambda fields should be nil") + } +} + +func TestExtract_DispatchesNATGateway(t *testing.T) { + r, err := Extract("aws_nat_gateway", map[string]interface{}{ + "subnet_id": "subnet-123", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_nat_gateway" { + t.Errorf("Type = %q", r.Type) + } + if r.NATGateway == nil || r.NATGateway.SubnetID != "subnet-123" { + t.Errorf("NATGateway not populated: %+v", r) + } +} + +func TestExtract_DispatchesRDSClusterInstance(t *testing.T) { + r, err := Extract("aws_rds_cluster_instance", map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.t3.medium", + "engine": "aurora-postgresql", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_rds_cluster_instance" { + t.Errorf("Type = %q", r.Type) + } + if r.RDSClusterInstance == nil || + r.RDSClusterInstance.ClusterIdentifier != "c1" || + r.RDSClusterInstance.Engine != "aurora-postgresql" { + t.Errorf("RDSClusterInstance not populated: %+v", r.RDSClusterInstance) + } +} + +// TestExtract_UnsupportedType verifies the contract documented in the +// dispatcher comment: unknown types are NOT errors, they are silently +// reported as "no data" so the caller can skip them. +func TestExtract_UnsupportedType(t *testing.T) { + r, err := Extract("aws_iam_role", map[string]interface{}{ + "name": "my-role", + }) + if err != nil { + t.Errorf("err = %v, want nil for unsupported type", err) + } + if r != nil { + t.Errorf("r = %+v, want nil for unsupported type", r) + } +} + +// TestExtract_PropagatesUnderlyingErrors verifies that when the type IS +// supported but extraction fails, the error from the inner extractor +// surfaces unchanged through Extract. +func TestExtract_PropagatesUnderlyingErrors(t *testing.T) { + cases := []struct { + name string + typ string + attrs map[string]interface{} + want string + }{ + { + "EC2 nil attrs", + "aws_instance", nil, + "aws_instance: empty attributes", + }, + { + "RDS missing required", + "aws_db_instance", + map[string]interface{}{"instance_class": "db.t3.micro"}, + `aws_db_instance: missing required attribute "engine"`, + }, + { + "EBS empty attrs", + "aws_ebs_volume", map[string]interface{}{}, + "aws_ebs_volume: empty attributes", + }, + { + "Lambda missing required", + "aws_lambda_function", map[string]interface{}{"runtime": "python3.12"}, + `aws_lambda_function: missing required attribute "function_name"`, + }, + { + "NAT empty attrs", + "aws_nat_gateway", nil, + "aws_nat_gateway: empty attributes", + }, + { + "RDS cluster instance missing required", + "aws_rds_cluster_instance", + map[string]interface{}{"cluster_identifier": "c1", "instance_class": "db.t3.medium"}, + `aws_rds_cluster_instance: missing required attribute "engine"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Extract(c.typ, c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestSupportedTypes(t *testing.T) { + got := SupportedTypes() + // Sort defensively — the contract is "stable order across calls", + // but the test asserts membership, not a particular order. + sortedGot := append([]string(nil), got...) + sort.Strings(sortedGot) + want := []string{ + "aws_db_instance", + "aws_ebs_volume", + "aws_instance", + "aws_lambda_function", + "aws_nat_gateway", + "aws_rds_cluster_instance", + } + if !reflect.DeepEqual(sortedGot, want) { + t.Errorf("got %v, want %v", sortedGot, want) + } + + // Mutating the returned slice must not affect future calls — the + // docstring promises a fresh allocation each call. + got[0] = "MUTATED" + again := SupportedTypes() + if again[0] == "MUTATED" { + t.Error("SupportedTypes() shares state across calls — should return a fresh slice") + } +} + +func ExampleExtract() { + attrs := map[string]interface{}{ + "instance_type": "m5.large", + } + r, err := Extract("aws_instance", attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s -> %s\n", r.Type, r.EC2.InstanceType) + // Output: aws_instance -> m5.large +} diff --git a/internal/iac/aws/ebs.go b/internal/iac/aws/ebs.go new file mode 100644 index 0000000..558d7ec --- /dev/null +++ b/internal/iac/aws/ebs.go @@ -0,0 +1,93 @@ +package aws + +// EBSAttributes captures the cost-impacting fields of an aws_ebs_volume +// resource (standalone volumes — root volumes attached to EC2 instances +// are tracked under EC2Attributes.RootBlock*). +type EBSAttributes struct { + // Type is the volume type: gp2, gp3, io1, io2, st1, sc1, or "standard" + // for the legacy magnetic volumes. Required. Cross-attribute validation + // (e.g. "io1 must have iops") is intentionally deferred — that's a + // pricing-engine concern; here we just surface what Terraform declared. + Type string + + // Size is the volume size in GB. Required. + Size int + + // Iops is the provisioned IOPS. Optional — required by io1/io2 and + // allowed for gp3, but we don't enforce that here. + Iops int + + // Throughput is the provisioned throughput in MB/s. Optional, only + // meaningful for gp3. + Throughput int + + // AvailabilityZone is the AZ the volume lives in. Optional — pricing + // for EBS is regional, not AZ-specific, but the attribute is included + // so callers can match the volume to its instance's AZ if needed. + AvailabilityZone string + + // Encrypted indicates whether the volume is encrypted at rest. + // Defaults to false. Encryption itself is free; the attribute is + // captured because some pricing analyses do compliance scoring. + Encrypted bool +} + +// ExtractEBS reads cost-impacting attributes from an aws_ebs_volume +// attribute map. +// +// Required: type, size. Defaults: iops=0, throughput=0, encrypted=false. +// +// We don't validate cross-attribute consistency (gp3 with/without +// throughput, io1 with/without iops) — that's the pricing engine's job. +// Here we report exactly what Terraform declared. +func ExtractEBS(attrs map[string]interface{}) (*EBSAttributes, error) { + const typ = "aws_ebs_volume" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + volType, present, err := getString(attrs, "type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "type") + } + + size, present, err := getInt(attrs, "size") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "size") + } + + iops, _, err := getInt(attrs, "iops") + if err != nil { + return nil, wrapAttr(typ, err) + } + + throughput, _, err := getInt(attrs, "throughput") + if err != nil { + return nil, wrapAttr(typ, err) + } + + az, _, err := getString(attrs, "availability_zone") + if err != nil { + return nil, wrapAttr(typ, err) + } + + encrypted, _, err := getBool(attrs, "encrypted") + if err != nil { + return nil, wrapAttr(typ, err) + } + + return &EBSAttributes{ + Type: volType, + Size: size, + Iops: iops, + Throughput: throughput, + AvailabilityZone: az, + Encrypted: encrypted, + }, nil +} diff --git a/internal/iac/aws/ebs_test.go b/internal/iac/aws/ebs_test.go new file mode 100644 index 0000000..0855af8 --- /dev/null +++ b/internal/iac/aws/ebs_test.go @@ -0,0 +1,174 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractEBS_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "type": "gp3", + "size": float64(500), + "iops": float64(3000), + "throughput": float64(125), + "availability_zone": "us-east-1a", + "encrypted": true, + "some_extra_field": "ignored", + } + got, err := ExtractEBS(attrs) + if err != nil { + t.Fatalf("ExtractEBS: %v", err) + } + want := EBSAttributes{ + Type: "gp3", + Size: 500, + Iops: 3000, + Throughput: 125, + AvailabilityZone: "us-east-1a", + Encrypted: true, + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractEBS_OnlyRequiredFields(t *testing.T) { + attrs := map[string]interface{}{ + "type": "standard", + "size": float64(100), + } + got, err := ExtractEBS(attrs) + if err != nil { + t.Fatalf("ExtractEBS: %v", err) + } + if got.Type != "standard" || got.Size != 100 { + t.Errorf("required fields wrong: %+v", got) + } + // Optional defaults. + if got.Iops != 0 || got.Throughput != 0 { + t.Errorf("Iops/Throughput should default to 0: %+v", got) + } + if got.Encrypted { + t.Error("Encrypted should default to false") + } +} + +// TestExtractEBS_NoCrossAttrValidation confirms the explicit non-feature: +// we do NOT reject io1 without iops here. That validation lives in the +// pricing engine. +func TestExtractEBS_NoCrossAttrValidation(t *testing.T) { + attrs := map[string]interface{}{ + "type": "io1", + "size": float64(100), + // iops absent — pricing might fail later, but the extractor must not. + } + got, err := ExtractEBS(attrs) + if err != nil { + t.Fatalf("io1 without iops should NOT error here: %v", err) + } + if got.Iops != 0 { + t.Errorf("Iops = %d, want 0", got.Iops) + } +} + +func TestExtractEBS_MissingRequired(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + { + "no type", + map[string]interface{}{"size": float64(100)}, + `aws_ebs_volume: missing required attribute "type"`, + }, + { + "no size", + map[string]interface{}{"type": "gp3"}, + `aws_ebs_volume: missing required attribute "size"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractEBS(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestExtractEBS_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + {"type as int", map[string]interface{}{"type": 42, "size": float64(10)}, "type"}, + {"size as string", map[string]interface{}{"type": "gp3", "size": "100"}, "size"}, + {"throughput fractional", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "throughput": 1.5, + }, "throughput"}, + {"encrypted as int", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "encrypted": 1, + }, "encrypted"}, + {"iops fractional", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "iops": 1.5, + }, "iops"}, + {"availability_zone as int", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "availability_zone": 1, + }, "availability_zone"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractEBS(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_ebs_volume:") { + t.Errorf("error should start with 'aws_ebs_volume:': %v", err) + } + }) + } +} + +func TestExtractEBS_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractEBS(attrs) + if err == nil { + t.Fatal("expected error for empty/nil attrs") + } + if err.Error() != "aws_ebs_volume: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractEBS() { + attrs := map[string]interface{}{ + "type": "gp3", + "size": float64(500), + "throughput": float64(250), + } + out, err := ExtractEBS(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s %dGB throughput=%dMB/s\n", out.Type, out.Size, out.Throughput) + // Output: gp3 500GB throughput=250MB/s +} diff --git a/internal/iac/aws/ec2.go b/internal/iac/aws/ec2.go new file mode 100644 index 0000000..13ad6f8 --- /dev/null +++ b/internal/iac/aws/ec2.go @@ -0,0 +1,106 @@ +package aws + +// EC2Attributes captures the cost-impacting fields of an aws_instance +// resource. We deliberately exclude attributes that don't affect price +// (tags, security_groups, key_name, etc.) so the struct stays focused on +// what the pricing engine cares about. +type EC2Attributes struct { + // InstanceType is the EC2 instance class, e.g. "t3.large", "m5.xlarge". + // Required. + InstanceType string + + // AvailabilityZone is the AZ the instance is launched into, e.g. + // "us-east-1a". Optional — when missing, region-level pricing applies. + AvailabilityZone string + + // Tenancy controls whether the instance shares hardware. Defaults to + // "default"; other valid values are "dedicated" and "host" — both have + // significant pricing implications. + Tenancy string + + // EBSOptimized indicates whether the instance has dedicated EBS + // throughput. Defaults to false. Some instance families have it + // enabled implicitly, but we track only the explicit attribute here. + EBSOptimized bool + + // RootBlockSize is the size of the root volume in GB, drawn from the + // first root_block_device entry. Zero when the block isn't specified + // (in which case AWS applies the AMI default). + RootBlockSize int + + // RootBlockType is the volume type of the root device (e.g. "gp3"). + // Empty when the block isn't specified. + RootBlockType string +} + +// ExtractEC2 reads cost-impacting attributes from an aws_instance attribute +// map (typically resource_changes[].change.after for a create, or +// resource_changes[].change.before for a delete). +// +// Required: instance_type. Defaults: tenancy="default", ebs_optimized=false. +// +// Unknown attributes are ignored. Terraform adds and renames fields between +// versions, so a strict allow-list would force a parser update on every +// minor Terraform release. +func ExtractEC2(attrs map[string]interface{}) (*EC2Attributes, error) { + const typ = "aws_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + instanceType, present, err := getString(attrs, "instance_type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "instance_type") + } + + az, _, err := getString(attrs, "availability_zone") + if err != nil { + return nil, wrapAttr(typ, err) + } + + tenancy, _, err := getString(attrs, "tenancy") + if err != nil { + return nil, wrapAttr(typ, err) + } + if tenancy == "" { + tenancy = "default" + } + + ebsOpt, _, err := getBool(attrs, "ebs_optimized") + if err != nil { + return nil, wrapAttr(typ, err) + } + + out := &EC2Attributes{ + InstanceType: instanceType, + AvailabilityZone: az, + Tenancy: tenancy, + EBSOptimized: ebsOpt, + } + + // root_block_device is an HCL block but the JSON plan renders it as a + // list, even though only one entry is allowed. Pull the first entry + // when present and read its size and type. + rbd, present, err := getNestedFirst(attrs, "root_block_device") + if err != nil { + return nil, wrapAttr(typ, err) + } + if present { + size, _, err := getInt(rbd, "volume_size") + if err != nil { + return nil, wrapAttr(typ+".root_block_device", err) + } + out.RootBlockSize = size + + volType, _, err := getString(rbd, "volume_type") + if err != nil { + return nil, wrapAttr(typ+".root_block_device", err) + } + out.RootBlockType = volType + } + + return out, nil +} diff --git a/internal/iac/aws/ec2_test.go b/internal/iac/aws/ec2_test.go new file mode 100644 index 0000000..a6839c0 --- /dev/null +++ b/internal/iac/aws/ec2_test.go @@ -0,0 +1,177 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractEC2_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "instance_type": "t3.large", + "availability_zone": "us-east-1a", + "tenancy": "dedicated", + "ebs_optimized": true, + "root_block_device": []interface{}{ + map[string]interface{}{ + "volume_size": float64(100), + "volume_type": "gp3", + }, + }, + // Unknown attribute — must be ignored, not error. + "some_future_field": "future-value", + } + got, err := ExtractEC2(attrs) + if err != nil { + t.Fatalf("ExtractEC2: %v", err) + } + want := EC2Attributes{ + InstanceType: "t3.large", + AvailabilityZone: "us-east-1a", + Tenancy: "dedicated", + EBSOptimized: true, + RootBlockSize: 100, + RootBlockType: "gp3", + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractEC2_OnlyRequiredFields(t *testing.T) { + attrs := map[string]interface{}{ + "instance_type": "m5.xlarge", + } + got, err := ExtractEC2(attrs) + if err != nil { + t.Fatalf("ExtractEC2: %v", err) + } + if got.InstanceType != "m5.xlarge" { + t.Errorf("InstanceType = %q", got.InstanceType) + } + // Defaults must apply when optional fields are absent. + if got.Tenancy != "default" { + t.Errorf("Tenancy = %q, want %q (default)", got.Tenancy, "default") + } + if got.EBSOptimized { + t.Error("EBSOptimized = true, want false (default)") + } + if got.AvailabilityZone != "" { + t.Errorf("AvailabilityZone = %q, want empty", got.AvailabilityZone) + } + if got.RootBlockSize != 0 || got.RootBlockType != "" { + t.Errorf("root block fields should be zero when block is absent: %+v", got) + } +} + +func TestExtractEC2_MissingInstanceType(t *testing.T) { + _, err := ExtractEC2(map[string]interface{}{ + "availability_zone": "us-east-1a", + }) + if err == nil { + t.Fatal("expected error for missing instance_type") + } + want := `aws_instance: missing required attribute "instance_type"` + if err.Error() != want { + t.Errorf("error = %q\nwant %q", err.Error(), want) + } +} + +func TestExtractEC2_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string // substring expected in error + }{ + {"instance_type as int", map[string]interface{}{"instance_type": 42}, "instance_type"}, + {"availability_zone as bool", map[string]interface{}{ + "instance_type": "t3.micro", + "availability_zone": true, + }, "availability_zone"}, + {"ebs_optimized as string", map[string]interface{}{ + "instance_type": "t3.micro", + "ebs_optimized": "yes", + }, "ebs_optimized"}, + {"root_block_device as string", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": "not-a-list", + }, "root_block_device"}, + {"root_block_device[0] not a map", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": []interface{}{"not-a-map"}, + }, "root_block_device"}, + {"tenancy as int", map[string]interface{}{ + "instance_type": "t3.micro", + "tenancy": 42, + }, "tenancy"}, + {"root_block_device.volume_size as string", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": []interface{}{ + map[string]interface{}{"volume_size": "100"}, + }, + }, "volume_size"}, + {"root_block_device.volume_type as bool", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": []interface{}{ + map[string]interface{}{"volume_type": true}, + }, + }, "volume_type"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractEC2(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_instance") { + t.Errorf("error should start with 'aws_instance': %v", err) + } + }) + } +} + +func TestExtractEC2_RootBlockDeviceEmptyList(t *testing.T) { + // HCL allows declaring `root_block_device {}` zero or one times. + // When zero times, JSON renders as []. Must not panic, must not error, + // must leave RootBlock* zero. + attrs := map[string]interface{}{ + "instance_type": "t3.small", + "root_block_device": []interface{}{}, + } + got, err := ExtractEC2(attrs) + if err != nil { + t.Fatalf("empty root_block_device list: %v", err) + } + if got.RootBlockSize != 0 || got.RootBlockType != "" { + t.Errorf("expected zero root block fields, got size=%d type=%q", + got.RootBlockSize, got.RootBlockType) + } +} + +func TestExtractEC2_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractEC2(attrs) + if err == nil { + t.Fatalf("expected error for empty/nil attrs (got nil for %v)", attrs) + } + if err.Error() != "aws_instance: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractEC2() { + attrs := map[string]interface{}{ + "instance_type": "t3.large", + "availability_zone": "us-east-1a", + } + out, err := ExtractEC2(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s in %s (tenancy=%s)\n", out.InstanceType, out.AvailabilityZone, out.Tenancy) + // Output: t3.large in us-east-1a (tenancy=default) +} diff --git a/internal/iac/aws/helpers.go b/internal/iac/aws/helpers.go new file mode 100644 index 0000000..13dbf61 --- /dev/null +++ b/internal/iac/aws/helpers.go @@ -0,0 +1,158 @@ +package aws + +import ( + "fmt" + "math" +) + +// The helpers in this file are intentionally unexported. They exist to keep +// each extractor (ec2.go / rds.go / ebs.go) small and to centralize three +// concerns that would otherwise be scattered: +// +// 1. The (value, present, error) tri-state for optional attributes — it's +// not enough to return zero+error because callers often need to apply +// a default *only* when the attribute is missing, vs. when it errored. +// 2. The float64-as-int quirk introduced by encoding/json (every JSON +// number decodes to float64 by default). +// 3. Consistent error messages across resource types so the diff engine +// in the next milestone can match on them if it needs to. + +// getString returns the string at key. +// +// - (s, true, nil) — key is present and is a string. +// - ("", false, nil) — key is missing or its value is JSON null. +// - ("", false, err) — key is present but the value is the wrong type. +// +// JSON null is treated as "missing" rather than "wrong type" because +// Terraform plans encode unset string attributes as null (not empty string), +// and the calling extractor wants to apply its own default in that case. +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 the int at key. +// +// JSON numbers unmarshal as float64 by default, so this accepts both +// float64 (whole-number values only — fractional input is an error) and +// int (in case the caller built the map programmatically). Anything else +// is a type mismatch. +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: + // JSON numbers are float64. Reject anything with a fractional part — + // the caller asked for an integer and a value like 3.5 means the + // upstream data is inconsistent, not "round it for me". + 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 the bool at key. Strict — JSON true/false only, never +// "true"/"false" strings or 0/1 integers. +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 as +// a map. This is the shape Terraform plans use for nested blocks like +// root_block_device — even when HCL only allows one block, the JSON plan +// always wraps it in an array. +// +// Returns (nil, false, nil) when the key is absent, JSON null, or an empty +// list. Returns (nil, false, err) when the value isn't a list, or its +// first element isn't a map. +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 +} + +// getStringList returns the value at key as a []string slice. +// +// JSON encodes string arrays as []interface{} of string entries, so each +// element gets a type assertion. A non-string element is an error rather +// than a silent skip — partial slices would lose data the caller likely +// needs (Lambda's `architectures`, security_group lists, etc.). +// +// Empty lists are reported as (nil, false, nil) — callers that distinguish +// "explicitly empty" from "absent" don't currently exist; aligning with +// the missing/null behavior keeps the helper predictable. +func getStringList(attrs map[string]interface{}, key string) ([]string, 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 + } + out := make([]string, len(list)) + for i, v := range list { + s, ok := v.(string) + if !ok { + return nil, false, fmt.Errorf("attribute %q[%d]: want string, got %T", key, i, v) + } + out[i] = s + } + return out, true, nil +} + +// errEmptyAttrs is the canonical error every extractor returns when its +// input map is nil or empty. Centralized so the message stays uniform. +func errEmptyAttrs(typ string) error { + return fmt.Errorf("%s: empty attributes", typ) +} + +// errMissingRequired produces a uniform message for required attributes +// that are absent or null. +func errMissingRequired(typ, key string) error { + return fmt.Errorf("%s: missing required attribute %q", typ, key) +} + +// wrapAttr wraps a helper-level error with the resource-type prefix so +// every error in the package starts with `aws_: …`. +func wrapAttr(typ string, err error) error { + return fmt.Errorf("%s: %w", typ, err) +} diff --git a/internal/iac/aws/helpers_test.go b/internal/iac/aws/helpers_test.go new file mode 100644 index 0000000..1d391e7 --- /dev/null +++ b/internal/iac/aws/helpers_test.go @@ -0,0 +1,244 @@ +package aws + +import ( + "reflect" + "strings" + "testing" +) + +func TestGetString(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + key string + want string + wantPres bool + wantErr bool + }{ + {"happy", map[string]interface{}{"k": "value"}, "k", "value", true, false}, + {"missing", map[string]interface{}{"other": "x"}, "k", "", false, false}, + {"null value treated as missing", map[string]interface{}{"k": nil}, "k", "", false, false}, + {"empty string is present", map[string]interface{}{"k": ""}, "k", "", true, false}, + {"wrong type int", map[string]interface{}{"k": 42}, "k", "", false, true}, + {"wrong type bool", map[string]interface{}{"k": true}, "k", "", false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, pres, err := getString(c.attrs, c.key) + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("value = %q, want %q", got, c.want) + } + if pres != c.wantPres { + t.Errorf("present = %v, want %v", pres, c.wantPres) + } + }) + } +} + +func TestGetInt(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want int + wantPres bool + wantErr bool + }{ + {"float64 whole number (JSON default)", map[string]interface{}{"k": float64(42)}, 42, true, false}, + {"int direct (programmatic map)", map[string]interface{}{"k": 7}, 7, true, false}, + {"zero is present", map[string]interface{}{"k": float64(0)}, 0, true, false}, + {"missing", map[string]interface{}{}, 0, false, false}, + {"null value treated as missing", map[string]interface{}{"k": nil}, 0, false, false}, + {"fractional float64 is error", map[string]interface{}{"k": 3.5}, 0, false, true}, + {"string is wrong type", map[string]interface{}{"k": "42"}, 0, false, true}, + {"bool is wrong type", map[string]interface{}{"k": true}, 0, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, pres, err := getInt(c.attrs, "k") + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("value = %d, want %d", got, c.want) + } + if pres != c.wantPres { + t.Errorf("present = %v, want %v", pres, c.wantPres) + } + }) + } +} + +func TestGetBool(t *testing.T) { + cases := []struct { + name string + val interface{} + want bool + wantPres bool + wantErr bool + }{ + {"true", true, true, true, false}, + {"false is present", false, false, true, false}, + {"missing", nil, false, false, false}, + {"string 'true' is wrong type", "true", false, false, true}, + {"int 1 is wrong type", 1, false, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := map[string]interface{}{} + if c.val != nil || c.name == "missing" { + if c.name == "missing" { + // Leave the key absent. + } else { + attrs["k"] = c.val + } + } + got, pres, err := getBool(attrs, "k") + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("value = %v, want %v", got, c.want) + } + if pres != c.wantPres { + t.Errorf("present = %v, want %v", pres, c.wantPres) + } + }) + } +} + +func TestGetNestedFirst(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + attrs := map[string]interface{}{ + "block": []interface{}{ + map[string]interface{}{"size": float64(100)}, + map[string]interface{}{"size": float64(200)}, + }, + } + got, pres, err := getNestedFirst(attrs, "block") + if err != nil || !pres { + t.Fatalf("err = %v, pres = %v", err, pres) + } + if got["size"] != float64(100) { + t.Errorf("first[size] = %v, want 100 (must take FIRST element)", got["size"]) + } + }) + + t.Run("missing key", func(t *testing.T) { + _, pres, err := getNestedFirst(map[string]interface{}{}, "block") + if err != nil || pres { + t.Errorf("missing key: err=%v pres=%v, want nil/false", err, pres) + } + }) + + t.Run("null value", func(t *testing.T) { + _, pres, err := getNestedFirst(map[string]interface{}{"block": nil}, "block") + if err != nil || pres { + t.Errorf("null value: err=%v pres=%v, want nil/false", err, pres) + } + }) + + t.Run("empty list", func(t *testing.T) { + _, pres, err := getNestedFirst(map[string]interface{}{"block": []interface{}{}}, "block") + if err != nil || pres { + t.Errorf("empty list: err=%v pres=%v, want nil/false", err, pres) + } + }) + + t.Run("not a list", func(t *testing.T) { + _, _, err := getNestedFirst(map[string]interface{}{"block": "not a list"}, "block") + if err == nil { + t.Fatal("expected error for string-typed value") + } + if !strings.Contains(err.Error(), "want list") { + t.Errorf("error should say 'want list': %v", err) + } + }) + + t.Run("first element not a map", func(t *testing.T) { + attrs := map[string]interface{}{"block": []interface{}{"string-not-map"}} + _, _, err := getNestedFirst(attrs, "block") + if err == nil { + t.Fatal("expected error for non-map first element") + } + if !strings.Contains(err.Error(), "want object") { + t.Errorf("error should say 'want object': %v", err) + } + }) +} + +func TestGetStringList(t *testing.T) { + t.Run("multi element", func(t *testing.T) { + attrs := map[string]interface{}{ + "k": []interface{}{"a", "b", "c"}, + } + got, pres, err := getStringList(attrs, "k") + if err != nil || !pres { + t.Fatalf("err=%v pres=%v", err, pres) + } + if !reflect.DeepEqual(got, []string{"a", "b", "c"}) { + t.Errorf("got %v", got) + } + }) + + t.Run("single element", func(t *testing.T) { + attrs := map[string]interface{}{"k": []interface{}{"only"}} + got, _, err := getStringList(attrs, "k") + if err != nil || len(got) != 1 || got[0] != "only" { + t.Errorf("got=%v err=%v", got, err) + } + }) + + t.Run("missing", func(t *testing.T) { + _, pres, err := getStringList(map[string]interface{}{}, "k") + if err != nil || pres { + t.Errorf("err=%v pres=%v", err, pres) + } + }) + + t.Run("null value", func(t *testing.T) { + _, pres, err := getStringList(map[string]interface{}{"k": nil}, "k") + if err != nil || pres { + t.Errorf("err=%v pres=%v", err, pres) + } + }) + + t.Run("empty list reported as not present", func(t *testing.T) { + _, pres, err := getStringList(map[string]interface{}{"k": []interface{}{}}, "k") + if err != nil || pres { + t.Errorf("err=%v pres=%v, want nil/false for empty list", err, pres) + } + }) + + t.Run("not a list", func(t *testing.T) { + _, _, err := getStringList(map[string]interface{}{"k": "string-value"}, "k") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "want list") { + t.Errorf("error should say 'want list': %v", err) + } + }) + + t.Run("non-string element", func(t *testing.T) { + attrs := map[string]interface{}{"k": []interface{}{"ok", 42}} + _, _, err := getStringList(attrs, "k") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), `"k"[1]`) { + t.Errorf("error should reference index 1: %v", err) + } + }) +} + +func TestErrorHelpers(t *testing.T) { + if got := errEmptyAttrs("aws_instance").Error(); got != `aws_instance: empty attributes` { + t.Errorf("errEmptyAttrs format unexpected: %q", got) + } + if got := errMissingRequired("aws_db_instance", "engine").Error(); got != `aws_db_instance: missing required attribute "engine"` { + t.Errorf("errMissingRequired format unexpected: %q", got) + } +} diff --git a/internal/iac/aws/lambda.go b/internal/iac/aws/lambda.go new file mode 100644 index 0000000..6f3f110 --- /dev/null +++ b/internal/iac/aws/lambda.go @@ -0,0 +1,115 @@ +package aws + +// LambdaAttributes captures the cost-impacting fields of an +// aws_lambda_function resource. +// +// Note: Lambda's main cost is invocation-based — per-request charges and +// per-GB-second compute time. The plan only declares the *standing* shape +// (memory, runtime, provisioned concurrency); we don't try to predict +// invocation volume here. The pricing engine in a later milestone will +// combine these attributes with usage assumptions or historical data. +type LambdaAttributes struct { + // FunctionName is the function's logical name. Required. + FunctionName string + + // Runtime is e.g. "python3.12" or "nodejs20.x". Optional and not used + // for pricing — we record it as context for diff messages and logs. + Runtime string + + // MemorySize is the configured memory in MB. AWS scales CPU + // proportionally, so this is the dominant per-invocation cost lever. + // Defaults to 128 MB (Lambda's API default) when absent. + MemorySize int + + // Timeout is the maximum execution time in seconds. Doesn't affect + // per-second pricing, but it does cap the per-invocation cost ceiling. + // Defaults to 3 seconds (Lambda's API default). + Timeout int + + // Architecture is "x86_64" or "arm64". arm64 is ~20% cheaper. Defaults + // to "x86_64" when absent. Validation of the value is intentionally + // deferred to the pricing engine. + Architecture string + + // ProvisionedConcurrency is the count of always-warm executions. Each + // one carries a flat per-hour fee on top of any invocation charges, + // so a non-zero value is what makes Lambda cost predictable from the + // plan alone. Zero means on-demand only. + ProvisionedConcurrency int +} + +// ExtractLambda reads cost-impacting attributes from an aws_lambda_function +// attribute map. +// +// Required: function_name. Defaults: memory_size=128, timeout=3, +// architecture="x86_64", provisioned_concurrency=0. +func ExtractLambda(attrs map[string]interface{}) (*LambdaAttributes, error) { + const typ = "aws_lambda_function" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + wrap := func(err error) error { return wrapAttr(typ, err) } + + functionName, present, err := getString(attrs, "function_name") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "function_name") + } + + runtime, _, err := getString(attrs, "runtime") + if err != nil { + return nil, wrap(err) + } + + memSize, present, err := getInt(attrs, "memory_size") + if err != nil { + return nil, wrap(err) + } + if !present { + memSize = 128 + } + + timeout, present, err := getInt(attrs, "timeout") + if err != nil { + return nil, wrap(err) + } + if !present { + timeout = 3 + } + + // `architectures` is a single-element list in the plan JSON even though + // the API only accepts one architecture per function. The extractor + // reports the first element. Empty/missing lists fall back to x86_64, + // which matches Lambda's default. Value validation (must be x86_64 + // or arm64) is a pricing concern, not a parse concern. + archList, _, err := getStringList(attrs, "architectures") + if err != nil { + return nil, wrap(err) + } + architecture := "x86_64" + if len(archList) > 0 { + architecture = archList[0] + } + + // In newer aws provider versions, provisioned concurrency moved into + // a sub-block (provisioned_concurrency_config { … }). This extractor + // reads only the top-level provisioned_concurrent_executions field; + // extending to the sub-block path is intentionally a future task — + // it would couple this milestone to provider-version detection logic + // we don't need yet. + provisioned, _, err := getInt(attrs, "provisioned_concurrent_executions") + if err != nil { + return nil, wrap(err) + } + + return &LambdaAttributes{ + FunctionName: functionName, + Runtime: runtime, + MemorySize: memSize, + Timeout: timeout, + Architecture: architecture, + ProvisionedConcurrency: provisioned, + }, nil +} diff --git a/internal/iac/aws/lambda_test.go b/internal/iac/aws/lambda_test.go new file mode 100644 index 0000000..cba5fc9 --- /dev/null +++ b/internal/iac/aws/lambda_test.go @@ -0,0 +1,173 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractLambda_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "function_name": "billing-processor", + "runtime": "python3.12", + "memory_size": float64(2048), + "timeout": float64(900), + "architectures": []interface{}{"arm64"}, + "provisioned_concurrent_executions": float64(5), + // Unknown field — must be ignored. + "some_future_attr": "ignored", + } + got, err := ExtractLambda(attrs) + if err != nil { + t.Fatalf("ExtractLambda: %v", err) + } + want := LambdaAttributes{ + FunctionName: "billing-processor", + Runtime: "python3.12", + MemorySize: 2048, + Timeout: 900, + Architecture: "arm64", + ProvisionedConcurrency: 5, + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractLambda_OnlyRequiredFields(t *testing.T) { + got, err := ExtractLambda(map[string]interface{}{ + "function_name": "minimal", + }) + if err != nil { + t.Fatalf("ExtractLambda: %v", err) + } + if got.MemorySize != 128 { + t.Errorf("MemorySize = %d, want 128 (Lambda default)", got.MemorySize) + } + if got.Timeout != 3 { + t.Errorf("Timeout = %d, want 3 (Lambda default)", got.Timeout) + } + if got.Architecture != "x86_64" { + t.Errorf("Architecture = %q, want x86_64 (default)", got.Architecture) + } + if got.ProvisionedConcurrency != 0 { + t.Errorf("ProvisionedConcurrency = %d, want 0", got.ProvisionedConcurrency) + } +} + +// TestExtractLambda_ArchitecturesVariants covers the three shapes the +// architectures field can arrive in: a single-element list (the canonical +// case), an empty list, and an absent field. The latter two must both +// produce the x86_64 default. +func TestExtractLambda_ArchitecturesVariants(t *testing.T) { + cases := []struct { + name string + val interface{} + want string + setIt bool + }{ + {"single element x86_64", []interface{}{"x86_64"}, "x86_64", true}, + {"single element arm64", []interface{}{"arm64"}, "arm64", true}, + {"empty list defaults", []interface{}{}, "x86_64", true}, + {"absent defaults", nil, "x86_64", false}, + // Unknown architecture value passes through — validation belongs + // to the pricing engine, not the extractor. + {"unknown value passes through", []interface{}{"riscv"}, "riscv", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := map[string]interface{}{"function_name": "fn"} + if c.setIt { + attrs["architectures"] = c.val + } + got, err := ExtractLambda(attrs) + if err != nil { + t.Fatalf("ExtractLambda: %v", err) + } + if got.Architecture != c.want { + t.Errorf("Architecture = %q, want %q", got.Architecture, c.want) + } + }) + } +} + +func TestExtractLambda_MissingFunctionName(t *testing.T) { + _, err := ExtractLambda(map[string]interface{}{ + "runtime": "python3.12", + }) + if err == nil { + t.Fatal("expected error for missing function_name") + } + want := `aws_lambda_function: missing required attribute "function_name"` + if err.Error() != want { + t.Errorf("error = %q\nwant %q", err.Error(), want) + } +} + +func TestExtractLambda_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + {"function_name as int", map[string]interface{}{"function_name": 42}, "function_name"}, + {"runtime as bool", map[string]interface{}{ + "function_name": "fn", "runtime": true, + }, "runtime"}, + {"memory_size as string", map[string]interface{}{ + "function_name": "fn", "memory_size": "1024", + }, "memory_size"}, + {"timeout fractional", map[string]interface{}{ + "function_name": "fn", "timeout": 5.5, + }, "timeout"}, + {"architectures not a list", map[string]interface{}{ + "function_name": "fn", "architectures": "arm64", + }, "architectures"}, + {"architectures contains non-string", map[string]interface{}{ + "function_name": "fn", "architectures": []interface{}{42}, + }, "architectures"}, + {"provisioned_concurrent_executions as string", map[string]interface{}{ + "function_name": "fn", "provisioned_concurrent_executions": "5", + }, "provisioned_concurrent_executions"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractLambda(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_lambda_function") { + t.Errorf("error should start with aws_lambda_function: %v", err) + } + }) + } +} + +func TestExtractLambda_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractLambda(attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "aws_lambda_function: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractLambda() { + attrs := map[string]interface{}{ + "function_name": "image-resize", + "memory_size": float64(1024), + "architectures": []interface{}{"arm64"}, + } + out, err := ExtractLambda(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s on %s, %dMB\n", out.FunctionName, out.Architecture, out.MemorySize) + // Output: image-resize on arm64, 1024MB +} diff --git a/internal/iac/aws/nat.go b/internal/iac/aws/nat.go new file mode 100644 index 0000000..5fd5aad --- /dev/null +++ b/internal/iac/aws/nat.go @@ -0,0 +1,56 @@ +package aws + +// NATGatewayAttributes captures the cost-impacting fields of an +// aws_nat_gateway resource. +// +// NAT Gateways are notorious in cost-review meetings because they have +// two charges that surprise people: a flat per-hour fee (~$32/month each, +// 24x7) plus per-GB data processing on every byte that flows through. +// A PR that adds even one of these to a private subnet should produce a +// loud signal in the diff comment. +type NATGatewayAttributes struct { + // SubnetID is the subnet the gateway lives in. Required because + // downstream pricing infers the AZ (and hence the region) from the + // subnet — NAT pricing is regional. We don't fetch the subnet's AZ + // here; that's the diff/pricing engine's job. + SubnetID string + + // ConnectivityType is "public" or "private". Public is the default + // and is what most teams use; "private" is for cross-VPC private + // access without internet egress and has a different price structure. + // Defaults to "public" when absent — matching the AWS provider default. + ConnectivityType string +} + +// ExtractNATGateway reads cost-impacting attributes from an aws_nat_gateway +// attribute map. +// +// Required: subnet_id. Defaults: connectivity_type="public". +func ExtractNATGateway(attrs map[string]interface{}) (*NATGatewayAttributes, error) { + const typ = "aws_nat_gateway" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + wrap := func(err error) error { return wrapAttr(typ, err) } + + subnetID, present, err := getString(attrs, "subnet_id") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "subnet_id") + } + + connType, _, err := getString(attrs, "connectivity_type") + if err != nil { + return nil, wrap(err) + } + if connType == "" { + connType = "public" + } + + return &NATGatewayAttributes{ + SubnetID: subnetID, + ConnectivityType: connType, + }, nil +} diff --git a/internal/iac/aws/nat_test.go b/internal/iac/aws/nat_test.go new file mode 100644 index 0000000..7aebef1 --- /dev/null +++ b/internal/iac/aws/nat_test.go @@ -0,0 +1,103 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractNATGateway_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "subnet_id": "subnet-0a1b2c3d4e5f", + "connectivity_type": "private", + "some_extra": "ignored", + } + got, err := ExtractNATGateway(attrs) + if err != nil { + t.Fatalf("ExtractNATGateway: %v", err) + } + want := NATGatewayAttributes{ + SubnetID: "subnet-0a1b2c3d4e5f", + ConnectivityType: "private", + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractNATGateway_OnlyRequiredFields(t *testing.T) { + got, err := ExtractNATGateway(map[string]interface{}{ + "subnet_id": "subnet-deadbeef", + }) + if err != nil { + t.Fatalf("ExtractNATGateway: %v", err) + } + if got.ConnectivityType != "public" { + t.Errorf("ConnectivityType = %q, want public (default)", got.ConnectivityType) + } +} + +func TestExtractNATGateway_MissingSubnetID(t *testing.T) { + _, err := ExtractNATGateway(map[string]interface{}{ + "connectivity_type": "public", + }) + if err == nil { + t.Fatal("expected error for missing subnet_id") + } + want := `aws_nat_gateway: missing required attribute "subnet_id"` + if err.Error() != want { + t.Errorf("error = %q\nwant %q", err.Error(), want) + } +} + +func TestExtractNATGateway_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + {"subnet_id as int", map[string]interface{}{"subnet_id": 42}, "subnet_id"}, + {"connectivity_type as bool", map[string]interface{}{ + "subnet_id": "subnet-x", + "connectivity_type": true, + }, "connectivity_type"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractNATGateway(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_nat_gateway") { + t.Errorf("error should start with aws_nat_gateway: %v", err) + } + }) + } +} + +func TestExtractNATGateway_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractNATGateway(attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "aws_nat_gateway: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractNATGateway() { + attrs := map[string]interface{}{ + "subnet_id": "subnet-1a2b3c4d", + } + out, err := ExtractNATGateway(attrs) + if err != nil { + panic(err) + } + fmt.Printf("nat in %s (type=%s)\n", out.SubnetID, out.ConnectivityType) + // Output: nat in subnet-1a2b3c4d (type=public) +} diff --git a/internal/iac/aws/rds.go b/internal/iac/aws/rds.go new file mode 100644 index 0000000..fefbecb --- /dev/null +++ b/internal/iac/aws/rds.go @@ -0,0 +1,111 @@ +package aws + +// RDSAttributes captures the cost-impacting fields of an aws_db_instance +// resource. Note: aws_rds_cluster (Aurora cluster-level) is a separate +// resource type and is out of scope for this milestone. +type RDSAttributes struct { + // Engine is the DB engine, e.g. "postgres", "mysql", "mariadb", + // "aurora-postgresql", "aurora-mysql". The aurora-* variants are valid + // because aws_db_instance is also used to declare Aurora *read replicas* + // (the cluster itself uses aws_rds_cluster, but instances attached to + // it are still aws_db_instance). Required. + Engine string + + // EngineVersion pins the engine release, e.g. "15.4". Optional — when + // absent, AWS picks the default version. + EngineVersion string + + // InstanceClass is the DB instance size, e.g. "db.t3.medium". Required. + InstanceClass string + + // AllocatedStorage is the size in GB of the underlying volume. Required. + AllocatedStorage int + + // StorageType selects the EBS-backed storage class (gp2 / gp3 / io1 / + // standard). Defaults to "gp2", matching the AWS provider default. + StorageType string + + // Iops is the provisioned IOPS for io1/gp3 storage. Zero means + // "use the storage class default". Cross-attribute consistency + // (io1 must have iops, gp3 may have iops) is *not* validated here — + // that's a pricing-engine concern. + Iops int + + // MultiAZ enables a synchronous standby replica in another AZ. + // Significant pricing impact (roughly doubles the per-hour cost). + // Defaults to false. + MultiAZ bool +} + +// ExtractRDS reads cost-impacting attributes from an aws_db_instance +// attribute map. +// +// Required: engine, instance_class, allocated_storage. Defaults: +// storage_type="gp2", iops=0, multi_az=false. +// +// The engine field accepts Aurora variants ("aurora-postgresql", +// "aurora-mysql") because aws_db_instance is the resource type for Aurora +// read replicas; the cluster head uses aws_rds_cluster (not handled here). +func ExtractRDS(attrs map[string]interface{}) (*RDSAttributes, error) { + const typ = "aws_db_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + engine, present, err := getString(attrs, "engine") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "engine") + } + + instanceClass, present, err := getString(attrs, "instance_class") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "instance_class") + } + + allocStorage, present, err := getInt(attrs, "allocated_storage") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "allocated_storage") + } + + engineVersion, _, err := getString(attrs, "engine_version") + if err != nil { + return nil, wrapAttr(typ, err) + } + + storageType, _, err := getString(attrs, "storage_type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if storageType == "" { + storageType = "gp2" + } + + iops, _, err := getInt(attrs, "iops") + if err != nil { + return nil, wrapAttr(typ, err) + } + + multiAZ, _, err := getBool(attrs, "multi_az") + if err != nil { + return nil, wrapAttr(typ, err) + } + + return &RDSAttributes{ + Engine: engine, + EngineVersion: engineVersion, + InstanceClass: instanceClass, + AllocatedStorage: allocStorage, + StorageType: storageType, + Iops: iops, + MultiAZ: multiAZ, + }, nil +} diff --git a/internal/iac/aws/rds_cluster_instance.go b/internal/iac/aws/rds_cluster_instance.go new file mode 100644 index 0000000..d459966 --- /dev/null +++ b/internal/iac/aws/rds_cluster_instance.go @@ -0,0 +1,78 @@ +package aws + +// RDSClusterInstanceAttributes captures the cost-impacting fields of an +// aws_rds_cluster_instance resource — i.e. a single Aurora compute node +// attached to a cluster. The cluster header itself (aws_rds_cluster) is +// out of scope for this milestone because its cost is mostly storage, +// while compute-per-instance is what shows up in PRs. +type RDSClusterInstanceAttributes struct { + // ClusterIdentifier links this instance to its parent cluster. + // Required — without it, downstream cost analysis can't associate the + // instance with cluster-level attributes (engine version, storage). + ClusterIdentifier string + + // InstanceClass is the Aurora compute class, e.g. "db.r5.large". Required. + // Aurora pricing uses the same db.* family naming as RDS but a different + // price sheet, so the value passes through untouched. + InstanceClass string + + // Engine is the Aurora engine variant: "aurora-postgresql", + // "aurora-mysql", or the legacy "aurora" (MySQL 5.6). Accepted values + // are intentionally not validated here — the pricing engine in a later + // milestone owns the catalog of recognized engines. + Engine string + + // EngineVersion pins the Aurora release. Optional; AWS picks the + // cluster default when absent. + EngineVersion string +} + +// ExtractRDSClusterInstance reads cost-impacting attributes from an +// aws_rds_cluster_instance attribute map. +// +// Required: cluster_identifier, instance_class, engine. EngineVersion is +// optional. We don't validate engine values against any known list — that +// catalog lives with the pricing engine. +func ExtractRDSClusterInstance(attrs map[string]interface{}) (*RDSClusterInstanceAttributes, error) { + const typ = "aws_rds_cluster_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + wrap := func(err error) error { return wrapAttr(typ, err) } + + clusterID, present, err := getString(attrs, "cluster_identifier") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "cluster_identifier") + } + + instanceClass, present, err := getString(attrs, "instance_class") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "instance_class") + } + + engine, present, err := getString(attrs, "engine") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "engine") + } + + engineVersion, _, err := getString(attrs, "engine_version") + if err != nil { + return nil, wrap(err) + } + + return &RDSClusterInstanceAttributes{ + ClusterIdentifier: clusterID, + InstanceClass: instanceClass, + Engine: engine, + EngineVersion: engineVersion, + }, nil +} diff --git a/internal/iac/aws/rds_cluster_instance_test.go b/internal/iac/aws/rds_cluster_instance_test.go new file mode 100644 index 0000000..ff6dd86 --- /dev/null +++ b/internal/iac/aws/rds_cluster_instance_test.go @@ -0,0 +1,162 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractRDSClusterInstance_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "cluster_identifier": "billing-cluster", + "instance_class": "db.r6g.xlarge", + "engine": "aurora-postgresql", + "engine_version": "15.4", + "new_provider_field": "ignored", + } + got, err := ExtractRDSClusterInstance(attrs) + if err != nil { + t.Fatalf("ExtractRDSClusterInstance: %v", err) + } + want := RDSClusterInstanceAttributes{ + ClusterIdentifier: "billing-cluster", + InstanceClass: "db.r6g.xlarge", + Engine: "aurora-postgresql", + EngineVersion: "15.4", + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractRDSClusterInstance_OnlyRequiredFields(t *testing.T) { + got, err := ExtractRDSClusterInstance(map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.t3.medium", + "engine": "aurora-mysql", + }) + if err != nil { + t.Fatalf("ExtractRDSClusterInstance: %v", err) + } + if got.EngineVersion != "" { + t.Errorf("EngineVersion = %q, want empty (optional, absent)", got.EngineVersion) + } +} + +// TestExtractRDSClusterInstance_AcceptsLegacyAuroraEngine confirms that +// "aurora" (the legacy MySQL 5.6 form) is accepted alongside the modern +// aurora-mysql/aurora-postgresql variants. The extractor doesn't validate +// against any catalog — that's the pricing engine's job. +func TestExtractRDSClusterInstance_AcceptsLegacyAuroraEngine(t *testing.T) { + for _, engine := range []string{"aurora", "aurora-mysql", "aurora-postgresql"} { + t.Run(engine, func(t *testing.T) { + got, err := ExtractRDSClusterInstance(map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.r5.large", + "engine": engine, + }) + if err != nil { + t.Fatalf("engine=%s: %v", engine, err) + } + if got.Engine != engine { + t.Errorf("Engine = %q, want %q", got.Engine, engine) + } + }) + } +} + +func TestExtractRDSClusterInstance_MissingRequired(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + { + "no cluster_identifier", + map[string]interface{}{"instance_class": "db.t3.medium", "engine": "aurora-mysql"}, + `aws_rds_cluster_instance: missing required attribute "cluster_identifier"`, + }, + { + "no instance_class", + map[string]interface{}{"cluster_identifier": "c1", "engine": "aurora-mysql"}, + `aws_rds_cluster_instance: missing required attribute "instance_class"`, + }, + { + "no engine", + map[string]interface{}{"cluster_identifier": "c1", "instance_class": "db.t3.medium"}, + `aws_rds_cluster_instance: missing required attribute "engine"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractRDSClusterInstance(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestExtractRDSClusterInstance_WrongTypes(t *testing.T) { + base := map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.t3.medium", + "engine": "aurora-mysql", + } + cases := []struct { + name string + key string + val interface{} + }{ + {"cluster_identifier as int", "cluster_identifier", 42}, + {"instance_class as bool", "instance_class", true}, + {"engine as int", "engine", 7}, + {"engine_version as bool", "engine_version", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := make(map[string]interface{}, len(base)+1) + for k, v := range base { + attrs[k] = v + } + attrs[c.key] = c.val + + _, err := ExtractRDSClusterInstance(attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.key) { + t.Errorf("error should mention %q: %v", c.key, err) + } + }) + } +} + +func TestExtractRDSClusterInstance_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractRDSClusterInstance(attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "aws_rds_cluster_instance: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractRDSClusterInstance() { + attrs := map[string]interface{}{ + "cluster_identifier": "billing-cluster", + "instance_class": "db.r6g.large", + "engine": "aurora-postgresql", + } + out, err := ExtractRDSClusterInstance(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s -> %s (%s)\n", out.ClusterIdentifier, out.InstanceClass, out.Engine) + // Output: billing-cluster -> db.r6g.large (aurora-postgresql) +} diff --git a/internal/iac/aws/rds_test.go b/internal/iac/aws/rds_test.go new file mode 100644 index 0000000..5430215 --- /dev/null +++ b/internal/iac/aws/rds_test.go @@ -0,0 +1,181 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractRDS_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "engine": "postgres", + "engine_version": "15.4", + "instance_class": "db.r5.large", + "allocated_storage": float64(500), + "storage_type": "io1", + "iops": float64(10000), + "multi_az": true, + // Unknown future field — must be ignored. + "new_attr": "ignored", + } + got, err := ExtractRDS(attrs) + if err != nil { + t.Fatalf("ExtractRDS: %v", err) + } + want := RDSAttributes{ + Engine: "postgres", + EngineVersion: "15.4", + InstanceClass: "db.r5.large", + AllocatedStorage: 500, + StorageType: "io1", + Iops: 10000, + MultiAZ: true, + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractRDS_OnlyRequiredFields(t *testing.T) { + attrs := map[string]interface{}{ + "engine": "mysql", + "instance_class": "db.t3.medium", + "allocated_storage": float64(20), + } + got, err := ExtractRDS(attrs) + if err != nil { + t.Fatalf("ExtractRDS: %v", err) + } + if got.StorageType != "gp2" { + t.Errorf("StorageType = %q, want %q (default)", got.StorageType, "gp2") + } + if got.MultiAZ { + t.Error("MultiAZ = true, want false (default)") + } + if got.Iops != 0 { + t.Errorf("Iops = %d, want 0", got.Iops) + } +} + +// TestExtractRDS_AuroraEngines verifies that aurora-postgresql / aurora-mysql +// are accepted as engine values. aws_db_instance covers Aurora *read replicas* +// (the cluster head is a separate resource type), so these must work. +func TestExtractRDS_AuroraEngines(t *testing.T) { + for _, engine := range []string{"aurora-postgresql", "aurora-mysql"} { + t.Run(engine, func(t *testing.T) { + attrs := map[string]interface{}{ + "engine": engine, + "instance_class": "db.r6g.large", + "allocated_storage": float64(0), + } + got, err := ExtractRDS(attrs) + if err != nil { + t.Fatalf("ExtractRDS(%s): %v", engine, err) + } + if got.Engine != engine { + t.Errorf("Engine = %q, want %q", got.Engine, engine) + } + }) + } +} + +func TestExtractRDS_MissingRequired(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + { + "no engine", + map[string]interface{}{"instance_class": "db.t3.micro", "allocated_storage": float64(20)}, + `aws_db_instance: missing required attribute "engine"`, + }, + { + "no instance_class", + map[string]interface{}{"engine": "postgres", "allocated_storage": float64(20)}, + `aws_db_instance: missing required attribute "instance_class"`, + }, + { + "no allocated_storage", + map[string]interface{}{"engine": "postgres", "instance_class": "db.t3.micro"}, + `aws_db_instance: missing required attribute "allocated_storage"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractRDS(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestExtractRDS_WrongTypes(t *testing.T) { + base := map[string]interface{}{ + "engine": "postgres", + "instance_class": "db.t3.micro", + "allocated_storage": float64(20), + } + cases := []struct { + name string + key string + val interface{} + }{ + {"engine as int", "engine", 42}, + {"instance_class as bool", "instance_class", true}, + {"engine_version as int", "engine_version", 15}, + {"storage_type as int", "storage_type", 7}, + {"allocated_storage as string", "allocated_storage", "20"}, + {"multi_az as string", "multi_az", "true"}, + {"iops fractional", "iops", 3.14}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := make(map[string]interface{}, len(base)+1) + for k, v := range base { + attrs[k] = v + } + attrs[c.key] = c.val + + _, err := ExtractRDS(attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.key) { + t.Errorf("error should mention %q: %v", c.key, err) + } + }) + } +} + +func TestExtractRDS_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractRDS(attrs) + if err == nil { + t.Fatal("expected error for empty/nil attrs") + } + if err.Error() != "aws_db_instance: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractRDS() { + attrs := map[string]interface{}{ + "engine": "postgres", + "instance_class": "db.t3.medium", + "allocated_storage": float64(100), + "multi_az": true, + } + out, err := ExtractRDS(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s %s, %dGB, multi_az=%v\n", + out.Engine, out.InstanceClass, out.AllocatedStorage, out.MultiAZ) + // Output: postgres db.t3.medium, 100GB, multi_az=true +} diff --git a/internal/iac/terraform.go b/internal/iac/terraform.go new file mode 100644 index 0000000..6dc4dd9 --- /dev/null +++ b/internal/iac/terraform.go @@ -0,0 +1,150 @@ +// Package iac parses infrastructure-as-code artifacts (Terraform plans today, +// other tools later) into a uniform shape that downstream cost-impact analysis +// can consume. +// +// This package owns *parsing and structural validation only*. Per-resource +// attribute extraction (instance_type for aws_instance, allocated_storage for +// aws_db_instance, etc.) is intentionally out of scope here — it lives in a +// later milestone so the parser doesn't have to be touched whenever a new +// resource type is supported. +package iac + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +// Plan represents a parsed Terraform plan, i.e. the JSON document produced by +// `terraform show -json plan.tfplan`. +// +// We intentionally model only the fields downstream code reads. Real plans +// also include `prior_state`, `configuration`, `planned_values`, etc.; +// encoding/json silently ignores unknown fields, which is what we want for +// forward compatibility with future Terraform versions. +type Plan struct { + FormatVersion string `json:"format_version"` + TerraformVersion string `json:"terraform_version"` + ResourceChanges []ResourceChange `json:"resource_changes"` +} + +// Action is the canonical action type for a resource change. +// +// Note: ActionReplace is a *synthetic* action — Terraform itself reports +// replacements as a two-element actions slice, usually ["delete","create"] +// (the default lifecycle) or ["create","delete"] (when create_before_destroy +// is set). We collapse both into ActionReplace so downstream code (cost +// diffs, PR comments) can pattern-match on a single value. +type Action string + +// Canonical action values. The string forms match Terraform's wire format +// 1:1 except for "replace", which Terraform doesn't emit directly. +const ( + ActionNoop Action = "no-op" + ActionCreate Action = "create" + ActionRead Action = "read" + ActionUpdate Action = "update" + ActionDelete Action = "delete" + ActionReplace Action = "replace" +) + +// ResourceChange describes a single resource's planned change in a Terraform +// plan. One ResourceChange per address — there is exactly one entry per +// resource regardless of how many attributes change. +type ResourceChange struct { + Address string `json:"address"` + Mode string `json:"mode"` // "managed" or "data" + Type string `json:"type"` // e.g. "aws_instance" + Name string `json:"name"` + ProviderName string `json:"provider_name"` + Change Change `json:"change"` +} + +// Change holds the before/after state of a resource and the actions +// Terraform plans to take. Before is nil for a create; After is nil for +// a delete; both are populated for update and replace. +// +// Before and After are intentionally kept as map[string]interface{} rather +// than strongly-typed structs because the shape depends on the resource +// type (aws_instance vs aws_db_instance vs google_compute_instance, etc.). +// Per-type attribute extraction is the job of the next milestone — keeping +// the raw map here means new resource types can be supported later without +// touching this parser. +type Change struct { + Actions []string `json:"actions"` + Before map[string]interface{} `json:"before"` + After map[string]interface{} `json:"after"` +} + +// Action returns the canonical Action for this resource change. +// +// Replacement detection: Terraform emits a two-element actions slice for +// resource replacements — ["delete","create"] under the default lifecycle, +// ["create","delete"] when create_before_destroy is set. Either order is +// reported as ActionReplace. +// +// For unknown action strings (e.g. a hypothetical future "import"), the +// raw value is returned as Action("…") rather than swallowed. Callers can +// compare against the known constants and treat anything else as unknown. +func (rc ResourceChange) Action() Action { + a := rc.Change.Actions + if len(a) == 2 { + if (a[0] == "delete" && a[1] == "create") || + (a[0] == "create" && a[1] == "delete") { + return ActionReplace + } + } + if len(a) == 0 { + // Defensive: Terraform always emits at least one action, but a + // hand-crafted or truncated plan might not. Treat empty as no-op. + return ActionNoop + } + return Action(a[0]) +} + +// IsManaged reports whether this is a managed resource (operator-controlled +// infrastructure) as opposed to a data source. Data sources are read-only +// lookups and never have a cost impact, so cost-diff code skips them. +func (rc ResourceChange) IsManaged() bool { + return rc.Mode == "managed" +} + +// ParsePlan parses a Terraform plan JSON from r. +// +// Returns an error if: +// - the JSON is malformed +// - the document is missing format_version (likely the wrong file: +// a terraform.tfstate, a non-JSON `terraform show`, or unrelated JSON) +// +// resource_changes may be absent or null; both forms are accepted as a +// valid empty plan and surface as a Plan with nil ResourceChanges. +func ParsePlan(r io.Reader) (*Plan, error) { + var p Plan + if err := json.NewDecoder(r).Decode(&p); err != nil { + return nil, fmt.Errorf("decoding terraform plan JSON: %w", err) + } + // format_version is the cheapest sanity check that this is actually a + // `terraform show -json` document. Without it we'd silently accept + // arbitrary JSON and surface confusing errors much later. + if p.FormatVersion == "" { + return nil, errors.New( + "terraform plan: missing required field format_version " + + "(was the file produced by `terraform show -json`?)", + ) + } + return &p, nil +} + +// ParsePlanFile is a convenience wrapper around ParsePlan that opens the +// file at path and reads from it. Errors from os.Open are wrapped so +// callers can use errors.Is(err, os.ErrNotExist) to detect missing files. +func ParsePlanFile(path string) (*Plan, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening terraform plan file %q: %w", path, err) + } + defer f.Close() + return ParsePlan(f) +} diff --git a/internal/iac/terraform_test.go b/internal/iac/terraform_test.go new file mode 100644 index 0000000..743927f --- /dev/null +++ b/internal/iac/terraform_test.go @@ -0,0 +1,251 @@ +package iac + +import ( + "errors" + "os" + "strings" + "testing" +) + +func TestParsePlan_SimpleCreate(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_simple_create.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + + if p.FormatVersion != "1.2" { + t.Errorf("FormatVersion = %q, want 1.2", p.FormatVersion) + } + if p.TerraformVersion != "1.6.0" { + t.Errorf("TerraformVersion = %q, want 1.6.0", p.TerraformVersion) + } + if len(p.ResourceChanges) != 1 { + t.Fatalf("len(ResourceChanges) = %d, want 1", len(p.ResourceChanges)) + } + + rc := p.ResourceChanges[0] + if rc.Address != "aws_instance.web" { + t.Errorf("Address = %q, want aws_instance.web", rc.Address) + } + if rc.Type != "aws_instance" { + t.Errorf("Type = %q, want aws_instance", rc.Type) + } + if rc.Action() != ActionCreate { + t.Errorf("Action() = %q, want %q", rc.Action(), ActionCreate) + } + if !rc.IsManaged() { + t.Error("IsManaged() = false, want true") + } + if rc.Change.Before != nil { + t.Errorf("Before = %v, want nil for create", rc.Change.Before) + } + if got := rc.Change.After["instance_type"]; got != "t3.large" { + t.Errorf("After[instance_type] = %v, want t3.large", got) + } +} + +// TestParsePlan_Mixed verifies that a plan with multiple kinds of changes +// produces the right Action for each, including the data-source read which +// IsManaged() must filter out. +func TestParsePlan_Mixed(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_mixed.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + + got := map[string]Action{} + managed := map[string]bool{} + for _, rc := range p.ResourceChanges { + got[rc.Address] = rc.Action() + managed[rc.Address] = rc.IsManaged() + } + + wantActions := map[string]Action{ + "aws_instance.api": ActionCreate, + "aws_ebs_volume.cache": ActionDelete, + "aws_db_instance.main": ActionUpdate, + "data.aws_ami.ubuntu": ActionRead, + } + for addr, want := range wantActions { + if got[addr] != want { + t.Errorf("Action(%s) = %q, want %q", addr, got[addr], want) + } + } + + wantManaged := map[string]bool{ + "aws_instance.api": true, + "aws_ebs_volume.cache": true, + "aws_db_instance.main": true, + "data.aws_ami.ubuntu": false, + } + for addr, want := range wantManaged { + if managed[addr] != want { + t.Errorf("IsManaged(%s) = %v, want %v", addr, managed[addr], want) + } + } +} + +func TestParsePlan_Replace(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_replace.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + if got := p.ResourceChanges[0].Action(); got != ActionReplace { + t.Errorf("Action() = %q, want %q (delete+create should collapse to replace)", + got, ActionReplace) + } +} + +// TestParsePlan_ReplaceReversed verifies that ["create","delete"] (the +// create_before_destroy lifecycle order) also collapses to ActionReplace, +// not just the default ["delete","create"] order. +func TestParsePlan_ReplaceReversed(t *testing.T) { + in := `{ + "format_version": "1.2", + "resource_changes": [{ + "address": "aws_instance.x", + "mode": "managed", "type": "aws_instance", "name": "x", + "change": { "actions": ["create","delete"], "before": null, "after": null } + }] + }` + p, err := ParsePlan(strings.NewReader(in)) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if got := p.ResourceChanges[0].Action(); got != ActionReplace { + t.Errorf("Action() = %q, want %q", got, ActionReplace) + } +} + +// TestParsePlan_EmptyPlan covers all three forms Terraform may use for a +// plan with no changes: explicit empty array, explicit null, and the field +// omitted entirely. None of them should be rejected. +func TestParsePlan_EmptyPlan(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_empty.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + if len(p.ResourceChanges) != 0 { + t.Errorf("len(ResourceChanges) = %d, want 0", len(p.ResourceChanges)) + } + + cases := []struct { + name string + json string + }{ + {"null array", `{"format_version": "1.2", "resource_changes": null}`}, + {"omitted field", `{"format_version": "1.2"}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plan, err := ParsePlan(strings.NewReader(c.json)) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if len(plan.ResourceChanges) != 0 { + t.Errorf("len(ResourceChanges) = %d, want 0", len(plan.ResourceChanges)) + } + }) + } +} + +func TestParsePlan_InvalidJSON(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"garbage", "not json at all"}, + {"unterminated object", "{"}, + {"empty input", ""}, + {"truncated mid-string", `{"format_version": "1.`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ParsePlan(strings.NewReader(c.in)) + if err == nil { + t.Errorf("expected error for %q, got nil", c.in) + return + } + if !strings.Contains(err.Error(), "decoding terraform plan JSON") { + t.Errorf("error should mention decoding context: %v", err) + } + }) + } +} + +func TestParsePlan_MissingFormatVersion(t *testing.T) { + in := `{"terraform_version": "1.6.0", "resource_changes": []}` + _, err := ParsePlan(strings.NewReader(in)) + if err == nil { + t.Fatal("expected error for missing format_version, got nil") + } + if !strings.Contains(err.Error(), "format_version") { + t.Errorf("error should mention format_version: %v", err) + } +} + +func TestIsManaged(t *testing.T) { + cases := []struct { + mode string + want bool + }{ + {"managed", true}, + {"data", false}, + {"", false}, + {"unknown", false}, + } + for _, c := range cases { + got := ResourceChange{Mode: c.mode}.IsManaged() + if got != c.want { + t.Errorf("Mode=%q: IsManaged() = %v, want %v", c.mode, got, c.want) + } + } +} + +// TestAction_EdgeCases covers Action() branches not exercised by the +// fixtures: empty actions slice (defensive no-op fallback), unknown action +// strings (passed through), and a non-replace two-element slice. +func TestAction_EdgeCases(t *testing.T) { + cases := []struct { + name string + actions []string + want Action + }{ + {"empty", []string{}, ActionNoop}, + {"nil", nil, ActionNoop}, + {"unknown action passes through", []string{"import"}, Action("import")}, + {"two non-replace actions does not collapse", []string{"create", "update"}, ActionCreate}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rc := ResourceChange{Change: Change{Actions: c.actions}} + if got := rc.Action(); got != c.want { + t.Errorf("Action() = %q, want %q", got, c.want) + } + }) + } +} + +func TestParsePlanFile(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_simple_create.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + if p == nil { + t.Fatal("got nil plan") + } + }) + + t.Run("file not found", func(t *testing.T) { + _, err := ParsePlanFile("testdata/this-does-not-exist.json") + if err == nil { + t.Fatal("expected error opening missing file") + } + // The error chain must preserve os.ErrNotExist so callers can + // distinguish "missing file" from other failure modes. + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error should wrap os.ErrNotExist: %v", err) + } + }) +} diff --git a/internal/iac/testdata/plan_empty.json b/internal/iac/testdata/plan_empty.json new file mode 100644 index 0000000..cc412b2 --- /dev/null +++ b/internal/iac/testdata/plan_empty.json @@ -0,0 +1,5 @@ +{ + "format_version": "1.2", + "terraform_version": "1.6.0", + "resource_changes": [] +} diff --git a/internal/iac/testdata/plan_mixed.json b/internal/iac/testdata/plan_mixed.json new file mode 100644 index 0000000..5df4b38 --- /dev/null +++ b/internal/iac/testdata/plan_mixed.json @@ -0,0 +1,72 @@ +{ + "format_version": "1.2", + "terraform_version": "1.7.4", + "resource_changes": [ + { + "address": "aws_instance.api", + "mode": "managed", + "type": "aws_instance", + "name": "api", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "instance_type": "m5.large", + "ami": "ami-9876543210abcdef0", + "availability_zone": "us-east-2a" + } + } + }, + { + "address": "aws_ebs_volume.cache", + "mode": "managed", + "type": "aws_ebs_volume", + "name": "cache", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["delete"], + "before": { + "size": 500, + "type": "gp3", + "availability_zone": "us-east-2a" + }, + "after": null + } + }, + { + "address": "aws_db_instance.main", + "mode": "managed", + "type": "aws_db_instance", + "name": "main", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["update"], + "before": { + "instance_class": "db.t3.medium", + "engine": "postgres", + "allocated_storage": 100 + }, + "after": { + "instance_class": "db.r5.large", + "engine": "postgres", + "allocated_storage": 100 + } + } + }, + { + "address": "data.aws_ami.ubuntu", + "mode": "data", + "type": "aws_ami", + "name": "ubuntu", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["read"], + "before": null, + "after": { + "id": "ami-deadbeef00000000" + } + } + } + ] +} diff --git a/internal/iac/testdata/plan_replace.json b/internal/iac/testdata/plan_replace.json new file mode 100644 index 0000000..5783931 --- /dev/null +++ b/internal/iac/testdata/plan_replace.json @@ -0,0 +1,24 @@ +{ + "format_version": "1.2", + "terraform_version": "1.6.0", + "resource_changes": [ + { + "address": "aws_instance.legacy", + "mode": "managed", + "type": "aws_instance", + "name": "legacy", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["delete", "create"], + "before": { + "instance_type": "t3.medium", + "ami": "ami-0000000000000aaaa" + }, + "after": { + "instance_type": "m5.xlarge", + "ami": "ami-1111111111111bbbb" + } + } + } + ] +} diff --git a/internal/iac/testdata/plan_simple_create.json b/internal/iac/testdata/plan_simple_create.json new file mode 100644 index 0000000..5d6df6b --- /dev/null +++ b/internal/iac/testdata/plan_simple_create.json @@ -0,0 +1,22 @@ +{ + "format_version": "1.2", + "terraform_version": "1.6.0", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "instance_type": "t3.large", + "ami": "ami-0abcd1234efgh5678", + "availability_zone": "us-east-1a" + } + } + } + ] +} diff --git a/internal/llm/claude.go b/internal/llm/claude.go index 410923c..477cdfa 100644 --- a/internal/llm/claude.go +++ b/internal/llm/claude.go @@ -56,8 +56,10 @@ type claudeResponse struct { } func (c *ClaudeProvider) GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) { - prompt := BuildPrompt(findings) + return c.GenerateText(ctx, BuildPrompt(findings)) +} +func (c *ClaudeProvider) GenerateText(ctx context.Context, prompt string) (string, error) { reqBody := claudeRequest{ Model: c.model, MaxTokens: 1024, diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 10f1cf3..7c54a8a 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -53,9 +53,10 @@ type geminiResponse struct { } func (g *GeminiProvider) GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) { + return g.GenerateText(ctx, BuildPrompt(findings)) +} - prompt := BuildPrompt(findings) - +func (g *GeminiProvider) GenerateText(ctx context.Context, prompt string) (string, error) { reqBody := geminiRequest{ Contents: []geminiContent{ { diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 15e5d3e..8e416f9 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -55,9 +55,10 @@ type openAIResponse struct { } func (o *OpenAPIProvider) GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) { + return o.GenerateText(ctx, BuildPrompt(findings)) +} - prompt := BuildPrompt(findings) - +func (o *OpenAPIProvider) GenerateText(ctx context.Context, prompt string) (string, error) { reqBody := openAIRequest{ Model: o.model, Messages: []openAIMessage{ diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 03f2381..65c5865 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -10,7 +10,17 @@ import ( var ErrNoProvider = errors.New("no LLM provider configured") type Provider interface { + // GenerateSummary builds the v1 executive-summary prompt from findings + // and returns the LLM response. Convenience method; thin wrapper over + // GenerateText that calls BuildPrompt for the caller. GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) + + // GenerateText sends an arbitrary prompt and returns the LLM response. + // The caller owns prompt construction. Used by v2 flows (e.g. PR + // narrative in internal/diff) that build their own context-specific + // prompts and do not want to go through findings shaping. + GenerateText(ctx context.Context, prompt string) (string, error) + Name() string } diff --git a/internal/pricing/aws.go b/internal/pricing/aws.go new file mode 100644 index 0000000..8054cf1 --- /dev/null +++ b/internal/pricing/aws.go @@ -0,0 +1,158 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + "sort" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/pricing" + "github.com/aws/aws-sdk-go-v2/service/pricing/types" +) + +// pricingRegion is the AWS region we pin the Pricing API client to. The +// API is only served from us-east-1 and ap-south-1; us-east-1 is the +// default sane choice. See package doc for the full quirk list. +const pricingRegion = "us-east-1" + +// maxPages caps GetProducts pagination defensively. A real GetProducts +// call rarely returns more than a handful of pages even with broad +// filters; hitting the cap signals a misconfigured filter set (or a +// pathological mock) rather than a genuinely huge result set. +const maxPages = 100 + +// pricingAPI is the subset of *pricing.Client we depend on. Defining it +// as an interface lets tests inject a mock without spinning up an +// httptest server. *pricing.Client satisfies this interface naturally. +type pricingAPI interface { + GetProducts(ctx context.Context, params *pricing.GetProductsInput, opts ...func(*pricing.Options)) (*pricing.GetProductsOutput, error) +} + +// Client wraps the AWS Pricing API for product lookups. Construct it +// with NewClient in production code; tests use newClientWithAPI to +// inject a fake implementation of pricingAPI. +type Client struct { + api pricingAPI +} + +// NewClient creates a production Client using the default AWS credentials +// chain (env vars, shared config, EC2/ECS/EKS metadata, etc.). +// +// Region is forced to us-east-1 — the Pricing API is only available +// there and in ap-south-1, and the endpoint region is independent from +// the region of the priced resource (that's a filter value). +func NewClient(ctx context.Context) (*Client, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(pricingRegion)) + if err != nil { + return nil, fmt.Errorf("pricing: loading AWS config: %w", err) + } + return &Client{api: pricing.NewFromConfig(cfg)}, nil +} + +// newClientWithAPI is the test-only constructor. Unexported on purpose — +// production code goes through NewClient so the credentials chain and +// region pinning happen exactly once and the same way everywhere. +func newClientWithAPI(api pricingAPI) *Client { + return &Client{api: api} +} + +// GetProducts queries the Pricing API for products matching the given +// filters and returns the raw PriceList strings. Each string is an +// opaque JSON document; this package does NOT decode them — that's the +// job of per-service mappers in a later milestone. +// +// serviceCode examples: "AmazonEC2", "AmazonRDS", "AmazonEBS". +// +// filters is a map of Pricing API field name → value; every entry is +// translated to a TERM_MATCH filter and the filters are ANDed by the +// service. An empty filters map is valid and returns every product for +// the service. Filters are sorted by field name before being sent so +// that two equivalent calls produce byte-identical request payloads +// (helpful for the upcoming caching layer in milestone 13.2). +// +// Pagination is automatic: if the response carries a NextToken, this +// method follows it until the API stops returning one. As a safety net, +// pagination stops after 100 pages and a slog warning is emitted. +// +// Context cancellation is honored between pages — if ctx is cancelled +// mid-pagination, the partially-collected results are discarded and the +// cancellation error is returned wrapped. +func (c *Client) GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) { + if serviceCode == "" { + return nil, fmt.Errorf("pricing: empty serviceCode") + } + + pricingFilters := buildFilters(filters) + + slog.Info("pricing: GetProducts starting", + "serviceCode", serviceCode, + "filters", len(pricingFilters), + ) + + var ( + all []string + nextToken *string + ) + + for page := 0; page < maxPages; page++ { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("pricing: GetProducts(%s): %w", serviceCode, err) + } + + sc := serviceCode + in := &pricing.GetProductsInput{ + ServiceCode: &sc, + Filters: pricingFilters, + NextToken: nextToken, + } + out, err := c.api.GetProducts(ctx, in) + if err != nil { + return nil, fmt.Errorf("pricing: GetProducts(%s): %w", serviceCode, err) + } + all = append(all, out.PriceList...) + + if out.NextToken == nil || *out.NextToken == "" { + slog.Info("pricing: GetProducts done", + "serviceCode", serviceCode, + "products", len(all), + ) + return all, nil + } + nextToken = out.NextToken + } + + slog.Warn("pricing: GetProducts hit pagination cap", + "serviceCode", serviceCode, + "maxPages", maxPages, + "products", len(all), + ) + return all, nil +} + +// buildFilters converts the caller's map into a sorted []types.Filter +// where every entry has Type=TERM_MATCH. Sorting by field name keeps +// the request payload deterministic across calls with semantically +// identical filter sets — important for the upcoming cache key. +func buildFilters(filters map[string]string) []types.Filter { + if len(filters) == 0 { + return nil + } + fields := make([]string, 0, len(filters)) + for k := range filters { + fields = append(fields, k) + } + sort.Strings(fields) + + out := make([]types.Filter, 0, len(fields)) + for _, f := range fields { + field := f + value := filters[f] + out = append(out, types.Filter{ + Type: types.FilterTypeTermMatch, + Field: &field, + Value: &value, + }) + } + return out +} diff --git a/internal/pricing/aws_test.go b/internal/pricing/aws_test.go new file mode 100644 index 0000000..2e8d336 --- /dev/null +++ b/internal/pricing/aws_test.go @@ -0,0 +1,296 @@ +package pricing + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/pricing" + "github.com/aws/aws-sdk-go-v2/service/pricing/types" +) + +// fakePricing records every GetProducts call and returns the next +// pre-canned response. Designed for table-style tests: pages are +// consumed in order; if the test runs past the end of pages, the fake +// errors loudly so a missing setup is obvious. +type fakePricing struct { + pages []fakePage + calls []pricing.GetProductsInput + + // loop, when set, makes the fake return pages[0] forever instead + // of advancing. Used to exercise the pagination cap. + loop bool + + // onCall is invoked with the (1-indexed) call number before the + // response is returned. Used to inject side effects like cancelling + // the caller's context between pages. + onCall func(callNum int) + + // firstCallErr, when non-nil, is returned on the first call instead + // of pages[0]. Subsequent calls behave as configured. + firstCallErr error +} + +type fakePage struct { + priceList []string + nextToken string // empty means terminal page +} + +func (f *fakePricing) GetProducts(_ context.Context, params *pricing.GetProductsInput, _ ...func(*pricing.Options)) (*pricing.GetProductsOutput, error) { + // Defensive copy: callers may reuse the input struct across calls. + f.calls = append(f.calls, *params) + + if f.onCall != nil { + f.onCall(len(f.calls)) + } + + if len(f.calls) == 1 && f.firstCallErr != nil { + return nil, f.firstCallErr + } + + idx := len(f.calls) - 1 + if f.loop { + idx = 0 + } + if idx >= len(f.pages) { + return nil, fmt.Errorf("fakePricing: unexpected call #%d (only %d pages configured)", len(f.calls), len(f.pages)) + } + + p := f.pages[idx] + out := &pricing.GetProductsOutput{PriceList: p.priceList} + if p.nextToken != "" { + tok := p.nextToken + out.NextToken = &tok + } + return out, nil +} + +func TestGetProducts_Success(t *testing.T) { + want := []string{`{"product":"a"}`, `{"product":"b"}`, `{"product":"c"}`} + fake := &fakePricing{ + pages: []fakePage{{priceList: want}}, + } + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", map[string]string{ + "instanceType": "t3.large", + }) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(got) != len(want) { + t.Fatalf("got %d products, want %d", len(got), len(want)) + } + for i, p := range got { + if p != want[i] { + t.Errorf("product[%d] = %q, want %q", i, p, want[i]) + } + } + if len(fake.calls) != 1 { + t.Errorf("expected exactly 1 API call, got %d", len(fake.calls)) + } + if fake.calls[0].ServiceCode == nil || *fake.calls[0].ServiceCode != "AmazonEC2" { + t.Errorf("ServiceCode not propagated: %+v", fake.calls[0].ServiceCode) + } +} + +func TestGetProducts_Pagination(t *testing.T) { + fake := &fakePricing{ + pages: []fakePage{ + {priceList: []string{"p1", "p2"}, nextToken: "tok-page-2"}, + {priceList: []string{"p3"}}, + }, + } + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + want := []string{"p1", "p2", "p3"} + if len(got) != len(want) { + t.Fatalf("got %d products, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("product[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if len(fake.calls) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(fake.calls)) + } + // First call: no NextToken. + if fake.calls[0].NextToken != nil { + t.Errorf("first call NextToken = %q, want nil", *fake.calls[0].NextToken) + } + // Second call: NextToken from the first response. + if fake.calls[1].NextToken == nil || *fake.calls[1].NextToken != "tok-page-2" { + t.Errorf("second call NextToken = %v, want %q", fake.calls[1].NextToken, "tok-page-2") + } +} + +func TestGetProducts_FiltersMappedCorrectly(t *testing.T) { + fake := &fakePricing{pages: []fakePage{{priceList: nil}}} + c := newClientWithAPI(fake) + + in := map[string]string{ + "instanceType": "t3.large", + "regionCode": "us-east-2", + "operatingSystem": "Linux", + } + if _, err := c.GetProducts(context.Background(), "AmazonEC2", in); err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(fake.calls)) + } + got := fake.calls[0].Filters + if len(got) != len(in) { + t.Fatalf("got %d filters, want %d", len(got), len(in)) + } + for _, f := range got { + if f.Type != types.FilterTypeTermMatch { + t.Errorf("filter type = %q, want TERM_MATCH", f.Type) + } + if f.Field == nil || f.Value == nil { + t.Fatalf("filter has nil Field/Value: %+v", f) + } + want, ok := in[*f.Field] + if !ok { + t.Errorf("unexpected filter field %q", *f.Field) + continue + } + if *f.Value != want { + t.Errorf("filter %q = %q, want %q", *f.Field, *f.Value, want) + } + } +} + +func TestGetProducts_EmptyServiceCode(t *testing.T) { + fake := &fakePricing{} + c := newClientWithAPI(fake) + + _, err := c.GetProducts(context.Background(), "", nil) + if err == nil { + t.Fatal("expected error for empty serviceCode") + } + if err.Error() != "pricing: empty serviceCode" { + t.Errorf("error = %q, want %q", err.Error(), "pricing: empty serviceCode") + } + if len(fake.calls) != 0 { + t.Errorf("API was called %d times despite empty serviceCode", len(fake.calls)) + } +} + +func TestGetProducts_APIError(t *testing.T) { + apiErr := errors.New("AccessDenied: not authorized") + fake := &fakePricing{firstCallErr: apiErr} + c := newClientWithAPI(fake) + + _, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, apiErr) { + t.Errorf("error does not wrap underlying API error: %v", err) + } + if !strings.Contains(err.Error(), "pricing: GetProducts(AmazonEC2):") { + t.Errorf("error missing wrap prefix: %q", err.Error()) + } +} + +func TestGetProducts_PaginationCap(t *testing.T) { + // Fake returns NextToken on every call — without the cap, the + // loop would run forever. With the cap, it should stop after + // maxPages and return what was collected. + fake := &fakePricing{ + pages: []fakePage{{priceList: []string{"p"}, nextToken: "always"}}, + loop: true, + } + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(fake.calls) != maxPages { + t.Errorf("call count = %d, want %d (pagination cap)", len(fake.calls), maxPages) + } + if len(got) != maxPages { + t.Errorf("product count = %d, want %d (one product per page)", len(got), maxPages) + } +} + +func TestGetProducts_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakePricing{ + pages: []fakePage{ + {priceList: []string{"p1"}, nextToken: "tok-2"}, + {priceList: []string{"p2"}, nextToken: "tok-3"}, + {priceList: []string{"p3"}}, + }, + // Cancel the parent context after the first successful page so + // the next iteration's ctx.Err() check trips. + onCall: func(n int) { + if n == 1 { + cancel() + } + }, + } + c := newClientWithAPI(fake) + + _, err := c.GetProducts(ctx, "AmazonEC2", nil) + if err == nil { + t.Fatal("expected error from cancelled context") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error does not wrap context.Canceled: %v", err) + } + if !strings.Contains(err.Error(), "pricing: GetProducts(AmazonEC2):") { + t.Errorf("error missing wrap prefix: %q", err.Error()) + } + // Page 1 succeeded; the cancellation should have prevented page 2. + if len(fake.calls) != 1 { + t.Errorf("expected exactly 1 call before cancellation, got %d", len(fake.calls)) + } +} + +func TestGetProducts_EmptyFilters(t *testing.T) { + // Empty/nil filters must be allowed (queries every product for the + // service). Verify the call goes through with zero filters. + fake := &fakePricing{pages: []fakePage{{priceList: []string{"x"}}}} + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(got) != 1 { + t.Errorf("got %d products, want 1", len(got)) + } + if len(fake.calls[0].Filters) != 0 { + t.Errorf("expected 0 filters, got %d", len(fake.calls[0].Filters)) + } +} + +func TestNewClient_RegionForced(t *testing.T) { + // LoadDefaultConfig touches the local AWS config files / env vars. + // On a clean CI box without any AWS env, it still succeeds (config + // loading is independent from credential resolution). If for some + // reason the environment makes it fail, skip rather than fail — + // the property under test is the region, not the credentials chain. + c, err := NewClient(context.Background()) + if err != nil { + t.Skipf("LoadDefaultConfig failed in this environment: %v", err) + } + pc, ok := c.api.(*pricing.Client) + if !ok { + t.Fatalf("api is %T, want *pricing.Client", c.api) + } + if got := pc.Options().Region; got != pricingRegion { + t.Errorf("client Region = %q, want %q", got, pricingRegion) + } +} diff --git a/internal/pricing/cache.go b/internal/pricing/cache.go new file mode 100644 index 0000000..6a47efd --- /dev/null +++ b/internal/pricing/cache.go @@ -0,0 +1,278 @@ +package pricing + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// productGetter is the abstraction Cache wraps. *Client satisfies it +// naturally; tests inject a fake. Defining it here (and not exporting) +// keeps the cache focused on a single shape without leaking an interface +// that callers might mistakenly depend on instead of *Client. +type productGetter interface { + GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) +} + +// Cache wraps a productGetter with disk-based caching of GetProducts +// results. +// +// Entries are stored as JSON files under dir, keyed by sha256 of +// (serviceCode + sorted filters). Entries older than ttl are treated as +// misses and refreshed from the underlying source. The on-disk format is +// described on cacheEntry. +// +// All cache operations are best-effort: if a read fails for any reason +// (missing file, IO error, corrupt JSON) the underlying source is +// consulted and its result returned. If a write fails, the products are +// still returned to the caller. Cache failures emit slog.Warn but never +// propagate to the caller — the cache must not break the happy path. +// +// Concurrent access: there is no explicit locking. Two processes +// hitting the same entry at the same time may both call the underlying +// source and both write the file; the OS gives last-writer-wins, which +// is acceptable for a TTL'd best-effort cache. Writes use a temp-file + +// rename so a reader never observes a half-written file. +type Cache struct { + inner productGetter + dir string + ttl time.Duration +} + +// cacheEntry is the on-disk shape of a single cache record. +// +// service_code and filters are denormalized into the file purely for +// human debugging — the cache integrity is guaranteed by the sha256 +// filename, and the loader does NOT cross-check these fields against +// the request. Treat them as "what was this entry for?" annotations +// rather than authoritative data. +type cacheEntry struct { + ServiceCode string `json:"service_code"` + Filters map[string]string `json:"filters"` + FetchedAt time.Time `json:"fetched_at"` + Products []string `json:"products"` +} + +// NewCache creates a Cache backed by inner, storing entries in dir with +// the given TTL. dir is created (with os.MkdirAll) if it doesn't exist. +// +// Returns an error only if dir cannot be created — that's a +// configuration problem (bad path, permission denied) and surfacing it +// at construction time avoids confusing best-effort failures later. +// +// Recommended ttl is 7 days: AWS public prices change rarely, and +// stale-by-a-week is much cheaper than hammering the API on every PR +// update. Recommended dir is DefaultCacheDir(). +func NewCache(inner productGetter, dir string, ttl time.Duration) (*Cache, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("pricing: creating cache dir %q: %w", dir, err) + } + return &Cache{inner: inner, dir: dir, ttl: ttl}, nil +} + +// DefaultCacheDir returns the recommended on-disk cache location: +// - Windows: %USERPROFILE%\.cloudoracle\pricing-cache +// - Unix: $HOME/.cloudoracle/pricing-cache +// +// Returns an error if the user's home directory cannot be determined +// (rare; happens in stripped-down container environments). +func DefaultCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("pricing: resolving user home dir: %w", err) + } + return filepath.Join(home, ".cloudoracle", "pricing-cache"), nil +} + +// GetProducts checks the cache before consulting the underlying source. +// +// Hit (file present, valid JSON, within TTL): returns the cached +// products. inner is not called. +// Miss (file absent, IO error, corrupt JSON, or expired): calls +// inner.GetProducts and writes the result to disk. Corrupt and expired +// files are removed opportunistically. Cache write failures log a +// warning but do not affect the returned data. +// +// If the underlying source returns an error, that error is propagated +// verbatim and nothing is written to disk. +func (c *Cache) GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) { + key := cacheKey(serviceCode, filters) + path := filepath.Join(c.dir, key+".json") + + if products, ok := c.readEntry(path); ok { + slog.Debug("pricing: cache hit", + "serviceCode", serviceCode, + "key", key, + ) + return products, nil + } + + slog.Debug("pricing: cache miss", + "serviceCode", serviceCode, + "key", key, + ) + + products, err := c.inner.GetProducts(ctx, serviceCode, filters) + if err != nil { + return nil, err + } + + c.writeEntry(path, cacheEntry{ + ServiceCode: serviceCode, + Filters: filters, + FetchedAt: time.Now().UTC(), + Products: products, + }) + + return products, nil +} + +// readEntry returns (products, true) on a valid, fresh cache hit and +// (nil, false) for any miss reason. Corrupt and expired files are +// deleted before returning so the next miss path doesn't keep tripping +// over the same garbage. +func (c *Cache) readEntry(path string) ([]string, bool) { + raw, err := os.ReadFile(path) + if err != nil { + // A missing file is the common case — don't even debug-log it. + // IO errors on a present file are worth a warn. + if !errors.Is(err, fs.ErrNotExist) { + slog.Warn("pricing: cache read failed", + "path", path, + "error", err, + ) + } + return nil, false + } + + var entry cacheEntry + if err := json.Unmarshal(raw, &entry); err != nil { + slog.Warn("pricing: cache file corrupt, removing", + "path", path, + "error", err, + ) + if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) { + slog.Warn("pricing: failed to remove corrupt cache file", + "path", path, + "error", rmErr, + ) + } + return nil, false + } + + if time.Since(entry.FetchedAt) > c.ttl { + slog.Debug("pricing: cache entry expired", + "path", path, + "fetched_at", entry.FetchedAt, + ) + if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) { + slog.Warn("pricing: failed to remove expired cache file", + "path", path, + "error", rmErr, + ) + } + return nil, false + } + + return entry.Products, true +} + +// writeEntry persists an entry atomically: marshal, write to a temp +// sibling, rename into place. Any failure is logged at warn and +// swallowed — the caller already has the data and shouldn't be +// penalised because we couldn't write to disk. +func (c *Cache) writeEntry(path string, entry cacheEntry) { + body, err := json.Marshal(entry) + if err != nil { + // json.Marshal on a struct of strings/time/[]string shouldn't + // fail in practice, but log if it ever does instead of panicking. + slog.Warn("pricing: cache marshal failed", + "path", path, + "error", err, + ) + return + } + + tmp, err := os.CreateTemp(c.dir, "tmp-*.json") + if err != nil { + slog.Warn("pricing: cache temp file create failed", + "dir", c.dir, + "error", err, + ) + return + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(body); err != nil { + slog.Warn("pricing: cache write failed", + "path", tmpPath, + "error", err, + ) + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + slog.Warn("pricing: cache temp file close failed", + "path", tmpPath, + "error", err, + ) + _ = os.Remove(tmpPath) + return + } + + if err := os.Rename(tmpPath, path); err != nil { + slog.Warn("pricing: cache rename failed", + "from", tmpPath, + "to", path, + "error", err, + ) + _ = os.Remove(tmpPath) + return + } +} + +// cacheKey computes the deterministic cache key for a lookup. +// +// Layout: sha256( serviceCode + "\n" + sortedFiltersSerialized ). +// sortedFiltersSerialized joins filter entries by alphabetical key as +// "key=value\n" pairs, producing the same string for nil filters and +// empty maps (both serialize to ""). +func cacheKey(serviceCode string, filters map[string]string) string { + var sb strings.Builder + sb.WriteString(serviceCode) + sb.WriteByte('\n') + sb.WriteString(serializeFilters(filters)) + sum := sha256.Sum256([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +func serializeFilters(filters map[string]string) string { + if len(filters) == 0 { + return "" + } + keys := make([]string, 0, len(filters)) + for k := range filters { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + for _, k := range keys { + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(filters[k]) + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/internal/pricing/cache_test.go b/internal/pricing/cache_test.go new file mode 100644 index 0000000..6eab365 --- /dev/null +++ b/internal/pricing/cache_test.go @@ -0,0 +1,431 @@ +package pricing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeProductGetter is a productGetter that records calls and replies +// from a script. Designed for cache tests where we need to assert +// "inner was/was not called" with precision. +type fakeProductGetter struct { + products []string + err error + calls int + // lastService and lastFilters record the most recent input so tests + // can verify forwarding behaviour without dragging in extra fields. + lastService string + lastFilters map[string]string +} + +func (f *fakeProductGetter) GetProducts(_ context.Context, serviceCode string, filters map[string]string) ([]string, error) { + f.calls++ + f.lastService = serviceCode + f.lastFilters = filters + if f.err != nil { + return nil, f.err + } + return f.products, nil +} + +// captureLogs swaps slog.Default for a text handler writing into the +// returned buffer for the duration of the test. Restored via t.Cleanup. +func captureLogs(t *testing.T, level slog.Level) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// readCachedEntry decodes the cache file at path. Used by tests that +// need to assert what we wrote. +func readCachedEntry(t *testing.T, path string) cacheEntry { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading cache file %q: %v", path, err) + } + var e cacheEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatalf("decoding cache file %q: %v", path, err) + } + return e +} + +// listCacheFiles returns every .json file in dir, ignoring temp files. +func listCacheFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading dir %q: %v", dir, err) + } + var out []string + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".json") && !strings.HasPrefix(name, "tmp-") { + out = append(out, filepath.Join(dir, name)) + } + } + return out +} + +func TestCache_MissCallsInnerAndStores(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{`{"a":1}`, `{"b":2}`}} + c, err := NewCache(fake, dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + got, err := c.GetProducts(context.Background(), "AmazonEC2", map[string]string{ + "instanceType": "t3.large", + }) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1", fake.calls) + } + if len(got) != 2 || got[0] != `{"a":1}` || got[1] != `{"b":2}` { + t.Errorf("returned products = %v, want fake's response", got) + } + + files := listCacheFiles(t, dir) + if len(files) != 1 { + t.Fatalf("got %d cache files, want 1", len(files)) + } + entry := readCachedEntry(t, files[0]) + if entry.ServiceCode != "AmazonEC2" { + t.Errorf("ServiceCode = %q", entry.ServiceCode) + } + if entry.Filters["instanceType"] != "t3.large" { + t.Errorf("Filters not persisted: %+v", entry.Filters) + } + if len(entry.Products) != 2 { + t.Errorf("persisted Products len = %d, want 2", len(entry.Products)) + } + if entry.FetchedAt.IsZero() { + t.Error("FetchedAt is zero") + } +} + +func TestCache_HitDoesNotCallInner(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"should-not-be-returned"}} + c, _ := NewCache(fake, dir, time.Hour) + + // Pre-populate by writing the entry file directly. + key := cacheKey("AmazonEC2", nil) + path := filepath.Join(dir, key+".json") + cached := cacheEntry{ + ServiceCode: "AmazonEC2", + FetchedAt: time.Now().UTC(), + Products: []string{"cached-1", "cached-2"}, + } + body, _ := json.Marshal(cached) + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 0 { + t.Errorf("inner was called %d times on a hit", fake.calls) + } + if len(got) != 2 || got[0] != "cached-1" || got[1] != "cached-2" { + t.Errorf("returned products = %v, want cached entries", got) + } +} + +func TestCache_ExpiredEntryRefreshes(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"fresh-1"}} + c, _ := NewCache(fake, dir, time.Hour) + + key := cacheKey("AmazonEC2", nil) + path := filepath.Join(dir, key+".json") + stale := cacheEntry{ + ServiceCode: "AmazonEC2", + FetchedAt: time.Now().UTC().Add(-2 * time.Hour), + Products: []string{"stale-1"}, + } + body, _ := json.Marshal(stale) + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (expired entry should refresh)", fake.calls) + } + if len(got) != 1 || got[0] != "fresh-1" { + t.Errorf("returned products = %v, want fresh response", got) + } + + // File should now be the refreshed entry, not the stale one. + entry := readCachedEntry(t, path) + if len(entry.Products) != 1 || entry.Products[0] != "fresh-1" { + t.Errorf("file not refreshed: %+v", entry) + } + if time.Since(entry.FetchedAt) > time.Minute { + t.Errorf("FetchedAt not updated: %v", entry.FetchedAt) + } +} + +func TestCache_CorruptFileTreatedAsMiss(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"recovered"}} + c, _ := NewCache(fake, dir, time.Hour) + + key := cacheKey("AmazonEC2", nil) + path := filepath.Join(dir, key+".json") + if err := os.WriteFile(path, []byte("not-json{{{"), 0o644); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + + logs := captureLogs(t, slog.LevelWarn) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (corrupt file should miss)", fake.calls) + } + if len(got) != 1 || got[0] != "recovered" { + t.Errorf("returned products = %v, want fresh response", got) + } + + if !strings.Contains(logs.String(), "cache file corrupt") { + t.Errorf("expected warn log about corrupt file, got: %s", logs.String()) + } + + // File should be replaced with a valid one. + entry := readCachedEntry(t, path) + if len(entry.Products) != 1 || entry.Products[0] != "recovered" { + t.Errorf("file not replaced with valid entry: %+v", entry) + } +} + +func TestCache_NilAndEmptyFiltersSameKey(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"x"}} + c, _ := NewCache(fake, dir, time.Hour) + + if _, err := c.GetProducts(context.Background(), "AmazonEC2", nil); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := c.GetProducts(context.Background(), "AmazonEC2", map[string]string{}); err != nil { + t.Fatalf("second call: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (nil and {} must hash equal)", fake.calls) + } +} + +func TestCache_DifferentFiltersOrderSameKey(t *testing.T) { + // Go maps don't actually carry insertion order so the test is really + // asserting "same {key:value} set hashes to the same key regardless + // of how the caller built the map". + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"x"}} + c, _ := NewCache(fake, dir, time.Hour) + + first := map[string]string{"a": "1", "b": "2"} + second := map[string]string{"b": "2", "a": "1"} + + if _, err := c.GetProducts(context.Background(), "AmazonEC2", first); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := c.GetProducts(context.Background(), "AmazonEC2", second); err != nil { + t.Fatalf("second call: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (filter order must not affect key)", fake.calls) + } + if files := listCacheFiles(t, dir); len(files) != 1 { + t.Errorf("got %d cache files, want 1", len(files)) + } +} + +func TestCache_DifferentServicesDifferentKeys(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"x"}} + c, _ := NewCache(fake, dir, time.Hour) + + filters := map[string]string{"region": "us-east-1"} + if _, err := c.GetProducts(context.Background(), "AmazonEC2", filters); err != nil { + t.Fatalf("EC2 call: %v", err) + } + if _, err := c.GetProducts(context.Background(), "AmazonRDS", filters); err != nil { + t.Fatalf("RDS call: %v", err) + } + + if fake.calls != 2 { + t.Errorf("inner calls = %d, want 2 (different services miss separately)", fake.calls) + } + if files := listCacheFiles(t, dir); len(files) != 2 { + t.Errorf("got %d cache files, want 2", len(files)) + } +} + +func TestCache_WriteFailureLogsButReturns(t *testing.T) { + // Cross-platform recipe: build the Cache, then remove the dir + // underneath it. The next write attempt fails at CreateTemp, the + // cache logs a warn, and the data is still returned to the caller. + // This avoids Windows' very different ACL semantics. + parent := t.TempDir() + dir := filepath.Join(parent, "pricing-cache") + fake := &fakeProductGetter{products: []string{"abc"}} + c, err := NewCache(fake, dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + if err := os.RemoveAll(dir); err != nil { + t.Fatalf("removing cache dir: %v", err) + } + + logs := captureLogs(t, slog.LevelWarn) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts returned error on write failure: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1", fake.calls) + } + if len(got) != 1 || got[0] != "abc" { + t.Errorf("returned products = %v, want fake's response", got) + } + if !strings.Contains(logs.String(), "level=WARN") { + t.Errorf("expected warn log on write failure, got: %s", logs.String()) + } +} + +func TestCache_ReadFailureTreatedAsMiss(t *testing.T) { + // When os.ReadFile on the cache path returns an error other than + // fs.ErrNotExist, we want a warn log and a fall-through to the inner + // source. Simulate that by occupying the cache path with a directory: + // reading a directory errors with EISDIR (Unix) / access-denied + // (Windows), neither of which is fs.ErrNotExist. + // + // This also exercises the writeEntry rename-failure branch, because + // the post-miss write tries to Rename a tmp file over the directory + // we created — Rename fails, the warn is logged, and the products + // still come back to the caller. + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"recovered"}} + c, _ := NewCache(fake, dir, time.Hour) + + key := cacheKey("AmazonEC2", nil) + blockingPath := filepath.Join(dir, key+".json") + if err := os.Mkdir(blockingPath, 0o755); err != nil { + t.Fatalf("mkdir blocker: %v", err) + } + + logs := captureLogs(t, slog.LevelWarn) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts returned error on read failure: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1", fake.calls) + } + if len(got) != 1 || got[0] != "recovered" { + t.Errorf("returned products = %v, want fresh response", got) + } + if !strings.Contains(logs.String(), "cache read failed") { + t.Errorf("expected warn log about read failure, got: %s", logs.String()) + } +} + +func TestNewCache_BadDir(t *testing.T) { + // Create a regular file, then try to use a path under it as the + // cache dir. MkdirAll cannot create a directory below a file on any + // supported platform, so this exercises the error branch in NewCache + // without relying on platform-specific permissions. + parent := t.TempDir() + blocker := filepath.Join(parent, "blocker") + if err := os.WriteFile(blocker, nil, 0o644); err != nil { + t.Fatalf("creating blocker file: %v", err) + } + bad := filepath.Join(blocker, "child") + + _, err := NewCache(&fakeProductGetter{}, bad, time.Hour) + if err == nil { + t.Fatal("expected error from NewCache with un-creatable dir") + } + if !strings.Contains(err.Error(), "pricing: creating cache dir") { + t.Errorf("error missing wrap prefix: %q", err.Error()) + } +} + +func TestCache_PropagatesInnerError(t *testing.T) { + dir := t.TempDir() + innerErr := errors.New("AccessDenied: not authorized") + fake := &fakeProductGetter{err: innerErr} + c, _ := NewCache(fake, dir, time.Hour) + + _, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner error: %v", err) + } + if files := listCacheFiles(t, dir); len(files) != 0 { + t.Errorf("got %d cache files, want 0 (errors must not be cached)", len(files)) + } +} + +func TestNewCache_CreatesDirIfMissing(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "nested", "cache") + + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("precondition: target should not exist yet, stat: %v", err) + } + + fake := &fakeProductGetter{} + if _, err := NewCache(fake, target, time.Hour); err != nil { + t.Fatalf("NewCache: %v", err) + } + + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat after NewCache: %v", err) + } + if !info.IsDir() { + t.Errorf("%q is not a directory", target) + } +} + +func TestDefaultCacheDir(t *testing.T) { + got, err := DefaultCacheDir() + if err != nil { + t.Fatalf("DefaultCacheDir: %v", err) + } + if got == "" { + t.Fatal("DefaultCacheDir returned empty path") + } + if filepath.Base(got) != "pricing-cache" { + t.Errorf("DefaultCacheDir = %q, want a path ending in pricing-cache", got) + } +} diff --git a/internal/pricing/change.go b/internal/pricing/change.go new file mode 100644 index 0000000..eb6d93c --- /dev/null +++ b/internal/pricing/change.go @@ -0,0 +1,282 @@ +package pricing + +import ( + "context" + "fmt" + + "CloudOracle/internal/iac" + "CloudOracle/internal/iac/aws" +) + +// EstimateChange returns the cost impact of a single resource change in +// a Terraform plan. It dispatches by rc.Type to the right Estimate* +// function, computes a before/after delta based on rc.Action(), and +// returns a uniform ChangeEstimate that downstream diff/comment code +// can render without caring about resource type. +// +// Action handling (see ChangeEstimate godoc for the sign convention): +// +// - create: BeforeMonthly = 0, AfterMonthly = price(After), delta = AfterMonthly +// - delete: BeforeMonthly = price(Before), AfterMonthly = 0, delta = -BeforeMonthly +// - update / replace: both states priced; delta = After - Before +// - no-op / read: Skipped, all costs 0 +// +// Behaviour for resource types we cannot price (aws_iam_role, S3, +// EKS, ...): EstimateChange returns Skipped=true with a SkipReason +// rather than an error. Callers iterate over the whole plan and want a +// per-resource result for every change, including the unpriced ones — +// silently dropping them would push "why didn't this show up?" lookups +// onto callers. No API call is made for unsupported types. +// +// Data sources (rc.IsManaged()==false) are also Skipped — they have no +// cost impact by definition. +// +// region is the AWS region for pricing queries (e.g. "us-east-2"); all +// resources in a plan should share a region. Multi-region plans need +// separate calls per region. +// +// Errors are returned only for genuine failures: API errors, malformed +// attributes that fail the per-type extractors, parser unit mismatches. +// "Unsupported type" and "no-op action" are NOT errors — they are +// Skipped results. +func EstimateChange(ctx context.Context, src productGetter, rc iac.ResourceChange, region string) (ChangeEstimate, error) { + if region == "" { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: empty region") + } + + action := rc.Action() + out := ChangeEstimate{ + ResourceAddress: rc.Address, + ResourceType: rc.Type, + Action: action, + Currency: "USD", + Confidence: ConfidenceHigh, + } + + if action == iac.ActionNoop || action == iac.ActionRead { + out.Skipped = true + out.SkipReason = "action has no cost impact" + return out, nil + } + if !rc.IsManaged() { + out.Skipped = true + out.SkipReason = "data sources have no cost" + return out, nil + } + + switch action { + case iac.ActionCreate: + afterEst, afterSkip, err := estimateState(ctx, src, rc.Type, rc.Change.After, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s after: %w", rc.Address, err) + } + if afterSkip != "" { + out.Skipped = true + out.SkipReason = afterSkip + return out, nil + } + out.AfterMonthly = afterEst.MonthlyUSD + out.MonthlyDelta = afterEst.MonthlyUSD + out.Confidence = afterEst.Confidence + out.Notes = afterEst.Notes + out.Breakdown = cloneBreakdown(afterEst.Breakdown) + + case iac.ActionDelete: + beforeEst, beforeSkip, err := estimateState(ctx, src, rc.Type, rc.Change.Before, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s before: %w", rc.Address, err) + } + if beforeSkip != "" { + out.Skipped = true + out.SkipReason = beforeSkip + return out, nil + } + out.BeforeMonthly = beforeEst.MonthlyUSD + out.MonthlyDelta = -beforeEst.MonthlyUSD + out.Confidence = beforeEst.Confidence + out.Notes = beforeEst.Notes + out.Breakdown = negateBreakdown(beforeEst.Breakdown) + + case iac.ActionUpdate, iac.ActionReplace: + beforeEst, beforeSkip, err := estimateState(ctx, src, rc.Type, rc.Change.Before, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s before: %w", rc.Address, err) + } + afterEst, afterSkip, err := estimateState(ctx, src, rc.Type, rc.Change.After, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s after: %w", rc.Address, err) + } + // If the type is unsupported on either side (it'll be the same + // type on both sides — Terraform doesn't change resource type + // across a single change), skip the whole change. + if beforeSkip != "" || afterSkip != "" { + out.Skipped = true + if beforeSkip != "" { + out.SkipReason = beforeSkip + } else { + out.SkipReason = afterSkip + } + return out, nil + } + out.BeforeMonthly = beforeEst.MonthlyUSD + out.AfterMonthly = afterEst.MonthlyUSD + out.MonthlyDelta = afterEst.MonthlyUSD - beforeEst.MonthlyUSD + out.Confidence = weakestConfidence(beforeEst.Confidence, afterEst.Confidence) + out.Notes = mergeNotes(beforeEst.Notes, afterEst.Notes) + out.Breakdown = mergeDeltaBreakdown(beforeEst.Breakdown, afterEst.Breakdown) + + default: + // Unknown action (a hypothetical future value). Treat as Skipped + // rather than crash — fail safely on unrecognised inputs. + out.Skipped = true + out.SkipReason = fmt.Sprintf("unsupported action %q", string(action)) + return out, nil + } + + return out, nil +} + +// estimateState extracts the typed attributes for resourceType and runs +// the appropriate per-resource estimator. The skipReason return is +// non-empty when the type is unsupported (Extract returned (nil, nil)) +// or when the attribute map was empty — both produce a Skipped change +// rather than an error. Real errors propagate to the caller. +func estimateState(ctx context.Context, src productGetter, resourceType string, attrs map[string]interface{}, region string) (Estimate, string, error) { + if len(attrs) == 0 { + return Estimate{}, "no attributes for state", nil + } + ra, err := aws.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.EC2 != nil: + est, err := EstimateEC2(ctx, src, ra.EC2, region) + return est, "", err + case ra.RDS != nil: + est, err := EstimateRDS(ctx, src, ra.RDS, region) + return est, "", err + case ra.EBS != nil: + est, err := EstimateEBS(ctx, src, ra.EBS, region) + return est, "", err + case ra.Lambda != nil: + est, err := EstimateLambda(ctx, src, ra.Lambda, region) + return est, "", err + case ra.NATGateway != nil: + est, err := EstimateNATGateway(ctx, src, ra.NATGateway, region) + return est, "", err + case ra.RDSClusterInstance != nil: + est, err := EstimateRDSClusterInstance(ctx, src, ra.RDSClusterInstance, region) + return est, "", err + } + // aws.Extract returned a non-nil ResourceAttributes with no inner + // pointer set. Defensively treat as unsupported rather than panic — + // this would only happen if SupportedTypes() and Extract drift apart. + 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 +// value: a high-confidence before paired with a low-confidence after +// produces a low-confidence change. +func weakestConfidence(a, b Confidence) Confidence { + if confidenceRank(a) >= confidenceRank(b) { + return a + } + return b +} + +func confidenceRank(c Confidence) int { + switch c { + case ConfidenceHigh: + return 0 + case ConfidenceMedium: + return 1 + case ConfidenceLow: + return 2 + } + // Unknown values rank as the weakest so they propagate correctly + // through merging instead of being silently dropped. + return 3 +} + +// mergeNotes concatenates two note slices in (before, after) order, +// dropping duplicates so identical caveats from both states don't +// double up in the rendered output. +func mergeNotes(before, after []string) []string { + seen := make(map[string]struct{}, len(before)+len(after)) + out := make([]string, 0, len(before)+len(after)) + for _, n := range before { + if _, ok := seen[n]; !ok { + seen[n] = struct{}{} + out = append(out, n) + } + } + for _, n := range after { + if _, ok := seen[n]; !ok { + seen[n] = struct{}{} + out = append(out, n) + } + } + return out +} + +// cloneBreakdown returns a fresh copy of the breakdown so the caller +// can't accidentally mutate the inner Estimate's slice through the +// returned ChangeEstimate. +func cloneBreakdown(in []LineItem) []LineItem { + if len(in) == 0 { + return nil + } + out := make([]LineItem, len(in)) + copy(out, in) + return out +} + +// negateBreakdown returns a copy of the breakdown with every component's +// MonthlyUSD sign flipped, used for delete actions where the breakdown +// represents a removed cost. +func negateBreakdown(in []LineItem) []LineItem { + if len(in) == 0 { + return nil + } + out := make([]LineItem, len(in)) + for i, li := range in { + out[i] = LineItem{Component: li.Component, MonthlyUSD: -li.MonthlyUSD} + } + return out +} + +// mergeDeltaBreakdown produces a per-component delta from a before and +// after breakdown for update/replace actions. Components in only the +// after appear with their full positive value; components in only the +// before appear with their negated value; components in both are +// after - before. +// +// The output preserves the order of components in `after`, then appends +// components seen only in `before`. Stable order matters because +// downstream comment renderers iterate the breakdown directly. +func mergeDeltaBreakdown(before, after []LineItem) []LineItem { + if len(before) == 0 && len(after) == 0 { + return nil + } + idx := make(map[string]int, len(after)) + out := make([]LineItem, 0, len(after)+len(before)) + for _, li := range after { + idx[li.Component] = len(out) + out = append(out, LineItem{Component: li.Component, MonthlyUSD: li.MonthlyUSD}) + } + for _, li := range before { + if i, ok := idx[li.Component]; ok { + out[i].MonthlyUSD -= li.MonthlyUSD + } else { + idx[li.Component] = len(out) + out = append(out, LineItem{Component: li.Component, MonthlyUSD: -li.MonthlyUSD}) + } + } + return out +} diff --git a/internal/pricing/change_test.go b/internal/pricing/change_test.go new file mode 100644 index 0000000..88185cc --- /dev/null +++ b/internal/pricing/change_test.go @@ -0,0 +1,460 @@ +package pricing + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac" +) + +// ec2CreateAttrs builds a valid aws_instance after-state map matching +// what a Terraform plan would emit (ints come through as float64). +func ec2CreateAttrs(instanceType, volumeType string, volumeSize int) map[string]interface{} { + m := map[string]interface{}{ + "instance_type": instanceType, + "tenancy": "default", + } + if volumeSize > 0 { + m["root_block_device"] = []interface{}{ + map[string]interface{}{ + "volume_size": float64(volumeSize), + "volume_type": volumeType, + }, + } + } + return m +} + +func rdsAttrs(instanceClass string) map[string]interface{} { + return map[string]interface{}{ + "engine": "postgres", + "instance_class": instanceClass, + "allocated_storage": float64(100), + "storage_type": "gp2", + } +} + +func ebsAttrs(volType string, size int) map[string]interface{} { + return map[string]interface{}{ + "type": volType, + "size": float64(size), + } +} + +func TestEstimateChange_CreateEC2(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}}} + + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: ec2CreateAttrs("t3.large", "gp3", 50), + }, + } + ce, err := EstimateChange(context.Background(), src, 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.Action != iac.ActionCreate { + t.Errorf("Action = %q, want create", ce.Action) + } + if ce.BeforeMonthly != 0 { + t.Errorf("BeforeMonthly = %v, want 0", ce.BeforeMonthly) + } + wantAfter := 0.0832*HoursPerMonth + 0.08*50 + if math.Abs(ce.AfterMonthly-wantAfter) > 1e-6 { + t.Errorf("AfterMonthly = %v, want %v", ce.AfterMonthly, wantAfter) + } + if math.Abs(ce.MonthlyDelta-wantAfter) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, wantAfter) + } + if ce.Currency != "USD" { + t.Errorf("Currency = %q", ce.Currency) + } + if ce.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", ce.Confidence) + } + if len(ce.Breakdown) != 2 { + t.Errorf("Breakdown len = %d, want 2", len(ce.Breakdown)) + } +} + +func TestEstimateChange_DeleteEBS(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + + rc := iac.ResourceChange{ + Address: "aws_ebs_volume.disk", + Mode: "managed", + Type: "aws_ebs_volume", + Change: iac.Change{ + Actions: []string{"delete"}, + Before: ebsAttrs("gp3", 100), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + wantBefore := 0.08 * 100 + if math.Abs(ce.BeforeMonthly-wantBefore) > 1e-6 { + t.Errorf("BeforeMonthly = %v, want %v", ce.BeforeMonthly, wantBefore) + } + if ce.AfterMonthly != 0 { + t.Errorf("AfterMonthly = %v, want 0", ce.AfterMonthly) + } + if math.Abs(ce.MonthlyDelta+wantBefore) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, -wantBefore) + } + if len(ce.Breakdown) != 1 || ce.Breakdown[0].MonthlyUSD >= 0 { + t.Errorf("Breakdown = %+v, expected single negative line item", ce.Breakdown) + } +} + +func TestEstimateChange_UpdateRDS(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + // "before": $0.082/hr compute. "after": $0.164/hr (mutated copy). + afterCompute := strings.Replace(compute, `"USD": "0.082"`, `"USD": "0.164"`, 1) + src := &scriptedGetter{responses: [][]string{{compute}, {storage}, {afterCompute}, {storage}}} + + rc := iac.ResourceChange{ + Address: "aws_db_instance.db", + Mode: "managed", + Type: "aws_db_instance", + Change: iac.Change{ + Actions: []string{"update"}, + Before: rdsAttrs("db.t3.medium"), + After: rdsAttrs("db.t3.large"), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + wantBefore := 0.082*HoursPerMonth + 0.115*100 + wantAfter := 0.164*HoursPerMonth + 0.115*100 + wantDelta := wantAfter - wantBefore + if math.Abs(ce.BeforeMonthly-wantBefore) > 1e-6 { + t.Errorf("BeforeMonthly = %v, want %v", ce.BeforeMonthly, wantBefore) + } + if math.Abs(ce.AfterMonthly-wantAfter) > 1e-6 { + t.Errorf("AfterMonthly = %v, want %v", ce.AfterMonthly, wantAfter) + } + if math.Abs(ce.MonthlyDelta-wantDelta) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, wantDelta) + } + + // Delta breakdown: Compute should be positive delta, Storage = 0. + gotCompute, gotStorage := math.NaN(), math.NaN() + for _, li := range ce.Breakdown { + switch li.Component { + case "Compute": + gotCompute = li.MonthlyUSD + case "Storage": + gotStorage = li.MonthlyUSD + } + } + wantComputeDelta := (0.164 - 0.082) * HoursPerMonth + if math.Abs(gotCompute-wantComputeDelta) > 1e-6 { + t.Errorf("Compute delta = %v, want %v", gotCompute, wantComputeDelta) + } + if math.Abs(gotStorage) > 1e-6 { + t.Errorf("Storage delta = %v, want 0", gotStorage) + } +} + +func TestEstimateChange_ReplaceEC2(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + afterCompute := strings.Replace(compute, `"USD": "0.0832"`, `"USD": "0.1664"`, 1) + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}, {afterCompute}, {gp3}}} + + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"delete", "create"}, // replacement + Before: ec2CreateAttrs("t3.large", "gp3", 50), + After: ec2CreateAttrs("t3.xlarge", "gp3", 50), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if ce.Action != iac.ActionReplace { + t.Errorf("Action = %q, want replace", ce.Action) + } + wantDelta := (0.1664 - 0.0832) * HoursPerMonth + if math.Abs(ce.MonthlyDelta-wantDelta) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, wantDelta) + } +} + +func TestEstimateChange_NoOp(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{Actions: []string{"no-op"}}, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } + if ce.MonthlyDelta != 0 || ce.BeforeMonthly != 0 || ce.AfterMonthly != 0 { + t.Errorf("expected all zero costs: %+v", ce) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for no-op, got %d", len(src.calls)) + } +} + +func TestEstimateChange_Read(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{Actions: []string{"read"}}, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } +} + +func TestEstimateChange_DataSource(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "data.aws_ami.ubuntu", + Mode: "data", + Type: "aws_ami", + Change: iac.Change{Actions: []string{"create"}}, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true (data source)") + } + if !strings.Contains(ce.SkipReason, "data source") { + t.Errorf("SkipReason = %q", ce.SkipReason) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for data source, got %d", len(src.calls)) + } +} + +func TestEstimateChange_UnsupportedTypeWithCreate(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_iam_role.r", + Mode: "managed", + Type: "aws_iam_role", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{"name": "r"}, + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } + if !strings.Contains(ce.SkipReason, "unsupported resource type") { + t.Errorf("SkipReason = %q", ce.SkipReason) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for unsupported type, got %d", len(src.calls)) + } +} + +func TestEstimateChange_UnsupportedTypeWithDelete(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_iam_role.r", + Mode: "managed", + Type: "aws_iam_role", + Change: iac.Change{ + Actions: []string{"delete"}, + Before: map[string]interface{}{"name": "r"}, + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } +} + +func TestEstimateChange_UnsupportedTypeWithUpdate(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_iam_role.r", + Mode: "managed", + Type: "aws_iam_role", + Change: iac.Change{ + Actions: []string{"update"}, + Before: map[string]interface{}{"name": "r"}, + After: map[string]interface{}{"name": "r2"}, + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } +} + +func TestEstimateChange_ConfidenceMerging(t *testing.T) { + // Update of an EBS volume gp2 (Medium confidence) → io1 (Low). + // scriptedGetter returns the same minimal product for both calls; only + // the type differs in the Estimate's classification. + src := &scriptedGetter{responses: [][]string{ + {minimalGBMoProduct}, + {minimalGBMoProduct}, + }} + rc := iac.ResourceChange{ + Address: "aws_ebs_volume.disk", + Mode: "managed", + Type: "aws_ebs_volume", + Change: iac.Change{ + Actions: []string{"update"}, + Before: ebsAttrs("gp2", 50), + After: ebsAttrs("io1", 50), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if ce.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (Medium + Low → Low)", ce.Confidence) + } +} + +func TestEstimateChange_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{Actions: []string{"create"}, After: ec2CreateAttrs("t3.large", "", 0)}, + } + _, err := EstimateChange(context.Background(), src, rc, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateChange_APIErrorPropagated(t *testing.T) { + innerErr := errors.New("AccessDenied") + src := &scriptedGetter{errs: []error{innerErr}} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: ec2CreateAttrs("t3.large", "", 0), + }, + } + _, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } + if !strings.Contains(err.Error(), "aws_instance.web") { + t.Errorf("error missing resource address context: %v", err) + } +} + +func TestEstimateChange_BeforeExtractFailsOnUpdate(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"update"}, + Before: map[string]interface{}{ + "instance_type": 42, // wrong type — extractor fails + }, + After: ec2CreateAttrs("t3.large", "", 0), + }, + } + _, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err == nil { + t.Fatal("expected error from before extraction failure") + } + if !strings.Contains(err.Error(), "before") { + t.Errorf("error missing 'before' context: %v", err) + } +} + +func TestWeakestConfidence(t *testing.T) { + cases := []struct { + a, b, want Confidence + }{ + {ConfidenceHigh, ConfidenceHigh, ConfidenceHigh}, + {ConfidenceHigh, ConfidenceMedium, ConfidenceMedium}, + {ConfidenceMedium, ConfidenceHigh, ConfidenceMedium}, + {ConfidenceMedium, ConfidenceLow, ConfidenceLow}, + {ConfidenceLow, ConfidenceMedium, ConfidenceLow}, + {ConfidenceLow, ConfidenceLow, ConfidenceLow}, + } + for _, c := range cases { + if got := weakestConfidence(c.a, c.b); got != c.want { + t.Errorf("weakestConfidence(%q, %q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} + +func TestMergeDeltaBreakdown_BeforeOnlyComponent(t *testing.T) { + before := []LineItem{ + {Component: "Compute", MonthlyUSD: 100}, + {Component: "RootEBS", MonthlyUSD: 5}, + } + after := []LineItem{ + {Component: "Compute", MonthlyUSD: 80}, + } + out := mergeDeltaBreakdown(before, after) + if len(out) != 2 { + t.Fatalf("len = %d, want 2", len(out)) + } + if out[0].Component != "Compute" || out[0].MonthlyUSD != -20 { + t.Errorf("Compute = %+v, want -20", out[0]) + } + if out[1].Component != "RootEBS" || out[1].MonthlyUSD != -5 { + t.Errorf("RootEBS = %+v, want -5", out[1]) + } +} diff --git a/internal/pricing/doc.go b/internal/pricing/doc.go new file mode 100644 index 0000000..97c655d --- /dev/null +++ b/internal/pricing/doc.go @@ -0,0 +1,28 @@ +// Package pricing wraps the AWS Pricing API for product lookups used by +// downstream cost-impact analysis. +// +// Scope: this package is a thin foundation client. It deliberately does +// NOT cache results, does NOT parse the per-product JSON returned by the +// API, and does NOT translate Terraform attribute structs into Pricing +// API filters. Those responsibilities live in later milestones (caching, +// per-service attribute mappers, monthly cost estimation). +// +// Quirks of the AWS Pricing API that this package handles for callers: +// +// 1. The Pricing API service endpoint is only available in us-east-1 +// and ap-south-1. NewClient hard-codes us-east-1 because it's the +// default sane choice. The endpoint region is unrelated to the region +// of the priced resource — that comes through as a regionCode filter +// value in the GetProducts call. +// +// 2. Products come back as a []string of opaque JSON documents on the +// PriceList field (each ~10–50 KB). We pass them through unparsed; +// per-service mappers in a later milestone are responsible for +// decoding them. Decoding here would couple this foundation to every +// supported resource type. +// +// 3. Filters are list-of-(field, value) with a filter type. We force +// TERM_MATCH for every filter — it's the only one we use today, and +// ANDing exact matches is what every downstream caller wants. +// ANY_OF / NONE_OF / CONTAINS are deliberately not exposed. +package pricing diff --git a/internal/pricing/ebs.go b/internal/pricing/ebs.go new file mode 100644 index 0000000..e166346 --- /dev/null +++ b/internal/pricing/ebs.go @@ -0,0 +1,137 @@ +package pricing + +import ( + "context" + "fmt" + + "CloudOracle/internal/iac/aws" +) + +// lookupEBSStoragePrice queries the Pricing API for the per-GB-month USD +// rate of an EBS volume type in a given region. Used by both EstimateEBS +// (standalone volumes) and EstimateEC2 (root block devices), so it is +// kept generic — the caller multiplies by size and adds context-specific +// notes. +// +// volumeType is the Terraform value: "gp2", "gp3", "io1", "io2", "st1", +// "sc1", or "standard". The Pricing API's volumeApiName field currently +// uses the same strings, but mapping through mapEBSVolumeAPIName means a +// future divergence (or a typo guard) lives in exactly one place. +// +// Returns an error for empty/unknown volume types, API failures, products +// that come back empty, parse failures, or any unit other than "GB-Mo" +// — that last case usually indicates the filters were too loose and the +// query matched a non-storage product family. +func lookupEBSStoragePrice(ctx context.Context, src productGetter, volumeType, region string) (float64, error) { + apiName, err := mapEBSVolumeAPIName(volumeType) + if err != nil { + return 0, err + } + filters := map[string]string{ + "productFamily": "Storage", + "volumeApiName": apiName, + "regionCode": region, + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return 0, fmt.Errorf("lookupEBSStoragePrice: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("lookupEBSStoragePrice: no EBS price found for %s in %s", volumeType, region) + } + if len(products) > 1 { + return 0, fmt.Errorf("lookupEBSStoragePrice: query returned %d products; filter under-constrained for volumeType=%s region=%s", + len(products), volumeType, region) + } + gbMo, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("lookupEBSStoragePrice: parsing EBS price: %w", err) + } + if unit != "GB-Mo" { + return 0, fmt.Errorf("lookupEBSStoragePrice: expected EBS unit GB-Mo, got %q", unit) + } + return gbMo, nil +} + +// mapEBSVolumeAPIName validates a Terraform EBS volume type and returns +// the Pricing API's volumeApiName filter value. Today the strings line +// up one-to-one, but isolating the mapping rejects typos up front (with +// a clear error) instead of via "no products found" pages later. +func mapEBSVolumeAPIName(tfType string) (string, error) { + switch tfType { + case "gp2", "gp3", "io1", "io2", "st1", "sc1", "standard": + return tfType, nil + case "": + return "", fmt.Errorf("lookupEBSStoragePrice: empty volume type") + default: + return "", fmt.Errorf("lookupEBSStoragePrice: unknown volume type %q", tfType) + } +} + +// EstimateEBS calculates the monthly cost of a standalone aws_ebs_volume. +// It charges the GB-month rate for the volume's type and size. +// +// Charges NOT included in the estimate: +// +// - IOPS-month billing for gp3 above the 3000 IOPS that ship by default +// - Throughput-month billing for gp3 above the 125 MB/s default +// - IOPS-month billing for io1/io2 (a separate Pricing API +// productFamily that's out of scope for this milestone) +// - Snapshot storage and data-transfer charges +// +// Confidence rules: +// +// - gp2, st1, sc1, standard: ConfidenceMedium — flat GB-month price, +// no IOPS/throughput add-ons exist for these types. +// - gp3 at default IOPS/throughput: ConfidenceMedium — the included +// defaults cover most workloads. +// - gp3 with Iops > 3000 or Throughput > 125: ConfidenceLow — the +// missing add-on charges are material. +// - io1, io2: ConfidenceLow — IOPS billing is the dominant cost for +// these types and we are not modelling it. +// +// Returns an error for nil attrs, empty region, empty Type, Size <= 0, +// unknown volume types, API failures, missing products, or unit +// mismatches. +func EstimateEBS(ctx context.Context, src productGetter, attrs *aws.EBSAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateEBS: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateEBS: nil attrs") + } + if attrs.Type == "" { + return Estimate{}, fmt.Errorf("EstimateEBS: empty Type") + } + if attrs.Size <= 0 { + return Estimate{}, fmt.Errorf("EstimateEBS: Size must be > 0, got %d", attrs.Size) + } + + gbMo, err := lookupEBSStoragePrice(ctx, src, attrs.Type, region) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateEBS: %w", err) + } + storageCost := gbMo * float64(attrs.Size) + + notes := []string{ + "IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s)", + } + confidence := ConfidenceMedium + switch attrs.Type { + case "io1", "io2": + notes = append(notes, "io1/io2 IOPS billing is separate and not included in this estimate") + confidence = ConfidenceLow + case "gp3": + if attrs.Iops > 3000 || attrs.Throughput > 125 { + confidence = ConfidenceLow + } + } + + return Estimate{ + MonthlyUSD: storageCost, + Currency: "USD", + Breakdown: []LineItem{{Component: "Storage", MonthlyUSD: storageCost}}, + Confidence: confidence, + Notes: notes, + }, nil +} diff --git a/internal/pricing/ebs_test.go b/internal/pricing/ebs_test.go new file mode 100644 index 0000000..58c73aa --- /dev/null +++ b/internal/pricing/ebs_test.go @@ -0,0 +1,254 @@ +package pricing + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +// minimalGBMoProduct is a synthetic but well-formed Pricing API product +// JSON for tests that don't care about the real values, only that the +// parser succeeds and the unit is "GB-Mo". +const minimalGBMoProduct = `{"terms":{"OnDemand":{"S.T":{"priceDimensions":{"S.T.D":{"unit":"GB-Mo","pricePerUnit":{"USD":"0.10"}}}}}}}` + +func TestLookupEBSStoragePrice_AllSupportedTypes(t *testing.T) { + types := []string{"gp2", "gp3", "io1", "io2", "st1", "sc1", "standard"} + for _, vt := range types { + t.Run(vt, func(t *testing.T) { + src := &scriptedGetter{responses: [][]string{{minimalGBMoProduct}}} + price, err := lookupEBSStoragePrice(context.Background(), src, vt, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if math.Abs(price-0.10) > 1e-9 { + t.Errorf("price = %v, want 0.10", price) + } + if len(src.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(src.calls)) + } + if got := src.calls[0].service; got != "AmazonEC2" { + t.Errorf("service = %q, want AmazonEC2", got) + } + if got := src.calls[0].filters["volumeApiName"]; got != vt { + t.Errorf("volumeApiName = %q, want %q", got, vt) + } + if got := src.calls[0].filters["productFamily"]; got != "Storage" { + t.Errorf("productFamily = %q, want Storage", got) + } + if got := src.calls[0].filters["regionCode"]; got != "us-east-2" { + t.Errorf("regionCode = %q", got) + } + }) + } +} + +func TestLookupEBSStoragePrice_UnknownType(t *testing.T) { + src := &scriptedGetter{} + _, err := lookupEBSStoragePrice(context.Background(), src, "weird", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown volume type") { + t.Fatalf("err = %v, want unknown-type error", err) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls on unknown type, got %d", len(src.calls)) + } +} + +func TestLookupEBSStoragePrice_EmptyType(t *testing.T) { + src := &scriptedGetter{} + _, err := lookupEBSStoragePrice(context.Background(), src, "", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty volume type") { + t.Fatalf("err = %v, want empty-type error", err) + } +} + +func TestLookupEBSStoragePrice_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no EBS price found") { + t.Fatalf("err = %v, want 'no EBS price found' error", err) + } +} + +func TestLookupEBSStoragePrice_MultipleProductsErrors(t *testing.T) { + // After 13.6 tightening, ambiguity is a hard error rather than a warn. + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + second := strings.Replace(gp3, `"USD": "0.08"`, `"USD": "9.99"`, 1) + src := &scriptedGetter{responses: [][]string{{gp3, second}}} + + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil { + t.Fatal("expected error on ambiguous EBS query") + } + if !strings.Contains(err.Error(), "filter under-constrained") { + t.Errorf("err = %v, want 'filter under-constrained' message", err) + } + if !strings.Contains(err.Error(), "volumeType=gp3") { + t.Errorf("err missing volumeType context: %v", err) + } +} + +func TestLookupEBSStoragePrice_BadUnit(t *testing.T) { + body := strings.Replace(minimalGBMoProduct, `"unit":"GB-Mo"`, `"unit":"Hrs"`, 1) + src := &scriptedGetter{responses: [][]string{{body}}} + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected EBS unit GB-Mo") { + t.Fatalf("err = %v, want unit-mismatch error", err) + } +} + +func TestLookupEBSStoragePrice_PropagatesAPIError(t *testing.T) { + innerErr := errors.New("RateExceeded") + src := &scriptedGetter{errs: []error{innerErr}} + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateEBS_GP3DefaultIOPS_ConfidenceMedium(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + + attrs := &aws.EBSAttributes{ + Type: "gp3", + Size: 100, + } + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + want := 0.08 * 100 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } + if len(est.Breakdown) != 1 || est.Breakdown[0].Component != "Storage" { + t.Errorf("Breakdown = %+v", est.Breakdown) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q", est.Currency) + } +} + +func TestEstimateEBS_GP3HighIOPS_ConfidenceLow(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + attrs := &aws.EBSAttributes{Type: "gp3", Size: 100, Iops: 5000} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (Iops > 3000)", est.Confidence) + } +} + +func TestEstimateEBS_GP3HighThroughput_ConfidenceLow(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + attrs := &aws.EBSAttributes{Type: "gp3", Size: 100, Throughput: 250} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (Throughput > 125)", est.Confidence) + } +} + +func TestEstimateEBS_IO1_ConfidenceLowWithNote(t *testing.T) { + io1 := loadFixture(t, "ebs_io1_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{io1}}} + + attrs := &aws.EBSAttributes{Type: "io1", Size: 200, Iops: 4000} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + want := 0.125 * 200 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + foundIOPS := false + for _, n := range est.Notes { + if strings.Contains(n, "io1/io2 IOPS billing") { + foundIOPS = true + break + } + } + if !foundIOPS { + t.Errorf("Notes missing io1/io2 IOPS caveat: %v", est.Notes) + } + // volumeApiName filter should be "io1" + if got := src.calls[0].filters["volumeApiName"]; got != "io1" { + t.Errorf("volumeApiName = %q, want io1", got) + } +} + +func TestEstimateEBS_GP2_ConfidenceMedium(t *testing.T) { + src := &scriptedGetter{responses: [][]string{{minimalGBMoProduct}}} + attrs := &aws.EBSAttributes{Type: "gp2", Size: 50} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } +} + +func TestEstimateEBS_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateEBS(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Type: "gp3", Size: 50} + _, err := EstimateEBS(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_EmptyType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Size: 50} + _, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty Type") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_SizeZero(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Type: "gp3"} + _, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "Size must be > 0") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_UnknownType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Type: "weird", Size: 50} + _, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown volume type") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/pricing/ec2.go b/internal/pricing/ec2.go new file mode 100644 index 0000000..4ea25d9 --- /dev/null +++ b/internal/pricing/ec2.go @@ -0,0 +1,149 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + + "CloudOracle/internal/iac/aws" +) + +// HoursPerMonth is the AWS-standard month length used to convert hourly +// On-Demand prices to a monthly figure. AWS uses 730 across every public +// pricing page (see https://aws.amazon.com/ec2/pricing/on-demand/). Cited +// inline because future readers always ask why 730 rather than 720, 744, +// or 8760/12. +const HoursPerMonth = 730 + +// EstimateEC2 calculates the monthly cost of a single EC2 instance using +// the AWS Pricing API. The total includes the compute hours and, when a +// root_block_device is present in the plan, the root EBS volume's +// GB-month rate. +// +// region is the AWS region of the resource (e.g. "us-east-2"). The +// Pricing API endpoint region — pinned to us-east-1 inside Client — is +// independent of this value: the resource's region is supplied as a +// regionCode filter. +// +// src can be a *Client (live calls) or a *Cache (disk-cached); the caller +// chooses. Tests inject a fake productGetter. +// +// The mapper applies four assumptions, each of which contributes to the +// returned Confidence being ConfidenceLow: +// +// 1. operatingSystem = "Linux". Terraform plans don't carry the OS — it +// comes from the AMI, and resolving the AMI requires an extra +// ec2:DescribeImages call (out of scope here). +// 2. preInstalledSw = "NA". No SQL Server, SAP, or other licensed +// software sold by the hour through AWS. +// 3. capacitystatus = "Used". On-Demand only — Reserved Instances, +// Savings Plans, and Capacity Reservations are not modelled. +// 4. tenancy mapping. Terraform's lowercase "default"/"dedicated"/"host" +// maps to the Pricing API's "Shared"/"Dedicated"/"Host" via +// mapTenancy. +// +// Returns an error for nil attrs, empty region, empty InstanceType, API +// failures, products that come back empty, parse failures, or any unit +// mismatch (compute price not in "Hrs", storage price not in "GB-Mo"). +// A unit mismatch usually means the filters were too loose and matched +// the wrong product family — failing fast surfaces it to the developer. +func EstimateEC2(ctx context.Context, src productGetter, attrs *aws.EC2Attributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateEC2: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateEC2: nil attrs") + } + if attrs.InstanceType == "" { + return Estimate{}, fmt.Errorf("EstimateEC2: empty InstanceType") + } + + compute, err := lookupComputePrice(ctx, src, attrs, region) + if err != nil { + return Estimate{}, err + } + + notes := []string{ + "Operating system assumed Linux (plan does not specify)", + "Pricing assumes On-Demand (no Reserved Instances or Savings Plans)", + } + breakdown := []LineItem{{Component: "Compute", MonthlyUSD: compute}} + total := compute + + if attrs.RootBlockSize > 0 { + gbMo, err := lookupEBSStoragePrice(ctx, src, attrs.RootBlockType, region) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateEC2: root EBS: %w", err) + } + rootEBS := gbMo * float64(attrs.RootBlockSize) + breakdown = append(breakdown, LineItem{Component: "RootEBS", MonthlyUSD: rootEBS}) + total += rootEBS + } else { + notes = append(notes, "Root block device unknown, compute-only estimate") + } + + return Estimate{ + MonthlyUSD: total, + Currency: "USD", + Breakdown: breakdown, + Confidence: ConfidenceLow, + Notes: notes, + }, nil +} + +// lookupComputePrice runs the Pricing API query for the EC2 compute +// component and returns the monthly USD cost (hourly price * 730). +func lookupComputePrice(ctx context.Context, src productGetter, attrs *aws.EC2Attributes, region string) (float64, error) { + filters := map[string]string{ + "productFamily": "Compute Instance", + "instanceType": attrs.InstanceType, + "regionCode": region, + "tenancy": mapTenancy(attrs.Tenancy), + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return 0, fmt.Errorf("EstimateEC2: compute lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateEC2: no compute price found for %s in %s", attrs.InstanceType, region) + } + if len(products) > 1 { + return 0, fmt.Errorf("EstimateEC2: compute query returned %d products; filter under-constrained for instanceType=%s region=%s", + len(products), attrs.InstanceType, region) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateEC2: parsing compute price: %w", err) + } + if unit != "Hrs" { + return 0, fmt.Errorf("EstimateEC2: expected compute unit Hrs, got %q", unit) + } + return hourly * HoursPerMonth, nil +} + +// mapTenancy translates Terraform's tenancy attribute to the AWS Pricing +// API's tenancy filter value. Terraform uses lowercase ("default", +// "dedicated", "host"); the Pricing API uses TitleCase ("Shared", +// "Dedicated", "Host"). The empty string maps to "Shared" because +// aws_instance defaults tenancy to "default" when omitted. +// +// Unknown values fall back to "Shared" with a warn log so an unexpected +// future tenancy doesn't silently mis-price. +func mapTenancy(tenancy string) string { + switch tenancy { + case "", "default": + return "Shared" + case "dedicated": + return "Dedicated" + case "host": + return "Host" + default: + slog.Warn("pricing: unknown EC2 tenancy, defaulting to Shared", + "tenancy", tenancy, + ) + return "Shared" + } +} diff --git a/internal/pricing/ec2_test.go b/internal/pricing/ec2_test.go new file mode 100644 index 0000000..e75b71d --- /dev/null +++ b/internal/pricing/ec2_test.go @@ -0,0 +1,418 @@ +package pricing + +import ( + "context" + "errors" + "log/slog" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +// scriptedGetter is a productGetter that replays pre-programmed responses +// in call order. It records every call's serviceCode and a copy of the +// filters map so tests can assert exactly what was sent. Different from +// fakeProductGetter (cache_test.go) which always returns the same data — +// EC2 tests need different replies for the compute and EBS queries. +type scriptedGetter struct { + responses [][]string + errs []error + calls []scriptedCall +} + +type scriptedCall struct { + service string + filters map[string]string +} + +func (s *scriptedGetter) GetProducts(_ context.Context, service string, filters map[string]string) ([]string, error) { + idx := len(s.calls) + cp := make(map[string]string, len(filters)) + for k, v := range filters { + cp[k] = v + } + s.calls = append(s.calls, scriptedCall{service: service, filters: cp}) + + if idx < len(s.errs) && s.errs[idx] != nil { + return nil, s.errs[idx] + } + if idx >= len(s.responses) { + return nil, errors.New("scriptedGetter: no response programmed for this call") + } + return s.responses[idx], nil +} + +func TestEstimateEC2_WithRootBlock(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + } + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + + wantCompute := 0.0832 * HoursPerMonth // 60.736 + wantEBS := 0.08 * 50 // 4.0 + wantTotal := wantCompute + wantEBS + + if math.Abs(est.MonthlyUSD-wantTotal) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, wantTotal) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q, want USD", est.Currency) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if len(est.Breakdown) != 2 { + t.Fatalf("Breakdown len = %d, want 2", len(est.Breakdown)) + } + if est.Breakdown[0].Component != "Compute" || math.Abs(est.Breakdown[0].MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("Breakdown[0] = %+v, want Compute=%v", est.Breakdown[0], wantCompute) + } + if est.Breakdown[1].Component != "RootEBS" || math.Abs(est.Breakdown[1].MonthlyUSD-wantEBS) > 1e-6 { + t.Errorf("Breakdown[1] = %+v, want RootEBS=%v", est.Breakdown[1], wantEBS) + } + + // Compute query filters + if len(src.calls) != 2 { + t.Fatalf("got %d API calls, want 2", len(src.calls)) + } + c := src.calls[0] + if c.service != "AmazonEC2" { + t.Errorf("compute call service = %q", c.service) + } + for k, want := range map[string]string{ + "productFamily": "Compute Instance", + "instanceType": "t3.large", + "regionCode": "us-east-2", + "tenancy": "Shared", + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + } { + if c.filters[k] != want { + t.Errorf("compute filter %s = %q, want %q", k, c.filters[k], want) + } + } + + // EBS query filters + e := src.calls[1] + if e.service != "AmazonEC2" { + t.Errorf("ebs call service = %q", e.service) + } + for k, want := range map[string]string{ + "productFamily": "Storage", + "volumeApiName": "gp3", + "regionCode": "us-east-2", + } { + if e.filters[k] != want { + t.Errorf("ebs filter %s = %q, want %q", k, e.filters[k], want) + } + } +} + +func TestEstimateEC2_NoRootBlock(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + } + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if len(src.calls) != 1 { + t.Errorf("got %d API calls, want 1 (no EBS lookup when RootBlockSize=0)", len(src.calls)) + } + if len(est.Breakdown) != 1 { + t.Fatalf("Breakdown len = %d, want 1", len(est.Breakdown)) + } + if est.Breakdown[0].Component != "Compute" { + t.Errorf("Breakdown[0].Component = %q, want Compute", est.Breakdown[0].Component) + } + wantCompute := 0.0832 * HoursPerMonth + if math.Abs(est.MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, wantCompute) + } + foundNote := false + for _, n := range est.Notes { + if strings.Contains(n, "Root block device unknown") { + foundNote = true + break + } + } + if !foundNote { + t.Errorf("Notes missing 'Root block device unknown' caveat: %v", est.Notes) + } +} + +func TestEstimateEC2_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateEC2(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v, want 'nil attrs' error", err) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls on nil attrs, got %d", len(src.calls)) + } +} + +func TestEstimateEC2_EmptyInstanceType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EC2Attributes{} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty InstanceType") { + t.Fatalf("err = %v, want 'empty InstanceType' error", err) + } +} + +func TestEstimateEC2_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v, want 'empty region' error", err) + } +} + +func TestEstimateEC2_NoComputeProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error when compute query returns 0 products") + } + if !strings.Contains(err.Error(), "no compute price found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestEstimateEC2_MultipleComputeProductsErrors(t *testing.T) { + // Multiple products signals an under-constrained filter set, not a + // "pick one and warn" scenario. Tightened in milestone 13.6 — see + // the godoc on lookupComputePrice. + first := loadFixture(t, "ec2_t3_large_us_east_2.json") + second := strings.Replace(first, `"USD": "0.0832"`, `"USD": "9.99"`, 1) + + src := &scriptedGetter{responses: [][]string{{first, second}}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error on ambiguous compute query") + } + if !strings.Contains(err.Error(), "filter under-constrained") { + t.Errorf("err = %v, want 'filter under-constrained' message", err) + } + if !strings.Contains(err.Error(), "instanceType=t3.large") { + t.Errorf("err missing instanceType context: %v", err) + } +} + +func TestEstimateEC2_BadComputeUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "ec2_t3_large_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected compute unit Hrs") { + t.Fatalf("err = %v, want unit-mismatch error", err) + } +} + +func TestEstimateEC2_BadEBSUnit(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + bad := strings.Replace( + loadFixture(t, "ec2_gp3_us_east_2.json"), + `"unit": "GB-Mo"`, + `"unit": "Hrs"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{compute}, {bad}}} + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected EBS unit GB-Mo") { + t.Fatalf("err = %v, want unit-mismatch error", err) + } +} + +func TestEstimateEC2_TenancyDedicated(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "dedicated", + } + if _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if got := src.calls[0].filters["tenancy"]; got != "Dedicated" { + t.Errorf("tenancy filter = %q, want Dedicated", got) + } +} + +func TestEstimateEC2_TenancyHost(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "host", + } + if _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if got := src.calls[0].filters["tenancy"]; got != "Host" { + t.Errorf("tenancy filter = %q, want Host", got) + } +} + +func TestEstimateEC2_ConfidenceAlwaysLow(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + } + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (OS is always assumed)", est.Confidence) + } + foundOS := false + for _, n := range est.Notes { + if strings.Contains(n, "Linux") { + foundOS = true + break + } + } + if !foundOS { + t.Errorf("Notes missing OS=Linux assumption: %v", est.Notes) + } +} + +func TestEstimateEC2_ComputeError(t *testing.T) { + innerErr := errors.New("AccessDenied") + src := &scriptedGetter{errs: []error{innerErr}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateEC2_EBSError(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + innerErr := errors.New("RateExceeded") + src := &scriptedGetter{ + responses: [][]string{{compute}, nil}, + errs: []error{nil, innerErr}, + } + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateEC2_NoEBSProducts(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, nil}} + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no EBS price found") { + t.Fatalf("err = %v, want 'no EBS price found' error", err) + } +} + +func TestEstimateEC2_BadComputeJSON(t *testing.T) { + src := &scriptedGetter{responses: [][]string{{"not-json{{{"}}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "parsing compute price") { + t.Fatalf("err = %v, want 'parsing compute price' error", err) + } +} + +func TestEstimateEC2_BadEBSJSON(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {"not-json{{{"}}} + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "parsing EBS price") { + t.Fatalf("err = %v, want 'parsing EBS price' error", err) + } +} + +func TestMapTenancy(t *testing.T) { + cases := []struct { + in, want string + }{ + {"default", "Shared"}, + {"", "Shared"}, + {"dedicated", "Dedicated"}, + {"host", "Host"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := mapTenancy(c.in); got != c.want { + t.Errorf("mapTenancy(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestMapTenancy_UnknownDefaultsToShared(t *testing.T) { + logs := captureLogs(t, slog.LevelWarn) + got := mapTenancy("on-prem-bring-your-own") + if got != "Shared" { + t.Errorf("mapTenancy = %q, want Shared", got) + } + if !strings.Contains(logs.String(), "unknown EC2 tenancy") { + t.Errorf("expected warn log on unknown tenancy, got: %s", logs.String()) + } +} diff --git a/internal/pricing/integration_test.go b/internal/pricing/integration_test.go new file mode 100644 index 0000000..a7f814e --- /dev/null +++ b/internal/pricing/integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +// Integration tests in this file hit the real AWS Pricing API. Run with: +// +// go test -tags=integration ./internal/pricing/... +// +// They require AWS credentials configured via the standard chain +// (env vars, shared credentials file, EC2/ECS/EKS instance metadata, +// SSO). When credentials are missing, NewClient itself does not error +// — it constructs a client that will fail at the first API call. To +// keep the suite friendly to credential-less machines we still attempt +// a NewClient and skip the test if construction fails; the per-test +// API call surfaces auth issues clearly through their own errors. +// +// What these tests check: +// +// 1. Each EstimateXxx mapper returns a price in a sane range against +// live AWS data (the upper bound has slack so AWS price changes +// don't flake the suite). +// 2. None of the queries return >1 product after milestone 13.6's +// filter tightening — TestIntegration_NoAmbiguity verifies all six +// mappers in one pass and asserts no "filter under-constrained" +// error escapes. + +package pricing + +import ( + "context" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +const integrationRegion = "us-east-2" + +// integrationClient builds a live pricing.Client or skips the test if +// AWS credential resolution fails. NewClient currently only fails when +// LoadDefaultConfig itself errors — typically because of a malformed +// AWS_PROFILE or a broken shared config — but skipping cleanly there +// keeps the suite usable on any machine. +func integrationClient(t *testing.T) *Client { + t.Helper() + c, err := NewClient(context.Background()) + if err != nil { + t.Skipf("integration test requires AWS credentials; got error: %v", err) + } + return c +} + +func inBand(t *testing.T, label string, got, lo, hi float64) { + t.Helper() + if got < lo || got > hi { + t.Errorf("%s = %.4f, want in band [%.4f, %.4f]", label, got, lo, hi) + } +} + +func TestIntegration_EC2_T3Large_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + } + est, err := EstimateEC2(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + // As of late 2024 the price is ~$64.74. The band is wide enough to + // absorb routine AWS price tweaks without flaking the suite. + inBand(t, "EC2 t3.large+50GB gp3", est.MonthlyUSD, 50, 80) +} + +func TestIntegration_RDS_PostgresT3Medium_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + est, err := EstimateRDS(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + // ~$71.36 baseline. + inBand(t, "RDS db.t3.medium+100GB gp2", est.MonthlyUSD, 50, 90) +} + +func TestIntegration_EBS_GP3_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.EBSAttributes{Type: "gp3", Size: 200} + est, err := EstimateEBS(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + // $0.08/GB-month * 200 = $16.00. + inBand(t, "EBS gp3 200GB", est.MonthlyUSD, 14, 20) +} + +func TestIntegration_Lambda_PCZero(t *testing.T) { + // PC=0 must short-circuit and return $0 with no API call. We cannot + // observe "no API call" through a *Client, but we can still assert + // the contract: cost is exactly 0. + c := integrationClient(t) + attrs := &aws.LambdaAttributes{ + FunctionName: "test", + MemorySize: 512, + Architecture: "arm64", + } + est, err := EstimateLambda(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + if est.MonthlyUSD != 0 { + t.Errorf("MonthlyUSD = %v, want exactly 0 for PC=0", est.MonthlyUSD) + } +} + +func TestIntegration_NATGateway_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.NATGatewayAttributes{ + SubnetID: "subnet-irrelevant", + ConnectivityType: "public", + } + est, err := EstimateNATGateway(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateNATGateway: %v", err) + } + // $0.045/hr * 730 = $32.85. + inBand(t, "NAT Gateway", est.MonthlyUSD, 30, 40) +} + +func TestIntegration_RDSClusterInstance_AuroraPGR5Large_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "test-cluster", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + est, err := EstimateRDSClusterInstance(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + // $0.29/hr * 730 = $211.7. + inBand(t, "Aurora PG db.r5.large", est.MonthlyUSD, 190, 230) +} + +// TestIntegration_NoAmbiguity exercises every mapper end-to-end in one +// pass and asserts that none of them surface the "filter under- +// constrained" error introduced in milestone 13.6. This is the +// regression test that catches a future AWS catalogue change adding a +// new SKU variant we hadn't anticipated. +func TestIntegration_NoAmbiguity(t *testing.T) { + c := integrationClient(t) + ctx := context.Background() + + type call func() error + calls := map[string]call{ + "EC2": func() error { + _, err := EstimateEC2(ctx, c, &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + }, integrationRegion) + return err + }, + "RDS": func() error { + _, err := EstimateRDS(ctx, c, &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + }, integrationRegion) + return err + }, + "EBS": func() error { + _, err := EstimateEBS(ctx, c, &aws.EBSAttributes{Type: "gp3", Size: 200}, integrationRegion) + return err + }, + "Lambda": func() error { + // Use PC>0 so the API is actually hit (PC=0 short-circuits). + _, err := EstimateLambda(ctx, c, &aws.LambdaAttributes{ + FunctionName: "test", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 1, + }, integrationRegion) + return err + }, + "NATGateway": func() error { + _, err := EstimateNATGateway(ctx, c, &aws.NATGatewayAttributes{ + SubnetID: "subnet-irrelevant", + ConnectivityType: "public", + }, integrationRegion) + return err + }, + "AuroraClusterInstance": func() error { + _, err := EstimateRDSClusterInstance(ctx, c, &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "test-cluster", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + }, integrationRegion) + return err + }, + } + + for name, fn := range calls { + t.Run(name, func(t *testing.T) { + err := fn() + if err == nil { + return + } + if strings.Contains(err.Error(), "filter under-constrained") { + t.Fatalf("%s: ambiguity error after 13.6 tightening: %v", name, err) + } + // Any other error is unexpected too — surface it loudly so + // it's debugged rather than silently passed over. + t.Fatalf("%s: %v", name, err) + }) + } +} diff --git a/internal/pricing/lambda.go b/internal/pricing/lambda.go new file mode 100644 index 0000000..b3cb22b --- /dev/null +++ b/internal/pricing/lambda.go @@ -0,0 +1,130 @@ +package pricing + +import ( + "context" + "fmt" + + "CloudOracle/internal/iac/aws" +) + +// SecondsPerMonth is HoursPerMonth * 3600. AWS Lambda Provisioned +// Concurrency is priced per GB-second rather than per GB-hour, so this +// is the conversion factor used to compute monthly cost from the +// per-second rate. Defined alongside HoursPerMonth so callers don't +// rediscover the constant on every per-second mapper. +const SecondsPerMonth = HoursPerMonth * 3600 + +// EstimateLambda calculates the monthly STANDING cost of a Lambda +// function. +// +// Lambda has two billing components and only the first is estimable +// from a Terraform plan: +// +// 1. Standing cost. $0 unless ProvisionedConcurrency > 0, in which +// case it is ProvisionedConcurrency * (MemorySize/1024) * +// SecondsPerMonth * pricePerGBSecond. Provisioned Concurrency +// keeps execution environments warm and is billed by the second +// regardless of invocations. +// 2. Invocation cost. Per-request fee plus per-GB-second of execution +// time. NOT estimable from a plan — depends on runtime traffic. +// +// This function returns the standing cost only. When +// ProvisionedConcurrency is 0 (the Lambda default) MonthlyUSD is 0 and +// the Notes explicitly call out that invocation charges are not +// modelled. No API call is made in that case — we already know the +// answer is 0. +// +// Confidence is always Low because the invocation component is unknown, +// even when the standing cost is precisely $0: the user reading the +// estimate should always be aware that real Lambda spend depends on +// traffic. The Notes carry the same warning in human-readable form. +// +// Filter strategy: AWS exposes the standing cost SKU under +// productFamily="Serverless" with usagetype="-Lambda- +// Provisioned-Concurrency" (x86_64) or "...-Provisioned-Concurrency-ARM" +// (arm64). There is no `architecture` attribute on these products, so +// usagetype IS the only architecture discriminator — that's why +// regionPrefix exists. +// +// Returns an error for nil attrs, empty region, unknown architectures, +// API failures, missing products, ambiguous matches, or any unit other +// than "Lambda-GB-Second". +func EstimateLambda(ctx context.Context, src productGetter, attrs *aws.LambdaAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateLambda: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateLambda: nil attrs") + } + + if attrs.ProvisionedConcurrency == 0 { + return Estimate{ + MonthlyUSD: 0, + Currency: "USD", + Breakdown: nil, + Confidence: ConfidenceLow, + Notes: []string{ + "Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled", + }, + }, nil + } + + suffix, err := lambdaArchitectureSuffix(attrs.Architecture) + if err != nil { + return Estimate{}, err + } + usageType := fmt.Sprintf("%s-Lambda-Provisioned-Concurrency%s", regionPrefix(region), suffix) + + filters := map[string]string{ + "productFamily": "Serverless", + "regionCode": region, + "usagetype": usageType, + } + products, err := src.GetProducts(ctx, "AWSLambda", filters) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateLambda: provisioned concurrency lookup: %w", err) + } + if len(products) == 0 { + return Estimate{}, fmt.Errorf("EstimateLambda: no provisioned concurrency price found for usagetype=%s", usageType) + } + if len(products) > 1 { + return Estimate{}, fmt.Errorf("EstimateLambda: PC query returned %d products; filter under-constrained for usagetype=%s region=%s", + len(products), usageType, region) + } + gbSecond, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateLambda: parsing PC price: %w", err) + } + if unit != "Lambda-GB-Second" { + return Estimate{}, fmt.Errorf("EstimateLambda: expected PC unit Lambda-GB-Second, got %q", unit) + } + + memGB := float64(attrs.MemorySize) / 1024.0 + cost := float64(attrs.ProvisionedConcurrency) * memGB * SecondsPerMonth * gbSecond + + return Estimate{ + MonthlyUSD: cost, + Currency: "USD", + Breakdown: []LineItem{{Component: "ProvisionedConcurrency", MonthlyUSD: cost}}, + Confidence: ConfidenceLow, + Notes: []string{ + "Provisioned Concurrency standing cost only; per-invocation charges not modeled", + }, + }, nil +} + +// lambdaArchitectureSuffix returns the suffix appended to the +// Provisioned Concurrency usagetype for a given Terraform architecture. +// Empty / "x86_64" → no suffix; "arm64" → "-ARM". Unknown values +// return an error so a typo in the plan doesn't silently match (and +// mis-price as) the wrong architecture's SKU. +func lambdaArchitectureSuffix(arch string) (string, error) { + switch arch { + case "", "x86_64": + return "", nil + case "arm64": + return "-ARM", nil + default: + return "", fmt.Errorf("EstimateLambda: unknown architecture %q", arch) + } +} diff --git a/internal/pricing/lambda_test.go b/internal/pricing/lambda_test.go new file mode 100644 index 0000000..7c658b1 --- /dev/null +++ b/internal/pricing/lambda_test.go @@ -0,0 +1,233 @@ +package pricing + +import ( + "context" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateLambda_ProvisionedConcurrencyZero_NoAPICall(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 128, + Architecture: "x86_64", + } + est, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + if est.MonthlyUSD != 0 { + t.Errorf("MonthlyUSD = %v, want 0", est.MonthlyUSD) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls when PC=0, got %d", len(src.calls)) + } + foundNote := false + for _, n := range est.Notes { + if strings.Contains(n, "Standing cost is $0") { + foundNote = true + break + } + } + if !foundNote { + t.Errorf("Notes missing 'Standing cost is $0' note: %v", est.Notes) + } +} + +func TestEstimateLambda_ProvisionedConcurrency_X86_64(t *testing.T) { + // x86_64 SKU: usagetype has no -ARM suffix, price is $0.0000041667/GB-Second. + body := loadFixture(t, "lambda_arm64_us_east_2.json") + body = strings.Replace(body, `"USD": "0.0000033334"`, `"USD": "0.0000041667"`, 1) + body = strings.Replace(body, `USE2-Lambda-Provisioned-Concurrency-ARM`, `USE2-Lambda-Provisioned-Concurrency`, -1) + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 1024, + Architecture: "x86_64", + ProvisionedConcurrency: 5, + } + est, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + want := 5 * 1.0 * SecondsPerMonth * 0.0000041667 // ~54.75 + if math.Abs(est.MonthlyUSD-want) > 1e-3 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if got := src.calls[0].filters["usagetype"]; got != "USE2-Lambda-Provisioned-Concurrency" { + t.Errorf("usagetype = %q, want USE2-Lambda-Provisioned-Concurrency", got) + } + if got := src.calls[0].filters["productFamily"]; got != "Serverless" { + t.Errorf("productFamily = %q, want Serverless", got) + } + if got := src.calls[0].service; got != "AWSLambda" { + t.Errorf("service = %q, want AWSLambda", got) + } +} + +func TestEstimateLambda_ProvisionedConcurrency_ARM64(t *testing.T) { + body := loadFixture(t, "lambda_arm64_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 10, + } + est, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + want := 10 * 0.5 * SecondsPerMonth * 0.0000033334 // ~43.80 + if math.Abs(est.MonthlyUSD-want) > 1e-3 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if got := src.calls[0].filters["usagetype"]; got != "USE2-Lambda-Provisioned-Concurrency-ARM" { + t.Errorf("usagetype = %q, want -ARM suffix", got) + } +} + +func TestEstimateLambda_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateLambda(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.LambdaAttributes{FunctionName: "f"} + _, err := EstimateLambda(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_UnknownArchitecture(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "weird", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown architecture") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "x86_64", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no provisioned concurrency price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_BadUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "lambda_arm64_us_east_2.json"), + `"unit": "Lambda-GB-Second"`, + `"unit": "Hrs"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected PC unit Lambda-GB-Second") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_AmbiguousQueryErrors(t *testing.T) { + body := loadFixture(t, "lambda_arm64_us_east_2.json") + second := strings.Replace(body, `"USD": "0.0000033334"`, `"USD": "0.99"`, 1) + src := &scriptedGetter{responses: [][]string{{body, second}}} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "filter under-constrained") { + t.Fatalf("err = %v, want under-constrained error", err) + } +} + +func TestLambdaArchitectureSuffix(t *testing.T) { + cases := []struct { + in, want string + err bool + }{ + {"x86_64", "", false}, + {"", "", false}, + {"arm64", "-ARM", false}, + {"weird", "", true}, + } + for _, c := range cases { + got, err := lambdaArchitectureSuffix(c.in) + if c.err { + if err == nil { + t.Errorf("input %q: expected error", c.in) + } + continue + } + if err != nil { + t.Errorf("input %q: unexpected err: %v", c.in, err) + } + if got != c.want { + t.Errorf("input %q: got %q, want %q", c.in, got, c.want) + } + } +} + +func TestRegionPrefix_Known(t *testing.T) { + cases := map[string]string{ + "us-east-1": "USE1", + "us-east-2": "USE2", + "us-west-1": "USW1", + "us-west-2": "USW2", + "eu-west-1": "EUW1", + "eu-central-1": "EUC1", + "ap-southeast-1": "APS1", + "ap-northeast-1": "APN1", + } + for region, want := range cases { + if got := regionPrefix(region); got != want { + t.Errorf("regionPrefix(%q) = %q, want %q", region, got, want) + } + } +} + +func TestRegionPrefix_UnknownFallsBackToInput(t *testing.T) { + got := regionPrefix("af-south-1") + if got != "af-south-1" { + t.Errorf("regionPrefix(af-south-1) = %q, want literal fallback", got) + } +} diff --git a/internal/pricing/nat.go b/internal/pricing/nat.go new file mode 100644 index 0000000..c48d548 --- /dev/null +++ b/internal/pricing/nat.go @@ -0,0 +1,71 @@ +package pricing + +import ( + "context" + "fmt" + + "CloudOracle/internal/iac/aws" +) + +// EstimateNATGateway calculates the monthly cost of a NAT Gateway. +// +// NAT Gateway has two billing components: +// +// 1. Hourly gateway charge. Region-dependent (~$0.045/hr in us-east-2, +// ~$32.85/month), billed regardless of traffic. Fully estimable from +// the plan. +// 2. Per-GB data processing. ~$0.045/GB on every byte that flows +// through. NOT estimable from a Terraform plan — depends on traffic. +// +// This function returns the hourly gateway cost only (price * 730). The +// Notes always include a warning about the unmodelled data-processing +// charges, because a NAT in a chatty subnet can cost an order of +// magnitude more than the standing cost. Confidence is Medium: the +// standing component is exact, but the unmodelled component can dominate +// the bill in real workloads. +// +// Returns an error for nil attrs, empty region, API failures, missing +// products, or any unit other than "Hrs". +func EstimateNATGateway(ctx context.Context, src productGetter, attrs *aws.NATGatewayAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateNATGateway: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateNATGateway: nil attrs") + } + + filters := map[string]string{ + "productFamily": "NAT Gateway", + "regionCode": region, + "groupDescription": "Hourly charge for NAT Gateways", + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateNATGateway: lookup: %w", err) + } + if len(products) == 0 { + return Estimate{}, fmt.Errorf("EstimateNATGateway: no NAT Gateway price found in %s", region) + } + if len(products) > 1 { + return Estimate{}, fmt.Errorf("EstimateNATGateway: query returned %d products; filter under-constrained for region=%s", + len(products), region) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateNATGateway: parsing price: %w", err) + } + if unit != "Hrs" { + return Estimate{}, fmt.Errorf("EstimateNATGateway: expected unit Hrs, got %q", unit) + } + cost := hourly * HoursPerMonth + + return Estimate{ + MonthlyUSD: cost, + Currency: "USD", + Breakdown: []LineItem{{Component: "Gateway", MonthlyUSD: cost}}, + Confidence: ConfidenceMedium, + Notes: []string{ + "Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled", + }, + }, nil +} diff --git a/internal/pricing/nat_test.go b/internal/pricing/nat_test.go new file mode 100644 index 0000000..2de3da8 --- /dev/null +++ b/internal/pricing/nat_test.go @@ -0,0 +1,103 @@ +package pricing + +import ( + "context" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateNATGateway_HappyPath(t *testing.T) { + body := loadFixture(t, "nat_gateway_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.NATGatewayAttributes{ + SubnetID: "subnet-abc", + ConnectivityType: "public", + } + est, err := EstimateNATGateway(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateNATGateway: %v", err) + } + want := 0.045 * HoursPerMonth // 32.85 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q", est.Currency) + } + if len(est.Breakdown) != 1 || est.Breakdown[0].Component != "Gateway" { + t.Errorf("Breakdown = %+v", est.Breakdown) + } + foundNote := false + for _, n := range est.Notes { + if strings.Contains(n, "data processing") { + foundNote = true + break + } + } + if !foundNote { + t.Errorf("Notes missing data-processing caveat: %v", est.Notes) + } + + // Filters + c := src.calls[0] + if c.service != "AmazonEC2" { + t.Errorf("service = %q, want AmazonEC2", c.service) + } + for k, want := range map[string]string{ + "productFamily": "NAT Gateway", + "regionCode": "us-east-2", + "groupDescription": "Hourly charge for NAT Gateways", + } { + if c.filters[k] != want { + t.Errorf("filter %s = %q, want %q", k, c.filters[k], want) + } + } +} + +func TestEstimateNATGateway_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateNATGateway(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateNATGateway_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.NATGatewayAttributes{SubnetID: "subnet-abc"} + _, err := EstimateNATGateway(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateNATGateway_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.NATGatewayAttributes{SubnetID: "subnet-abc"} + _, err := EstimateNATGateway(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no NAT Gateway price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateNATGateway_BadUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "nat_gateway_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.NATGatewayAttributes{SubnetID: "subnet-abc"} + _, err := EstimateNATGateway(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected unit Hrs") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/pricing/parse.go b/internal/pricing/parse.go new file mode 100644 index 0000000..e15af15 --- /dev/null +++ b/internal/pricing/parse.go @@ -0,0 +1,82 @@ +package pricing + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" +) + +// parseOnDemandPriceUSD extracts the per-unit OnDemand USD price from a +// single AWS Pricing API product JSON document — one element of the +// PriceList slice returned by GetProducts. +// +// The product payload nests pricing under +// +// terms.OnDemand..priceDimensions..pricePerUnit.USD +// +// where and are AWS-internal opaque strings. EC2 compute, +// EBS storage, and RDS instances each carry exactly one OnDemand SKU +// with a single price dimension, so this helper picks the first entry of +// each map sorted by key. Sorting matters because Go map iteration order +// is randomised: we want byte-identical results across runs. +// +// Tiered services (S3 storage classes, Lambda invocations) expose +// multiple price dimensions in a single SKU and need a different helper +// — they are out of scope here. +// +// The unit string ("Hrs", "GB-Mo", ...) is returned alongside the price +// so callers can confirm the product they pulled matches the cost +// component they expected (compute → "Hrs", storage → "GB-Mo"). A +// mismatch usually means the filters were too loose and selected the +// wrong product family. +// +// An error is returned (with context) when the JSON does not parse, +// terms.OnDemand is missing or empty, the priceDimensions map is missing +// or empty, the USD entry is absent, or the price string fails to parse +// as a float. +func parseOnDemandPriceUSD(productJSON string) (price float64, unit string, err error) { + var doc struct { + Terms struct { + OnDemand map[string]struct { + PriceDimensions map[string]struct { + Unit string `json:"unit"` + PricePerUnit map[string]string `json:"pricePerUnit"` + } `json:"priceDimensions"` + } `json:"OnDemand"` + } `json:"terms"` + } + if err := json.Unmarshal([]byte(productJSON), &doc); err != nil { + return 0, "", fmt.Errorf("pricing: parsing product JSON: %w", err) + } + + if len(doc.Terms.OnDemand) == 0 { + return 0, "", fmt.Errorf("pricing: no OnDemand pricing in product") + } + skuKeys := make([]string, 0, len(doc.Terms.OnDemand)) + for k := range doc.Terms.OnDemand { + skuKeys = append(skuKeys, k) + } + sort.Strings(skuKeys) + sku := doc.Terms.OnDemand[skuKeys[0]] + + if len(sku.PriceDimensions) == 0 { + return 0, "", fmt.Errorf("pricing: OnDemand SKU has no priceDimensions") + } + dimKeys := make([]string, 0, len(sku.PriceDimensions)) + for k := range sku.PriceDimensions { + dimKeys = append(dimKeys, k) + } + sort.Strings(dimKeys) + dim := sku.PriceDimensions[dimKeys[0]] + + usdStr, ok := dim.PricePerUnit["USD"] + if !ok { + return 0, "", fmt.Errorf("pricing: priceDimension has no USD price") + } + usd, err := strconv.ParseFloat(usdStr, 64) + if err != nil { + return 0, "", fmt.Errorf("pricing: parsing USD price %q: %w", usdStr, err) + } + return usd, dim.Unit, nil +} diff --git a/internal/pricing/parse_test.go b/internal/pricing/parse_test.go new file mode 100644 index 0000000..d2b9379 --- /dev/null +++ b/internal/pricing/parse_test.go @@ -0,0 +1,201 @@ +package pricing + +import ( + "math" + "os" + "path/filepath" + "strings" + "testing" +) + +// loadFixture returns the contents of a JSON file under testdata/. Panics +// (via t.Fatal) on any IO error so the test fails loudly with a path the +// developer can investigate. +func loadFixture(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("testdata", name) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("loading fixture %q: %v", path, err) + } + return string(raw) +} + +func TestParseOnDemandPriceUSD_HappyPathCompute(t *testing.T) { + body := loadFixture(t, "ec2_t3_large_us_east_2.json") + price, unit, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-0.0832) > 1e-9 { + t.Errorf("price = %v, want 0.0832", price) + } + if unit != "Hrs" { + t.Errorf("unit = %q, want Hrs", unit) + } +} + +func TestParseOnDemandPriceUSD_HappyPathStorage(t *testing.T) { + body := loadFixture(t, "ec2_gp3_us_east_2.json") + price, unit, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-0.08) > 1e-9 { + t.Errorf("price = %v, want 0.08", price) + } + if unit != "GB-Mo" { + t.Errorf("unit = %q, want GB-Mo", unit) + } +} + +func TestParseOnDemandPriceUSD_MultipleSKUsPicksFirstSorted(t *testing.T) { + // Two SKUs: "ZZZ.JRTC" (price 0.99) and "AAA.JRTC" (price 0.01). + // Sorted ascending the AAA SKU wins regardless of map iteration order. + body := `{ + "terms": { + "OnDemand": { + "ZZZ.JRTC": { + "priceDimensions": { + "ZZZ.JRTC.D1": { + "unit": "Hrs", + "pricePerUnit": {"USD": "0.99"} + } + } + }, + "AAA.JRTC": { + "priceDimensions": { + "AAA.JRTC.D1": { + "unit": "Hrs", + "pricePerUnit": {"USD": "0.01"} + } + } + } + } + } + }` + price, _, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-0.01) > 1e-9 { + t.Errorf("price = %v, want 0.01 (sorted-first SKU)", price) + } +} + +func TestParseOnDemandPriceUSD_MultiplePriceDimensionsPicksFirstSorted(t *testing.T) { + body := `{ + "terms": { + "OnDemand": { + "S1.T1": { + "priceDimensions": { + "S1.T1.ZZZ": {"unit": "Hrs", "pricePerUnit": {"USD": "9.00"}}, + "S1.T1.AAA": {"unit": "Hrs", "pricePerUnit": {"USD": "1.00"}} + } + } + } + } + }` + price, _, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-1.00) > 1e-9 { + t.Errorf("price = %v, want 1.00 (sorted-first dimension)", price) + } +} + +func TestParseOnDemandPriceUSD_InvalidJSON(t *testing.T) { + _, _, err := parseOnDemandPriceUSD("not-json{{{") + if err == nil { + t.Fatal("expected error on invalid JSON") + } + if !strings.Contains(err.Error(), "parsing product JSON") { + t.Errorf("error missing context: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingTerms(t *testing.T) { + _, _, err := parseOnDemandPriceUSD(`{"product": {}}`) + if err == nil { + t.Fatal("expected error when terms is missing") + } + if !strings.Contains(err.Error(), "no OnDemand pricing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingOnDemand(t *testing.T) { + body := `{"terms": {"Reserved": {"X.Y": {}}}}` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error when OnDemand block is missing") + } + if !strings.Contains(err.Error(), "no OnDemand pricing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_EmptyOnDemand(t *testing.T) { + body := `{"terms": {"OnDemand": {}}}` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error on empty OnDemand block") + } + if !strings.Contains(err.Error(), "no OnDemand pricing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingPriceDimensions(t *testing.T) { + body := `{"terms": {"OnDemand": {"S.T": {}}}}` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error when priceDimensions missing") + } + if !strings.Contains(err.Error(), "priceDimensions") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingUSD(t *testing.T) { + body := `{ + "terms": { + "OnDemand": { + "S.T": { + "priceDimensions": { + "S.T.D": {"unit": "Hrs", "pricePerUnit": {"CNY": "0.10"}} + } + } + } + } + }` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error when USD price missing") + } + if !strings.Contains(err.Error(), "no USD price") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_NonNumericPrice(t *testing.T) { + body := `{ + "terms": { + "OnDemand": { + "S.T": { + "priceDimensions": { + "S.T.D": {"unit": "Hrs", "pricePerUnit": {"USD": "not-a-number"}} + } + } + } + } + }` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error on non-numeric price") + } + if !strings.Contains(err.Error(), "parsing USD price") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/internal/pricing/rds.go b/internal/pricing/rds.go new file mode 100644 index 0000000..6690b66 --- /dev/null +++ b/internal/pricing/rds.go @@ -0,0 +1,225 @@ +package pricing + +import ( + "context" + "fmt" + "strings" + + "CloudOracle/internal/iac/aws" +) + +// EstimateRDS calculates the monthly cost of an RDS database instance, +// covering both compute (instance hours * 730) and database storage +// (GB-month * AllocatedStorage). +// +// Aurora engines (aurora, aurora-mysql, aurora-postgresql) are NOT +// supported here. Aurora's per-second compute and storage I/O billing +// uses a different Pricing API shape and lives behind aws_rds_cluster / +// aws_rds_cluster_instance — see EstimateRDSClusterInstance (Hito 13.5). +// EstimateRDS returns an error if attrs.Engine starts with "aurora". +// +// Supported engines are postgres, mysql, mariadb. Commercial engines +// (oracle*, sqlserver*) carry license-model branching that is out of +// scope for this milestone. +// +// Assumptions made by this function (each lowers Confidence): +// +// 1. licenseModel = "No license required". Valid for the three +// supported OSS engines, where AWS does not charge a license fee. +// 2. Multi-AZ doubles compute and storage cost. AWS encodes this in +// the deploymentOption filter rather than in our math: passing +// "Multi-AZ" yields a per-hour rate that is already roughly 2x the +// Single-AZ rate, and the same applies to GB-month storage. +// 3. IOPS-month billing for io1/io2 storage is NOT included. Only the +// GB-month base rate is charged. A note is appended when storage +// type is io1/io2 so the omission is visible to users. +// +// Returns an error for nil attrs, empty region/Engine/InstanceClass, +// AllocatedStorage <= 0, Aurora engines, unsupported engines, unknown +// storage types, API failures, missing products, or unit mismatches. +func EstimateRDS(ctx context.Context, src productGetter, attrs *aws.RDSAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateRDS: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateRDS: nil attrs") + } + if attrs.Engine == "" { + return Estimate{}, fmt.Errorf("EstimateRDS: empty Engine") + } + if strings.HasPrefix(attrs.Engine, "aurora") { + return Estimate{}, fmt.Errorf("EstimateRDS: Aurora engine %q must be priced via EstimateRDSClusterInstance", attrs.Engine) + } + if attrs.InstanceClass == "" { + return Estimate{}, fmt.Errorf("EstimateRDS: empty InstanceClass") + } + if attrs.AllocatedStorage <= 0 { + return Estimate{}, fmt.Errorf("EstimateRDS: AllocatedStorage must be > 0, got %d", attrs.AllocatedStorage) + } + + dbEngine, err := mapEngine(attrs.Engine) + if err != nil { + return Estimate{}, err + } + storageVolType, err := mapRDSStorageType(attrs.StorageType) + if err != nil { + return Estimate{}, err + } + deployment := mapDeploymentOption(attrs.MultiAZ) + + compute, err := lookupRDSComputePrice(ctx, src, attrs.InstanceClass, dbEngine, deployment, region) + if err != nil { + return Estimate{}, err + } + storage, err := lookupRDSStoragePrice(ctx, src, storageVolType, dbEngine, deployment, region, attrs.AllocatedStorage) + if err != nil { + return Estimate{}, err + } + + notes := []string{"License: No license required (postgres/mysql/mariadb)"} + if attrs.MultiAZ { + notes = append(notes, "Multi-AZ deployment (2x base price)") + } + if attrs.StorageType == "io1" || attrs.StorageType == "io2" { + notes = append(notes, "io1/io2 IOPS-month billing not included in estimate") + } + + return Estimate{ + MonthlyUSD: compute + storage, + Currency: "USD", + Breakdown: []LineItem{ + {Component: "Compute", MonthlyUSD: compute}, + {Component: "Storage", MonthlyUSD: storage}, + }, + Confidence: ConfidenceLow, + Notes: notes, + }, nil +} + +// lookupRDSComputePrice runs the RDS Pricing API query for a database +// instance and returns the monthly USD cost (hourly price * 730). The +// licenseModel filter is hard-coded to "No license required" because +// EstimateRDS only accepts engines for which that is the correct value. +func lookupRDSComputePrice(ctx context.Context, src productGetter, instanceClass, dbEngine, deployment, region string) (float64, error) { + filters := map[string]string{ + "productFamily": "Database Instance", + "instanceType": instanceClass, + "databaseEngine": dbEngine, + "deploymentOption": deployment, + "regionCode": region, + "licenseModel": "No license required", + } + products, err := src.GetProducts(ctx, "AmazonRDS", filters) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: compute lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateRDS: no compute price found for %s/%s/%s in %s", instanceClass, dbEngine, deployment, region) + } + if len(products) > 1 { + return 0, fmt.Errorf("EstimateRDS: compute query returned %d products; filter under-constrained for instanceClass=%s engine=%s deployment=%s region=%s", + len(products), instanceClass, dbEngine, deployment, region) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: parsing compute price: %w", err) + } + if unit != "Hrs" { + return 0, fmt.Errorf("EstimateRDS: expected compute unit Hrs, got %q", unit) + } + return hourly * HoursPerMonth, nil +} + +// lookupRDSStoragePrice runs the RDS Pricing API query for the database +// storage volume and returns the monthly USD cost (GB-month * size). +// Note that RDS uses a different filter vocabulary than EC2/EBS: the +// filter name is volumeType (not volumeApiName) and the values are the +// long-form names produced by mapRDSStorageType. +// +// databaseEngine is required to disambiguate the storage SKU. AWS +// catalogues a separate Database Storage row per supported engine +// (PostgreSQL, MySQL, MariaDB, Oracle×N editions, SQL Server×N editions, +// "Any") even though the per-GB price is currently identical across the +// OSS engines. Filtering without it returns ~15 products in us-east-2, +// which is what motivated this milestone's tightening. +func lookupRDSStoragePrice(ctx context.Context, src productGetter, storageVolType, dbEngine, deployment, region string, sizeGB int) (float64, error) { + filters := map[string]string{ + "productFamily": "Database Storage", + "volumeType": storageVolType, + "databaseEngine": dbEngine, + "deploymentOption": deployment, + "regionCode": region, + } + products, err := src.GetProducts(ctx, "AmazonRDS", filters) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: storage lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateRDS: no storage price found for %s/%s/%s in %s", storageVolType, dbEngine, deployment, region) + } + if len(products) > 1 { + return 0, fmt.Errorf("EstimateRDS: storage query returned %d products; filter under-constrained for volumeType=%s engine=%s deployment=%s region=%s", + len(products), storageVolType, dbEngine, deployment, region) + } + gbMo, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: parsing storage price: %w", err) + } + if unit != "GB-Mo" { + return 0, fmt.Errorf("EstimateRDS: expected storage unit GB-Mo, got %q", unit) + } + return gbMo * float64(sizeGB), nil +} + +// mapEngine converts a Terraform RDS engine value to the Pricing API's +// databaseEngine filter value. Only postgres, mysql, and mariadb are +// supported; any other engine (including Aurora variants) returns an +// error so the caller produces a precise message at the boundary. +func mapEngine(engine string) (string, error) { + switch engine { + case "postgres": + return "PostgreSQL", nil + case "mysql": + return "MySQL", nil + case "mariadb": + return "MariaDB", nil + default: + return "", fmt.Errorf("EstimateRDS: engine %q not supported in this version", engine) + } +} + +// mapRDSStorageType converts a Terraform RDS storage_type to the Pricing +// API's volumeType filter value. Note that RDS uses a different field +// (volumeType) and value vocabulary than EC2/EBS — they happen to model +// the same physical storage but the Pricing API surfaces them separately. +// +// Empty input defaults to "gp2", matching the AWS provider default for +// aws_db_instance.storage_type. Unknown input returns an error. +func mapRDSStorageType(tfType string) (string, error) { + switch tfType { + case "", "gp2": + return "General Purpose", nil + case "gp3": + return "General Purpose-GP3", nil + case "io1": + return "Provisioned IOPS", nil + case "io2": + return "Provisioned IOPS-IO2", nil + case "standard": + return "Magnetic", nil + default: + return "", fmt.Errorf("EstimateRDS: unknown storage type %q", tfType) + } +} + +// mapDeploymentOption converts a Multi-AZ bool to the Pricing API's +// deploymentOption filter value. Single-AZ is the cost baseline; +// Multi-AZ roughly doubles both compute and storage line items in +// AWS's catalogue (the doubling is encoded in the catalogue rates, not +// applied by us). +func mapDeploymentOption(multiAZ bool) string { + if multiAZ { + return "Multi-AZ" + } + return "Single-AZ" +} diff --git a/internal/pricing/rds_cluster_instance.go b/internal/pricing/rds_cluster_instance.go new file mode 100644 index 0000000..8a8aede --- /dev/null +++ b/internal/pricing/rds_cluster_instance.go @@ -0,0 +1,120 @@ +package pricing + +import ( + "context" + "fmt" + + "CloudOracle/internal/iac/aws" +) + +// EstimateRDSClusterInstance calculates the monthly cost of a single +// Aurora cluster instance (writer or reader replica). Unlike +// aws_db_instance, this is for aws_rds_cluster_instance, which is the +// unit of compute in Aurora's storage-decoupled architecture. +// +// Aurora bills storage and I/O at the cluster level (aws_rds_cluster), +// not per-instance, so this function returns ONLY compute. The cluster +// header's storage cost is out of scope here — pricing it would need +// EstimateRDSCluster, which is not implemented. +// +// Supported engines: aurora-postgresql, aurora-mysql, the legacy +// "aurora" (MySQL 5.6). Other engines return an error so callers don't +// silently mis-price an unrelated cluster type. +// +// Assumptions made by this function (each lowers Confidence to Low): +// +// 1. Single-AZ deploymentOption. Aurora's redundancy model is +// "multiple instances across AZs in one cluster", so the per-instance +// pricing row is always Single-AZ — multi-AZ is achieved by adding +// more aws_rds_cluster_instance resources, not by toggling a flag. +// 2. licenseModel = "No license required". Aurora doesn't charge a +// license fee on top of the compute rate. +// 3. storage = "EBS Only" (standard Aurora compute pricing). Aurora's +// I/O Optimization mode is exposed as a second SKU +// ("Aurora IO Optimization Mode", ~30% higher per-hour rate that +// waives per-I/O charges); supporting it would require a cluster- +// level attribute we don't currently extract, so we hard-code the +// standard mode and document the trade-off here. +// +// Returns an error for nil attrs, empty region/Engine/InstanceClass, +// unsupported engines, API failures, missing products, or unit +// mismatches. +func EstimateRDSClusterInstance(ctx context.Context, src productGetter, attrs *aws.RDSClusterInstanceAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: nil attrs") + } + if attrs.Engine == "" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: empty Engine") + } + if attrs.InstanceClass == "" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: empty InstanceClass") + } + + dbEngine, err := mapAuroraEngine(attrs.Engine) + if err != nil { + return Estimate{}, err + } + + filters := map[string]string{ + "productFamily": "Database Instance", + "databaseEngine": dbEngine, + "instanceType": attrs.InstanceClass, + "regionCode": region, + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "storage": "EBS Only", + } + products, err := src.GetProducts(ctx, "AmazonRDS", filters) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: lookup: %w", err) + } + if len(products) == 0 { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: no compute price found for %s/%s in %s", attrs.InstanceClass, dbEngine, region) + } + if len(products) > 1 { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: query returned %d products; filter under-constrained for instanceClass=%s engine=%s region=%s", + len(products), attrs.InstanceClass, dbEngine, region) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: parsing price: %w", err) + } + if unit != "Hrs" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: expected unit Hrs, got %q", unit) + } + cost := hourly * HoursPerMonth + + return Estimate{ + MonthlyUSD: cost, + Currency: "USD", + Breakdown: []LineItem{{Component: "Compute", MonthlyUSD: cost}}, + Confidence: ConfidenceLow, + Notes: []string{ + "Cluster-level storage and I/O charges not included (priced at aws_rds_cluster)", + "Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag", + "Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled", + }, + }, nil +} + +// mapAuroraEngine converts a Terraform Aurora engine value to the +// Pricing API's databaseEngine filter value. Aurora MySQL is exposed +// under both "aurora-mysql" (recent) and "aurora" (legacy MySQL 5.6) by +// the AWS provider; both map to the same Pricing API row. Non-Aurora +// engines return an error pointing the caller to EstimateRDS, which is +// the correct entry point for aws_db_instance. +func mapAuroraEngine(engine string) (string, error) { + switch engine { + case "aurora-postgresql": + return "Aurora PostgreSQL", nil + case "aurora-mysql", "aurora": + return "Aurora MySQL", nil + case "postgres", "mysql", "mariadb": + return "", fmt.Errorf("EstimateRDSClusterInstance: engine %q is non-Aurora; use EstimateRDS", engine) + default: + return "", fmt.Errorf("EstimateRDSClusterInstance: unsupported engine %q", engine) + } +} diff --git a/internal/pricing/rds_cluster_instance_test.go b/internal/pricing/rds_cluster_instance_test.go new file mode 100644 index 0000000..debe51a --- /dev/null +++ b/internal/pricing/rds_cluster_instance_test.go @@ -0,0 +1,200 @@ +package pricing + +import ( + "context" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateRDSClusterInstance_AuroraPostgres_HappyPath(t *testing.T) { + body := loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "test-cluster", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + est, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + want := 0.29 * HoursPerMonth // 211.7 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + foundClusterNote, foundMAZNote := false, false + for _, n := range est.Notes { + if strings.Contains(n, "Cluster-level storage") { + foundClusterNote = true + } + if strings.Contains(n, "Aurora Multi-AZ is via reader replicas") { + foundMAZNote = true + } + } + if !foundClusterNote { + t.Errorf("Notes missing cluster-storage caveat: %v", est.Notes) + } + if !foundMAZNote { + t.Errorf("Notes missing Aurora-Multi-AZ caveat: %v", est.Notes) + } + + // Filters + c := src.calls[0] + if c.service != "AmazonRDS" { + t.Errorf("service = %q, want AmazonRDS", c.service) + } + for k, want := range map[string]string{ + "productFamily": "Database Instance", + "databaseEngine": "Aurora PostgreSQL", + "instanceType": "db.r5.large", + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "regionCode": "us-east-2", + } { + if c.filters[k] != want { + t.Errorf("filter %s = %q, want %q", k, c.filters[k], want) + } + } +} + +func TestEstimateRDSClusterInstance_AuroraMySQL_EngineMapping(t *testing.T) { + body := loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-mysql", + } + if _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + if got := src.calls[0].filters["databaseEngine"]; got != "Aurora MySQL" { + t.Errorf("databaseEngine = %q, want Aurora MySQL", got) + } +} + +func TestEstimateRDSClusterInstance_AuroraLegacy_MapsToMySQL(t *testing.T) { + body := loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora", + } + if _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + if got := src.calls[0].filters["databaseEngine"]; got != "Aurora MySQL" { + t.Errorf("databaseEngine = %q, want Aurora MySQL (legacy aurora)", got) + } +} + +func TestEstimateRDSClusterInstance_NonAuroraPointsToEstimateRDS(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.t3.medium", + Engine: "postgres", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "use EstimateRDS") { + t.Fatalf("err = %v, want pointer to EstimateRDS", err) + } +} + +func TestEstimateRDSClusterInstance_UnsupportedEngine(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "weird", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unsupported engine") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateRDSClusterInstance(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_EmptyEngine(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty Engine") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_EmptyInstanceClass(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty InstanceClass") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no compute price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_BadUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected unit Hrs") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/pricing/rds_test.go b/internal/pricing/rds_test.go new file mode 100644 index 0000000..10ff8a3 --- /dev/null +++ b/internal/pricing/rds_test.go @@ -0,0 +1,436 @@ +package pricing + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateRDS_PostgresSingleAZGP2_HappyPath(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + est, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + + wantCompute := 0.082 * HoursPerMonth // 59.86 + wantStorage := 0.115 * 100 // 11.5 + wantTotal := wantCompute + wantStorage + + if math.Abs(est.MonthlyUSD-wantTotal) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, wantTotal) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q", est.Currency) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if len(est.Breakdown) != 2 { + t.Fatalf("Breakdown len = %d, want 2", len(est.Breakdown)) + } + if est.Breakdown[0].Component != "Compute" || math.Abs(est.Breakdown[0].MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("Breakdown[0] = %+v, want Compute=%v", est.Breakdown[0], wantCompute) + } + if est.Breakdown[1].Component != "Storage" || math.Abs(est.Breakdown[1].MonthlyUSD-wantStorage) > 1e-6 { + t.Errorf("Breakdown[1] = %+v, want Storage=%v", est.Breakdown[1], wantStorage) + } + + // Compute query + if len(src.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(src.calls)) + } + c := src.calls[0] + if c.service != "AmazonRDS" { + t.Errorf("compute service = %q", c.service) + } + for k, want := range map[string]string{ + "productFamily": "Database Instance", + "instanceType": "db.t3.medium", + "databaseEngine": "PostgreSQL", + "deploymentOption": "Single-AZ", + "regionCode": "us-east-2", + "licenseModel": "No license required", + } { + if c.filters[k] != want { + t.Errorf("compute filter %s = %q, want %q", k, c.filters[k], want) + } + } + + // Storage query + s := src.calls[1] + if s.service != "AmazonRDS" { + t.Errorf("storage service = %q", s.service) + } + for k, want := range map[string]string{ + "productFamily": "Database Storage", + "volumeType": "General Purpose", + "deploymentOption": "Single-AZ", + "regionCode": "us-east-2", + } { + if s.filters[k] != want { + t.Errorf("storage filter %s = %q, want %q", k, s.filters[k], want) + } + } +} + +func TestEstimateRDS_MySQLMultiAZGP3_FilterCheck(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "mysql", + InstanceClass: "db.m5.large", + AllocatedStorage: 50, + StorageType: "gp3", + MultiAZ: true, + } + est, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + + if got := src.calls[0].filters["databaseEngine"]; got != "MySQL" { + t.Errorf("databaseEngine = %q, want MySQL", got) + } + if got := src.calls[0].filters["deploymentOption"]; got != "Multi-AZ" { + t.Errorf("deploymentOption = %q, want Multi-AZ", got) + } + if got := src.calls[1].filters["volumeType"]; got != "General Purpose-GP3" { + t.Errorf("storage volumeType = %q, want General Purpose-GP3", got) + } + if got := src.calls[1].filters["deploymentOption"]; got != "Multi-AZ" { + t.Errorf("storage deploymentOption = %q, want Multi-AZ", got) + } + + foundMAZ := false + for _, n := range est.Notes { + if strings.Contains(n, "Multi-AZ") { + foundMAZ = true + break + } + } + if !foundMAZ { + t.Errorf("Notes missing Multi-AZ caveat: %v", est.Notes) + } +} + +func TestEstimateRDS_MariaDB_EngineMapping(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "mariadb", + InstanceClass: "db.t3.medium", + AllocatedStorage: 20, + StorageType: "gp2", + } + if _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + if got := src.calls[0].filters["databaseEngine"]; got != "MariaDB" { + t.Errorf("databaseEngine = %q, want MariaDB", got) + } +} + +func TestEstimateRDS_MultiAZWithIO1_BothNotes(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "io1", + MultiAZ: true, + Iops: 1000, + } + est, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + foundMAZ, foundIOPS := false, false + for _, n := range est.Notes { + if strings.Contains(n, "Multi-AZ") { + foundMAZ = true + } + if strings.Contains(n, "io1/io2 IOPS-month") { + foundIOPS = true + } + } + if !foundMAZ { + t.Errorf("Notes missing Multi-AZ caveat: %v", est.Notes) + } + if !foundIOPS { + t.Errorf("Notes missing io1/io2 IOPS caveat: %v", est.Notes) + } + if got := src.calls[1].filters["volumeType"]; got != "Provisioned IOPS" { + t.Errorf("storage volumeType = %q, want Provisioned IOPS", got) + } +} + +func TestEstimateRDS_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateRDS(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{Engine: "postgres", InstanceClass: "db.t3.medium", AllocatedStorage: 100} + _, err := EstimateRDS(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_AuroraEngineRejectedImmediately(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "aurora-postgresql", + InstanceClass: "db.r5.large", + AllocatedStorage: 100, + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "Aurora") { + t.Fatalf("err = %v, want Aurora rejection", err) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for Aurora, got %d", len(src.calls)) + } +} + +func TestEstimateRDS_OracleEngineNotSupported(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "oracle-ee", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "not supported in this version") { + t.Fatalf("err = %v, want not-supported error", err) + } +} + +func TestEstimateRDS_EmptyEngine(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{InstanceClass: "db.t3.medium", AllocatedStorage: 100} + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty Engine") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_EmptyInstanceClass(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{Engine: "postgres", AllocatedStorage: 100} + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty InstanceClass") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_AllocatedStorageZero(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "AllocatedStorage must be > 0") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_NoComputeProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no compute price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_NoStorageProducts(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, nil}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no storage price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_BadComputeUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected compute unit Hrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_BadStorageUnit(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + bad := strings.Replace( + loadFixture(t, "rds_storage_gp2_us_east_2.json"), + `"unit": "GB-Mo"`, + `"unit": "Hrs"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{compute}, {bad}}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected storage unit GB-Mo") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_PropagatesAPIError(t *testing.T) { + innerErr := errors.New("AccessDenied") + src := &scriptedGetter{errs: []error{innerErr}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateRDS_UnknownStorageType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "weird", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown storage type") { + t.Fatalf("err = %v", err) + } +} + +func TestMapRDSStorageType(t *testing.T) { + cases := []struct { + in, want string + err bool + }{ + {"", "General Purpose", false}, + {"gp2", "General Purpose", false}, + {"gp3", "General Purpose-GP3", false}, + {"io1", "Provisioned IOPS", false}, + {"io2", "Provisioned IOPS-IO2", false}, + {"standard", "Magnetic", false}, + {"weird", "", true}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := mapRDSStorageType(c.in) + if c.err { + if err == nil { + t.Errorf("expected error for input %q", c.in) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestMapDeploymentOption(t *testing.T) { + if got := mapDeploymentOption(false); got != "Single-AZ" { + t.Errorf("false -> %q, want Single-AZ", got) + } + if got := mapDeploymentOption(true); got != "Multi-AZ" { + t.Errorf("true -> %q, want Multi-AZ", got) + } +} + +func TestMapEngine(t *testing.T) { + cases := []struct { + in, want string + err bool + }{ + {"postgres", "PostgreSQL", false}, + {"mysql", "MySQL", false}, + {"mariadb", "MariaDB", false}, + {"oracle-ee", "", true}, + {"sqlserver-ee", "", true}, + {"", "", true}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := mapEngine(c.in) + if c.err { + if err == nil { + t.Errorf("expected error for %q", c.in) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/pricing/regions.go b/internal/pricing/regions.go new file mode 100644 index 0000000..2e41365 --- /dev/null +++ b/internal/pricing/regions.go @@ -0,0 +1,48 @@ +package pricing + +import ( + "log/slog" +) + +// regionPrefix maps an AWS regionCode to the prefix used in the Pricing +// API's usagetype values. Pattern observed: us-east-2 → "USE2", +// eu-west-1 → "EUW1", ap-northeast-1 → "APN1". +// +// AWS does not expose this mapping in any public API — it lives in the +// billing schema only — so it is hard-coded here. The list covers the +// regions most commonly used by the kind of teams that look at PR cost +// diffs; gov-cloud, China, and the more exotic regions are out of scope. +// Unknown regions emit a slog.Warn and fall back to returning the region +// string unchanged. That fallback is rarely catastrophic because most +// queries that need a usagetype filter also carry at least one other +// discriminator (engine, instance type), so the query still narrows +// correctly enough to surface a clear "no products found" error. +// +// Currently only EstimateLambda's Provisioned Concurrency lookup uses +// this — usagetype is the only filter that disambiguates the x86 and +// arm64 PC SKUs (AWS does not expose `architecture` as an attribute on +// those products). +func regionPrefix(region string) string { + switch region { + case "us-east-1": + return "USE1" + case "us-east-2": + return "USE2" + case "us-west-1": + return "USW1" + case "us-west-2": + return "USW2" + case "eu-west-1": + return "EUW1" + case "eu-central-1": + return "EUC1" + case "ap-southeast-1": + return "APS1" + case "ap-northeast-1": + return "APN1" + } + slog.Warn("pricing: no usagetype prefix mapped for region; using region literal as fallback", + "region", region, + ) + return region +} diff --git a/internal/pricing/testdata/ebs_io1_us_east_2.json b/internal/pricing/testdata/ebs_io1_us_east_2.json new file mode 100644 index 0000000..814fda7 --- /dev/null +++ b/internal/pricing/testdata/ebs_io1_us_east_2.json @@ -0,0 +1,36 @@ +{ + "product": { + "productFamily": "Storage", + "attributes": { + "volumeApiName": "io1", + "regionCode": "us-east-2", + "storageMedia": "SSD-backed", + "volumeType": "Provisioned IOPS" + }, + "sku": "EBSIO1USE2" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "EBSIO1USE2.JRTCKXETXF": { + "priceDimensions": { + "EBSIO1USE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Mo", + "endRange": "Inf", + "description": "$0.125 per GB-month of Provisioned IOPS (io1) volume - US East (Ohio)", + "appliesTo": [], + "rateCode": "EBSIO1USE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.125"} + } + }, + "sku": "EBSIO1USE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/ec2_gp3_us_east_2.json b/internal/pricing/testdata/ec2_gp3_us_east_2.json new file mode 100644 index 0000000..088cfc8 --- /dev/null +++ b/internal/pricing/testdata/ec2_gp3_us_east_2.json @@ -0,0 +1,36 @@ +{ + "product": { + "productFamily": "Storage", + "attributes": { + "volumeApiName": "gp3", + "regionCode": "us-east-2", + "storageMedia": "SSD-backed", + "volumeType": "General Purpose" + }, + "sku": "ABDXKE9QMAJYGGHA" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "ABDXKE9QMAJYGGHA.JRTCKXETXF": { + "priceDimensions": { + "ABDXKE9QMAJYGGHA.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Mo", + "endRange": "Inf", + "description": "$0.08 per GB-month of General Purpose SSD (gp3) provisioned storage - US East (Ohio)", + "appliesTo": [], + "rateCode": "ABDXKE9QMAJYGGHA.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.08"} + } + }, + "sku": "ABDXKE9QMAJYGGHA", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/ec2_t3_large_us_east_2.json b/internal/pricing/testdata/ec2_t3_large_us_east_2.json new file mode 100644 index 0000000..982d0cb --- /dev/null +++ b/internal/pricing/testdata/ec2_t3_large_us_east_2.json @@ -0,0 +1,40 @@ +{ + "product": { + "productFamily": "Compute Instance", + "attributes": { + "instanceType": "t3.large", + "regionCode": "us-east-2", + "operatingSystem": "Linux", + "tenancy": "Shared", + "preInstalledSw": "NA", + "capacitystatus": "Used", + "vcpu": "2", + "memory": "8 GiB" + }, + "sku": "JX5VPQ7TKDF8MWZE" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "JX5VPQ7TKDF8MWZE.JRTCKXETXF": { + "priceDimensions": { + "JX5VPQ7TKDF8MWZE.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.0832 per On Demand Linux t3.large Instance Hour", + "appliesTo": [], + "rateCode": "JX5VPQ7TKDF8MWZE.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0832"} + } + }, + "sku": "JX5VPQ7TKDF8MWZE", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/lambda_arm64_us_east_2.json b/internal/pricing/testdata/lambda_arm64_us_east_2.json new file mode 100644 index 0000000..536c6c2 --- /dev/null +++ b/internal/pricing/testdata/lambda_arm64_us_east_2.json @@ -0,0 +1,41 @@ +{ + "product": { + "productFamily": "Serverless", + "attributes": { + "regionCode": "us-east-2", + "servicecode": "AWSLambda", + "groupDescription": "Concurrency weighted by memory assigned to function over the period for which it is provisioned, measured in GB-s for ARM", + "usagetype": "USE2-Lambda-Provisioned-Concurrency-ARM", + "locationType": "AWS Region", + "location": "US East (Ohio)", + "servicename": "AWS Lambda", + "operation": "", + "group": "AWS-Lambda-Provisioned-Concurrency-ARM" + }, + "sku": "U6QUQCGH788KUY5N" + }, + "serviceCode": "AWSLambda", + "terms": { + "OnDemand": { + "U6QUQCGH788KUY5N.JRTCKXETXF": { + "priceDimensions": { + "U6QUQCGH788KUY5N.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Lambda-GB-Second", + "endRange": "Inf", + "description": "AWS Lambda - Provisioned Concurrency for ARM - US East (Ohio)", + "appliesTo": [], + "rateCode": "U6QUQCGH788KUY5N.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0000033334"} + } + }, + "sku": "U6QUQCGH788KUY5N", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/nat_gateway_us_east_2.json b/internal/pricing/testdata/nat_gateway_us_east_2.json new file mode 100644 index 0000000..a259dd5 --- /dev/null +++ b/internal/pricing/testdata/nat_gateway_us_east_2.json @@ -0,0 +1,35 @@ +{ + "product": { + "productFamily": "NAT Gateway", + "attributes": { + "regionCode": "us-east-2", + "groupDescription": "Hourly charge for NAT Gateways", + "usagetype": "USE2-NatGateway-Hours" + }, + "sku": "NATGWUSE2" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "NATGWUSE2.JRTCKXETXF": { + "priceDimensions": { + "NATGWUSE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.045 per NAT Gateway Hour - US East (Ohio)", + "appliesTo": [], + "rateCode": "NATGWUSE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.045"} + } + }, + "sku": "NATGWUSE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json b/internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json new file mode 100644 index 0000000..1ff028c --- /dev/null +++ b/internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json @@ -0,0 +1,39 @@ +{ + "product": { + "productFamily": "Database Instance", + "attributes": { + "instanceType": "db.r5.large", + "regionCode": "us-east-2", + "databaseEngine": "Aurora PostgreSQL", + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "vcpu": "2", + "memory": "16 GiB" + }, + "sku": "AURPGR5LARGEUSE2" + }, + "serviceCode": "AmazonRDS", + "terms": { + "OnDemand": { + "AURPGR5LARGEUSE2.JRTCKXETXF": { + "priceDimensions": { + "AURPGR5LARGEUSE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.29 per Aurora PostgreSQL db.r5.large instance hour - US East (Ohio)", + "appliesTo": [], + "rateCode": "AURPGR5LARGEUSE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.29"} + } + }, + "sku": "AURPGR5LARGEUSE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json b/internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json new file mode 100644 index 0000000..dd9816b --- /dev/null +++ b/internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json @@ -0,0 +1,39 @@ +{ + "product": { + "productFamily": "Database Instance", + "attributes": { + "instanceType": "db.t3.medium", + "regionCode": "us-east-2", + "databaseEngine": "PostgreSQL", + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "vcpu": "2", + "memory": "4 GiB" + }, + "sku": "RDSPGT3MEDXYZUS2" + }, + "serviceCode": "AmazonRDS", + "terms": { + "OnDemand": { + "RDSPGT3MEDXYZUS2.JRTCKXETXF": { + "priceDimensions": { + "RDSPGT3MEDXYZUS2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.082 per RDS db.t3.medium Single-AZ instance hour (or partial hour) running PostgreSQL", + "appliesTo": [], + "rateCode": "RDSPGT3MEDXYZUS2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.082"} + } + }, + "sku": "RDSPGT3MEDXYZUS2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/rds_storage_gp2_us_east_2.json b/internal/pricing/testdata/rds_storage_gp2_us_east_2.json new file mode 100644 index 0000000..7ae67b8 --- /dev/null +++ b/internal/pricing/testdata/rds_storage_gp2_us_east_2.json @@ -0,0 +1,36 @@ +{ + "product": { + "productFamily": "Database Storage", + "attributes": { + "volumeType": "General Purpose", + "regionCode": "us-east-2", + "deploymentOption": "Single-AZ", + "storageMedia": "SSD-backed" + }, + "sku": "RDSSTORGP2USE2" + }, + "serviceCode": "AmazonRDS", + "terms": { + "OnDemand": { + "RDSSTORGP2USE2.JRTCKXETXF": { + "priceDimensions": { + "RDSSTORGP2USE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Mo", + "endRange": "Inf", + "description": "$0.115 per GB-month of provisioned gp2 storage running Single-AZ - US East (Ohio)", + "appliesTo": [], + "rateCode": "RDSSTORGP2USE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.115"} + } + }, + "sku": "RDSSTORGP2USE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/types.go b/internal/pricing/types.go new file mode 100644 index 0000000..32b2e5c --- /dev/null +++ b/internal/pricing/types.go @@ -0,0 +1,85 @@ +package pricing + +import "CloudOracle/internal/iac" + +// Estimate is the result of a cost estimation for a single resource. +// +// MonthlyUSD is the sum of every Breakdown line item, expressed in USD +// per AWS-standard 730-hour month (see HoursPerMonth). Currency is always +// "USD" today; the AWS Pricing API supports CNY for AWS China but we do +// not — adding it would multiply the surface area of every mapper. +// +// Confidence reports how many defaults the mapper had to assume. Low +// estimates should reach the user with a "may differ from real bill" +// caveat; the assumptions themselves are listed in Notes for transparency +// rather than buried in the Confidence value. +type Estimate struct { + MonthlyUSD float64 + Currency string + Breakdown []LineItem + Confidence Confidence + Notes []string +} + +// LineItem is one component of an Estimate's total cost. The components +// of an EC2 Estimate are "Compute" and (optionally) "RootEBS"; future +// mappers will introduce their own component names. +type LineItem struct { + Component string + MonthlyUSD float64 +} + +// Confidence indicates how reliable an Estimate is. +// +// - ConfidenceHigh: no defaults applied, all relevant attributes +// were present on the resource. +// - ConfidenceMedium: minor defaults applied (e.g., AZ unknown so +// region-level pricing was used). +// - ConfidenceLow: one or more strong assumptions were made (e.g., +// OS=Linux assumed because Terraform plans don't carry OS). +type Confidence string + +const ( + ConfidenceHigh Confidence = "high" + ConfidenceMedium Confidence = "medium" + ConfidenceLow Confidence = "low" +) + +// ChangeEstimate is the cost impact of a single resource change in a +// Terraform plan, produced by EstimateChange. One ChangeEstimate maps +// one-to-one with one iac.ResourceChange. +// +// Sign convention for the cost fields: +// +// - create: BeforeMonthly = 0, AfterMonthly >= 0, MonthlyDelta = AfterMonthly +// - delete: AfterMonthly = 0, BeforeMonthly >= 0, MonthlyDelta = -BeforeMonthly +// - update: both populated, MonthlyDelta = AfterMonthly - BeforeMonthly +// - replace: same as update; the resource is destroyed and re-created so +// the delta is computed against the priced "after" shape, not zero +// - no-op / read / data sources: all three are 0, Skipped is true +// +// Unsupported resource types (aws_iam_role, aws_route53_zone, anything +// outside aws.SupportedTypes()) also produce a ChangeEstimate with all +// costs at 0 and Skipped=true. This is INTENTIONAL: callers iterate over +// the entire plan and want a per-resource result for every change, even +// ones we can't price — silently dropping unsupported types would force +// callers to maintain their own "why didn't this show up?" lookup. +// +// Confidence reflects how reliable the cost numbers are. For Skipped +// estimates the cost is deterministically 0, so Confidence is High; for +// priced estimates it is the weakest of the before/after confidences +// (low > medium > high in "weakness" order). +type ChangeEstimate struct { + ResourceAddress string + ResourceType string + Action iac.Action + BeforeMonthly float64 + AfterMonthly float64 + MonthlyDelta float64 + Currency string + Confidence Confidence + Notes []string + Breakdown []LineItem + Skipped bool + SkipReason string +}