cloudcostprops.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. package opencost
  2. import (
  3. "encoding/hex"
  4. "fmt"
  5. "hash/fnv"
  6. "sort"
  7. "strings"
  8. "github.com/opencost/opencost/core/pkg/log"
  9. "golang.org/x/exp/maps"
  10. )
  11. type CloudCostProperty string
  12. // IsLabel returns true if the allocation property has a label prefix
  13. func (apt *CloudCostProperty) IsLabel() bool {
  14. return strings.HasPrefix(string(*apt), "label:")
  15. }
  16. // GetLabel returns the label string associated with the label property if it exists.
  17. // Otherwise, empty string is returned.
  18. func (apt *CloudCostProperty) GetLabel() string {
  19. if apt.IsLabel() {
  20. return strings.TrimSpace(strings.TrimPrefix(string(*apt), "label:"))
  21. }
  22. return ""
  23. }
  24. const (
  25. CloudCostInvoiceEntityIDProp string = "invoiceEntityID"
  26. CloudCostAccountIDProp string = "accountID"
  27. CloudCostProviderProp string = "provider"
  28. CloudCostProviderIDProp string = "providerID"
  29. CloudCostCategoryProp string = "category"
  30. CloudCostServiceProp string = "service"
  31. CloudCostLabelProp string = "label"
  32. CloudCostLabelSetProp string = "labelSet"
  33. )
  34. func ParseCloudProperties(props []string) ([]CloudCostProperty, error) {
  35. properties := []CloudCostProperty{}
  36. added := make(map[CloudCostProperty]struct{})
  37. for _, prop := range props {
  38. property, err := ParseCloudCostProperty(prop)
  39. if err != nil {
  40. return nil, fmt.Errorf("Failed to parse property: %w", err)
  41. }
  42. if _, ok := added[property]; !ok {
  43. added[property] = struct{}{}
  44. properties = append(properties, property)
  45. }
  46. }
  47. return properties, nil
  48. }
  49. func ParseCloudCostProperty(text string) (CloudCostProperty, error) {
  50. switch strings.TrimSpace(strings.ToLower(text)) {
  51. case "invoiceentityid":
  52. return CloudCostProperty(CloudCostInvoiceEntityIDProp), nil
  53. case "accountid":
  54. return CloudCostProperty(CloudCostAccountIDProp), nil
  55. case "provider":
  56. return CloudCostProperty(CloudCostProviderProp), nil
  57. case "providerid":
  58. return CloudCostProperty(CloudCostProviderIDProp), nil
  59. case "category":
  60. return CloudCostProperty(CloudCostCategoryProp), nil
  61. case "service":
  62. return CloudCostProperty(CloudCostServiceProp), nil
  63. }
  64. if strings.HasPrefix(text, "label:") {
  65. label := strings.TrimSpace(strings.TrimPrefix(text, "label:"))
  66. return CloudCostProperty(fmt.Sprintf("label:%s", label)), nil
  67. }
  68. return "", fmt.Errorf("invalid cloud cost property: %s", text)
  69. }
  70. const (
  71. // CloudCostClusterManagementCategory describes CloudCost representing Hosted Kubernetes Fees
  72. CloudCostClusterManagementCategory string = "Cluster Management"
  73. // CloudCostDiskCategory describes CloudCost representing Disk usage
  74. CloudCostDiskCategory string = "Disk"
  75. // CloudCostLoadBalancerCategory describes CloudCost representing Load Balancer usage
  76. CloudCostLoadBalancerCategory string = "Load Balancer"
  77. // CloudCostNetworkCategory describes CloudCost representing Network usage
  78. CloudCostNetworkCategory string = "Network"
  79. // CloudCostVirtualMachineCategory describes CloudCost representing VM usage
  80. CloudCostVirtualMachineCategory string = "Virtual Machine"
  81. // CloudCostOtherCategory describes CloudCost that do not belong to a defined category
  82. CloudCostOtherCategory string = "Other"
  83. )
  84. type CloudCostLabels map[string]string
  85. func (ccl CloudCostLabels) Clone() CloudCostLabels {
  86. result := make(map[string]string, len(ccl))
  87. for k, v := range ccl {
  88. result[k] = v
  89. }
  90. return result
  91. }
  92. func (ccl CloudCostLabels) Equal(that CloudCostLabels) bool {
  93. if len(ccl) != len(that) {
  94. return false
  95. }
  96. // Maps are of equal length, so if all keys are in both maps, we don't
  97. // have to check the keys of the other map.
  98. for k, val := range ccl {
  99. if thatVal, ok := that[k]; !ok || val != thatVal {
  100. return false
  101. }
  102. }
  103. return true
  104. }
  105. // Intersection returns the set of labels that have the same key and value in the receiver and arg
  106. func (ccl CloudCostLabels) Intersection(that CloudCostLabels) CloudCostLabels {
  107. intersection := make(map[string]string)
  108. if len(ccl) == 0 || len(that) == 0 {
  109. return intersection
  110. }
  111. // Pick the smaller of the two label sets
  112. smallerLabels := ccl
  113. largerLabels := that
  114. if len(ccl) > len(that) {
  115. smallerLabels = that
  116. largerLabels = ccl
  117. }
  118. // Loop through the smaller label set
  119. for k, sVal := range smallerLabels {
  120. if lVal, ok := largerLabels[k]; ok && sVal == lVal {
  121. intersection[k] = sVal
  122. }
  123. }
  124. return intersection
  125. }
  126. type CloudCostProperties struct {
  127. ProviderID string `json:"providerID,omitempty"`
  128. Provider string `json:"provider,omitempty"`
  129. AccountID string `json:"accountID,omitempty"`
  130. InvoiceEntityID string `json:"invoiceEntityID,omitempty"`
  131. Service string `json:"service,omitempty"`
  132. Category string `json:"category,omitempty"`
  133. Labels CloudCostLabels `json:"labels,omitempty"`
  134. }
  135. func (ccp *CloudCostProperties) Equal(that *CloudCostProperties) bool {
  136. return ccp.ProviderID == that.ProviderID &&
  137. ccp.Provider == that.Provider &&
  138. ccp.AccountID == that.AccountID &&
  139. ccp.InvoiceEntityID == that.InvoiceEntityID &&
  140. ccp.Service == that.Service &&
  141. ccp.Category == that.Category &&
  142. ccp.Labels.Equal(that.Labels)
  143. }
  144. func (ccp *CloudCostProperties) Clone() *CloudCostProperties {
  145. return &CloudCostProperties{
  146. ProviderID: ccp.ProviderID,
  147. Provider: ccp.Provider,
  148. AccountID: ccp.AccountID,
  149. InvoiceEntityID: ccp.InvoiceEntityID,
  150. Service: ccp.Service,
  151. Category: ccp.Category,
  152. Labels: ccp.Labels.Clone(),
  153. }
  154. }
  155. // Intersection ensure the values of two CloudCostAggregateProperties are maintain only if they are equal
  156. func (ccp *CloudCostProperties) Intersection(that *CloudCostProperties) *CloudCostProperties {
  157. if ccp == nil || that == nil {
  158. return nil
  159. }
  160. if ccp.Equal(that) {
  161. return ccp
  162. }
  163. intersectionCCP := &CloudCostProperties{}
  164. if ccp.Equal(intersectionCCP) || that.Equal(intersectionCCP) {
  165. return intersectionCCP
  166. }
  167. if ccp.Provider == that.Provider {
  168. intersectionCCP.Provider = ccp.Provider
  169. }
  170. if ccp.ProviderID == that.ProviderID {
  171. intersectionCCP.ProviderID = ccp.ProviderID
  172. }
  173. if ccp.AccountID == that.AccountID {
  174. intersectionCCP.AccountID = ccp.AccountID
  175. }
  176. if ccp.InvoiceEntityID == that.InvoiceEntityID {
  177. intersectionCCP.InvoiceEntityID = ccp.InvoiceEntityID
  178. }
  179. if ccp.Service == that.Service {
  180. intersectionCCP.Service = ccp.Service
  181. }
  182. if ccp.Category == that.Category {
  183. intersectionCCP.Category = ccp.Category
  184. }
  185. intersectionCCP.Labels = ccp.Labels.Intersection(that.Labels)
  186. return intersectionCCP
  187. }
  188. var cloudCostDefaultKeyProperties = []string{
  189. CloudCostProviderProp,
  190. CloudCostInvoiceEntityIDProp,
  191. CloudCostAccountIDProp,
  192. CloudCostCategoryProp,
  193. CloudCostServiceProp,
  194. CloudCostProviderIDProp,
  195. }
  196. // GenerateKey takes a list of properties and creates a "/" seperated key based on the values of the requested properties.
  197. // Invalid values are ignored with a warning. A nil input returns the default key, while an empty slice returns the empty string
  198. func (ccp *CloudCostProperties) GenerateKey(props []string) string {
  199. // nil props replaced with default property list
  200. if props == nil {
  201. return ccp.hashKey()
  202. }
  203. values := make([]string, len(props))
  204. for i, prop := range props {
  205. propVal := UnallocatedSuffix
  206. switch true {
  207. case prop == CloudCostProviderProp:
  208. if ccp.Provider != "" {
  209. propVal = ccp.Provider
  210. }
  211. case prop == CloudCostProviderIDProp:
  212. if ccp.ProviderID != "" {
  213. propVal = ccp.ProviderID
  214. }
  215. case prop == CloudCostCategoryProp:
  216. if ccp.Category != "" {
  217. propVal = ccp.Category
  218. }
  219. case prop == CloudCostInvoiceEntityIDProp:
  220. if ccp.InvoiceEntityID != "" {
  221. propVal = ccp.InvoiceEntityID
  222. }
  223. case prop == CloudCostAccountIDProp:
  224. if ccp.AccountID != "" {
  225. propVal = ccp.AccountID
  226. }
  227. case prop == CloudCostServiceProp:
  228. if ccp.Service != "" {
  229. propVal = ccp.Service
  230. }
  231. case strings.HasPrefix(prop, "label:"):
  232. labels := ccp.Labels
  233. if labels != nil {
  234. labelName := strings.TrimPrefix(prop, "label:")
  235. if labelValue, ok := labels[labelName]; ok && labelValue != "" {
  236. propVal = labelValue
  237. }
  238. }
  239. default:
  240. // This case should never be reached, as input up until this point
  241. // should be checked and rejected if invalid. But if we do get a
  242. // value we don't recognize, log a warning.
  243. log.Warnf("CloudCost: GenerateKey: illegal aggregation parameter: %s", prop)
  244. }
  245. values[i] = propVal
  246. }
  247. return strings.Join(values, "/")
  248. }
  249. // HashKey creates a key on the entire property set including labels of a uniform length.
  250. // This key is meant to be used when constructing unaggregated CloudCostSet for storage.
  251. // Including labels prevents CloudCosts that are missing providerIDs from having their labels
  252. // erased as they are saved to cloud cost set.
  253. func (ccp *CloudCostProperties) hashKey() string {
  254. builder := strings.Builder{}
  255. builder.WriteString(ccp.ProviderID)
  256. builder.WriteString(ccp.Provider)
  257. builder.WriteString(ccp.AccountID)
  258. builder.WriteString(ccp.InvoiceEntityID)
  259. builder.WriteString(ccp.Service)
  260. builder.WriteString(ccp.Category)
  261. // Sort label keys before adding key/value pairs to the hash string to ensure label set is
  262. // always returns the same key
  263. labelKeys := maps.Keys(ccp.Labels)
  264. sort.Strings(labelKeys)
  265. for _, k := range labelKeys {
  266. builder.WriteString(k)
  267. builder.WriteString(ccp.Labels[k])
  268. }
  269. hasher := fnv.New64a()
  270. hasher.Write([]byte(builder.String()))
  271. return hex.EncodeToString(hasher.Sum(nil))
  272. }