From 614cb777e63c3cda0356f4e8beeab8827280b236 Mon Sep 17 00:00:00 2001 From: Andrea Cappelletti Date: Tue, 29 Sep 2026 13:45:35 -0400 Subject: [PATCH] fix: CloudFormation string numbers, template directories and block devices; Lambda arm64, Azure VM sizes, Cloud SQL regions CloudFormation: - Numeric and boolean strings ("250", "true"), which CloudFormation accepts and which every parameter reference produces, are converted when they read back identically, so catalog arithmetic and pick() no longer fail with "expected number, got string". RunNumber also parses a numeric string, as its doc comment already claimed. - A --path directory with templates and no .tf/.tofu files estimates the templates (YAML/JSON with Resources plus AWSTemplateFormatVersion or AWS:: types). Terraform directories are unchanged. - EC2 BlockDeviceMappings map to root_block_device / ebs_block_device, so their EBS volumes are priced. Lambda Architectures and EphemeralStorage.Size are translated. Catalog (mirrors the pricing API catalog): - aws_lambda_function: arm64 detection works on the architectures list. - Azure VM-sized kinds filter on armSkuName, not skuName. - google_sql_database_instance: SKU location follows the region. Engine: expressions see the region a resource is priced in as `region` when the resource does not set that attribute. --- CHANGELOG.md | 35 +++ internal/calculator/demo_regressions_test.go | 200 ++++++++++++++++++ internal/calculator/engine.go | 24 ++- internal/expr/evaluator.go | 10 + internal/expr/expr_test.go | 31 +++ internal/parser/cloudformation/coerce.go | 61 ++++++ internal/parser/cloudformation/coerce_test.go | 171 +++++++++++++++ internal/parser/cloudformation/parser.go | 30 ++- internal/parser/cloudformation/translate.go | 78 ++++++- internal/parser/parser.go | 70 +++++- internal/parser/parser_test.go | 62 ++++++ internal/parser/terraform/files.go | 7 + resources/aws/aws_lambda_function.toml | 12 +- resources/azure/azurerm_batch_pool.toml | 2 +- .../azure/azurerm_kubernetes_cluster.toml | 2 +- .../azurerm_kubernetes_cluster_node_pool.toml | 2 +- .../azure/azurerm_linux_virtual_machine.toml | 17 +- ...urerm_linux_virtual_machine_scale_set.toml | 2 +- ...rchestrated_virtual_machine_scale_set.toml | 2 +- resources/azure/azurerm_virtual_machine.toml | 2 +- .../azurerm_windows_virtual_machine.toml | 24 +-- ...erm_windows_virtual_machine_scale_set.toml | 2 +- .../gcp/google_sql_database_instance.toml | 139 +++++++++++- 23 files changed, 934 insertions(+), 51 deletions(-) create mode 100644 internal/calculator/demo_regressions_test.go create mode 100644 internal/parser/cloudformation/coerce.go create mode 100644 internal/parser/cloudformation/coerce_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ae4731..ea761173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/calculator/demo_regressions_test.go b/internal/calculator/demo_regressions_test.go new file mode 100644 index 00000000..08a2dc53 --- /dev/null +++ b/internal/calculator/demo_regressions_test.go @@ -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) + } + } + } +} diff --git a/internal/calculator/engine.go b/internal/calculator/engine.go index 55e789fc..6e52a754 100644 --- a/internal/calculator/engine.go +++ b/internal/calculator/engine.go @@ -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 != "" { @@ -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 } @@ -293,6 +294,7 @@ func (e *Engine) buildQuery( func resolveFilter( af catalog.AttributeFilter, r domain.Resource, + region string, cache *programCache, kind string, ) (string, error) { @@ -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(" { diff --git a/internal/expr/evaluator.go b/internal/expr/evaluator.go index 5fea4de8..e578da3f 100644 --- a/internal/expr/evaluator.go +++ b/internal/expr/evaluator.go @@ -2,6 +2,9 @@ package expr import ( "fmt" + "math" + "strconv" + "strings" "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" @@ -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) } diff --git a/internal/expr/expr_test.go b/internal/expr/expr_test.go index 1d96333b..e1dab4fe 100644 --- a/internal/expr/expr_test.go +++ b/internal/expr/expr_test.go @@ -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) + } + } +} diff --git a/internal/parser/cloudformation/coerce.go b/internal/parser/cloudformation/coerce.go new file mode 100644 index 00000000..5ccfca3c --- /dev/null +++ b/internal/parser/cloudformation/coerce.go @@ -0,0 +1,61 @@ +package cloudformation + +import ( + "regexp" + "strconv" + "strings" +) + +// numericString matches a plain decimal number: an optional minus sign, +// digits without a leading zero, and an optional fraction. Exponents, +// hex, and leading "+" are left as strings. +var numericString = regexp.MustCompile(`^-?(0|[1-9]\d*)(\.\d+)?$`) + +// coerceScalars converts numeric and boolean strings to the numbers and +// booleans they spell, recursively through maps and lists. +// +// CloudFormation accepts a string wherever a property takes a number or +// a boolean (`AllocatedStorage: "250"`, `MultiAZ: "true"`), and every +// parameter value is a string once it is referenced (`Type: Number` +// parameters included, and `--var` overrides always). The catalog +// expressions do arithmetic and comparisons on those values, which +// fail on a string, so the parser hands them over typed. +// +// Only strings that read back identically are converted: "250" and +// "16.4" become numbers, while "8.0", "007" or "1e3" stay strings, so +// a version or identifier that happens to look numeric keeps its exact +// spelling when a catalog filter stringifies it again. +func coerceScalars(v any) any { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + t[k] = coerceScalars(val) + } + return t + case []any: + for i, val := range t { + t[i] = coerceScalars(val) + } + return t + case string: + return coerceString(t) + } + return v +} + +func coerceString(s string) any { + switch strings.ToLower(s) { + case "true": + return true + case "false": + return false + } + if !numericString.MatchString(s) { + return s + } + f, err := strconv.ParseFloat(s, 64) + if err != nil || strconv.FormatFloat(f, 'f', -1, 64) != s { + return s + } + return f +} diff --git a/internal/parser/cloudformation/coerce_test.go b/internal/parser/cloudformation/coerce_test.go new file mode 100644 index 00000000..c5fd565b --- /dev/null +++ b/internal/parser/cloudformation/coerce_test.go @@ -0,0 +1,171 @@ +package cloudformation_test + +import ( + "reflect" + "testing" + + "github.com/c3xdev/c3x/internal/parser/cloudformation" +) + +// CloudFormation accepts strings for numeric and boolean properties, and +// every parameter value is a string. The parser hands the catalog typed +// values so its arithmetic and comparisons work. +func TestNumericAndBooleanStringsAreCoerced(t *testing.T) { + t.Parallel() + doc := ` +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Storage: + Type: Number + Default: "250" +Resources: + DB: + Type: AWS::RDS::DBInstance + Properties: + DBInstanceClass: db.r7g.large + Engine: postgres + EngineVersion: "8.0" + AllocatedStorage: !Ref Storage + Iops: "3000" + MultiAZ: "true" + DBInstanceIdentifier: "007" +` + got, err := cloudformation.ParseBytes([]byte(doc), "t.yaml", cloudformation.Options{}) + if err != nil { + t.Fatal(err) + } + attrs := got[0].Attributes + want := map[string]any{ + "allocated_storage": 250.0, + "Iops": 3000.0, + "multi_az": true, + // Strings that would not read back identically keep their + // spelling: a version like "8.0" or a zero-padded name. + "EngineVersion": "8.0", + "identifier": "007", + "engine": "postgres", + } + for k, v := range want { + if !reflect.DeepEqual(attrs[k], v) { + t.Errorf("%s = %#v, want %#v", k, attrs[k], v) + } + } +} + +func TestParameterOverrideStringsAreCoerced(t *testing.T) { + t.Parallel() + doc := ` +Parameters: + Size: + Type: Number + Default: 100 +Resources: + Vol: + Type: AWS::EC2::Volume + Properties: + Size: !Ref Size +` + got, err := cloudformation.ParseBytes([]byte(doc), "t.yaml", cloudformation.Options{ + Parameters: map[string]any{"Size": "500"}, // --var Size=500 + }) + if err != nil { + t.Fatal(err) + } + if v := got[0].Attributes["size"]; v != 500.0 { + t.Errorf("size = %#v, want 500", v) + } +} + +// BlockDeviceMappings become the root_block_device / ebs_block_device +// attributes the aws_instance catalog prices, the root chosen by the +// AMI's root device name. +func TestBlockDeviceMappingsMapToTerraformBlocks(t *testing.T) { + t.Parallel() + doc := ` +Resources: + Web: + Type: AWS::EC2::Instance + Properties: + InstanceType: m7i.xlarge + BlockDeviceMappings: + - DeviceName: /dev/xvda + Ebs: + VolumeSize: 50 + VolumeType: gp3 + - DeviceName: /dev/xvdf + Ebs: + VolumeSize: "200" + VolumeType: io2 + Iops: 4000 + - DeviceName: /dev/xvdg + Ebs: + VolumeSize: 100 + - DeviceName: /dev/sdb + VirtualName: ephemeral0 + - DeviceName: /dev/xvdh + NoDevice: {} + Ebs: + VolumeSize: 999 +` + got, err := cloudformation.ParseBytes([]byte(doc), "t.yaml", cloudformation.Options{}) + if err != nil { + t.Fatal(err) + } + attrs := got[0].Attributes + wantRoot := map[string]any{"volume_size": 50, "volume_type": "gp3"} + if !reflect.DeepEqual(attrs["root_block_device"], wantRoot) { + t.Errorf("root_block_device = %#v, want %#v", attrs["root_block_device"], wantRoot) + } + wantEBS := []any{ + map[string]any{"device_name": "/dev/xvdf", "volume_size": 200.0, "volume_type": "io2", "iops": 4000}, + map[string]any{"device_name": "/dev/xvdg", "volume_size": 100}, + } + if !reflect.DeepEqual(attrs["ebs_block_device"], wantEBS) { + t.Errorf("ebs_block_device = %#v, want %#v", attrs["ebs_block_device"], wantEBS) + } +} + +func TestLambdaArchitecturesAndEphemeralStorage(t *testing.T) { + t.Parallel() + doc := ` +Resources: + Fn: + Type: AWS::Lambda::Function + Properties: + MemorySize: 1024 + Architectures: [arm64] + EphemeralStorage: + Size: 2048 +` + got, err := cloudformation.ParseBytes([]byte(doc), "t.yaml", cloudformation.Options{}) + if err != nil { + t.Fatal(err) + } + attrs := got[0].Attributes + if !reflect.DeepEqual(attrs["architectures"], []any{"arm64"}) { + t.Errorf("architectures = %#v", attrs["architectures"]) + } + if attrs["ephemeral_storage_size"] != 2048 { + t.Errorf("ephemeral_storage_size = %#v", attrs["ephemeral_storage_size"]) + } +} + +func TestIsTemplate(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + "AWSTemplateFormatVersion: '2010-09-09'\nResources:\n B:\n Type: Custom::Thing\n": true, + "Resources:\n B:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: !Ref N\n": true, + `{"Resources":{"B":{"Type":"AWS::S3::Bucket"}}}`: true, + "version: 0.1\nresource_usage:\n aws_s3_bucket.b:\n storage_gb: 10\n": false, + "Resources:\n B:\n Type: Custom::Thing\n": false, + "AWSTemplateFormatVersion: '2010-09-09'\nResources: {}\n": false, + "- a\n- b\n": false, + "apiVersion: v1\nkind: ConfigMap\n": false, + `{"format_version":"1.2","resource_changes":[]}`: false, + } + for doc, want := range cases { + if got := cloudformation.IsTemplate([]byte(doc)); got != want { + t.Errorf("IsTemplate(%q) = %v, want %v", doc, got, want) + } + } +} diff --git a/internal/parser/cloudformation/parser.go b/internal/parser/cloudformation/parser.go index 86c6e777..66cb2eca 100644 --- a/internal/parser/cloudformation/parser.go +++ b/internal/parser/cloudformation/parser.go @@ -76,7 +76,7 @@ func ParseBytes(raw []byte, originPath string, opts Options) ([]domain.Resource, "logical_id", logicalID, "type", res.Type) continue } - resolved, ok := resolve(res.Properties, sc).(map[string]any) + resolved, ok := coerceScalars(resolve(res.Properties, sc)).(map[string]any) if !ok { resolved = map[string]any{} } @@ -102,9 +102,10 @@ func ParseBytes(raw []byte, originPath string, opts Options) ([]domain.Resource, // bind every field — the goformation library does, at the cost of // hundreds of generated files, and we don't need that depth. type template struct { - Parameters map[string]parameterDef `yaml:"Parameters" json:"Parameters"` - Mappings map[string]map[string]map[string]any `yaml:"Mappings" json:"Mappings"` - Resources map[string]resourceDef `yaml:"Resources" json:"Resources"` + FormatVersion any `yaml:"AWSTemplateFormatVersion" json:"AWSTemplateFormatVersion"` + Parameters map[string]parameterDef `yaml:"Parameters" json:"Parameters"` + Mappings map[string]map[string]map[string]any `yaml:"Mappings" json:"Mappings"` + Resources map[string]resourceDef `yaml:"Resources" json:"Resources"` } type parameterDef struct { @@ -272,3 +273,24 @@ func nonEmpty(s, fallback string) string { } return s } + +// IsTemplate reports whether raw is a CloudFormation template: a +// document with a non-empty Resources section that either declares +// AWSTemplateFormatVersion or has at least one resource of an AWS:: +// type. Used to pick templates out of a directory, where YAML and JSON +// files can be anything (usage files, CI config, Kubernetes manifests). +func IsTemplate(raw []byte) bool { + t, err := decodeTemplate(raw, "") + if err != nil || len(t.Resources) == 0 { + return false + } + if t.FormatVersion != nil { + return true + } + for _, r := range t.Resources { + if strings.HasPrefix(r.Type, "AWS::") { + return true + } + } + return false +} diff --git a/internal/parser/cloudformation/translate.go b/internal/parser/cloudformation/translate.go index f7534b44..200a833b 100644 --- a/internal/parser/cloudformation/translate.go +++ b/internal/parser/cloudformation/translate.go @@ -69,8 +69,9 @@ var propertyMap = map[string]map[string]string{ "aws_instance": { "InstanceType": "instance_type", "ImageId": "ami", - // BlockDeviceMappings gets special handling in extractProperties - // because it's an array of objects rather than a scalar. + // BlockDeviceMappings is a list of objects rather than a + // scalar; translateBlockDevices maps it onto the Terraform + // root_block_device / ebs_block_device shape. }, "aws_ebs_volume": { "Size": "size", @@ -105,9 +106,11 @@ var propertyMap = map[string]map[string]string{ "NumCacheNodes": "num_cache_nodes", }, "aws_lambda_function": { - "MemorySize": "memory_size", - "FunctionName": "function_name", - "Runtime": "runtime", + "MemorySize": "memory_size", + "FunctionName": "function_name", + "Runtime": "runtime", + "Architectures": "architectures", + "EphemeralStorage.Size": "ephemeral_storage_size", }, "aws_redshift_cluster": { "NodeType": "node_type", @@ -150,9 +153,74 @@ func translateProps(kind string, cfn map[string]any) map[string]any { // can still pick up an attribute we didn't formally rewrite. out[cfnKey] = val } + if kind == "aws_instance" { + translateBlockDevices(out) + } return out } +// rootDeviceNames are the device names EC2 AMIs use for the root +// volume: /dev/xvda on Amazon Linux and most HVM images, /dev/sda1 on +// Ubuntu, Windows and older AMIs. +var rootDeviceNames = map[string]bool{ + "/dev/xvda": true, "xvda": true, + "/dev/sda1": true, "sda1": true, + "/dev/sda": true, "sda": true, + "/dev/nvme0n1": true, +} + +// translateBlockDevices rewrites an instance's CloudFormation +// BlockDeviceMappings into the Terraform aws_instance shape the catalog +// prices: the mapping on the AMI's root device name becomes +// `root_block_device`, every other EBS mapping an `ebs_block_device` +// entry. Instance-store (VirtualName) and suppressed (NoDevice) +// mappings carry no EBS volume and are skipped. +func translateBlockDevices(attrs map[string]any) { + mappings, ok := attrs["BlockDeviceMappings"].([]any) + if !ok { + return + } + var extra []any + for _, m := range mappings { + bdm, ok := m.(map[string]any) + if !ok { + continue + } + ebs, ok := bdm["Ebs"].(map[string]any) + if !ok { + continue + } + if _, suppressed := bdm["NoDevice"]; suppressed { + continue + } + device, _ := bdm["DeviceName"].(string) + vol := map[string]any{} + if device != "" { + vol["device_name"] = device + } + for cfnKey, tfKey := range map[string]string{ + "VolumeSize": "volume_size", + "VolumeType": "volume_type", + "Iops": "iops", + "Throughput": "throughput", + } { + if v, ok := ebs[cfnKey]; ok && v != nil { + vol[tfKey] = v + } + } + _, haveRoot := attrs["root_block_device"] + if rootDeviceNames[device] && !haveRoot { + delete(vol, "device_name") + attrs["root_block_device"] = vol + continue + } + extra = append(extra, vol) + } + if len(extra) > 0 { + attrs["ebs_block_device"] = extra + } +} + // flattenNested expands `{ "ClusterConfig": { "InstanceType": "x" } }` // into `{ "ClusterConfig.InstanceType": "x" }` so the dot-keyed // propertyMap entries (like OpenSearch's `ClusterConfig.InstanceType`) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 300c737f..318848ac 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -57,7 +57,11 @@ type Options struct { // Parse auto-detects the input type and returns the parsed resources. // // Detection rules: -// - directory → Terraform .tf / OpenTofu .tofu files +// - directory → Terraform .tf / OpenTofu .tofu files; +// a directory with none of those but +// CloudFormation templates +// (.yaml/.yml/.json/.template) → those +// templates // - .tf / .tofu / .hcl → single Terraform or OpenTofu file // - .json → Terraform plan JSON (Terraform shape // detected by `resource_changes` key @@ -107,6 +111,15 @@ func parseRaw(path string, opts Options) ([]domain.Resource, error) { Untrusted: opts.Untrusted, }) } + if !terraform.HasConfigFiles(path) { + templates, err := cloudFormationTemplates(path) + if err != nil { + return nil, err + } + if len(templates) > 0 { + return parseTemplates(templates, opts) + } + } return terraform.ParseDirectory(path, toTerraformOptions(opts)) } lower := strings.ToLower(path) @@ -258,3 +271,58 @@ func isCFNJSON(path string) bool { } return false } + +// templateExtensions are the file suffixes a directory scan considers +// as CloudFormation templates. Each candidate is still sniffed with +// [cloudformation.IsTemplate], so a usage file or CI config sitting +// next to the template is not mistaken for one. +var templateExtensions = []string{".yaml", ".yml", ".json", ".template"} + +// cloudFormationTemplates lists the CloudFormation templates directly +// inside dir (not in subdirectories, matching how a Terraform directory +// is read), in lexical order. +func cloudFormationTemplates(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", dir, err) + } + var out []string + for _, e := range entries { + if e.IsDir() || !hasTemplateExtension(e.Name()) { + continue + } + p := filepath.Join(dir, e.Name()) + raw, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", p, err) + } + if cloudformation.IsTemplate(raw) { + out = append(out, p) + } + } + return out, nil +} + +func hasTemplateExtension(name string) bool { + lower := strings.ToLower(name) + for _, ext := range templateExtensions { + if strings.HasSuffix(lower, ext) { + return true + } + } + return false +} + +// parseTemplates parses each CloudFormation template and returns their +// resources together, as one project. +func parseTemplates(paths []string, opts Options) ([]domain.Resource, error) { + var out []domain.Resource + for _, p := range paths { + res, err := cloudformation.ParseFile(p, toCFNOptions(opts)) + if err != nil { + return nil, err + } + out = append(out, res...) + } + return out, nil +} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index c3560eeb..8a6056e9 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -63,6 +63,68 @@ func TestParseDirectoryDispatchesToTerraform(t *testing.T) { } } +// A directory with CloudFormation templates and no Terraform files is +// estimated from its templates; the usage file and other YAML next to +// them are not mistaken for templates. +func TestParseDirectoryOfCloudFormationTemplates(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "template.yaml"), ` +AWSTemplateFormatVersion: "2010-09-09" +Resources: + Web: + Type: AWS::EC2::Instance + Properties: + InstanceType: m5.xlarge +`) + writeFile(t, filepath.Join(dir, "storage.json"), + `{"Resources":{"Bucket":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"x"}}}}`) + writeFile(t, filepath.Join(dir, "c3x-usage.yml"), "version: 0.1\nresource_usage: {}\n") + writeFile(t, filepath.Join(dir, "buildspec.yml"), "version: 0.2\nphases: {}\n") + + got, err := parser.Parse(dir, parser.Options{Region: "eu-west-1"}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + kinds := map[string]bool{} + for _, r := range got { + kinds[r.Ref.Label()] = true + } + if len(got) != 2 || !kinds["aws_instance.Web"] || !kinds["aws_s3_bucket.Bucket"] { + t.Fatalf("resources = %v, want aws_instance.Web and aws_s3_bucket.Bucket", kinds) + } +} + +// Terraform files win: a directory holding both is a Terraform +// directory, as before, and its YAML is not parsed as CloudFormation. +func TestParseDirectoryPrefersTerraformOverTemplates(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.tf"), ` + resource "aws_instance" "web" { + instance_type = "m5.xlarge" + } + `) + writeFile(t, filepath.Join(dir, "template.yaml"), + "Resources:\n Bucket:\n Type: AWS::S3::Bucket\n") + got, err := parser.Parse(dir, parser.Options{}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(got) != 1 || got[0].Ref.Kind != "aws_instance" { + t.Fatalf("expected only the Terraform aws_instance, got %+v", got) + } +} + +func TestParseDirectoryWithNoConfigurationStillErrors(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "c3x-usage.yml"), "version: 0.1\n") + if _, err := parser.Parse(dir, parser.Options{}); err == nil { + t.Fatal("expected an error for a directory with no configuration") + } +} + func TestParseTFFileDispatchesToTerraform(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/internal/parser/terraform/files.go b/internal/parser/terraform/files.go index babbd607..32fa32aa 100644 --- a/internal/parser/terraform/files.go +++ b/internal/parser/terraform/files.go @@ -98,3 +98,10 @@ func splitOverrides(paths []string) (primary, overrides []string) { } return primary, overrides } + +// HasConfigFiles reports whether dir holds any Terraform or OpenTofu +// configuration file (.tf, .tf.json, .tofu, .tofu.json). +func HasConfigFiles(dir string) bool { + files, err := configFiles(dir) + return err == nil && len(files) > 0 +} diff --git a/resources/aws/aws_lambda_function.toml b/resources/aws/aws_lambda_function.toml index 1a7b1ba1..c6527ff1 100644 --- a/resources/aws/aws_lambda_function.toml +++ b/resources/aws/aws_lambda_function.toml @@ -16,6 +16,12 @@ attribute_filters = [ { key = "group", const = "AWS-Lambda-Duration" }, ] +# `architectures` is a list in Terraform and CloudFormation +# (architectures = ["arm64"]), so comparing it to "arm64" never matched and +# Graviton functions were quoted the x86 rate. toJSON(...) contains +# "arm64" is true for the list and for a bare "arm64" string alike. +# us-east-1 first-tier duration: x86 $0.0000166667, Arm $0.0000133334 per +# GB-second (https://aws.amazon.com/lambda/pricing/). [mappings.duration_arm] service = "AWSLambda" product_family = "Serverless" @@ -41,13 +47,13 @@ quantity = ''' * (default(request_duration_ms, 100) / 1000) * (default(memory_size, 128) / 1024) ''' -rate = 'pick(default(architectures, "x86_64") == "arm64", price("duration_arm"), price("duration_x86"))' +rate = 'pick(toJSON(default(architectures, "")) contains "arm64", price("duration_arm"), price("duration_x86"))' [mappings.provisioned_concurrency] service = "AWSLambda" attribute_filters = [ { key = "usagetype", expr = """ - default(architectures, "x86_64") == "arm64" ? + toJSON(default(architectures, "")) contains "arm64" ? "Lambda-Provisioned-Concurrency-ARM" : "Lambda-Provisioned-Concurrency" """ }, ] @@ -56,7 +62,7 @@ attribute_filters = [ service = "AWSLambda" attribute_filters = [ { key = "usagetype", expr = """ - default(architectures, "x86_64") == "arm64" ? + toJSON(default(architectures, "")) contains "arm64" ? "Lambda-Provisioned-GB-Second-ARM" : "Lambda-Provisioned-GB-Second" """ }, ] diff --git a/resources/azure/azurerm_batch_pool.toml b/resources/azure/azurerm_batch_pool.toml index 7850fded..76ad2264 100644 --- a/resources/azure/azurerm_batch_pool.toml +++ b/resources/azure/azurerm_batch_pool.toml @@ -6,7 +6,7 @@ provider = "azure" service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(vm_size, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(vm_size, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_kubernetes_cluster.toml b/resources/azure/azurerm_kubernetes_cluster.toml index fc160413..92c76b10 100644 --- a/resources/azure/azurerm_kubernetes_cluster.toml +++ b/resources/azure/azurerm_kubernetes_cluster.toml @@ -57,7 +57,7 @@ rate = 'price("premium_lts")' service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(default_node_pool_vm_size, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(default_node_pool_vm_size, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_kubernetes_cluster_node_pool.toml b/resources/azure/azurerm_kubernetes_cluster_node_pool.toml index 43a19c9e..9778b12d 100644 --- a/resources/azure/azurerm_kubernetes_cluster_node_pool.toml +++ b/resources/azure/azurerm_kubernetes_cluster_node_pool.toml @@ -6,7 +6,7 @@ provider = "azure" service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(vm_size, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(vm_size, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_linux_virtual_machine.toml b/resources/azure/azurerm_linux_virtual_machine.toml index f3d20279..854b27fe 100644 --- a/resources/azure/azurerm_linux_virtual_machine.toml +++ b/resources/azure/azurerm_linux_virtual_machine.toml @@ -2,18 +2,21 @@ kind = "azurerm_linux_virtual_machine" display_name = "Azure Linux Virtual Machine" provider = "azure" -# Match the canonical "regular" pricing for Linux VMs by filtering against -# the meterName that has no Spot/Low Priority suffix. Note: the upstream -# returns both Linux and Windows variants for a given skuName — without a -# productName filter we may pick the first match. In practice Linux is -# the first hit, but a richer rule (productName containing the family -# suffix vs " Windows") would be more robust. Tracked in CATALOG_STATUS. +# The VM size (Standard_B2s, Standard_D2s_v5) is the Retail Prices API's +# armSkuName. skuName is not: Azure spells it "Standard_D2s_v5" for some +# series but "B2s", "B2s v2" or "D2 v3" for most others, so filtering on +# skuName matched nothing for most sizes. armSkuName also matches the +# size's Spot and Low Priority meters; those are always cheaper, and the +# engine quotes the highest matching price, so the pay-as-you-go rate is +# the one that comes back. `os` (derived by the pricing API from +# productName) separates Linux from Windows rates and excludes the +# Cloud Services products that reuse the same armSkuName. [mappings.compute] service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(size, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(size, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_linux_virtual_machine_scale_set.toml b/resources/azure/azurerm_linux_virtual_machine_scale_set.toml index 7d94cebd..5db9f591 100644 --- a/resources/azure/azurerm_linux_virtual_machine_scale_set.toml +++ b/resources/azure/azurerm_linux_virtual_machine_scale_set.toml @@ -8,7 +8,7 @@ provider = "azure" service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(sku, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(sku, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_orchestrated_virtual_machine_scale_set.toml b/resources/azure/azurerm_orchestrated_virtual_machine_scale_set.toml index 293ac556..700fe012 100644 --- a/resources/azure/azurerm_orchestrated_virtual_machine_scale_set.toml +++ b/resources/azure/azurerm_orchestrated_virtual_machine_scale_set.toml @@ -6,7 +6,7 @@ provider = "azure" service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(sku_name, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(sku_name, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_virtual_machine.toml b/resources/azure/azurerm_virtual_machine.toml index 9a836675..d577406f 100644 --- a/resources/azure/azurerm_virtual_machine.toml +++ b/resources/azure/azurerm_virtual_machine.toml @@ -6,7 +6,7 @@ provider = "azure" service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(vm_size, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(vm_size, "Standard_D2s_v5")' }, { key = "os", const = "Linux" }, ] diff --git a/resources/azure/azurerm_windows_virtual_machine.toml b/resources/azure/azurerm_windows_virtual_machine.toml index 4c01c54b..13ad362b 100644 --- a/resources/azure/azurerm_windows_virtual_machine.toml +++ b/resources/azure/azurerm_windows_virtual_machine.toml @@ -2,32 +2,24 @@ kind = "azurerm_windows_virtual_machine" display_name = "Azure Windows Virtual Machine" provider = "azure" -# Azure's catalogue surfaces Linux and Windows variants of the same SKU -# under different `productName` values (e.g. "Virtual Machines Dsv5 Series" -# vs "Virtual Machines Dsv5 Series Windows"). The skuName attribute alone -# is identical between the two, so without a productName filter we'd pick -# whichever the upstream returns first — historically Linux, producing a -# silently-wrong Windows estimate. -# -# Today's compromise: hard-code productName for the Dsv5 family because -# that's the default the test fixture uses and the most common general- -# purpose SKU. Other families (Ev5, NCv3, M-series, …) currently fall -# back to the Linux-first match. Tracked in CATALOG_STATUS.md as the -# "Windows OS dispatch" item; a richer pick()-driven mapping or a -# client-side productName regex (when the upstream gains regex support) -# is the long-term fix. +# The VM size is the Retail Prices API's armSkuName (see +# azurerm_linux_virtual_machine.toml for why not skuName). Linux and +# Windows rates share it and differ by productName ("... Series" vs +# "... Series Windows"); the pricing API derives `os` from that, so the +# os filter picks the Windows rate for any series. The mapping keeps its +# historical compute_dsv5 name. [mappings.compute_dsv5] service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(size, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(size, "Standard_D2s_v5")' }, { key = "os", const = "Windows" }, ] [[dimensions]] id = "compute_hours" -label = "Virtual machine (Windows, Dsv5)" +label = "Virtual machine (Windows)" unit = "hours" quantity = "monthly_hours()" rate = 'price("compute_dsv5")' diff --git a/resources/azure/azurerm_windows_virtual_machine_scale_set.toml b/resources/azure/azurerm_windows_virtual_machine_scale_set.toml index 522ee58e..e4ff6c89 100644 --- a/resources/azure/azurerm_windows_virtual_machine_scale_set.toml +++ b/resources/azure/azurerm_windows_virtual_machine_scale_set.toml @@ -8,7 +8,7 @@ provider = "azure" service = "Virtual Machines" product_family = "Compute" attribute_filters = [ - { key = "skuName", expr = 'default(sku, "Standard_D2s_v5")' }, + { key = "armSkuName", expr = 'default(sku, "Standard_D2s_v5")' }, { key = "os", const = "Windows" }, ] diff --git a/resources/gcp/google_sql_database_instance.toml b/resources/gcp/google_sql_database_instance.toml index 9efaef81..0e098799 100644 --- a/resources/gcp/google_sql_database_instance.toml +++ b/resources/gcp/google_sql_database_instance.toml @@ -12,18 +12,51 @@ provider = "gcp" # # Engine prefix maps database_version (POSTGRES_15 → PostgreSQL, # MYSQL_8_0 → MySQL, SQLSERVER → SQL Server). +# +# Location suffix: the SKU descriptions end in the location they are sold +# in, and that name is per region, not per continent: "vCPU in Americas" +# is us-central1/us-east1/us-west1, but us-east4 is "in Northern +# Virginia", europe-west1 "in EMEA", europe-west3 "in Frankfurt", +# asia-southeast1 "in Singapore". The `loc` table maps each region the +# pricing data carries to its suffix (taken from the Cloud Billing +# catalog descriptions); a region missing from it falls back to +# "Americas", which prices at the us-central1 rate with a caveat. +# `region` is the resource's own attribute, or the region it is priced in. [mappings.vcpu] service = "Cloud SQL" product_family = "ApplicationServices" attribute_filters = [ { key = "description", expr = ''' + let loc = { + "africa-south1": "Johannesburg", "asia-east1": "APAC", + "asia-east2": "Hong Kong", "asia-northeast1": "Japan", + "asia-northeast2": "Osaka", "asia-northeast3": "Seoul", + "asia-south1": "Mumbai", "asia-south2": "Delhi", + "asia-southeast1": "Singapore", "asia-southeast2": "Jakarta", + "asia-southeast3": "Bangkok", "australia-southeast1": "Sydney", + "australia-southeast2": "Melbourne", "europe-central2": "Warsaw", + "europe-north1": "Finland", "europe-north2": "Stockholm", + "europe-southwest1": "Madrid", "europe-west1": "EMEA", + "europe-west10": "Berlin", "europe-west12": "Turin", + "europe-west2": "London", "europe-west3": "Frankfurt", + "europe-west4": "Netherlands", "europe-west6": "Zurich", + "europe-west8": "Milan", "europe-west9": "Paris", + "me-central1": "Doha", "me-central2": "Dammam", "me-west1": "Israel", + "northamerica-northeast1": "Montreal", + "northamerica-northeast2": "Toronto", "northamerica-south1": "Mexico", + "southamerica-east1": "Sao Paulo", "southamerica-west1": "Santiago", + "us-east4": "Northern Virginia", "us-east5": "Columbus", + "us-east7": "Day0 (Alabama)", "us-south1": "Dallas", + "us-west2": "Los Angeles", "us-west3": "Salt Lake City", + "us-west4": "Las Vegas", "us-west8": "Phoenix" + }[default(region, "us-central1")] ?? "Americas"; "Cloud SQL for " + (default(database_version, "POSTGRES_15") startsWith "MYSQL" ? "MySQL" : default(database_version, "POSTGRES_15") startsWith "SQLSERVER" ? "SQL Server" : "PostgreSQL") + ": " + (default(default(settings_availability_type, availability_type), "ZONAL") == "REGIONAL" ? "Regional" : "Zonal") + - " - vCPU in Americas" + " - vCPU in " + loc ''' }, ] @@ -32,12 +65,35 @@ service = "Cloud SQL" product_family = "ApplicationServices" attribute_filters = [ { key = "description", expr = ''' + let loc = { + "africa-south1": "Johannesburg", "asia-east1": "APAC", + "asia-east2": "Hong Kong", "asia-northeast1": "Japan", + "asia-northeast2": "Osaka", "asia-northeast3": "Seoul", + "asia-south1": "Mumbai", "asia-south2": "Delhi", + "asia-southeast1": "Singapore", "asia-southeast2": "Jakarta", + "asia-southeast3": "Bangkok", "australia-southeast1": "Sydney", + "australia-southeast2": "Melbourne", "europe-central2": "Warsaw", + "europe-north1": "Finland", "europe-north2": "Stockholm", + "europe-southwest1": "Madrid", "europe-west1": "EMEA", + "europe-west10": "Berlin", "europe-west12": "Turin", + "europe-west2": "London", "europe-west3": "Frankfurt", + "europe-west4": "Netherlands", "europe-west6": "Zurich", + "europe-west8": "Milan", "europe-west9": "Paris", + "me-central1": "Doha", "me-central2": "Dammam", "me-west1": "Israel", + "northamerica-northeast1": "Montreal", + "northamerica-northeast2": "Toronto", "northamerica-south1": "Mexico", + "southamerica-east1": "Sao Paulo", "southamerica-west1": "Santiago", + "us-east4": "Northern Virginia", "us-east5": "Columbus", + "us-east7": "Day0 (Alabama)", "us-south1": "Dallas", + "us-west2": "Los Angeles", "us-west3": "Salt Lake City", + "us-west4": "Las Vegas", "us-west8": "Phoenix" + }[default(region, "us-central1")] ?? "Americas"; "Cloud SQL for " + (default(database_version, "POSTGRES_15") startsWith "MYSQL" ? "MySQL" : default(database_version, "POSTGRES_15") startsWith "SQLSERVER" ? "SQL Server" : "PostgreSQL") + ": " + (default(default(settings_availability_type, availability_type), "ZONAL") == "REGIONAL" ? "Regional" : "Zonal") + - " - RAM in Americas" + " - RAM in " + loc ''' }, ] @@ -45,14 +101,64 @@ attribute_filters = [ service = "Cloud SQL" product_family = "ApplicationServices" attribute_filters = [ - { key = "description", const = "Cloud SQL for PostgreSQL: Zonal - Micro instance in Americas" }, + { key = "description", expr = ''' + let loc = { + "africa-south1": "Johannesburg", "asia-east1": "APAC", + "asia-east2": "Hong Kong", "asia-northeast1": "Japan", + "asia-northeast2": "Osaka", "asia-northeast3": "Seoul", + "asia-south1": "Mumbai", "asia-south2": "Delhi", + "asia-southeast1": "Singapore", "asia-southeast2": "Jakarta", + "asia-southeast3": "Bangkok", "australia-southeast1": "Sydney", + "australia-southeast2": "Melbourne", "europe-central2": "Warsaw", + "europe-north1": "Finland", "europe-north2": "Stockholm", + "europe-southwest1": "Madrid", "europe-west1": "EMEA", + "europe-west10": "Berlin", "europe-west12": "Turin", + "europe-west2": "London", "europe-west3": "Frankfurt", + "europe-west4": "Netherlands", "europe-west6": "Zurich", + "europe-west8": "Milan", "europe-west9": "Paris", + "me-central1": "Doha", "me-central2": "Dammam", "me-west1": "Israel", + "northamerica-northeast1": "Montreal", + "northamerica-northeast2": "Toronto", "northamerica-south1": "Mexico", + "southamerica-east1": "Sao Paulo", "southamerica-west1": "Santiago", + "us-east4": "Northern Virginia", "us-east5": "Columbus", + "us-east7": "Day0 (Alabama)", "us-south1": "Dallas", + "us-west2": "Los Angeles", "us-west3": "Salt Lake City", + "us-west4": "Las Vegas", "us-west8": "Phoenix" + }[default(region, "us-central1")] ?? "Americas"; + "Cloud SQL for PostgreSQL: Zonal - Micro instance in " + loc + ''' }, ] [mappings.small] service = "Cloud SQL" product_family = "ApplicationServices" attribute_filters = [ - { key = "description", const = "Cloud SQL for PostgreSQL: Zonal - Small instance in Americas" }, + { key = "description", expr = ''' + let loc = { + "africa-south1": "Johannesburg", "asia-east1": "APAC", + "asia-east2": "Hong Kong", "asia-northeast1": "Japan", + "asia-northeast2": "Osaka", "asia-northeast3": "Seoul", + "asia-south1": "Mumbai", "asia-south2": "Delhi", + "asia-southeast1": "Singapore", "asia-southeast2": "Jakarta", + "asia-southeast3": "Bangkok", "australia-southeast1": "Sydney", + "australia-southeast2": "Melbourne", "europe-central2": "Warsaw", + "europe-north1": "Finland", "europe-north2": "Stockholm", + "europe-southwest1": "Madrid", "europe-west1": "EMEA", + "europe-west10": "Berlin", "europe-west12": "Turin", + "europe-west2": "London", "europe-west3": "Frankfurt", + "europe-west4": "Netherlands", "europe-west6": "Zurich", + "europe-west8": "Milan", "europe-west9": "Paris", + "me-central1": "Doha", "me-central2": "Dammam", "me-west1": "Israel", + "northamerica-northeast1": "Montreal", + "northamerica-northeast2": "Toronto", "northamerica-south1": "Mexico", + "southamerica-east1": "Sao Paulo", "southamerica-west1": "Santiago", + "us-east4": "Northern Virginia", "us-east5": "Columbus", + "us-east7": "Day0 (Alabama)", "us-south1": "Dallas", + "us-west2": "Los Angeles", "us-west3": "Salt Lake City", + "us-west4": "Las Vegas", "us-west8": "Phoenix" + }[default(region, "us-central1")] ?? "Americas"; + "Cloud SQL for PostgreSQL: Zonal - Small instance in " + loc + ''' }, ] [mappings.storage] @@ -60,12 +166,35 @@ service = "Cloud SQL" product_family = "ApplicationServices" attribute_filters = [ { key = "description", expr = ''' + let loc = { + "africa-south1": "Johannesburg", "asia-east1": "APAC", + "asia-east2": "Hong Kong", "asia-northeast1": "Japan", + "asia-northeast2": "Osaka", "asia-northeast3": "Seoul", + "asia-south1": "Mumbai", "asia-south2": "Delhi", + "asia-southeast1": "Singapore", "asia-southeast2": "Jakarta", + "asia-southeast3": "Bangkok", "australia-southeast1": "Sydney", + "australia-southeast2": "Melbourne", "europe-central2": "Warsaw", + "europe-north1": "Finland", "europe-north2": "Stockholm", + "europe-southwest1": "Madrid", "europe-west1": "EMEA", + "europe-west10": "Berlin", "europe-west12": "Turin", + "europe-west2": "London", "europe-west3": "Frankfurt", + "europe-west4": "Netherlands", "europe-west6": "Zurich", + "europe-west8": "Milan", "europe-west9": "Paris", + "me-central1": "Doha", "me-central2": "Dammam", "me-west1": "Israel", + "northamerica-northeast1": "Montreal", + "northamerica-northeast2": "Toronto", "northamerica-south1": "Mexico", + "southamerica-east1": "Sao Paulo", "southamerica-west1": "Santiago", + "us-east4": "Northern Virginia", "us-east5": "Columbus", + "us-east7": "Day0 (Alabama)", "us-south1": "Dallas", + "us-west2": "Los Angeles", "us-west3": "Salt Lake City", + "us-west4": "Las Vegas", "us-west8": "Phoenix" + }[default(region, "us-central1")] ?? "Americas"; "Cloud SQL for " + (default(database_version, "POSTGRES_15") startsWith "MYSQL" ? "MySQL" : default(database_version, "POSTGRES_15") startsWith "SQLSERVER" ? "SQL Server" : "PostgreSQL") + ": " + (default(default(settings_availability_type, availability_type), "ZONAL") == "REGIONAL" ? "Regional" : "Zonal") + - " - Standard storage in Americas" + " - Standard storage in " + loc ''' }, ]