Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- CloudFormation: numbers and booleans written as strings, which
CloudFormation accepts (`AllocatedStorage: "250"`, `MultiAZ: "true"`,
a `Type: Number` parameter, a `--var` override), no longer fail the
estimate with "expected number, got string" or "Call using string as
type bool". The parser converts strings that read back identically
("250", "16.4", "true"); "8.0" or "007" keep their spelling. A
quantity expression that yields a numeric string also evaluates as
that number.
- CloudFormation: `--path` pointing at a directory that holds templates
and no `.tf`/`.tofu` files estimates those templates (`.yaml`, `.yml`,
`.json`, `.template` files with a `Resources` section that declares
`AWSTemplateFormatVersion` or `AWS::` types). It failed with "no .tf,
.tf.json, .tofu or .tofu.json files found". Directories with Terraform
files are read as before.
- CloudFormation: EC2 `BlockDeviceMappings` EBS volumes are priced. The
mapping on the root device name (`/dev/xvda`, `/dev/sda1`) becomes the
root volume and the others additional EBS volumes, as for Terraform's
`root_block_device` / `ebs_block_device`. Lambda `Architectures` and
`EphemeralStorage.Size` are read too.
- `aws_lambda_function` with `architectures = ["arm64"]` is priced at the
Arm (Graviton) duration rate, $0.0000133334 per GB-second in us-east-1
instead of the x86 $0.0000166667; provisioned concurrency likewise.
- Azure virtual machines, scale sets, AKS node pools and Batch pools look
the size up by `armSkuName`. B-series sizes such as Standard_B2s and
Standard_B2s_v2, and most other series outside Dsv5/Esv5-style names,
had no price.
- `google_sql_database_instance` is priced at its region's rates. Only
the "in Americas" SKUs were asked for, so other regions (europe-west3,
asia-southeast1, us-east4, ...) were quoted us-central1 prices with a
region-fallback caveat. Catalog expressions can now read the region a
resource is priced in as `region` when the resource does not set that
attribute itself.

## [0.3.15] - 2026-09-29

### Fixed
Expand Down
200 changes: 200 additions & 0 deletions internal/calculator/demo_regressions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package calculator_test

import (
"context"
"strings"
"testing"

"github.com/c3xdev/c3x/internal/domain"
"github.com/c3xdev/c3x/internal/parser/cloudformation"
"github.com/c3xdev/c3x/internal/pricing"
"github.com/shopspring/decimal"
)

// byFilter prices a lookup by the value one attribute filter carries, so a
// test can tell which of two mappings a rate expression chose.
type byFilter struct {
key string
rates map[string]string
}

func (b byFilter) Lookup(_ context.Context, q pricing.Query) (decimal.Decimal, string, error) {
for _, kv := range q.AttributeFilters {
if kv.Key == b.key {
if r, ok := b.rates[kv.Value]; ok {
return decimal.RequireFromString(r), domain.PriceSourceLive, nil
}
}
}
return decimal.NewFromInt(1), domain.PriceSourceLive, nil
}

func lineRate(c domain.Cost, dim string) string {
for _, li := range c.LineItems {
if li.Dimension == dim {
return li.UnitRate.String()
}
}
return ""
}

// architectures is a list (["arm64"]); an arm64 function is billed at the
// Graviton duration rate and an x86 one (or one with no architectures)
// at the x86 rate.
func TestLambdaArchitecturePicksDurationRate(t *testing.T) {
t.Parallel()
src := byFilter{key: "group", rates: map[string]string{
"AWS-Lambda-Duration": "0.0000166667",
"AWS-Lambda-Duration-ARM": "0.0000133334",
}}
cases := map[string]struct {
arch any
want string
}{
"arm64 list": {[]any{"arm64"}, "0.0000133334"},
"x86_64 list": {[]any{"x86_64"}, "0.0000166667"},
"unset": {nil, "0.0000166667"},
"arm64 bare": {"arm64", "0.0000133334"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
attrs := map[string]any{"memory_size": 1024.0, "monthly_requests": 1e6}
if tc.arch != nil {
attrs["architectures"] = tc.arch
}
c := estimateWith(t, src, resource("aws_lambda_function", attrs))
if got := lineRate(c, "duration"); got != tc.want {
t.Errorf("duration rate = %s, want %s", got, tc.want)
}
})
}
}

