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 ''' }, ]