carbonassets_test.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package carbon
  2. import (
  3. "math"
  4. "testing"
  5. "time"
  6. "github.com/opencost/opencost/core/pkg/opencost"
  7. v1 "k8s.io/api/core/v1"
  8. )
  9. const (
  10. // Known-good row from carbonlookupdata.csv:
  11. // AWS,us-east-1,t4g.nano,Node,0.012788433076234564,4.84769853777516e-06
  12. awsT4gNanoUSEast1Coeff = 4.84769853777516e-06
  13. // AWS,average-region,,Node,0.186739186034359,7.278989705005508e-05
  14. awsAvgRegionNodeCoeff = 7.278989705005508e-05
  15. // AWS,us-east-1,,Network,0.001135,4.30243315e-7
  16. awsUSEast1NetworkCoeff = 4.30243315e-7
  17. )
  18. // floatEqual compares floats at a tolerance appropriate for the lookup table
  19. // values, which are stored with full float64 precision in the CSV.
  20. func floatEqual(a, b float64) bool {
  21. if a == b {
  22. return true
  23. }
  24. return math.Abs(a-b) <= 1e-18+1e-12*math.Max(math.Abs(a), math.Abs(b))
  25. }
  26. func nodeWithLabels(provider, providerID, region, instanceType string, minutes float64) *opencost.Node {
  27. start := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
  28. end := start.Add(time.Duration(minutes) * time.Minute)
  29. window := opencost.NewWindow(&start, &end)
  30. n := opencost.NewNode("node", "cluster", providerID, start, end, window)
  31. n.Properties.Provider = provider
  32. labels := opencost.AssetLabels{}
  33. if region != "" {
  34. labels[v1.LabelTopologyRegion] = region
  35. }
  36. if instanceType != "" {
  37. labels[v1.LabelInstanceTypeStable] = instanceType
  38. }
  39. n.Labels = labels
  40. return n
  41. }
  42. func diskWithLabels(provider, providerID, region string, minutes float64) *opencost.Disk {
  43. start := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
  44. end := start.Add(time.Duration(minutes) * time.Minute)
  45. window := opencost.NewWindow(&start, &end)
  46. d := opencost.NewDisk("disk", "cluster", providerID, start, end, window)
  47. d.Properties.Provider = provider
  48. if region != "" {
  49. d.Labels = opencost.AssetLabels{v1.LabelTopologyRegion: region}
  50. }
  51. return d
  52. }
  53. func networkWithLabels(provider, providerID, region string, minutes float64) *opencost.Network {
  54. start := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
  55. end := start.Add(time.Duration(minutes) * time.Minute)
  56. window := opencost.NewWindow(&start, &end)
  57. nw := opencost.NewNetwork("network", "cluster", providerID, start, end, window)
  58. nw.Properties.Provider = provider
  59. if region != "" {
  60. nw.Labels = opencost.AssetLabels{v1.LabelTopologyRegion: region}
  61. }
  62. return nw
  63. }
  64. func TestInferProviderFromProviderID(t *testing.T) {
  65. cases := []struct {
  66. name string
  67. id string
  68. want string
  69. }{
  70. {"empty", "", ""},
  71. {"aws standard", "aws:///us-east-1a/i-0abc123", opencost.AWSProvider},
  72. {"aws raw instance", "i-0abc123", opencost.AWSProvider},
  73. {"gce standard", "gce://my-project/us-central1-a/gke-node-1", opencost.GCPProvider},
  74. {"legacy gke prefix", "gke-node-1", opencost.GCPProvider},
  75. {"azure standard", "azure:///subscriptions/x/resourceGroups/y/providers/Microsoft.Compute/virtualMachines/z", opencost.AzureProvider},
  76. {"unknown prefix", "something-else", ""},
  77. {"whitespace and case", " AWS:///eu-west-1a/i-xyz ", opencost.AWSProvider},
  78. }
  79. for _, tc := range cases {
  80. t.Run(tc.name, func(t *testing.T) {
  81. if got := inferProviderFromProviderID(tc.id); got != tc.want {
  82. t.Fatalf("inferProviderFromProviderID(%q) = %q, want %q", tc.id, got, tc.want)
  83. }
  84. })
  85. }
  86. }
  87. func TestResolveProvider_PrefersCanonicalProperty(t *testing.T) {
  88. // ProviderID is a GCP-shaped string but the canonical property says AWS.
  89. // Canonical property wins.
  90. n := nodeWithLabels(opencost.AWSProvider, "gce://foo/bar/baz", "us-east-1", "t4g.nano", 60)
  91. if got := resolveProvider(n); got != opencost.AWSProvider {
  92. t.Fatalf("resolveProvider = %q, want %q", got, opencost.AWSProvider)
  93. }
  94. }
  95. func TestResolveProvider_FallsBackToProviderID(t *testing.T) {
  96. // No canonical Provider property — must fall back to parsing ProviderID.
  97. n := nodeWithLabels("", "gce://my-project/us-central1-a/gke-node-1", "us-central1", "e2-standard-2", 60)
  98. if got := resolveProvider(n); got != opencost.GCPProvider {
  99. t.Fatalf("resolveProvider = %q, want %q", got, opencost.GCPProvider)
  100. }
  101. }
  102. func TestLookupCarbonCoeff_Node_ExactMatch(t *testing.T) {
  103. n := nodeWithLabels(opencost.AWSProvider, "aws:///us-east-1a/i-1", "us-east-1", "t4g.nano", 60)
  104. if got := lookupCarbonCoeff(n); !floatEqual(got, awsT4gNanoUSEast1Coeff) {
  105. t.Fatalf("lookupCarbonCoeff = %g, want %g", got, awsT4gNanoUSEast1Coeff)
  106. }
  107. }
  108. func TestLookupCarbonCoeff_Node_FallsBackWhenRegionUnknown(t *testing.T) {
  109. // Region is garbage; instance type is fine. Should fall back to
  110. // (AWS, average-region, "") instead of returning zero.
  111. n := nodeWithLabels(opencost.AWSProvider, "aws:///xx/i-1", "not-a-real-region", "t4g.nano", 60)
  112. if got := lookupCarbonCoeff(n); !floatEqual(got, awsAvgRegionNodeCoeff) {
  113. t.Fatalf("lookupCarbonCoeff = %g, want %g (average-region fallback)", got, awsAvgRegionNodeCoeff)
  114. }
  115. }
  116. func TestLookupCarbonCoeff_Node_FallsBackWhenInstanceTypeUnknown(t *testing.T) {
  117. // Region is real; instance type is unknown. Previously returned 0 because
  118. // only the region was reset. Must now fall back to average-region.
  119. n := nodeWithLabels(opencost.AWSProvider, "aws:///us-east-1a/i-1", "us-east-1", "future-xxlarge", 60)
  120. if got := lookupCarbonCoeff(n); !floatEqual(got, awsAvgRegionNodeCoeff) {
  121. t.Fatalf("lookupCarbonCoeff = %g, want %g (average-region fallback)", got, awsAvgRegionNodeCoeff)
  122. }
  123. }
  124. func TestLookupCarbonCoeff_Node_FallsBackWhenBothUnknown(t *testing.T) {
  125. n := nodeWithLabels(opencost.AWSProvider, "aws:///xx/i-1", "not-a-real-region", "future-xxlarge", 60)
  126. if got := lookupCarbonCoeff(n); !floatEqual(got, awsAvgRegionNodeCoeff) {
  127. t.Fatalf("lookupCarbonCoeff = %g, want %g (average-region fallback)", got, awsAvgRegionNodeCoeff)
  128. }
  129. }
  130. func TestLookupCarbonCoeff_Node_ZeroForUnknownProvider(t *testing.T) {
  131. n := nodeWithLabels("", "some-unknown-id", "us-east-1", "t4g.nano", 60)
  132. if got := lookupCarbonCoeff(n); got != 0 {
  133. t.Fatalf("lookupCarbonCoeff = %g, want 0", got)
  134. }
  135. }
  136. func TestLookupCarbonCoeff_Disk_ExactMatch(t *testing.T) {
  137. // The CSV contains several disk rows per (provider, region), one per
  138. // disk type. They collide under a key of (provider, region), so we
  139. // check the lookup against whatever value the table actually holds.
  140. want, ok := carbonLookupDisk[carbonLookupKeyRegion{opencost.AWSProvider, "us-east-1"}]
  141. if !ok || want == 0 {
  142. t.Fatalf("expected AWS/us-east-1 disk coefficient to be loaded")
  143. }
  144. d := diskWithLabels(opencost.AWSProvider, "aws:///us-east-1a/vol-1", "us-east-1", 60)
  145. if got := lookupCarbonCoeff(d); !floatEqual(got, want) {
  146. t.Fatalf("lookupCarbonCoeff disk = %g, want %g", got, want)
  147. }
  148. }
  149. func TestLookupCarbonCoeff_Disk_FallsBackWhenRegionUnknown(t *testing.T) {
  150. d := diskWithLabels(opencost.AWSProvider, "aws:///xx/vol-1", "not-a-real-region", 60)
  151. want, ok := carbonLookupDisk[carbonLookupKeyRegion{opencost.AWSProvider, averageRegionKey}]
  152. if !ok {
  153. t.Fatalf("expected AWS average-region disk coefficient to be loaded")
  154. }
  155. if got := lookupCarbonCoeff(d); !floatEqual(got, want) {
  156. t.Fatalf("lookupCarbonCoeff disk fallback = %g, want %g", got, want)
  157. }
  158. }
  159. func TestLookupCarbonCoeff_Network_Populated(t *testing.T) {
  160. // Regression: Network rows were loaded but never consulted, so every
  161. // Network asset produced 0 emissions.
  162. nw := networkWithLabels(opencost.AWSProvider, "aws:///us-east-1a/net-1", "us-east-1", 60)
  163. if got := lookupCarbonCoeff(nw); !floatEqual(got, awsUSEast1NetworkCoeff) {
  164. t.Fatalf("lookupCarbonCoeff network = %g, want %g", got, awsUSEast1NetworkCoeff)
  165. }
  166. }
  167. func TestRelateCarbonAssets_MinutesToHours(t *testing.T) {
  168. // Coefficient is tonnes CO2e per hour, so 120 minutes should yield exactly
  169. // twice the coefficient.
  170. n := nodeWithLabels(opencost.AWSProvider, "aws:///us-east-1a/i-1", "us-east-1", "t4g.nano", 120)
  171. as := opencost.NewAssetSet(*n.Window.Start(), *n.Window.End(), n)
  172. rows, err := RelateCarbonAssets(as)
  173. if err != nil {
  174. t.Fatalf("RelateCarbonAssets: %v", err)
  175. }
  176. if len(rows) != 1 {
  177. t.Fatalf("got %d rows, want 1", len(rows))
  178. }
  179. var row CarbonRow
  180. for _, r := range rows {
  181. row = r
  182. }
  183. want := awsT4gNanoUSEast1Coeff * 2
  184. if !floatEqual(row.Co2e, want) {
  185. t.Fatalf("Co2e = %g, want %g", row.Co2e, want)
  186. }
  187. }
  188. func TestRelateCarbonAssets_ZeroForUnknownProvider(t *testing.T) {
  189. n := nodeWithLabels("", "totally-unknown", "us-east-1", "t4g.nano", 60)
  190. as := opencost.NewAssetSet(*n.Window.Start(), *n.Window.End(), n)
  191. rows, err := RelateCarbonAssets(as)
  192. if err != nil {
  193. t.Fatalf("RelateCarbonAssets: %v", err)
  194. }
  195. for _, r := range rows {
  196. if r.Co2e != 0 {
  197. t.Fatalf("Co2e = %g, want 0 for unknown provider", r.Co2e)
  198. }
  199. }
  200. }
  201. func TestLookupCarbonCoeff_NoPanicOnNilProperties(t *testing.T) {
  202. // A bare Node with nil Properties must not panic — older code would
  203. // dereference props.ProviderID in the log line after resolveProvider
  204. // returned "" for nil properties.
  205. start := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
  206. end := start.Add(60 * time.Minute)
  207. window := opencost.NewWindow(&start, &end)
  208. n := opencost.NewNode("node", "cluster", "", start, end, window)
  209. n.Properties = nil
  210. if got := lookupCarbonCoeff(n); got != 0 {
  211. t.Fatalf("lookupCarbonCoeff with nil properties = %g, want 0", got)
  212. }
  213. }
  214. func TestLookupTables_LoadedAtInit(t *testing.T) {
  215. if len(carbonLookupNode) == 0 {
  216. t.Error("carbonLookupNode is empty — init did not populate node lookups")
  217. }
  218. if len(carbonLookupDisk) == 0 {
  219. t.Error("carbonLookupDisk is empty — init did not populate disk lookups")
  220. }
  221. if len(carbonLookupNetwork) == 0 {
  222. t.Error("carbonLookupNetwork is empty — init did not populate network lookups")
  223. }
  224. }