// The VM size is Azure's armSkuName; skuName is "B2s" / "B2s v2" for the
// B-series, so filtering on it found no price.
func TestAzureVMsFilterOnArmSkuName(t *testing.T) {
t.Parallel()
for _, kind := range []string{
"azurerm_linux_virtual_machine", "azurerm_windows_virtual_machine",
} {
rec := &recorder{}
estimateWith(t, rec, resource(kind, map[string]any{"size": "Standard_B2s_v2", "location": "westeurope"}))
if got := rec.filter(t, "Virtual Machines", "armSkuName"); got != "Standard_B2s_v2" {
t.Errorf("%s: armSkuName = %q, want Standard_B2s_v2", kind, got)
}
for _, q := range rec.queries {
for _, kv := range q.AttributeFilters {
if q.Service == "Virtual Machines" && kv.Key == "skuName" {
t.Errorf("%s still filters on skuName = %q", kind, kv.Value)
}
}
}
}
}

// Cloud SQL SKUs are named after the location they are sold in, which is
// per region ("vCPU in Frankfurt"), not "in Americas" everywhere. The
// location follows the resource's region attribute, and the region it
// is priced in when the attribute is unset.
func TestCloudSQLDescriptionFollowsRegion(t *testing.T) {
t.Parallel()
cases := []struct {
name string
attrs map[string]any
region *string
want string
}{
{
"attribute",
map[string]any{"region": "europe-west3"},
nil,
"Cloud SQL for PostgreSQL: Zonal - vCPU in Frankfurt",
},
{
"resource region",
map[string]any{},
region("asia-southeast1"),
"Cloud SQL for PostgreSQL: Zonal - vCPU in Singapore",
},
{
"americas",
map[string]any{"region": "us-east1"},
nil,
"Cloud SQL for PostgreSQL: Zonal - vCPU in Americas",
},
{
"unknown region",
map[string]any{"region": "mars-north1"},
nil,
"Cloud SQL for PostgreSQL: Zonal - vCPU in Americas",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.attrs["tier"] = "db-custom-2-7680"
r := resource("google_sql_database_instance", tc.attrs)
r.Region = tc.region
rec := &recorder{}
estimateWith(t, rec, r)
var got string
for _, q := range rec.queries {
for _, kv := range q.AttributeFilters {
if kv.Key == "description" && strings.Contains(kv.Value, "vCPU") {
got = kv.Value
}
}
}
if got != tc.want {
t.Errorf("vCPU description = %q, want %q", got, tc.want)
}
})
}
}

