cloudcost.go 15 KB

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