cloudcostitem.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. package kubecost
  2. import (
  3. "fmt"
  4. "strings"
  5. "time"
  6. "github.com/opencost/opencost/pkg/filter"
  7. "github.com/opencost/opencost/pkg/log"
  8. )
  9. // These contain some labels that can be used on Cloud cost
  10. // item to get the corresponding cluster its associated.
  11. const (
  12. AWSMatchLabel1 = "eks_cluster_name"
  13. AWSMatchLabel2 = "alpha_eksctl_io_cluster_name"
  14. AlibabaMatchLabel1 = "ack.aliyun.com"
  15. GCPMatchLabel1 = "goog-k8s-cluster-name"
  16. )
  17. type CloudCostItemLabels map[string]string
  18. func (ccil CloudCostItemLabels) Clone() CloudCostItemLabels {
  19. result := make(map[string]string, len(ccil))
  20. for k, v := range ccil {
  21. result[k] = v
  22. }
  23. return result
  24. }
  25. func (ccil CloudCostItemLabels) Equal(that CloudCostItemLabels) bool {
  26. if len(ccil) != len(that) {
  27. return false
  28. }
  29. // Maps are of equal length, so if all keys are in both maps, we don't
  30. // have to check the keys of the other map.
  31. for k, v := range ccil {
  32. if tv, ok := that[k]; !ok || v != tv {
  33. return false
  34. }
  35. }
  36. return true
  37. }
  38. type CloudCostItemProperties struct {
  39. ProviderID string `json:"providerID,omitempty"`
  40. Provider string `json:"provider,omitempty"`
  41. WorkGroupID string `json:"workGroupID,omitempty"`
  42. BillingID string `json:"billingID,omitempty"`
  43. Service string `json:"service,omitempty"`
  44. Category string `json:"category,omitempty"`
  45. Labels CloudCostItemLabels `json:"labels,omitempty"`
  46. }
  47. func (ccip CloudCostItemProperties) Equal(that CloudCostItemProperties) bool {
  48. return ccip.ProviderID == that.ProviderID &&
  49. ccip.Provider == that.Provider &&
  50. ccip.WorkGroupID == that.WorkGroupID &&
  51. ccip.BillingID == that.BillingID &&
  52. ccip.Service == that.Service &&
  53. ccip.Category == that.Category &&
  54. ccip.Labels.Equal(that.Labels)
  55. }
  56. func (ccip CloudCostItemProperties) Clone() CloudCostItemProperties {
  57. return CloudCostItemProperties{
  58. ProviderID: ccip.ProviderID,
  59. Provider: ccip.Provider,
  60. WorkGroupID: ccip.WorkGroupID,
  61. BillingID: ccip.BillingID,
  62. Service: ccip.Service,
  63. Category: ccip.Category,
  64. Labels: ccip.Labels.Clone(),
  65. }
  66. }
  67. func (ccip CloudCostItemProperties) Key() string {
  68. return fmt.Sprintf("%s/%s/%s/%s/%s/%s", ccip.Provider, ccip.BillingID, ccip.WorkGroupID, ccip.Category, ccip.Service, ccip.ProviderID)
  69. }
  70. func (ccip CloudCostItemProperties) MonitoringKey() string {
  71. return fmt.Sprintf("%s/%s", ccip.Provider, ccip.ProviderID)
  72. }
  73. // CloudCostItem represents a CUR line item, identifying a cloud resource and
  74. // its cost over some period of time.
  75. type CloudCostItem struct {
  76. Properties CloudCostItemProperties `json:"properties"`
  77. IsKubernetes bool `json:"isKubernetes"`
  78. Window Window `json:"window"`
  79. Cost float64 `json:"cost"`
  80. NetCost float64 `json:"netCost"`
  81. }
  82. // NewCloudCostItem instantiates a new CloudCostItem asset
  83. func NewCloudCostItem(start, end time.Time, cciProperties CloudCostItemProperties, isKubernetes bool, cost, netcost float64) *CloudCostItem {
  84. return &CloudCostItem{
  85. Properties: cciProperties,
  86. IsKubernetes: isKubernetes,
  87. Window: NewWindow(&start, &end),
  88. Cost: cost,
  89. NetCost: netcost,
  90. }
  91. }
  92. func (cci *CloudCostItem) Clone() *CloudCostItem {
  93. return &CloudCostItem{
  94. Properties: cci.Properties.Clone(),
  95. IsKubernetes: cci.IsKubernetes,
  96. Window: cci.Window.Clone(),
  97. Cost: cci.Cost,
  98. NetCost: cci.NetCost,
  99. }
  100. }
  101. func (cci *CloudCostItem) Equal(that *CloudCostItem) bool {
  102. if that == nil {
  103. return false
  104. }
  105. return cci.Properties.Equal(that.Properties) &&
  106. cci.IsKubernetes == that.IsKubernetes &&
  107. cci.Window.Equal(that.Window) &&
  108. cci.Cost == that.Cost &&
  109. cci.NetCost == that.NetCost
  110. }
  111. func (cci *CloudCostItem) Key() string {
  112. return cci.Properties.Key()
  113. }
  114. func (cci *CloudCostItem) add(that *CloudCostItem) {
  115. if cci == nil {
  116. log.Warnf("cannot add to nil CloudCostItem")
  117. return
  118. }
  119. cci.Cost += that.Cost
  120. cci.NetCost += that.NetCost
  121. cci.Window = cci.Window.Expand(that.Window)
  122. }
  123. func (cci *CloudCostItem) MonitoringKey() string {
  124. return cci.Properties.MonitoringKey()
  125. }
  126. // Ony use compute resources to get Cluster names
  127. func (cci *CloudCostItem) GetCluster() string {
  128. switch provider := cci.Properties.Provider; provider {
  129. case AWSProvider:
  130. return cci.GetAWSCluster()
  131. case AzureProvider:
  132. return cci.GetAzureCluster()
  133. case GCPProvider:
  134. return cci.GetGCPCluster()
  135. case AlibabaProvider:
  136. return cci.GetAlibabaCluster()
  137. default:
  138. log.Warnf("unsupported CloudCostItem found for a provider: %s", provider)
  139. return ""
  140. }
  141. }
  142. // Add any new ways of finding GCP cluster from Cloud cost Item
  143. func (cci *CloudCostItem) GetGCPCluster() string {
  144. // currently from Cloud cost compute unable to get cluster name so returning empty
  145. return ""
  146. }
  147. // Add any new ways of finding AWS cluster from Cloud cost Item
  148. func (cci *CloudCostItem) GetAWSCluster() string {
  149. if cci == nil {
  150. return ""
  151. }
  152. // This flag should be removed with filters in the compute query
  153. if cci.Properties.Provider != AWSProvider || cci.Properties.Category != ComputeCategory {
  154. return ""
  155. }
  156. // cn be either of these two labels to distinguish cluster name for a given providerID
  157. if val, ok := cci.Properties.Labels[AWSMatchLabel1]; ok {
  158. return val
  159. }
  160. if val, ok := cci.Properties.Labels[AWSMatchLabel2]; ok {
  161. return val
  162. }
  163. return ""
  164. }
  165. // Add any new ways of finding Azure cluster from Cloud cost Item
  166. func (cci *CloudCostItem) GetAzureCluster() string {
  167. if cci == nil {
  168. return ""
  169. }
  170. // This flag should be removed with filters in the compute query
  171. if cci.Properties.Provider != AzureProvider || cci.Properties.Category != ComputeCategory {
  172. return ""
  173. }
  174. providerIDSplit := strings.Split(cci.Properties.ProviderID, "/")
  175. // ensure this is actually returnable before return
  176. if len(providerIDSplit) < 6 {
  177. return ""
  178. }
  179. return strings.Split(cci.Properties.ProviderID, "/")[6]
  180. }
  181. // Add any new ways of finding Alibaba cluster from Cloud cost Item
  182. func (cci *CloudCostItem) GetAlibabaCluster() string {
  183. if cci == nil {
  184. return ""
  185. }
  186. // This flag should be removed with filters in the compute query
  187. if cci.Properties.Provider != AlibabaProvider || cci.Properties.Category != ComputeCategory {
  188. return ""
  189. }
  190. if val, ok := cci.Properties.Labels[AlibabaMatchLabel1]; ok {
  191. return val
  192. }
  193. return ""
  194. }
  195. type CloudCostItemSet struct {
  196. CloudCostItems map[string]*CloudCostItem `json:"items"`
  197. Window Window `json:"window"`
  198. Integration string `json:"-"`
  199. }
  200. // NewAssetSet instantiates a new AssetSet and, optionally, inserts
  201. // the given list of Assets
  202. func NewCloudCostItemSet(start, end time.Time, cloudCostItems ...*CloudCostItem) *CloudCostItemSet {
  203. ccis := &CloudCostItemSet{
  204. CloudCostItems: map[string]*CloudCostItem{},
  205. Window: NewWindow(&start, &end),
  206. }
  207. for _, cci := range cloudCostItems {
  208. ccis.Insert(cci)
  209. }
  210. return ccis
  211. }
  212. func (ccis *CloudCostItemSet) Accumulate(that *CloudCostItemSet) (*CloudCostItemSet, error) {
  213. if ccis.IsEmpty() {
  214. return that.Clone(), nil
  215. }
  216. if that.IsEmpty() {
  217. return ccis.Clone(), nil
  218. }
  219. // Set start, end to min(start), max(end)
  220. start := ccis.Window.Start()
  221. end := ccis.Window.End()
  222. if that.Window.Start().Before(*start) {
  223. start = that.Window.Start()
  224. }
  225. if that.Window.End().After(*end) {
  226. end = that.Window.End()
  227. }
  228. acc := NewCloudCostItemSet(*start, *end)
  229. for _, cci := range ccis.CloudCostItems {
  230. err := acc.Insert(cci)
  231. if err != nil {
  232. return nil, err
  233. }
  234. }
  235. for _, cci := range that.CloudCostItems {
  236. err := acc.Insert(cci)
  237. if err != nil {
  238. return nil, err
  239. }
  240. }
  241. return acc, nil
  242. }
  243. func (ccis *CloudCostItemSet) Equal(that *CloudCostItemSet) bool {
  244. if ccis.Integration != that.Integration {
  245. return false
  246. }
  247. if !ccis.Window.Equal(that.Window) {
  248. return false
  249. }
  250. if len(ccis.CloudCostItems) != len(that.CloudCostItems) {
  251. return false
  252. }
  253. for k, cci := range ccis.CloudCostItems {
  254. tcci, ok := that.CloudCostItems[k]
  255. if !ok {
  256. return false
  257. }
  258. if !cci.Equal(tcci) {
  259. return false
  260. }
  261. }
  262. return true
  263. }
  264. func (ccis *CloudCostItemSet) Filter(filters filter.Filter[*CloudCostItem]) *CloudCostItemSet {
  265. if ccis == nil {
  266. return nil
  267. }
  268. if filters == nil {
  269. return ccis.Clone()
  270. }
  271. result := NewCloudCostItemSet(*ccis.Window.start, *ccis.Window.end)
  272. for _, cci := range ccis.CloudCostItems {
  273. if filters.Matches(cci) {
  274. result.Insert(cci.Clone())
  275. }
  276. }
  277. return result
  278. }
  279. func (ccis *CloudCostItemSet) Insert(that *CloudCostItem) error {
  280. if ccis == nil {
  281. return fmt.Errorf("cannot insert into nil CloudCostItemSet")
  282. }
  283. if that == nil {
  284. return fmt.Errorf("cannot insert nil CloudCostItem into CloudCostItemSet")
  285. }
  286. if ccis.CloudCostItems == nil {
  287. ccis.CloudCostItems = map[string]*CloudCostItem{}
  288. }
  289. // Add the given CloudCostItem to the existing entry, if there is one;
  290. // otherwise just set directly into allocations
  291. if _, ok := ccis.CloudCostItems[that.Key()]; !ok {
  292. ccis.CloudCostItems[that.Key()] = that.Clone()
  293. } else {
  294. ccis.CloudCostItems[that.Key()].add(that)
  295. }
  296. return nil
  297. }
  298. func (ccis *CloudCostItemSet) Clone() *CloudCostItemSet {
  299. items := make(map[string]*CloudCostItem, len(ccis.CloudCostItems))
  300. for k, v := range ccis.CloudCostItems {
  301. items[k] = v.Clone()
  302. }
  303. return &CloudCostItemSet{
  304. CloudCostItems: items,
  305. Integration: ccis.Integration,
  306. Window: ccis.Window.Clone(),
  307. }
  308. }
  309. func (ccis *CloudCostItemSet) IsEmpty() bool {
  310. if ccis == nil {
  311. return true
  312. }
  313. if len(ccis.CloudCostItems) == 0 {
  314. return true
  315. }
  316. return false
  317. }
  318. func (ccis *CloudCostItemSet) Length() int {
  319. if ccis == nil {
  320. return 0
  321. }
  322. return len(ccis.CloudCostItems)
  323. }
  324. func (ccis *CloudCostItemSet) GetWindow() Window {
  325. return ccis.Window
  326. }
  327. func (ccis *CloudCostItemSet) Merge(that *CloudCostItemSet) (*CloudCostItemSet, error) {
  328. if ccis == nil {
  329. return nil, fmt.Errorf("cannot merge nil CloudCostItemSets")
  330. }
  331. if that.IsEmpty() {
  332. return ccis.Clone(), nil
  333. }
  334. if !ccis.Window.Equal(that.Window) {
  335. return nil, fmt.Errorf("cannot merge CloudCostItemSets with different windows")
  336. }
  337. start, end := *ccis.Window.Start(), *ccis.Window.End()
  338. result := NewCloudCostItemSet(start, end)
  339. for _, cci := range ccis.CloudCostItems {
  340. result.Insert(cci)
  341. }
  342. for _, cci := range that.CloudCostItems {
  343. result.Insert(cci)
  344. }
  345. return result, nil
  346. }
  347. type CloudCostItemSetRange struct {
  348. CloudCostItemSets []*CloudCostItemSet `json:"sets"`
  349. Window Window `json:"window"`
  350. }
  351. // NewCloudCostItemSetRange create a CloudCostItemSetRange containing CloudCostItemSets with windows of equal duration
  352. // the duration between start and end must be divisible by the window duration argument
  353. func NewCloudCostItemSetRange(start time.Time, end time.Time, window time.Duration, integration string) (*CloudCostItemSetRange, error) {
  354. windows, err := GetWindows(start, end, window)
  355. if err != nil {
  356. return nil, err
  357. }
  358. // Build slice of CloudCostItemSet to cover the range
  359. cloudCostItemSets := make([]*CloudCostItemSet, len(windows))
  360. for i, w := range windows {
  361. ccis := NewCloudCostItemSet(*w.Start(), *w.End())
  362. ccis.Integration = integration
  363. cloudCostItemSets[i] = ccis
  364. }
  365. return &CloudCostItemSetRange{
  366. Window: NewWindow(&start, &end),
  367. CloudCostItemSets: cloudCostItemSets,
  368. }, nil
  369. }
  370. func (ccisr *CloudCostItemSetRange) Clone() *CloudCostItemSetRange {
  371. ccisSlice := make([]*CloudCostItemSet, len(ccisr.CloudCostItemSets))
  372. for i, ccis := range ccisr.CloudCostItemSets {
  373. ccisSlice[i] = ccis.Clone()
  374. }
  375. return &CloudCostItemSetRange{
  376. Window: ccisr.Window.Clone(),
  377. CloudCostItemSets: ccisSlice,
  378. }
  379. }
  380. // Accumulate sums each CloudCostItemSet in the given range, returning a single cumulative
  381. // CloudCostItemSet for the entire range.
  382. func (ccisr *CloudCostItemSetRange) Accumulate() (*CloudCostItemSet, error) {
  383. var cloudCostItemSet *CloudCostItemSet
  384. var err error
  385. for _, ccis := range ccisr.CloudCostItemSets {
  386. cloudCostItemSet, err = cloudCostItemSet.Accumulate(ccis)
  387. if err != nil {
  388. return nil, err
  389. }
  390. }
  391. return cloudCostItemSet, nil
  392. }
  393. // LoadCloudCostItem loads CloudCostItems into existing CloudCostItemSets of the CloudCostItemSetRange.
  394. // This function service to aggregate and distribute costs over predefined windows
  395. // are accumulated here so that the resulting CloudCostItem with the 1d window has the correct price for the entire day.
  396. // If all or a portion of the window of the CloudCostItem is outside of the windows of the existing CloudCostItemSets,
  397. // that portion of the CloudCostItem's cost will not be inserted
  398. func (ccisr *CloudCostItemSetRange) LoadCloudCostItem(cloudCostItem *CloudCostItem) {
  399. window := cloudCostItem.Window
  400. if window.IsOpen() {
  401. log.Errorf("CloudCostItemSetRange: LoadCloudCostItem: invalid window %s", window.String())
  402. return
  403. }
  404. totalPct := 0.0
  405. // Distribute cost of the current item across one or more CloudCostItems in
  406. // across each relevant CloudCostItemSet. Stop when the end of the current
  407. // block reaches the item's end time or the end of the range.
  408. for _, ccis := range ccisr.CloudCostItemSets {
  409. setWindow := ccis.Window
  410. // get percent of item window contained in set window
  411. pct := setWindow.GetPercentInWindow(window)
  412. if pct == 0 {
  413. continue
  414. }
  415. cci := cloudCostItem
  416. // If the current set Window only contains a portion of the CloudCostItem Window, insert costs relative to that portion
  417. if pct < 1.0 {
  418. cci = &CloudCostItem{
  419. Properties: cloudCostItem.Properties,
  420. IsKubernetes: cloudCostItem.IsKubernetes,
  421. Window: window.Contract(setWindow),
  422. Cost: cloudCostItem.Cost * pct,
  423. NetCost: cloudCostItem.NetCost * pct,
  424. }
  425. }
  426. err := ccis.Insert(cci)
  427. if err != nil {
  428. log.Errorf("CloudCostItemSetRange: LoadCloudCostItem: failed to load CloudCostItem with key %s and window %s: %s", cci.Key(), ccis.GetWindow().String(), err.Error())
  429. }
  430. // If all cost has been inserted then finish
  431. totalPct += pct
  432. if totalPct >= 1.0 {
  433. return
  434. }
  435. }
  436. }