usageintegration_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. package ibm
  2. import (
  3. "testing"
  4. "time"
  5. "github.com/IBM/platform-services-go-sdk/usagereportsv4"
  6. "github.com/opencost/opencost/core/pkg/opencost"
  7. )
  8. func TestMonthsOverlapping(t *testing.T) {
  9. start := time.Date(2026, 1, 28, 0, 0, 0, 0, time.UTC)
  10. end := time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)
  11. got := monthsOverlapping(start, end)
  12. want := []string{"2026-01", "2026-02", "2026-03"}
  13. if len(got) != len(want) {
  14. t.Fatalf("monthsOverlapping len = %d, want %d (%v)", len(got), len(want), got)
  15. }
  16. for i := range want {
  17. if got[i] != want[i] {
  18. t.Errorf("monthsOverlapping[%d] = %q, want %q", i, got[i], want[i])
  19. }
  20. }
  21. }
  22. func TestMonthsOverlappingSameMonth(t *testing.T) {
  23. start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  24. end := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC)
  25. got := monthsOverlapping(start, end)
  26. if len(got) != 1 || got[0] != "2026-02" {
  27. t.Errorf("got %v, want [2026-02]", got)
  28. }
  29. }
  30. func TestDaysInMonth(t *testing.T) {
  31. if got := daysInMonth(2026, 2); got != 28 {
  32. t.Errorf("Feb 2026 days = %d, want 28", got)
  33. }
  34. if got := daysInMonth(2024, 2); got != 29 {
  35. t.Errorf("Feb 2024 days = %d, want 29", got)
  36. }
  37. if got := daysInMonth(2026, 1); got != 31 {
  38. t.Errorf("Jan 2026 days = %d, want 31", got)
  39. }
  40. }
  41. func TestProrationDays(t *testing.T) {
  42. monthStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  43. if got := prorationDays(monthStart, time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)); got != 31 {
  44. t.Errorf("completed month = %d, want 31", got)
  45. }
  46. if got := prorationDays(monthStart, time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)); got != 5 {
  47. t.Errorf("MTD day 5 = %d, want 5", got)
  48. }
  49. if got := prorationDays(monthStart, time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)); got != 31 {
  50. t.Errorf("last day of month = %d, want 31", got)
  51. }
  52. }
  53. func TestSelectIBMCategory(t *testing.T) {
  54. tests := []struct {
  55. service string
  56. want string
  57. }{
  58. {"is.instance", opencost.ComputeCategory},
  59. {"is.bare-metal-server", opencost.ComputeCategory},
  60. {"is.dedicated-host", opencost.ComputeCategory},
  61. {"codeengine", opencost.ComputeCategory},
  62. {"containers-kubernetes", opencost.ComputeCategory},
  63. {"is.volume", opencost.StorageCategory},
  64. {"is.snapshot", opencost.StorageCategory},
  65. {"is.share", opencost.StorageCategory},
  66. {"cloud-object-storage", opencost.StorageCategory},
  67. {"databases-for-postgresql", opencost.StorageCategory},
  68. {"databases-for-redis", opencost.StorageCategory},
  69. {"is.load-balancer", opencost.NetworkCategory},
  70. {"is.floating-ip", opencost.NetworkCategory},
  71. {"is.public-gateway", opencost.NetworkCategory},
  72. {"is.vpn", opencost.NetworkCategory},
  73. {"transit", opencost.NetworkCategory},
  74. {"internet-svcs", opencost.NetworkCategory},
  75. // Prefix arms removed: these must not silently widen beyond the locked table.
  76. {"is.vpn-server", opencost.OtherCategory},
  77. {"transit-gateway", opencost.OtherCategory},
  78. {"codeengine-app", opencost.OtherCategory},
  79. {"is.vpc", opencost.OtherCategory},
  80. {"dns", opencost.OtherCategory},
  81. {"cdn", opencost.OtherCategory},
  82. {"", opencost.OtherCategory},
  83. {"unknown-service", opencost.OtherCategory},
  84. }
  85. for _, tt := range tests {
  86. if got := selectIBMCategory(tt.service); got != tt.want {
  87. t.Errorf("selectIBMCategory(%q) = %q, want %q", tt.service, got, tt.want)
  88. }
  89. }
  90. }
  91. func TestParseTags(t *testing.T) {
  92. tags := parseTags([]any{
  93. "env:prod",
  94. "staging",
  95. map[string]any{"key": "owner", "value": "platform"},
  96. map[string]any{"Key": "team", "Value": "sre"},
  97. map[string]any{"key": "empty", "value": ""},
  98. })
  99. if tags["env"] != "prod" {
  100. t.Errorf("env tag = %q, want prod", tags["env"])
  101. }
  102. if tags["owner"] != "platform" {
  103. t.Errorf("owner tag = %q, want platform", tags["owner"])
  104. }
  105. if tags["team"] != "sre" {
  106. t.Errorf("team tag = %q, want sre", tags["team"])
  107. }
  108. if _, ok := tags["staging"]; ok {
  109. t.Error("non key:value string tag should be omitted")
  110. }
  111. if _, ok := tags["empty"]; ok {
  112. t.Error("empty tag value should be omitted")
  113. }
  114. }
  115. func TestInstanceUsageFromSDK_SkipsNonBillable(t *testing.T) {
  116. billable := false
  117. cost := 10.0
  118. item := usagereportsv4.InstanceUsage{
  119. Billable: &billable,
  120. Usage: []usagereportsv4.Metric{
  121. {Cost: &cost, RatedCost: &cost},
  122. },
  123. }
  124. if _, ok := instanceUsageFromSDK(item); ok {
  125. t.Fatal("expected non-billable instance to be skipped")
  126. }
  127. }
  128. func TestInstanceUsageFromSDK_CurrencyConversion(t *testing.T) {
  129. billable := true
  130. cost := 10.0
  131. rated := 20.0
  132. rate := 1.5
  133. month := "2026-01"
  134. account := "acct"
  135. resourceID := "is.instance"
  136. item := usagereportsv4.InstanceUsage{
  137. Billable: &billable,
  138. AccountID: &account,
  139. ResourceID: &resourceID,
  140. Month: &month,
  141. CurrencyRate: &rate,
  142. Usage: []usagereportsv4.Metric{
  143. {Cost: &cost, RatedCost: &rated},
  144. },
  145. Tags: []any{"env:test"},
  146. ServiceTags: []any{"svc:iks"},
  147. }
  148. record, ok := instanceUsageFromSDK(item)
  149. if !ok {
  150. t.Fatal("expected billable instance")
  151. }
  152. if record.Cost != 15.0 {
  153. t.Errorf("Cost = %v, want 15", record.Cost)
  154. }
  155. if record.RatedCost != 30.0 {
  156. t.Errorf("RatedCost = %v, want 30", record.RatedCost)
  157. }
  158. labels := parseTags(record.Tags)
  159. if labels["env"] != "test" || labels["svc"] != "iks" {
  160. t.Errorf("merged tags = %#v", labels)
  161. }
  162. }
  163. func TestInstanceUsageFromSDK_NormalizesPrefixedAccountID(t *testing.T) {
  164. billable := true
  165. month := "2026-01"
  166. account := "a/b09edf5642ebfad587c594f4d4a354b0"
  167. resourceID := "is.instance"
  168. cost := 1.0
  169. item := usagereportsv4.InstanceUsage{
  170. Billable: &billable,
  171. AccountID: &account,
  172. ResourceID: &resourceID,
  173. Month: &month,
  174. Usage: []usagereportsv4.Metric{
  175. {Cost: &cost, RatedCost: &cost},
  176. },
  177. }
  178. record, ok := instanceUsageFromSDK(item)
  179. if !ok {
  180. t.Fatal("expected billable instance")
  181. }
  182. if record.AccountID != "b09edf5642ebfad587c594f4d4a354b0" {
  183. t.Errorf("AccountID = %q, want bare hex", record.AccountID)
  184. }
  185. }
  186. func TestInstanceUsageFromSDK_SkipsNonChargeableMetrics(t *testing.T) {
  187. billable := true
  188. cost := 5.0
  189. rated := 5.0
  190. info := true
  191. month := "2026-01"
  192. item := usagereportsv4.InstanceUsage{
  193. Billable: &billable,
  194. Month: &month,
  195. Usage: []usagereportsv4.Metric{
  196. {Cost: &cost, RatedCost: &rated, NonChargeable: &info},
  197. {Cost: &cost, RatedCost: &rated},
  198. },
  199. }
  200. record, ok := instanceUsageFromSDK(item)
  201. if !ok {
  202. t.Fatal("expected billable instance")
  203. }
  204. if record.Cost != 5.0 || record.RatedCost != 5.0 {
  205. t.Errorf("got cost=%v rated=%v, want 5/5", record.Cost, record.RatedCost)
  206. }
  207. }
  208. func TestCloudCostsFromInstance(t *testing.T) {
  209. start := time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC)
  210. end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
  211. asOf := time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)
  212. item := instanceUsageRecord{
  213. AccountID: "b09edf5642ebfad587c594f4d4a354b0",
  214. ResourceInstanceID: "crn:v1:bluemix:public:containers-kubernetes:us-south:a/b09edf5642ebfad587c594f4d4a354b0:8042b2a8af6a4a5cbf6dbe09e07311d2:worker:kube-w1",
  215. ResourceID: "containers-kubernetes",
  216. ResourceName: "IBM Cloud Kubernetes Service",
  217. Region: "us-south",
  218. Month: "2026-01",
  219. Cost: 31.0,
  220. RatedCost: 62.0,
  221. Tags: []any{"team:sre"},
  222. }
  223. costs := cloudCostsFromInstance(item, start, end, asOf)
  224. // Every day the month total was prorated across is emitted, not just the days inside the
  225. // window, so the whole of the completed January is returned.
  226. if len(costs) != 31 {
  227. t.Fatalf("got %d cloud costs, want 31 (whole prorated January)", len(costs))
  228. }
  229. dailyNet := 31.0 / 31.0
  230. dailyList := 62.0 / 31.0
  231. for _, cc := range costs {
  232. if cc.Properties.Provider != opencost.IBMProvider {
  233. t.Errorf("provider = %q", cc.Properties.Provider)
  234. }
  235. if cc.Properties.AccountID != "b09edf5642ebfad587c594f4d4a354b0" {
  236. t.Errorf("account = %q", cc.Properties.AccountID)
  237. }
  238. if cc.Properties.InvoiceEntityID != cc.Properties.AccountID {
  239. t.Errorf("InvoiceEntityID = %q, want AccountID", cc.Properties.InvoiceEntityID)
  240. }
  241. if cc.Properties.Service != "containers-kubernetes" {
  242. t.Errorf("service = %q, want ResourceID", cc.Properties.Service)
  243. }
  244. if cc.Properties.Category != opencost.ComputeCategory {
  245. t.Errorf("category = %q", cc.Properties.Category)
  246. }
  247. if cc.Properties.Labels["team"] != "sre" {
  248. t.Errorf("labels = %#v", cc.Properties.Labels)
  249. }
  250. if cc.Properties.Labels["ibm_resource_name"] != "IBM Cloud Kubernetes Service" {
  251. t.Errorf("ibm_resource_name = %q", cc.Properties.Labels["ibm_resource_name"])
  252. }
  253. if cc.NetCost.Cost != dailyNet {
  254. t.Errorf("net cost = %v, want %v", cc.NetCost.Cost, dailyNet)
  255. }
  256. if cc.ListCost.Cost != dailyList {
  257. t.Errorf("list cost = %v, want %v", cc.ListCost.Cost, dailyList)
  258. }
  259. if cc.AmortizedCost.Cost != dailyList {
  260. t.Errorf("amortized cost = %v, want list/rated %v", cc.AmortizedCost.Cost, dailyList)
  261. }
  262. }
  263. }
  264. func TestCloudCostsFromInstance_RatedCostZeroKept(t *testing.T) {
  265. start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  266. end := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
  267. asOf := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  268. item := instanceUsageRecord{
  269. AccountID: "acct",
  270. ResourceInstanceID: "inst",
  271. ResourceID: "is.instance",
  272. Month: "2026-01",
  273. Cost: 31.0,
  274. RatedCost: 0,
  275. }
  276. costs := cloudCostsFromInstance(item, start, end, asOf)
  277. if len(costs) != 31 {
  278. t.Fatalf("got %d costs, want 31 (whole prorated month)", len(costs))
  279. }
  280. if costs[0].ListCost.Cost != 0 {
  281. t.Errorf("ListCost = %v, want 0 (rated_cost zero is valid)", costs[0].ListCost.Cost)
  282. }
  283. if costs[0].NetCost.Cost != 1.0 {
  284. t.Errorf("NetCost = %v, want 1", costs[0].NetCost.Cost)
  285. }
  286. }
  287. func TestCloudCostsFromInstance_MTDProration(t *testing.T) {
  288. start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  289. end := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
  290. asOf := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
  291. item := instanceUsageRecord{
  292. AccountID: "acct",
  293. ResourceInstanceID: "inst",
  294. ResourceID: "is.instance",
  295. Month: "2026-01",
  296. Cost: 50.0,
  297. RatedCost: 50.0,
  298. }
  299. costs := cloudCostsFromInstance(item, start, end, asOf)
  300. if len(costs) != 5 {
  301. t.Fatalf("got %d costs, want 5 MTD days", len(costs))
  302. }
  303. if costs[0].NetCost.Cost != 10.0 {
  304. t.Errorf("daily net = %v, want 10 (50/5)", costs[0].NetCost.Cost)
  305. }
  306. }
  307. func TestServiceUsesResourceID(t *testing.T) {
  308. item := instanceUsageRecord{
  309. AccountID: "acct",
  310. ResourceInstanceID: "inst",
  311. ResourceID: "is.instance",
  312. ResourceName: "Virtual Server for VPC",
  313. Month: "2026-02",
  314. Cost: 28.0,
  315. RatedCost: 28.0,
  316. }
  317. start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  318. end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
  319. asOf := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  320. costs := cloudCostsFromInstance(item, start, end, asOf)
  321. if len(costs) != 28 {
  322. t.Fatalf("got %d costs, want 28 (whole prorated February)", len(costs))
  323. }
  324. if costs[0].Properties.Service != "is.instance" {
  325. t.Errorf("service = %q, want is.instance", costs[0].Properties.Service)
  326. }
  327. if costs[0].Properties.Labels["ibm_resource_name"] != "Virtual Server for VPC" {
  328. t.Errorf("display name label = %q", costs[0].Properties.Labels["ibm_resource_name"])
  329. }
  330. }
  331. func TestNormalizeAccountID(t *testing.T) {
  332. tests := []struct {
  333. in, want string
  334. }{
  335. {"b09edf5642ebfad587c594f4d4a354b0", "b09edf5642ebfad587c594f4d4a354b0"},
  336. {"a/b09edf5642ebfad587c594f4d4a354b0", "b09edf5642ebfad587c594f4d4a354b0"},
  337. {" a/acct ", "acct"},
  338. {"", ""},
  339. }
  340. for _, tt := range tests {
  341. if got := normalizeAccountID(tt.in); got != tt.want {
  342. t.Errorf("normalizeAccountID(%q) = %q, want %q", tt.in, got, tt.want)
  343. }
  344. }
  345. }
  346. func TestUsageConfigurationKeySanitizesSlash(t *testing.T) {
  347. cfg := &UsageConfiguration{AccountID: "a/b09edf5642ebfad587c594f4d4a354b0"}
  348. if got := cfg.Key(); got != "b09edf5642ebfad587c594f4d4a354b0" {
  349. t.Errorf("Key() = %q, want bare hex", got)
  350. }
  351. }
  352. // The billing-export producer (cloudability/kubecost-saas, bingen/ibm) marks every IKS/ROKS row
  353. // as fully Kubernetes. Both producers write into the same aggregation, so if this path leaves
  354. // KubernetesPercent at zero, migrating a customer from the export to an API key silently zeroes
  355. // their entire IBM Kubernetes spend.
  356. func TestKubernetesPercentMatchesBillingExportProducer(t *testing.T) {
  357. const workerCRN = "crn:v1:bluemix:public:containers-kubernetes:jp-tok:a/f8ce6d5aa4cf4:" +
  358. "ch9goilt0elehhb3utbg:worker:kube-ch9goilt0elehhb3utbg-ccmdefault-0000017d"
  359. tests := []struct {
  360. name string
  361. resourceID string
  362. providerID string
  363. want float64
  364. }{
  365. {"kubernetes by service id", "containers-kubernetes", "crn:v1:bluemix:public:containers-kubernetes:us-south:a/x:y::", 1.0},
  366. {"kubernetes by crn service segment", "", workerCRN, 1.0},
  367. {"service id is case insensitive", "Containers-Kubernetes", "", 1.0},
  368. {"unrelated service", "is.volume", "crn:v1:bluemix:public:is:us-south:a/x::volume:r006-1", 0.0},
  369. }
  370. start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  371. end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
  372. asOf := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  373. for _, tt := range tests {
  374. tt := tt
  375. t.Run(tt.name, func(t *testing.T) {
  376. item := instanceUsageRecord{
  377. AccountID: "acct",
  378. ResourceInstanceID: tt.providerID,
  379. ResourceID: tt.resourceID,
  380. Month: "2026-02",
  381. Cost: 28.0,
  382. RatedCost: 56.0,
  383. }
  384. costs := cloudCostsFromInstance(item, start, end, asOf)
  385. if len(costs) != 28 {
  386. t.Fatalf("got %d costs, want 28 (whole prorated February)", len(costs))
  387. }
  388. cc := costs[0]
  389. for metric, got := range map[string]float64{
  390. "ListCost": cc.ListCost.KubernetesPercent,
  391. "NetCost": cc.NetCost.KubernetesPercent,
  392. "AmortizedNetCost": cc.AmortizedNetCost.KubernetesPercent,
  393. "AmortizedCost": cc.AmortizedCost.KubernetesPercent,
  394. "InvoicedCost": cc.InvoicedCost.KubernetesPercent,
  395. } {
  396. if got != tt.want {
  397. t.Errorf("%s.KubernetesPercent = %v, want %v", metric, got, tt.want)
  398. }
  399. }
  400. })
  401. }
  402. }
  403. // bingen/ibm sets AccountName and InvoiceEntityName to the same normalized account id, because IBM
  404. // billing data carries no account display name. Both are user-selectable aggregation properties, so
  405. // leaving them empty here would split rows when a customer switches producers.
  406. func TestAccountNamesMatchBillingExportProducer(t *testing.T) {
  407. item := instanceUsageRecord{
  408. AccountID: "4756fbd48cbc4b6d968340888c27fd05",
  409. ResourceInstanceID: "inst",
  410. ResourceID: "is.instance",
  411. Month: "2026-02",
  412. Cost: 28.0,
  413. RatedCost: 28.0,
  414. }
  415. start := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
  416. end := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)
  417. asOf := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
  418. costs := cloudCostsFromInstance(item, start, end, asOf)
  419. if len(costs) != 28 {
  420. t.Fatalf("got %d costs, want 28 (whole prorated February)", len(costs))
  421. }
  422. props := costs[0].Properties
  423. if props.AccountName != item.AccountID {
  424. t.Errorf("AccountName = %q, want %q", props.AccountName, item.AccountID)
  425. }
  426. if props.InvoiceEntityName != item.AccountID {
  427. t.Errorf("InvoiceEntityName = %q, want %q", props.InvoiceEntityName, item.AccountID)
  428. }
  429. // Pinned so a later edit cannot populate it and split rows against the other producer.
  430. if props.AvailabilityZone != "" {
  431. t.Errorf("AvailabilityZone = %q, want empty", props.AvailabilityZone)
  432. }
  433. }
  434. // A month total is divided by a divisor derived from asOf, so the resulting flat daily rate is only
  435. // self-consistent if every day it covers is written in the same pass. The ingestor's Put replaces
  436. // whole day-sets, so emitting only the days inside the caller's window leaves the rest of the month
  437. // holding rates computed at an earlier asOf, and the stored month sums to neither total.
  438. //
  439. // The integration already fetches the whole month from IBM regardless of the window, so covering
  440. // every prorated day costs no extra API calls.
  441. func TestCloudCostsFromInstanceCoversWholeProratedMonth(t *testing.T) {
  442. // Stock ingestor shape: a 7-day window that does not reach the first of the month.
  443. start := time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC)
  444. end := time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
  445. asOf := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC)
  446. item := instanceUsageRecord{
  447. AccountID: "acct",
  448. ResourceInstanceID: "inst",
  449. ResourceID: "is.instance",
  450. Month: "2026-10",
  451. Cost: 110.0,
  452. RatedCost: 110.0,
  453. }
  454. costs := cloudCostsFromInstance(item, start, end, asOf)
  455. // asOf is the 11th, so the month-to-date total spreads over days 1..11.
  456. if len(costs) != 11 {
  457. t.Fatalf("got %d costs, want 11 (every prorated day of the month)", len(costs))
  458. }
  459. var total float64
  460. for _, cc := range costs {
  461. total += cc.NetCost.Cost
  462. }
  463. if diff := total - item.Cost; diff > 1e-9 || diff < -1e-9 {
  464. t.Errorf("emitted total = %v, want %v (IBM's reported month total)", total, item.Cost)
  465. }
  466. // First emitted day must be the first of the month, not the window start.
  467. if got := costs[0].Window.Start().UTC(); !got.Equal(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)) {
  468. t.Errorf("first day = %s, want 2026-10-01", got.Format("2006-01-02"))
  469. }
  470. }