usageintegration.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. package ibm
  2. import (
  3. "fmt"
  4. "strings"
  5. "time"
  6. "github.com/IBM/platform-services-go-sdk/usagereportsv4"
  7. "github.com/opencost/opencost/core/pkg/log"
  8. "github.com/opencost/opencost/core/pkg/opencost"
  9. "github.com/opencost/opencost/pkg/cloud"
  10. )
  11. // Field-mapping contract for IBM CloudCost (shared with Cloudability CAC path):
  12. //
  13. // Provider = "IBM"
  14. // ProviderID = ResourceInstanceID from Usage Reports (typically a full CRN)
  15. // AccountID = account_id normalized to bare 32-hex (strip leading "a/" if present)
  16. // InvoiceEntityID = AccountID (no payer/enterprise column in row data)
  17. // Service = ResourceID (stable service id — not the display name)
  18. // Category = selectIBMCategory(ResourceID) only — pure function of service id
  19. // UsageType = N/A on CloudCostProperties in this tree (and must remain unset if added later)
  20. // ListCost / AmortizedCost = sum(rated_cost) converted to USD, prorated
  21. // NetCost / AmortizedNetCost / InvoicedCost = sum(cost) converted to USD, prorated
  22. //
  23. // ResourceName (when names=true) is stored as label "ibm_resource_name", not Service.
  24. // Daily values are synthetic rather than true per-day usage: report totals ÷ covered days
  25. // (full month, or MTD day-of-month). Each refresh rewrites every covered day of each touched
  26. // month together so the stored month reconciles to IBM's report even when the ingestor window
  27. // starts mid-month. Non-billable instances are skipped.
  28. // UsageIntegration ingests IBM Cloud Usage Reports into CloudCost.
  29. type UsageIntegration struct {
  30. UsageConfiguration
  31. ConnectionStatus cloud.ConnectionStatus
  32. clientFactory func() (*usagereportsv4.UsageReportsV4, error)
  33. }
  34. func (ui *UsageIntegration) GetCloudCost(start, end time.Time) (*opencost.CloudCostSetRange, error) {
  35. return ui.getCloudCost(start, end, time.Now().UTC())
  36. }
  37. func (ui *UsageIntegration) getCloudCost(start, end, asOf time.Time) (*opencost.CloudCostSetRange, error) {
  38. client, err := ui.usageReportsClient()
  39. if err != nil {
  40. ui.ConnectionStatus = cloud.FailedConnection
  41. return nil, fmt.Errorf("getting IBM usage reports client: %w", err)
  42. }
  43. // Cover whole months: cloudCostsFromInstance prorates a month total across every covered day,
  44. // so all of those days have to be rewritten together for the stored month to equal IBM's total.
  45. rangeStart, rangeEnd := monthRangeCovering(start, end)
  46. ccsr, err := opencost.NewCloudCostSetRange(rangeStart, rangeEnd, opencost.AccumulateOptionDay, ui.Key())
  47. if err != nil {
  48. return nil, err
  49. }
  50. months := monthsOverlapping(start, end)
  51. itemsSeen := 0
  52. for _, month := range months {
  53. options := client.NewGetResourceUsageAccountOptions(normalizeAccountID(ui.AccountID), month)
  54. options.SetLimit(200)
  55. // names=true populates ResourceName for the ibm_resource_name label only;
  56. // Service always keys on ResourceID for CAC / billing-export agreement.
  57. options.SetNames(true)
  58. options.SetTags(true)
  59. pager, err := client.NewGetResourceUsageAccountPager(options)
  60. if err != nil {
  61. ui.ConnectionStatus = cloud.FailedConnection
  62. return nil, fmt.Errorf("creating usage pager for %s: %w", month, err)
  63. }
  64. for pager.HasNext() {
  65. page, err := pager.GetNext()
  66. if err != nil {
  67. ui.ConnectionStatus = cloud.FailedConnection
  68. return nil, fmt.Errorf("querying IBM resource usage for %s: %w", month, err)
  69. }
  70. for _, item := range page {
  71. itemsSeen++
  72. record, ok := instanceUsageFromSDK(item)
  73. if !ok {
  74. continue
  75. }
  76. for _, cc := range cloudCostsFromInstance(record, start, end, asOf) {
  77. ccsr.LoadCloudCost(cc)
  78. }
  79. }
  80. }
  81. }
  82. if itemsSeen == 0 && ui.ConnectionStatus != cloud.SuccessfulConnection {
  83. ui.ConnectionStatus = cloud.MissingData
  84. return ccsr, nil
  85. }
  86. ui.ConnectionStatus = cloud.SuccessfulConnection
  87. return ccsr, nil
  88. }
  89. func (ui *UsageIntegration) usageReportsClient() (*usagereportsv4.UsageReportsV4, error) {
  90. if ui.clientFactory != nil {
  91. return ui.clientFactory()
  92. }
  93. return ui.GetUsageReportsClient()
  94. }
  95. func (ui *UsageIntegration) GetStatus() cloud.ConnectionStatus {
  96. if ui.ConnectionStatus.String() == "" {
  97. ui.ConnectionStatus = cloud.InitialStatus
  98. }
  99. return ui.ConnectionStatus
  100. }
  101. func (ui *UsageIntegration) RefreshStatus() cloud.ConnectionStatus {
  102. log.Warn("status refresh is not supported for the IBM Cloud provider")
  103. return ui.ConnectionStatus
  104. }
  105. // instanceUsageRecord is a testable projection of Usage Reports instance usage.
  106. // Costs are stored after conversion to USD.
  107. type instanceUsageRecord struct {
  108. AccountID string
  109. ResourceInstanceID string
  110. ResourceID string
  111. ResourceName string
  112. Region string
  113. Month string
  114. Cost float64
  115. RatedCost float64
  116. Tags []any
  117. }
  118. // instanceUsageFromSDK projects an SDK row. ok is false when the row should be skipped
  119. // (explicitly non-billable).
  120. func instanceUsageFromSDK(item usagereportsv4.InstanceUsage) (instanceUsageRecord, bool) {
  121. if item.Billable != nil && !*item.Billable {
  122. return instanceUsageRecord{}, false
  123. }
  124. record := instanceUsageRecord{
  125. Tags: mergeTagSlices(item.Tags, item.ServiceTags),
  126. }
  127. if item.AccountID != nil {
  128. record.AccountID = normalizeAccountID(*item.AccountID)
  129. }
  130. if item.ResourceInstanceID != nil {
  131. record.ResourceInstanceID = *item.ResourceInstanceID
  132. }
  133. if item.ResourceID != nil {
  134. record.ResourceID = *item.ResourceID
  135. }
  136. if item.ResourceName != nil {
  137. record.ResourceName = *item.ResourceName
  138. }
  139. if item.Region != nil {
  140. record.Region = *item.Region
  141. }
  142. if item.Month != nil {
  143. record.Month = *item.Month
  144. }
  145. rate := 1.0
  146. if item.CurrencyRate != nil && *item.CurrencyRate > 0 {
  147. rate = *item.CurrencyRate
  148. }
  149. for _, metric := range item.Usage {
  150. if metric.NonChargeable != nil && *metric.NonChargeable {
  151. continue
  152. }
  153. if metric.Cost != nil {
  154. record.Cost += *metric.Cost * rate
  155. }
  156. if metric.RatedCost != nil {
  157. record.RatedCost += *metric.RatedCost * rate
  158. }
  159. }
  160. return record, true
  161. }
  162. func mergeTagSlices(parts ...[]any) []any {
  163. var out []any
  164. for _, part := range parts {
  165. out = append(out, part...)
  166. }
  167. return out
  168. }
  169. func cloudCostsFromInstance(item instanceUsageRecord, start, end, asOf time.Time) []*opencost.CloudCost {
  170. if item.Month == "" || (item.Cost == 0 && item.RatedCost == 0) {
  171. return nil
  172. }
  173. monthStart, err := time.Parse("2006-01", item.Month)
  174. if err != nil {
  175. return nil
  176. }
  177. monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, time.UTC)
  178. days := prorationDays(monthStart, asOf)
  179. if days <= 0 {
  180. return nil
  181. }
  182. dailyNet := item.Cost / float64(days)
  183. dailyList := item.RatedCost / float64(days)
  184. labels := parseTags(item.Tags)
  185. if item.ResourceName != "" {
  186. labels["ibm_resource_name"] = item.ResourceName
  187. }
  188. properties := &opencost.CloudCostProperties{
  189. ProviderID: item.ResourceInstanceID,
  190. Provider: opencost.IBMProvider,
  191. AccountID: item.AccountID,
  192. // IBM billing data carries no account display name. The billing-export producer collapses
  193. // both names onto the account id; match it exactly or the two split rows on aggregation.
  194. AccountName: item.AccountID,
  195. InvoiceEntityID: item.AccountID,
  196. InvoiceEntityName: item.AccountID,
  197. RegionID: item.Region,
  198. Service: item.ResourceID,
  199. Category: selectIBMCategory(item.ResourceID),
  200. Labels: labels,
  201. }
  202. k8sPct := 0.0
  203. if isKubernetesResource(item.ResourceID, item.ResourceInstanceID) {
  204. k8sPct = 1.0
  205. }
  206. var costs []*opencost.CloudCost
  207. // Every day the total was divided across must be emitted, not just the days inside the caller's
  208. // window. The repository replaces whole day-sets, so a partial rewrite leaves the rest of the
  209. // month holding a rate computed at a different asOf and the stored month sums to neither total.
  210. // getCloudCost widens its range to the months covered here so all of these days are persisted.
  211. for d := 0; d < days; d++ {
  212. dayStart := monthStart.AddDate(0, 0, d)
  213. dayEnd := dayStart.AddDate(0, 0, 1)
  214. ds := dayStart
  215. de := dayEnd
  216. costs = append(costs, &opencost.CloudCost{
  217. Properties: properties,
  218. Window: opencost.NewWindow(&ds, &de),
  219. ListCost: opencost.CostMetric{
  220. Cost: dailyList,
  221. KubernetesPercent: k8sPct,
  222. },
  223. NetCost: opencost.CostMetric{
  224. Cost: dailyNet,
  225. KubernetesPercent: k8sPct,
  226. },
  227. AmortizedNetCost: opencost.CostMetric{
  228. Cost: dailyNet,
  229. KubernetesPercent: k8sPct,
  230. },
  231. AmortizedCost: opencost.CostMetric{
  232. Cost: dailyList,
  233. KubernetesPercent: k8sPct,
  234. },
  235. InvoicedCost: opencost.CostMetric{
  236. Cost: dailyNet,
  237. KubernetesPercent: k8sPct,
  238. },
  239. })
  240. }
  241. return costs
  242. }
  243. // kubernetesServiceID is IBM's service identifier for both IKS and ROKS clusters.
  244. const kubernetesServiceID = "containers-kubernetes"
  245. // isKubernetesResource reports whether a usage row belongs to an IKS or ROKS cluster, by service
  246. // identifier or by the service segment of the resource CRN. Mirrors the billing-export producer so
  247. // both paths mark the same rows as Kubernetes.
  248. func isKubernetesResource(serviceID, providerID string) bool {
  249. if strings.EqualFold(strings.TrimSpace(serviceID), kubernetesServiceID) {
  250. return true
  251. }
  252. return strings.Contains(strings.ToLower(providerID), ":"+kubernetesServiceID+":")
  253. }
  254. // prorationDays returns the divisor for spreading a monthly (or MTD) report total.
  255. // Completed months use calendar days; the asOf month uses day-of-month (MTD).
  256. func prorationDays(monthStart, asOf time.Time) int {
  257. asOf = asOf.UTC()
  258. monthStart = monthStart.UTC()
  259. full := daysInMonth(monthStart.Year(), int(monthStart.Month()))
  260. if asOf.Year() == monthStart.Year() && asOf.Month() == monthStart.Month() {
  261. if asOf.Day() < 1 {
  262. return full
  263. }
  264. if asOf.Day() < full {
  265. return asOf.Day()
  266. }
  267. }
  268. return full
  269. }
  270. // monthRangeCovering returns the half-open range spanning every whole month that [start, end)
  271. // touches. It mirrors monthsOverlapping's exclusive-end convention: an end landing exactly on a
  272. // month boundary does not pull in that month.
  273. func monthRangeCovering(start, end time.Time) (time.Time, time.Time) {
  274. rangeStart := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
  275. lastMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
  276. if end.Equal(lastMonth) {
  277. lastMonth = lastMonth.AddDate(0, -1, 0)
  278. }
  279. rangeEnd := lastMonth.AddDate(0, 1, 0)
  280. if !rangeStart.Before(rangeEnd) {
  281. return start, end
  282. }
  283. return rangeStart, rangeEnd
  284. }
  285. func monthsOverlapping(start, end time.Time) []string {
  286. if !start.Before(end) {
  287. return nil
  288. }
  289. cursor := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
  290. last := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
  291. // end is exclusive; if end is exactly month start, previous month is last needed
  292. if end.Equal(last) {
  293. last = last.AddDate(0, -1, 0)
  294. }
  295. var months []string
  296. for !cursor.After(last) {
  297. months = append(months, cursor.Format("2006-01"))
  298. cursor = cursor.AddDate(0, 1, 0)
  299. }
  300. return months
  301. }
  302. func daysInMonth(year, month int) int {
  303. start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
  304. return start.AddDate(0, 1, -1).Day()
  305. }
  306. func parseTags(raw []any) opencost.CloudCostLabels {
  307. labels := opencost.CloudCostLabels{}
  308. for _, tag := range raw {
  309. switch v := tag.(type) {
  310. case string:
  311. key, value, ok := splitTagString(v)
  312. if !ok {
  313. continue
  314. }
  315. labels[key] = value
  316. case map[string]any:
  317. key, _ := v["key"].(string)
  318. if key == "" {
  319. key, _ = v["Key"].(string)
  320. }
  321. value, _ := v["value"].(string)
  322. if value == "" {
  323. value, _ = v["Value"].(string)
  324. }
  325. if key == "" || value == "" {
  326. continue
  327. }
  328. labels[key] = value
  329. }
  330. }
  331. return labels
  332. }
  333. func splitTagString(tag string) (string, string, bool) {
  334. tag = strings.TrimSpace(tag)
  335. if tag == "" {
  336. return "", "", false
  337. }
  338. key, value, found := strings.Cut(tag, ":")
  339. key = strings.TrimSpace(key)
  340. value = strings.TrimSpace(value)
  341. if !found || key == "" || value == "" {
  342. return "", "", false
  343. }
  344. return key, value, true
  345. }
  346. // normalizeAccountID returns the bare IBM account GUID. Billing exports and CRNs
  347. // may carry "a/<hex>"; CloudCost AccountID must not contain "/" (aggregation key
  348. // and storage path). Published Usage Reports samples are already bare hex.
  349. func normalizeAccountID(accountID string) string {
  350. accountID = strings.TrimSpace(accountID)
  351. return strings.TrimPrefix(accountID, "a/")
  352. }
  353. // selectIBMCategory maps IBM Usage Reports resource_id (service id) to an OpenCost category.
  354. // Pure function of resourceID. Exact matches only, except the documented databases-for-* family.
  355. func selectIBMCategory(resourceID string) string {
  356. id := strings.ToLower(strings.TrimSpace(resourceID))
  357. switch id {
  358. case "is.instance",
  359. "is.bare-metal-server",
  360. "is.dedicated-host",
  361. "codeengine",
  362. "containers-kubernetes":
  363. return opencost.ComputeCategory
  364. case "is.volume",
  365. "is.snapshot",
  366. "is.share",
  367. "cloud-object-storage":
  368. return opencost.StorageCategory
  369. case "is.load-balancer",
  370. "is.floating-ip",
  371. "is.public-gateway",
  372. "is.vpn",
  373. "transit",
  374. "internet-svcs":
  375. return opencost.NetworkCategory
  376. }
  377. // Prefix family (shared with billing-export / CAC): all IBM Databases for X services.
  378. if strings.HasPrefix(id, "databases-for-") {
  379. return opencost.StorageCategory
  380. }
  381. return opencost.OtherCategory
  382. }