costintegration.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. package stackit
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "time"
  7. "github.com/opencost/opencost/core/pkg/log"
  8. "github.com/opencost/opencost/core/pkg/opencost"
  9. "github.com/opencost/opencost/pkg/cloud"
  10. "github.com/stackitcloud/stackit-sdk-go/core/config"
  11. cost "github.com/stackitcloud/stackit-sdk-go/services/cost/v3api"
  12. )
  13. type CostIntegration struct {
  14. CostConfiguration
  15. ConnectionStatus cloud.ConnectionStatus
  16. }
  17. func (ci *CostIntegration) GetCloudCost(start time.Time, end time.Time) (*opencost.CloudCostSetRange, error) {
  18. var opts []config.ConfigurationOption
  19. if ci.ServiceAccountKeyPath != "" {
  20. opts = append(opts, config.WithServiceAccountKeyPath(ci.ServiceAccountKeyPath))
  21. }
  22. client, err := cost.NewAPIClient(opts...)
  23. if err != nil {
  24. ci.ConnectionStatus = cloud.FailedConnection
  25. return nil, fmt.Errorf("creating STACKIT cost API client: %w", err)
  26. }
  27. fromStr := start.Format("2006-01-02")
  28. // STACKIT Cost API uses inclusive end dates; OpenCost windows are end-exclusive,
  29. // so subtract one day to align.
  30. toStr := end.AddDate(0, 0, -1).Format("2006-01-02")
  31. resp, err := client.DefaultAPI.
  32. GetCostsForProject(context.Background(), ci.CustomerAccountID, ci.ProjectID).
  33. From(fromStr).
  34. To(toStr).
  35. Depth("service").
  36. Granularity("daily").
  37. Execute()
  38. if err != nil {
  39. ci.ConnectionStatus = cloud.FailedConnection
  40. return nil, fmt.Errorf("querying STACKIT costs: %w", err)
  41. }
  42. ccsr, err := opencost.NewCloudCostSetRange(start, end, opencost.AccumulateOptionDay, ci.Key())
  43. if err != nil {
  44. return nil, err
  45. }
  46. if resp == nil || resp.ProjectCostWithDetailedServices == nil {
  47. if ci.ConnectionStatus != cloud.SuccessfulConnection {
  48. ci.ConnectionStatus = cloud.MissingData
  49. }
  50. return ccsr, nil
  51. }
  52. detailed := resp.ProjectCostWithDetailedServices
  53. for _, svc := range detailed.GetServices() {
  54. serviceName := svc.GetServiceName()
  55. category := selectSTACKITCategory(serviceName)
  56. sku := svc.GetSku()
  57. regionID := extractRegionFromServiceName(serviceName)
  58. reportData := svc.GetReportData()
  59. if len(reportData) == 0 {
  60. // No daily granularity data; use total charge
  61. totalChargeCents := svc.GetTotalCharge()
  62. totalDiscountCents := svc.GetTotalDiscount()
  63. totalCharge := totalChargeCents / 100.0
  64. totalDiscount := totalDiscountCents / 100.0
  65. netCost := totalCharge
  66. properties := &opencost.CloudCostProperties{
  67. Provider: opencost.STACKITProvider,
  68. AccountID: ci.CustomerAccountID,
  69. InvoiceEntityID: ci.CustomerAccountID,
  70. RegionID: regionID,
  71. Service: serviceName,
  72. Category: category,
  73. ProviderID: sku,
  74. Labels: opencost.CloudCostLabels{},
  75. }
  76. listCost := totalCharge + totalDiscount
  77. cc := &opencost.CloudCost{
  78. Properties: properties,
  79. Window: opencost.NewWindow(&start, &end),
  80. ListCost: opencost.CostMetric{
  81. Cost: listCost,
  82. },
  83. NetCost: opencost.CostMetric{
  84. Cost: netCost,
  85. },
  86. AmortizedNetCost: opencost.CostMetric{
  87. Cost: netCost,
  88. },
  89. AmortizedCost: opencost.CostMetric{
  90. Cost: listCost,
  91. },
  92. InvoicedCost: opencost.CostMetric{
  93. Cost: netCost,
  94. },
  95. }
  96. ccsr.LoadCloudCost(cc)
  97. continue
  98. }
  99. for _, rd := range reportData {
  100. chargeCents := rd.GetCharge()
  101. discountCents := rd.GetDiscount()
  102. charge := chargeCents / 100.0
  103. discount := discountCents / 100.0
  104. tp := rd.GetTimePeriod()
  105. periodStart, periodEnd := parsePeriod(tp.GetStart(), tp.GetEnd(), start, end)
  106. properties := &opencost.CloudCostProperties{
  107. Provider: opencost.STACKITProvider,
  108. AccountID: ci.CustomerAccountID,
  109. InvoiceEntityID: ci.CustomerAccountID,
  110. RegionID: regionID,
  111. Service: serviceName,
  112. Category: category,
  113. ProviderID: sku,
  114. Labels: opencost.CloudCostLabels{},
  115. }
  116. listCost := charge + discount
  117. cc := &opencost.CloudCost{
  118. Properties: properties,
  119. Window: opencost.NewWindow(&periodStart, &periodEnd),
  120. ListCost: opencost.CostMetric{
  121. Cost: listCost,
  122. },
  123. NetCost: opencost.CostMetric{
  124. Cost: charge,
  125. },
  126. AmortizedNetCost: opencost.CostMetric{
  127. Cost: charge,
  128. },
  129. AmortizedCost: opencost.CostMetric{
  130. Cost: listCost,
  131. },
  132. InvoicedCost: opencost.CostMetric{
  133. Cost: charge,
  134. },
  135. }
  136. ccsr.LoadCloudCost(cc)
  137. }
  138. }
  139. ci.ConnectionStatus = cloud.SuccessfulConnection
  140. return ccsr, nil
  141. }
  142. // parsePeriod parses start/end date strings from the STACKIT API, falling back to the given defaults.
  143. func parsePeriod(startStr, endStr string, defaultStart, defaultEnd time.Time) (time.Time, time.Time) {
  144. periodStart := defaultStart
  145. periodEnd := defaultEnd
  146. if startStr != "" {
  147. if t, err := time.Parse("2006-01-02", startStr); err == nil {
  148. periodStart = t
  149. } else if t, err := time.Parse(time.RFC3339, startStr); err == nil {
  150. periodStart = t
  151. }
  152. }
  153. if endStr != "" {
  154. if t, err := time.Parse("2006-01-02", endStr); err == nil {
  155. // End date is inclusive in the API, add one day for the window
  156. periodEnd = t.AddDate(0, 0, 1)
  157. } else if t, err := time.Parse(time.RFC3339, endStr); err == nil {
  158. periodEnd = t
  159. }
  160. }
  161. return periodStart, periodEnd
  162. }
  163. func (ci *CostIntegration) GetStatus() cloud.ConnectionStatus {
  164. if ci.ConnectionStatus.String() == "" {
  165. ci.ConnectionStatus = cloud.InitialStatus
  166. }
  167. return ci.ConnectionStatus
  168. }
  169. func (ci *CostIntegration) RefreshStatus() cloud.ConnectionStatus {
  170. log.Warn("status refresh is not supported for the STACKIT provider")
  171. return ci.ConnectionStatus
  172. }
  173. // extractRegionFromServiceName extracts the region suffix from a STACKIT Cost API
  174. // service name (e.g. "Tiny Server-t1.2-EU01" -> "eu01").
  175. func extractRegionFromServiceName(serviceName string) string {
  176. idx := strings.LastIndex(serviceName, "-")
  177. if idx >= 0 {
  178. suffix := strings.ToLower(serviceName[idx+1:])
  179. if strings.HasPrefix(suffix, "eu") || strings.HasPrefix(suffix, "us") {
  180. return suffix
  181. }
  182. }
  183. return "eu01"
  184. }
  185. func selectSTACKITCategory(serviceName string) string {
  186. lower := strings.ToLower(serviceName)
  187. switch {
  188. case strings.Contains(lower, "compute") || strings.Contains(lower, "server") || strings.Contains(lower, "ske"):
  189. return opencost.ComputeCategory
  190. case strings.Contains(lower, "storage") || strings.Contains(lower, "object store") || strings.Contains(lower, "backup"):
  191. return opencost.StorageCategory
  192. case strings.Contains(lower, "network") || strings.Contains(lower, "load balancer") || strings.Contains(lower, "dns"):
  193. return opencost.NetworkCategory
  194. default:
  195. return opencost.OtherCategory
  196. }
  197. }