carbonassets.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. package carbon
  2. import (
  3. "embed"
  4. "encoding/csv"
  5. "strconv"
  6. "strings"
  7. "github.com/opencost/opencost/core/pkg/log"
  8. "github.com/opencost/opencost/core/pkg/opencost"
  9. "github.com/opencost/opencost/core/pkg/util"
  10. )
  11. //go:embed carbonlookupdata.csv
  12. var f embed.FS
  13. // averageRegionKey is the fallback region label used in the lookup CSV when a
  14. // specific (provider, region, instanceType) tuple cannot be matched.
  15. const averageRegionKey = "average-region"
  16. type carbonLookupKeyRegion struct {
  17. provider string
  18. region string
  19. }
  20. type carbonLookupKeyNode struct {
  21. provider string
  22. region string
  23. instanceType string
  24. }
  25. var (
  26. carbonLookupNode map[carbonLookupKeyNode]float64
  27. carbonLookupDisk map[carbonLookupKeyRegion]float64
  28. carbonLookupNetwork map[carbonLookupKeyRegion]float64
  29. )
  30. func init() {
  31. carbonData, err := f.ReadFile("carbonlookupdata.csv")
  32. if err != nil {
  33. log.Errorf("Error getting content of carbon lookup file: %s", err)
  34. return
  35. }
  36. reader := csv.NewReader(strings.NewReader(string(carbonData)))
  37. if _, err := reader.Read(); err != nil {
  38. log.Errorf("Error reading carbon lookup header: %s", err)
  39. return
  40. }
  41. rows, err := reader.ReadAll()
  42. if err != nil {
  43. log.Errorf("Error reading carbon lookup data: %s", err)
  44. return
  45. }
  46. carbonLookupNode = make(map[carbonLookupKeyNode]float64)
  47. carbonLookupDisk = make(map[carbonLookupKeyRegion]float64)
  48. carbonLookupNetwork = make(map[carbonLookupKeyRegion]float64)
  49. for _, row := range rows {
  50. // Skip blank records (e.g. a trailing newline in the CSV).
  51. if len(row) == 0 || (len(row) == 1 && strings.TrimSpace(row[0]) == "") {
  52. continue
  53. }
  54. if len(row) < 6 {
  55. log.Warnf("carbon: skipping malformed lookup row %v", row)
  56. continue
  57. }
  58. coeff, err := strconv.ParseFloat(row[5], 64)
  59. if err != nil {
  60. log.Warnf("carbon: skipping row with malformed carbon coefficient %q", row[5])
  61. continue
  62. }
  63. provider := row[0]
  64. region := row[1]
  65. instanceType := row[2]
  66. assetType := row[3]
  67. switch assetType {
  68. case "Node":
  69. carbonLookupNode[carbonLookupKeyNode{
  70. provider: provider,
  71. region: region,
  72. instanceType: instanceType,
  73. }] = coeff
  74. case "Disk":
  75. carbonLookupDisk[carbonLookupKeyRegion{
  76. provider: provider,
  77. region: region,
  78. }] = coeff
  79. case "Network":
  80. carbonLookupNetwork[carbonLookupKeyRegion{
  81. provider: provider,
  82. region: region,
  83. }] = coeff
  84. }
  85. }
  86. }
  87. type CarbonRow struct {
  88. Co2e float64 `json:"co2e"`
  89. }
  90. // RelateCarbonAssets returns an estimated CO2e value for each asset in the set.
  91. // The returned value is in metric tonnes of CO2e, consistent with the units of
  92. // the embedded lookup table (tonnes CO2e per hour of asset runtime).
  93. func RelateCarbonAssets(as *opencost.AssetSet) (map[string]CarbonRow, error) {
  94. res := make(map[string]CarbonRow, len(as.Assets))
  95. for key, asset := range as.Assets {
  96. coeff := lookupCarbonCoeff(asset)
  97. res[key] = CarbonRow{
  98. Co2e: coeff * asset.Minutes() / 60,
  99. }
  100. }
  101. return res, nil
  102. }
  103. // lookupCarbonCoeff resolves the carbon coefficient (tonnes CO2e per hour) for
  104. // the given asset, falling back to the provider-wide average-region value when
  105. // a specific region or instance type is not present in the lookup table.
  106. func lookupCarbonCoeff(asset opencost.Asset) float64 {
  107. props := asset.GetProperties()
  108. provider := resolveProvider(asset)
  109. if provider == "" {
  110. if isCarbonTrackedAsset(asset.Type()) {
  111. providerID := ""
  112. if props != nil {
  113. providerID = props.ProviderID
  114. }
  115. log.DedupedWarningf(10, "carbon: cannot infer provider for asset %q", providerID)
  116. }
  117. return 0
  118. }
  119. region, _ := util.GetRegion(asset.GetLabels())
  120. instanceType, _ := util.GetInstanceType(asset.GetLabels())
  121. switch asset.Type() {
  122. case opencost.NodeAssetType:
  123. if coeff, ok := carbonLookupNode[carbonLookupKeyNode{provider, region, instanceType}]; ok {
  124. return coeff
  125. }
  126. if coeff, ok := carbonLookupNode[carbonLookupKeyNode{provider, averageRegionKey, ""}]; ok {
  127. log.DedupedWarningf(10, "carbon: falling back to average-region for node (provider=%s region=%q instanceType=%q)", provider, region, instanceType)
  128. return coeff
  129. }
  130. case opencost.DiskAssetType:
  131. if coeff, ok := carbonLookupDisk[carbonLookupKeyRegion{provider, region}]; ok {
  132. return coeff
  133. }
  134. if coeff, ok := carbonLookupDisk[carbonLookupKeyRegion{provider, averageRegionKey}]; ok {
  135. log.DedupedWarningf(10, "carbon: falling back to average-region for disk (provider=%s region=%q)", provider, region)
  136. return coeff
  137. }
  138. case opencost.NetworkAssetType:
  139. if coeff, ok := carbonLookupNetwork[carbonLookupKeyRegion{provider, region}]; ok {
  140. return coeff
  141. }
  142. if coeff, ok := carbonLookupNetwork[carbonLookupKeyRegion{provider, averageRegionKey}]; ok {
  143. return coeff
  144. }
  145. }
  146. return 0
  147. }
  148. func isCarbonTrackedAsset(t opencost.AssetType) bool {
  149. switch t {
  150. case opencost.NodeAssetType, opencost.DiskAssetType, opencost.NetworkAssetType:
  151. return true
  152. }
  153. return false
  154. }
  155. // resolveProvider returns the canonical provider name for an asset. It prefers
  156. // the canonical Provider property populated by the cost model, falling back to
  157. // parsing the cloud provider ID when the property is missing.
  158. func resolveProvider(asset opencost.Asset) string {
  159. props := asset.GetProperties()
  160. if props == nil {
  161. return ""
  162. }
  163. switch props.Provider {
  164. case opencost.AWSProvider, opencost.GCPProvider, opencost.AzureProvider:
  165. return props.Provider
  166. }
  167. return inferProviderFromProviderID(props.ProviderID)
  168. }
  169. // inferProviderFromProviderID is a best-effort fallback that matches the
  170. // conventional shapes of Kubernetes Node `spec.providerID` values for the
  171. // cloud providers present in the embedded lookup data (AWS, GCP, Azure).
  172. //
  173. // Real-world formats:
  174. // - AWS: aws:///<availability-zone>/<instance-id> (or raw "i-…")
  175. // - GCP: gce://<project>/<zone>/<instance-name>
  176. // - Azure: azure:///subscriptions/<sub>/resourceGroups/<rg>/…
  177. func inferProviderFromProviderID(providerID string) string {
  178. id := strings.ToLower(strings.TrimSpace(providerID))
  179. if id == "" {
  180. return ""
  181. }
  182. switch {
  183. case strings.HasPrefix(id, "aws:"), strings.HasPrefix(id, "i-"):
  184. return opencost.AWSProvider
  185. case strings.HasPrefix(id, "gce:"), strings.HasPrefix(id, "gke"):
  186. return opencost.GCPProvider
  187. case strings.HasPrefix(id, "azure:"):
  188. return opencost.AzureProvider
  189. }
  190. return ""
  191. }