cloudcost.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. package kubecost
  2. import (
  3. "errors"
  4. "fmt"
  5. "time"
  6. "github.com/opencost/opencost/pkg/filter"
  7. "github.com/opencost/opencost/pkg/log"
  8. )
  9. // CloudCost represents a CUR line item, identifying a cloud resource and
  10. // its cost over some period of time.
  11. type CloudCost struct {
  12. Properties *CloudCostProperties `json:"properties"`
  13. Window Window `json:"window"`
  14. ListCost CostMetric `json:"listCost"`
  15. NetCost CostMetric `json:"netCost"`
  16. AmortizedNetCost CostMetric `json:"amortizedNetCost"`
  17. InvoicedCost CostMetric `json:"invoicedCost"`
  18. AmortizedCost CostMetric `json:"amortizedCost"`
  19. }
  20. // NewCloudCost instantiates a new CloudCost
  21. func NewCloudCost(start, end time.Time, ccProperties *CloudCostProperties, kubernetesPercent, listCost, netCost, amortizedNetCost, invoicedCost, amortizedCost float64) *CloudCost {
  22. return &CloudCost{
  23. Properties: ccProperties,
  24. Window: NewWindow(&start, &end),
  25. ListCost: CostMetric{
  26. Cost: listCost,
  27. KubernetesPercent: kubernetesPercent,
  28. },
  29. NetCost: CostMetric{
  30. Cost: netCost,
  31. KubernetesPercent: kubernetesPercent,
  32. },
  33. AmortizedNetCost: CostMetric{
  34. Cost: amortizedNetCost,
  35. KubernetesPercent: kubernetesPercent,
  36. },
  37. InvoicedCost: CostMetric{
  38. Cost: invoicedCost,
  39. KubernetesPercent: kubernetesPercent,
  40. },
  41. AmortizedCost: CostMetric{
  42. Cost: amortizedCost,
  43. KubernetesPercent: kubernetesPercent,
  44. },
  45. }
  46. }
  47. func (cc *CloudCost) Clone() *CloudCost {
  48. return &CloudCost{
  49. Properties: cc.Properties.Clone(),
  50. Window: cc.Window.Clone(),
  51. ListCost: cc.ListCost.Clone(),
  52. NetCost: cc.NetCost.Clone(),
  53. AmortizedNetCost: cc.AmortizedNetCost.Clone(),
  54. InvoicedCost: cc.InvoicedCost.Clone(),
  55. AmortizedCost: cc.AmortizedCost.Clone(),
  56. }
  57. }
  58. func (cc *CloudCost) Equal(that *CloudCost) bool {
  59. if that == nil {
  60. return false
  61. }
  62. return cc.Properties.Equal(that.Properties) &&
  63. cc.Window.Equal(that.Window) &&
  64. cc.ListCost.Equal(that.ListCost) &&
  65. cc.NetCost.Equal(that.NetCost) &&
  66. cc.AmortizedNetCost.Equal(that.AmortizedNetCost) &&
  67. cc.InvoicedCost.Equal(that.InvoicedCost) &&
  68. cc.AmortizedCost.Equal(that.AmortizedCost)
  69. }
  70. func (cc *CloudCost) add(that *CloudCost) {
  71. if cc == nil {
  72. log.Warnf("cannot add to nil CloudCost")
  73. return
  74. }
  75. // Preserve properties of cloud cost that are matching between the two CloudCost
  76. cc.Properties = cc.Properties.Intersection(that.Properties)
  77. cc.ListCost = cc.ListCost.add(that.ListCost)
  78. cc.NetCost = cc.NetCost.add(that.NetCost)
  79. cc.AmortizedNetCost = cc.AmortizedNetCost.add(that.AmortizedNetCost)
  80. cc.InvoicedCost = cc.InvoicedCost.add(that.InvoicedCost)
  81. cc.AmortizedCost = cc.AmortizedCost.add(that.AmortizedCost)
  82. cc.Window = cc.Window.Expand(that.Window)
  83. }
  84. func (cc *CloudCost) StringProperty(prop string) (string, error) {
  85. if cc == nil {
  86. return "", nil
  87. }
  88. switch prop {
  89. case CloudCostInvoiceEntityIDProp:
  90. return cc.Properties.InvoiceEntityID, nil
  91. case CloudCostAccountIDProp:
  92. return cc.Properties.AccountID, nil
  93. case CloudCostProviderProp:
  94. return cc.Properties.Provider, nil
  95. case CloudCostProviderIDProp:
  96. return cc.Properties.ProviderID, nil
  97. case CloudCostServiceProp:
  98. return cc.Properties.Service, nil
  99. case CloudCostCategoryProp:
  100. return cc.Properties.Category, nil
  101. default:
  102. return "", fmt.Errorf("invalid property name: %s", prop)
  103. }
  104. }
  105. func (cc *CloudCost) StringMapProperty(property string) (map[string]string, error) {
  106. switch property {
  107. case CloudCostLabelProp:
  108. if cc.Properties == nil {
  109. return nil, nil
  110. }
  111. return cc.Properties.Labels, nil
  112. default:
  113. return nil, fmt.Errorf("CloudCost: StringMapProperty: invalid property name: %s", property)
  114. }
  115. }
  116. func (cc *CloudCost) GetCostMetric(costMetricName string) (CostMetric, error) {
  117. switch costMetricName {
  118. case ListCostMetric:
  119. return cc.ListCost, nil
  120. case NetCostMetric:
  121. return cc.NetCost, nil
  122. case AmortizedNetCostMetric:
  123. return cc.AmortizedNetCost, nil
  124. case InvoicedCostMetric:
  125. return cc.InvoicedCost, nil
  126. case AmortizedCostMetric:
  127. return cc.AmortizedCost, nil
  128. }
  129. return CostMetric{}, fmt.Errorf("invalid Cost Metric: %s", costMetricName)
  130. }
  131. // CloudCostSet follows the established set pattern of windowed data types. It has addition metadata types that can be
  132. // used to preserve data consistency and be used for validation.
  133. // - Integration is the ID for the integration that a CloudCostSet was sourced from, this value is cleared if when a
  134. // set is joined with another with a different key
  135. // - AggregationProperties is set by the Aggregate function and ensures that any additional inserts are keyed correctly
  136. type CloudCostSet struct {
  137. CloudCosts map[string]*CloudCost `json:"cloudCosts"`
  138. Window Window `json:"window"`
  139. Integration string `json:"-"`
  140. AggregationProperties []string `json:"aggregationProperties"`
  141. }
  142. // NewCloudCostSet instantiates a new CloudCostSet and, optionally, inserts
  143. // the given list of CloudCosts
  144. func NewCloudCostSet(start, end time.Time, cloudCosts ...*CloudCost) *CloudCostSet {
  145. ccs := &CloudCostSet{
  146. CloudCosts: map[string]*CloudCost{},
  147. Window: NewWindow(&start, &end),
  148. }
  149. for _, cc := range cloudCosts {
  150. ccs.Insert(cc)
  151. }
  152. return ccs
  153. }
  154. func (ccs *CloudCostSet) Aggregate(props []string) (*CloudCostSet, error) {
  155. if ccs == nil {
  156. return nil, errors.New("cannot aggregate a nil CloudCostSet")
  157. }
  158. if ccs.Window.IsOpen() {
  159. return nil, fmt.Errorf("cannot aggregate a CloudCostSet with an open window: %s", ccs.Window)
  160. }
  161. // Create a new result set, with the given aggregation property
  162. result := ccs.cloneSet()
  163. result.AggregationProperties = props
  164. // Insert clones of each item in the set, keyed by the given property.
  165. // The underlying insert logic will add binned items together.
  166. for name, cc := range ccs.CloudCosts {
  167. ccClone := cc.Clone()
  168. err := result.Insert(ccClone)
  169. if err != nil {
  170. return nil, fmt.Errorf("error aggregating %s by %v: %s", name, props, err)
  171. }
  172. }
  173. return result, nil
  174. }
  175. func (ccs *CloudCostSet) Accumulate(that *CloudCostSet) (*CloudCostSet, error) {
  176. if ccs.IsEmpty() {
  177. return that.Clone(), nil
  178. }
  179. acc := ccs.Clone()
  180. err := acc.accumulateInto(that)
  181. if err == nil {
  182. return nil, err
  183. }
  184. return acc, nil
  185. }
  186. // accumulateInto accumulates a the arg CloudCostSet Into the receiver
  187. func (ccs *CloudCostSet) accumulateInto(that *CloudCostSet) error {
  188. if ccs == nil {
  189. return fmt.Errorf("CloudCost: cannot accumulate into nil set")
  190. }
  191. if that.IsEmpty() {
  192. return nil
  193. }
  194. if ccs.Integration != that.Integration {
  195. ccs.Integration = ""
  196. }
  197. ccs.Window.Expand(that.Window)
  198. for _, cc := range that.CloudCosts {
  199. err := ccs.Insert(cc)
  200. if err != nil {
  201. return err
  202. }
  203. }
  204. return nil
  205. }
  206. func (ccs *CloudCostSet) Equal(that *CloudCostSet) bool {
  207. if ccs.Integration != that.Integration {
  208. return false
  209. }
  210. if !ccs.Window.Equal(that.Window) {
  211. return false
  212. }
  213. // Check Aggregation Properties, slice order is grounds for inequality
  214. if len(ccs.AggregationProperties) != len(that.AggregationProperties) {
  215. return false
  216. }
  217. for i, prop := range ccs.AggregationProperties {
  218. if that.AggregationProperties[i] != prop {
  219. return false
  220. }
  221. }
  222. if len(ccs.CloudCosts) != len(that.CloudCosts) {
  223. return false
  224. }
  225. for k, cc := range ccs.CloudCosts {
  226. if tcc, ok := that.CloudCosts[k]; !ok || !cc.Equal(tcc) {
  227. return false
  228. }
  229. }
  230. return true
  231. }
  232. func (ccs *CloudCostSet) Filter(filters filter.Filter[*CloudCost]) *CloudCostSet {
  233. if ccs == nil {
  234. return nil
  235. }
  236. if filters == nil {
  237. return ccs.Clone()
  238. }
  239. result := ccs.cloneSet()
  240. for _, cc := range ccs.CloudCosts {
  241. if filters.Matches(cc) {
  242. result.Insert(cc.Clone())
  243. }
  244. }
  245. return result
  246. }
  247. // Insert adds a CloudCost to a CloudCostSet using its AggregationProperties and LabelConfig
  248. // to determine the key where it will be inserted
  249. func (ccs *CloudCostSet) Insert(cc *CloudCost) error {
  250. if ccs == nil {
  251. return fmt.Errorf("cannot insert into nil CloudCostSet")
  252. }
  253. if cc == nil {
  254. return fmt.Errorf("cannot insert nil CloudCost into CloudCostSet")
  255. }
  256. if ccs.CloudCosts == nil {
  257. ccs.CloudCosts = map[string]*CloudCost{}
  258. }
  259. ccKey := cc.Properties.GenerateKey(ccs.AggregationProperties)
  260. // Add the given CloudCost to the existing entry, if there is one;
  261. // otherwise just set directly into allocations
  262. if _, ok := ccs.CloudCosts[ccKey]; !ok {
  263. ccs.CloudCosts[ccKey] = cc.Clone()
  264. } else {
  265. ccs.CloudCosts[ccKey].add(cc)
  266. }
  267. return nil
  268. }
  269. func (ccs *CloudCostSet) Clone() *CloudCostSet {
  270. cloudCosts := make(map[string]*CloudCost, len(ccs.CloudCosts))
  271. for k, v := range ccs.CloudCosts {
  272. cloudCosts[k] = v.Clone()
  273. }
  274. cloneCCS := ccs.cloneSet()
  275. cloneCCS.CloudCosts = cloudCosts
  276. return cloneCCS
  277. }
  278. // cloneSet creates a copy of the receiver without any of its CloudCosts
  279. func (ccs *CloudCostSet) cloneSet() *CloudCostSet {
  280. aggProps := make([]string, len(ccs.AggregationProperties))
  281. for i, v := range ccs.AggregationProperties {
  282. aggProps[i] = v
  283. }
  284. return &CloudCostSet{
  285. CloudCosts: make(map[string]*CloudCost),
  286. Integration: ccs.Integration,
  287. AggregationProperties: aggProps,
  288. Window: ccs.Window.Clone(),
  289. }
  290. }
  291. func (ccs *CloudCostSet) IsEmpty() bool {
  292. if ccs == nil {
  293. return true
  294. }
  295. if len(ccs.CloudCosts) == 0 {
  296. return true
  297. }
  298. return false
  299. }
  300. func (ccs *CloudCostSet) Length() int {
  301. if ccs == nil {
  302. return 0
  303. }
  304. return len(ccs.CloudCosts)
  305. }
  306. func (ccs *CloudCostSet) GetWindow() Window {
  307. return ccs.Window
  308. }
  309. func (ccs *CloudCostSet) Merge(that *CloudCostSet) (*CloudCostSet, error) {
  310. if ccs == nil {
  311. return nil, fmt.Errorf("cannot merge nil CloudCostSets")
  312. }
  313. if that.IsEmpty() {
  314. return ccs.Clone(), nil
  315. }
  316. if !ccs.Window.Equal(that.Window) {
  317. return nil, fmt.Errorf("cannot merge CloudCostSets with different windows")
  318. }
  319. result := ccs.cloneSet()
  320. // clear integration if it is not equal
  321. if ccs.Integration != that.Integration {
  322. result.Integration = ""
  323. }
  324. for _, cc := range ccs.CloudCosts {
  325. result.Insert(cc)
  326. }
  327. for _, cc := range that.CloudCosts {
  328. result.Insert(cc)
  329. }
  330. return result, nil
  331. }
  332. type CloudCostSetRange struct {
  333. CloudCostSets []*CloudCostSet `json:"sets"`
  334. Window Window `json:"window"`
  335. }
  336. // NewCloudCostSetRange create a CloudCostSetRange containing CloudCostSets with windows of equal duration
  337. // the duration between start and end must be divisible by the window duration argument
  338. func NewCloudCostSetRange(start time.Time, end time.Time, window time.Duration, integration string) (*CloudCostSetRange, error) {
  339. windows, err := GetWindows(start, end, window)
  340. if err != nil {
  341. return nil, err
  342. }
  343. // Build slice of CloudCostSet to cover the range
  344. cloudCostItemSets := make([]*CloudCostSet, len(windows))
  345. for i, w := range windows {
  346. ccs := NewCloudCostSet(*w.Start(), *w.End())
  347. ccs.Integration = integration
  348. cloudCostItemSets[i] = ccs
  349. }
  350. return &CloudCostSetRange{
  351. Window: NewWindow(&start, &end),
  352. CloudCostSets: cloudCostItemSets,
  353. }, nil
  354. }
  355. func (ccsr *CloudCostSetRange) Clone() *CloudCostSetRange {
  356. ccsSlice := make([]*CloudCostSet, len(ccsr.CloudCostSets))
  357. for i, ccs := range ccsr.CloudCostSets {
  358. ccsSlice[i] = ccs.Clone()
  359. }
  360. return &CloudCostSetRange{
  361. Window: ccsr.Window.Clone(),
  362. CloudCostSets: ccsSlice,
  363. }
  364. }
  365. func (ccsr *CloudCostSetRange) IsEmpty() bool {
  366. for _, ccs := range ccsr.CloudCostSets {
  367. if !ccs.IsEmpty() {
  368. return false
  369. }
  370. }
  371. return true
  372. }
  373. // Accumulate sums each CloudCostSet in the given range, returning a single cumulative
  374. // CloudCostSet for the entire range.
  375. func (ccsr *CloudCostSetRange) Accumulate() (*CloudCostSet, error) {
  376. var cloudCostSet *CloudCostSet
  377. var err error
  378. for _, ccs := range ccsr.CloudCostSets {
  379. if cloudCostSet == nil {
  380. cloudCostSet = ccs.Clone()
  381. continue
  382. }
  383. err = cloudCostSet.accumulateInto(ccs)
  384. if err != nil {
  385. return nil, err
  386. }
  387. }
  388. return cloudCostSet, nil
  389. }
  390. // LoadCloudCost loads CloudCosts into existing CloudCostSets of the CloudCostSetRange.
  391. // This function service to aggregate and distribute costs over predefined windows
  392. // are accumulated here so that the resulting CloudCost with the 1d window has the correct price for the entire day.
  393. // If all or a portion of the window of the CloudCost is outside of the windows of the existing CloudCostSets,
  394. // that portion of the CloudCost's cost will not be inserted
  395. func (ccsr *CloudCostSetRange) LoadCloudCost(cloudCost *CloudCost) {
  396. window := cloudCost.Window
  397. if window.IsOpen() {
  398. log.Errorf("CloudCostSetRange: LoadCloudCost: invalid window %s", window.String())
  399. return
  400. }
  401. totalPct := 0.0
  402. // Distribute cost of the current item across one or more CloudCosts in
  403. // across each relevant CloudCostSet. Stop when the end of the current
  404. // block reaches the item's end time or the end of the range.
  405. for _, ccs := range ccsr.CloudCostSets {
  406. setWindow := ccs.Window
  407. // get percent of item window contained in set window
  408. pct := setWindow.GetPercentInWindow(window)
  409. if pct == 0 {
  410. continue
  411. }
  412. cc := cloudCost
  413. // If the current set Window only contains a portion of the CloudCost Window, insert costs relative to that portion
  414. if pct < 1.0 {
  415. cc = &CloudCost{
  416. Properties: cloudCost.Properties,
  417. Window: window.Contract(setWindow),
  418. ListCost: cloudCost.ListCost.percent(pct),
  419. NetCost: cloudCost.NetCost.percent(pct),
  420. AmortizedNetCost: cloudCost.AmortizedNetCost.percent(pct),
  421. InvoicedCost: cloudCost.InvoicedCost.percent(pct),
  422. AmortizedCost: cloudCost.AmortizedCost.percent(pct),
  423. }
  424. }
  425. err := ccs.Insert(cc)
  426. if err != nil {
  427. log.Errorf("CloudCostSetRange: LoadCloudCost: failed to load CloudCost with window %s: %s", setWindow.String(), err.Error())
  428. }
  429. // If all cost has been inserted, then there is no need to check later days in the range
  430. totalPct += pct
  431. if totalPct >= 1.0 {
  432. return
  433. }
  434. }
  435. }
  436. const (
  437. ListCostMetric string = "ListCost"
  438. NetCostMetric string = "NetCost"
  439. AmortizedNetCostMetric string = "AmortizedNetCost"
  440. InvoicedCostMetric string = "InvoicedCost"
  441. AmortizedCostMetric string = "AmortizedCost"
  442. )
  443. type CostMetric struct {
  444. Cost float64 `json:"cost"`
  445. KubernetesPercent float64 `json:"kubernetesPercent"`
  446. }
  447. func (cm CostMetric) Equal(that CostMetric) bool {
  448. return cm.Cost == that.Cost && cm.KubernetesPercent == that.KubernetesPercent
  449. }
  450. func (cm CostMetric) Clone() CostMetric {
  451. return CostMetric{
  452. Cost: cm.Cost,
  453. KubernetesPercent: cm.KubernetesPercent,
  454. }
  455. }
  456. func (cm CostMetric) add(that CostMetric) CostMetric {
  457. // Compute KubernetesPercent for sum
  458. k8sPct := 0.0
  459. sumCost := cm.Cost + that.Cost
  460. if sumCost > 0.0 {
  461. thisK8sCost := cm.Cost * cm.KubernetesPercent
  462. thatK8sCost := that.Cost * that.KubernetesPercent
  463. k8sPct = (thisK8sCost + thatK8sCost) / sumCost
  464. }
  465. return CostMetric{
  466. Cost: sumCost,
  467. KubernetesPercent: k8sPct,
  468. }
  469. }
  470. // percent returns the product of the given percent and the cost, KubernetesPercent remains the same
  471. func (cm CostMetric) percent(pct float64) CostMetric {
  472. return CostMetric{
  473. Cost: cm.Cost * pct,
  474. KubernetesPercent: cm.KubernetesPercent,
  475. }
  476. }