| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- package ibm
- import (
- "testing"
- "time"
- "github.com/IBM/platform-services-go-sdk/usagereportsv4"
- "github.com/opencost/opencost/core/pkg/opencost"
- )
- func TestMonthsOverlapping(t *testing.T) {
- start := time.Date(2026, 1, 28, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)
- got := monthsOverlapping(start, end)
- want := []string{"2026-01", "2026-02", "2026-03"}
- if len(got) != len(want) {
- t.Fatalf("monthsOverlapping len = %d, want %d (%v)", len(got), len(want), got)
- }
- for i := range want {
- if got[i] != want[i] {
- t.Errorf("monthsOverlapping[%d] = %q, want %q", i, got[i], want[i])
- }
- }
- }
- func TestMonthsOverlappingSameMonth(t *testing.T) {
- start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC)
- got := monthsOverlapping(start, end)
- if len(got) != 1 || got[0] != "2026-02" {
- t.Errorf("got %v, want [2026-02]", got)
- }
- }
- func TestDaysInMonth(t *testing.T) {
- if got := daysInMonth(2026, 2); got != 28 {
- t.Errorf("Feb 2026 days = %d, want 28", got)
- }
- if got := daysInMonth(2024, 2); got != 29 {
- t.Errorf("Feb 2024 days = %d, want 29", got)
- }
- if got := daysInMonth(2026, 1); got != 31 {
- t.Errorf("Jan 2026 days = %d, want 31", got)
- }
- }
- func TestProrationDays(t *testing.T) {
- monthStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- if got := prorationDays(monthStart, time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)); got != 31 {
- t.Errorf("completed month = %d, want 31", got)
- }
- if got := prorationDays(monthStart, time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)); got != 5 {
- t.Errorf("MTD day 5 = %d, want 5", got)
- }
- if got := prorationDays(monthStart, time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)); got != 31 {
- t.Errorf("last day of month = %d, want 31", got)
- }
- }
- func TestSelectIBMCategory(t *testing.T) {
- tests := []struct {
- service string
- want string
- }{
- {"is.instance", opencost.ComputeCategory},
- {"is.bare-metal-server", opencost.ComputeCategory},
- {"is.dedicated-host", opencost.ComputeCategory},
- {"codeengine", opencost.ComputeCategory},
- {"containers-kubernetes", opencost.ComputeCategory},
- {"is.volume", opencost.StorageCategory},
- {"is.snapshot", opencost.StorageCategory},
- {"is.share", opencost.StorageCategory},
- {"cloud-object-storage", opencost.StorageCategory},
- {"databases-for-postgresql", opencost.StorageCategory},
- {"databases-for-redis", opencost.StorageCategory},
- {"is.load-balancer", opencost.NetworkCategory},
- {"is.floating-ip", opencost.NetworkCategory},
- {"is.public-gateway", opencost.NetworkCategory},
- {"is.vpn", opencost.NetworkCategory},
- {"transit", opencost.NetworkCategory},
- {"internet-svcs", opencost.NetworkCategory},
- // Prefix arms removed: these must not silently widen beyond the locked table.
- {"is.vpn-server", opencost.OtherCategory},
- {"transit-gateway", opencost.OtherCategory},
- {"codeengine-app", opencost.OtherCategory},
- {"is.vpc", opencost.OtherCategory},
- {"dns", opencost.OtherCategory},
- {"cdn", opencost.OtherCategory},
- {"", opencost.OtherCategory},
- {"unknown-service", opencost.OtherCategory},
- }
- for _, tt := range tests {
- if got := selectIBMCategory(tt.service); got != tt.want {
- t.Errorf("selectIBMCategory(%q) = %q, want %q", tt.service, got, tt.want)
- }
- }
- }
- func TestParseTags(t *testing.T) {
- tags := parseTags([]any{
- "env:prod",
- "staging",
- map[string]any{"key": "owner", "value": "platform"},
- map[string]any{"Key": "team", "Value": "sre"},
- map[string]any{"key": "empty", "value": ""},
- })
- if tags["env"] != "prod" {
- t.Errorf("env tag = %q, want prod", tags["env"])
- }
- if tags["owner"] != "platform" {
- t.Errorf("owner tag = %q, want platform", tags["owner"])
- }
- if tags["team"] != "sre" {
- t.Errorf("team tag = %q, want sre", tags["team"])
- }
- if _, ok := tags["staging"]; ok {
- t.Error("non key:value string tag should be omitted")
- }
- if _, ok := tags["empty"]; ok {
- t.Error("empty tag value should be omitted")
- }
- }
- func TestInstanceUsageFromSDK_SkipsNonBillable(t *testing.T) {
- billable := false
- cost := 10.0
- item := usagereportsv4.InstanceUsage{
- Billable: &billable,
- Usage: []usagereportsv4.Metric{
- {Cost: &cost, RatedCost: &cost},
- },
- }
- if _, ok := instanceUsageFromSDK(item); ok {
- t.Fatal("expected non-billable instance to be skipped")
- }
- }
- func TestInstanceUsageFromSDK_CurrencyConversion(t *testing.T) {
- billable := true
- cost := 10.0
- rated := 20.0
- rate := 1.5
- month := "2026-01"
- account := "acct"
- resourceID := "is.instance"
- item := usagereportsv4.InstanceUsage{
- Billable: &billable,
- AccountID: &account,
- ResourceID: &resourceID,
- Month: &month,
- CurrencyRate: &rate,
- Usage: []usagereportsv4.Metric{
- {Cost: &cost, RatedCost: &rated},
- },
- Tags: []any{"env:test"},
- ServiceTags: []any{"svc:iks"},
- }
- record, ok := instanceUsageFromSDK(item)
- if !ok {
- t.Fatal("expected billable instance")
- }
- if record.Cost != 15.0 {
- t.Errorf("Cost = %v, want 15", record.Cost)
- }
- if record.RatedCost != 30.0 {
- t.Errorf("RatedCost = %v, want 30", record.RatedCost)
- }
- labels := parseTags(record.Tags)
- if labels["env"] != "test" || labels["svc"] != "iks" {
- t.Errorf("merged tags = %#v", labels)
- }
- }
- func TestInstanceUsageFromSDK_NormalizesPrefixedAccountID(t *testing.T) {
- billable := true
- month := "2026-01"
- account := "a/b09edf5642ebfad587c594f4d4a354b0"
- resourceID := "is.instance"
- cost := 1.0
- item := usagereportsv4.InstanceUsage{
- Billable: &billable,
- AccountID: &account,
- ResourceID: &resourceID,
- Month: &month,
- Usage: []usagereportsv4.Metric{
- {Cost: &cost, RatedCost: &cost},
- },
- }
- record, ok := instanceUsageFromSDK(item)
- if !ok {
- t.Fatal("expected billable instance")
- }
- if record.AccountID != "b09edf5642ebfad587c594f4d4a354b0" {
- t.Errorf("AccountID = %q, want bare hex", record.AccountID)
- }
- }
- func TestInstanceUsageFromSDK_SkipsNonChargeableMetrics(t *testing.T) {
- billable := true
- cost := 5.0
- rated := 5.0
- info := true
- month := "2026-01"
- item := usagereportsv4.InstanceUsage{
- Billable: &billable,
- Month: &month,
- Usage: []usagereportsv4.Metric{
- {Cost: &cost, RatedCost: &rated, NonChargeable: &info},
- {Cost: &cost, RatedCost: &rated},
- },
- }
- record, ok := instanceUsageFromSDK(item)
- if !ok {
- t.Fatal("expected billable instance")
- }
- if record.Cost != 5.0 || record.RatedCost != 5.0 {
- t.Errorf("got cost=%v rated=%v, want 5/5", record.Cost, record.RatedCost)
- }
- }
- func TestCloudCostsFromInstance(t *testing.T) {
- start := time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)
- item := instanceUsageRecord{
- AccountID: "b09edf5642ebfad587c594f4d4a354b0",
- ResourceInstanceID: "crn:v1:bluemix:public:containers-kubernetes:us-south:a/b09edf5642ebfad587c594f4d4a354b0:8042b2a8af6a4a5cbf6dbe09e07311d2:worker:kube-w1",
- ResourceID: "containers-kubernetes",
- ResourceName: "IBM Cloud Kubernetes Service",
- Region: "us-south",
- Month: "2026-01",
- Cost: 31.0,
- RatedCost: 62.0,
- Tags: []any{"team:sre"},
- }
- costs := cloudCostsFromInstance(item, start, end, asOf)
- // Every day the month total was prorated across is emitted, not just the days inside the
- // window, so the whole of the completed January is returned.
- if len(costs) != 31 {
- t.Fatalf("got %d cloud costs, want 31 (whole prorated January)", len(costs))
- }
- dailyNet := 31.0 / 31.0
- dailyList := 62.0 / 31.0
- for _, cc := range costs {
- if cc.Properties.Provider != opencost.IBMProvider {
- t.Errorf("provider = %q", cc.Properties.Provider)
- }
- if cc.Properties.AccountID != "b09edf5642ebfad587c594f4d4a354b0" {
- t.Errorf("account = %q", cc.Properties.AccountID)
- }
- if cc.Properties.InvoiceEntityID != cc.Properties.AccountID {
- t.Errorf("InvoiceEntityID = %q, want AccountID", cc.Properties.InvoiceEntityID)
- }
- if cc.Properties.Service != "containers-kubernetes" {
- t.Errorf("service = %q, want ResourceID", cc.Properties.Service)
- }
- if cc.Properties.Category != opencost.ComputeCategory {
- t.Errorf("category = %q", cc.Properties.Category)
- }
- if cc.Properties.Labels["team"] != "sre" {
- t.Errorf("labels = %#v", cc.Properties.Labels)
- }
- if cc.Properties.Labels["ibm_resource_name"] != "IBM Cloud Kubernetes Service" {
- t.Errorf("ibm_resource_name = %q", cc.Properties.Labels["ibm_resource_name"])
- }
- if cc.NetCost.Cost != dailyNet {
- t.Errorf("net cost = %v, want %v", cc.NetCost.Cost, dailyNet)
- }
- if cc.ListCost.Cost != dailyList {
- t.Errorf("list cost = %v, want %v", cc.ListCost.Cost, dailyList)
- }
- if cc.AmortizedCost.Cost != dailyList {
- t.Errorf("amortized cost = %v, want list/rated %v", cc.AmortizedCost.Cost, dailyList)
- }
- }
- }
- func TestCloudCostsFromInstance_RatedCostZeroKept(t *testing.T) {
- start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
- item := instanceUsageRecord{
- AccountID: "acct",
- ResourceInstanceID: "inst",
- ResourceID: "is.instance",
- Month: "2026-01",
- Cost: 31.0,
- RatedCost: 0,
- }
- costs := cloudCostsFromInstance(item, start, end, asOf)
- if len(costs) != 31 {
- t.Fatalf("got %d costs, want 31 (whole prorated month)", len(costs))
- }
- if costs[0].ListCost.Cost != 0 {
- t.Errorf("ListCost = %v, want 0 (rated_cost zero is valid)", costs[0].ListCost.Cost)
- }
- if costs[0].NetCost.Cost != 1.0 {
- t.Errorf("NetCost = %v, want 1", costs[0].NetCost.Cost)
- }
- }
- func TestCloudCostsFromInstance_MTDProration(t *testing.T) {
- start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
- item := instanceUsageRecord{
- AccountID: "acct",
- ResourceInstanceID: "inst",
- ResourceID: "is.instance",
- Month: "2026-01",
- Cost: 50.0,
- RatedCost: 50.0,
- }
- costs := cloudCostsFromInstance(item, start, end, asOf)
- if len(costs) != 5 {
- t.Fatalf("got %d costs, want 5 MTD days", len(costs))
- }
- if costs[0].NetCost.Cost != 10.0 {
- t.Errorf("daily net = %v, want 10 (50/5)", costs[0].NetCost.Cost)
- }
- }
- func TestServiceUsesResourceID(t *testing.T) {
- item := instanceUsageRecord{
- AccountID: "acct",
- ResourceInstanceID: "inst",
- ResourceID: "is.instance",
- ResourceName: "Virtual Server for VPC",
- Month: "2026-02",
- Cost: 28.0,
- RatedCost: 28.0,
- }
- start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
- costs := cloudCostsFromInstance(item, start, end, asOf)
- if len(costs) != 28 {
- t.Fatalf("got %d costs, want 28 (whole prorated February)", len(costs))
- }
- if costs[0].Properties.Service != "is.instance" {
- t.Errorf("service = %q, want is.instance", costs[0].Properties.Service)
- }
- if costs[0].Properties.Labels["ibm_resource_name"] != "Virtual Server for VPC" {
- t.Errorf("display name label = %q", costs[0].Properties.Labels["ibm_resource_name"])
- }
- }
- func TestNormalizeAccountID(t *testing.T) {
- tests := []struct {
- in, want string
- }{
- {"b09edf5642ebfad587c594f4d4a354b0", "b09edf5642ebfad587c594f4d4a354b0"},
- {"a/b09edf5642ebfad587c594f4d4a354b0", "b09edf5642ebfad587c594f4d4a354b0"},
- {" a/acct ", "acct"},
- {"", ""},
- }
- for _, tt := range tests {
- if got := normalizeAccountID(tt.in); got != tt.want {
- t.Errorf("normalizeAccountID(%q) = %q, want %q", tt.in, got, tt.want)
- }
- }
- }
- func TestUsageConfigurationKeySanitizesSlash(t *testing.T) {
- cfg := &UsageConfiguration{AccountID: "a/b09edf5642ebfad587c594f4d4a354b0"}
- if got := cfg.Key(); got != "b09edf5642ebfad587c594f4d4a354b0" {
- t.Errorf("Key() = %q, want bare hex", got)
- }
- }
- // The billing-export producer (cloudability/kubecost-saas, bingen/ibm) marks every IKS/ROKS row
- // as fully Kubernetes. Both producers write into the same aggregation, so if this path leaves
- // KubernetesPercent at zero, migrating a customer from the export to an API key silently zeroes
- // their entire IBM Kubernetes spend.
- func TestKubernetesPercentMatchesBillingExportProducer(t *testing.T) {
- const workerCRN = "crn:v1:bluemix:public:containers-kubernetes:jp-tok:a/f8ce6d5aa4cf4:" +
- "ch9goilt0elehhb3utbg:worker:kube-ch9goilt0elehhb3utbg-ccmdefault-0000017d"
- tests := []struct {
- name string
- resourceID string
- providerID string
- want float64
- }{
- {"kubernetes by service id", "containers-kubernetes", "crn:v1:bluemix:public:containers-kubernetes:us-south:a/x:y::", 1.0},
- {"kubernetes by crn service segment", "", workerCRN, 1.0},
- {"service id is case insensitive", "Containers-Kubernetes", "", 1.0},
- {"unrelated service", "is.volume", "crn:v1:bluemix:public:is:us-south:a/x::volume:r006-1", 0.0},
- }
- start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- item := instanceUsageRecord{
- AccountID: "acct",
- ResourceInstanceID: tt.providerID,
- ResourceID: tt.resourceID,
- Month: "2026-02",
- Cost: 28.0,
- RatedCost: 56.0,
- }
- costs := cloudCostsFromInstance(item, start, end, asOf)
- if len(costs) != 28 {
- t.Fatalf("got %d costs, want 28 (whole prorated February)", len(costs))
- }
- cc := costs[0]
- for metric, got := range map[string]float64{
- "ListCost": cc.ListCost.KubernetesPercent,
- "NetCost": cc.NetCost.KubernetesPercent,
- "AmortizedNetCost": cc.AmortizedNetCost.KubernetesPercent,
- "AmortizedCost": cc.AmortizedCost.KubernetesPercent,
- "InvoicedCost": cc.InvoicedCost.KubernetesPercent,
- } {
- if got != tt.want {
- t.Errorf("%s.KubernetesPercent = %v, want %v", metric, got, tt.want)
- }
- }
- })
- }
- }
- // bingen/ibm sets AccountName and InvoiceEntityName to the same normalized account id, because IBM
- // billing data carries no account display name. Both are user-selectable aggregation properties, so
- // leaving them empty here would split rows when a customer switches producers.
- func TestAccountNamesMatchBillingExportProducer(t *testing.T) {
- item := instanceUsageRecord{
- AccountID: "4756fbd48cbc4b6d968340888c27fd05",
- ResourceInstanceID: "inst",
- ResourceID: "is.instance",
- Month: "2026-02",
- Cost: 28.0,
- RatedCost: 28.0,
- }
- start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
- costs := cloudCostsFromInstance(item, start, end, asOf)
- if len(costs) != 28 {
- t.Fatalf("got %d costs, want 28 (whole prorated February)", len(costs))
- }
- props := costs[0].Properties
- if props.AccountName != item.AccountID {
- t.Errorf("AccountName = %q, want %q", props.AccountName, item.AccountID)
- }
- if props.InvoiceEntityName != item.AccountID {
- t.Errorf("InvoiceEntityName = %q, want %q", props.InvoiceEntityName, item.AccountID)
- }
- // Pinned so a later edit cannot populate it and split rows against the other producer.
- if props.AvailabilityZone != "" {
- t.Errorf("AvailabilityZone = %q, want empty", props.AvailabilityZone)
- }
- }
- // A month total is divided by a divisor derived from asOf, so the resulting flat daily rate is only
- // self-consistent if every day it covers is written in the same pass. The ingestor's Put replaces
- // whole day-sets, so emitting only the days inside the caller's window leaves the rest of the month
- // holding rates computed at an earlier asOf, and the stored month sums to neither total.
- //
- // The integration already fetches the whole month from IBM regardless of the window, so covering
- // every prorated day costs no extra API calls.
- func TestCloudCostsFromInstanceCoversWholeProratedMonth(t *testing.T) {
- // Stock ingestor shape: a 7-day window that does not reach the first of the month.
- start := time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC)
- end := time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
- asOf := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC)
- item := instanceUsageRecord{
- AccountID: "acct",
- ResourceInstanceID: "inst",
- ResourceID: "is.instance",
- Month: "2026-10",
- Cost: 110.0,
- RatedCost: 110.0,
- }
- costs := cloudCostsFromInstance(item, start, end, asOf)
- // asOf is the 11th, so the month-to-date total spreads over days 1..11.
- if len(costs) != 11 {
- t.Fatalf("got %d costs, want 11 (every prorated day of the month)", len(costs))
- }
- var total float64
- for _, cc := range costs {
- total += cc.NetCost.Cost
- }
- if diff := total - item.Cost; diff > 1e-9 || diff < -1e-9 {
- t.Errorf("emitted total = %v, want %v (IBM's reported month total)", total, item.Cost)
- }
- // First emitted day must be the first of the month, not the window start.
- if got := costs[0].Window.Start().UTC(); !got.Equal(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)) {
- t.Errorf("first day = %s, want 2026-10-01", got.Format("2006-01-02"))
- }
- }
|