// A CloudFormation template writing numbers as strings and declaring EBS
// volumes in BlockDeviceMappings estimates end to end: the storage is
// 250 GB (not an "expected number, got string" error) and the root and
// extra volumes are priced.
func TestCloudFormationStringNumbersAndBlockDevices(t *testing.T) {
t.Parallel()
doc := `
AWSTemplateFormatVersion: "2010-09-09"
Resources:
App:
Type: AWS::EC2::Instance
Properties:
InstanceType: m7i.xlarge
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs: {VolumeSize: 50, VolumeType: gp3}
- DeviceName: /dev/xvdf
Ebs: {VolumeSize: "200", VolumeType: gp3}
DB:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceClass: db.r7g.large
Engine: postgres
AllocatedStorage: "250"
StorageType: gp3
MultiAZ: "false"
`
res, err := cloudformation.ParseBytes([]byte(doc), "t.yaml", cloudformation.Options{Region: "us-east-1"})
if err != nil {
t.Fatal(err)
}
for _, r := range res {
q := lineQty(estimateWith(t, &recorder{}, r))
switch r.Ref.Kind {
case "aws_instance":
if q["root_volume"] != 50 || q["ebs_volumes"] != 200 {
t.Errorf("instance volumes = %v, want root 50 GB and 200 GB extra", q)
}
case "aws_db_instance":
if q["storage"] != 250 {
t.Errorf("db storage = %v, want 250 GB", q)
}
}
}
}
24 changes: 21 additions & 3 deletions internal/calculator/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func (e *Engine) costFor(ctx context.Context, r domain.Resource) (domain.Cost, s

for _, dim := range def.Dimensions {
rec := &lineRecorder{}
env := expr.EnvFor(r, rec.wrap(lookup), dim.Constants)
env := withRegion(expr.EnvFor(r, rec.wrap(lookup), dim.Constants), region)

// `when` predicate (optional).
if dim.When != "" {
Expand Down Expand Up @@ -261,12 +261,13 @@ func (e *Engine) buildQuery(
m catalog.PriceMapping,
region string,
) (pricing.Query, error) {
resourceRegion := region
if m.Region != "" {
region = m.Region
}
filters := make([]pricing.KV, 0, len(m.AttributeFilters))
for _, af := range m.AttributeFilters {
value, err := resolveFilter(af, r, e.programs, def.Kind)
value, err := resolveFilter(af, r, resourceRegion, e.programs, def.Kind)
if err != nil {
return pricing.Query{}, err
}
Expand All @@ -293,6 +294,7 @@ func (e *Engine) buildQuery(
func resolveFilter(
af catalog.AttributeFilter,
r domain.Resource,
region string,
cache *programCache,
kind string,
) (string, error) {
Expand All @@ -310,10 +312,26 @@ func resolveFilter(
if err != nil {
return "", err
}
env := expr.EnvFor(r, nil, nil)
env := withRegion(expr.EnvFor(r, nil, nil), region)
return expr.RunString(prog, env)
}

// withRegion exposes the region the resource is priced in as `region`
// when the resource does not set that attribute itself, so a catalog
// expression can depend on it (a SKU named after its location, such as
// Cloud SQL's "vCPU in EMEA") whether the region came from the
// resource, its provider block, or the configured default. A resource's
// own `region` attribute is left as written.
func withRegion(env map[string]any, region string) map[string]any {
if region == "" {
return env
}
if v, ok := env["region"]; !ok || v == nil || v == "" {
env["region"] = region
}
return env
}

func invokesPrice(rate string) bool {
for i := 0; i+6 <= len(rate); i++ {
if rate[i:i+6] == "price(" {
Expand Down
10 changes: 10 additions & 0 deletions internal/expr/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package expr

import (
"fmt"
"math"
"strconv"
"strings"

"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
Expand Down Expand Up @@ -117,6 +120,13 @@ func RunNumber(p Program, env map[string]any) (float64, error) {
return 1, nil
}
return 0, nil
case string:
// CloudFormation accepts strings for numeric properties
// (`AllocatedStorage: "250"`), and a Terraform variable typed
// string can carry a number the same way.
if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) {
return f, nil
}
}
return 0, fmt.Errorf("expression %q: expected number, got %T", p.source, v)
}
31 changes: 31 additions & 0 deletions internal/expr/expr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,34 @@ func TestCompileEmptyFails(t *testing.T) {
t.Errorf("expected error on empty expression")
}
}

// A quantity expression that resolves to a numeric string (CloudFormation
// accepts `AllocatedStorage: "250"`) evaluates as that number instead of
// failing the whole estimate; a non-numeric string still errors.
func TestRunNumberParsesNumericStrings(t *testing.T) {
t.Parallel()

env := c3xexpr.EnvFor(domain.Resource{Attributes: map[string]any{
"allocated_storage": "250",
"padded": " 12.5 ",
"engine": "postgres",
"nan": "NaN",
}}, nil, nil)
for src, want := range map[string]float64{
`default(allocated_storage, 20)`: 250,
`padded`: 12.5,
} {
got, err := c3xexpr.RunNumber(mustCompile(t, src), env)
if err != nil {
t.Fatalf("%s: %v", src, err)
}
if got != want {
t.Errorf("%s = %v, want %v", src, got, want)
}
}
for _, src := range []string{`engine`, `nan`} {
if _, err := c3xexpr.RunNumber(mustCompile(t, src), env); err == nil {
t.Errorf("%s: want an error for a non-numeric string", src)
}
}
}
Loading
Loading