provider.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. package otc
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. "sync"
  10. "time"
  11. "github.com/opencost/opencost/core/pkg/log"
  12. "github.com/opencost/opencost/core/pkg/opencost"
  13. "github.com/opencost/opencost/core/pkg/util"
  14. "github.com/opencost/opencost/pkg/cloud/models"
  15. "github.com/opencost/opencost/pkg/clustercache"
  16. "github.com/opencost/opencost/pkg/env"
  17. v1 "k8s.io/api/core/v1"
  18. )
  19. // OTC node pricing attributes
  20. type OTCNodeAttributes struct {
  21. Type string // like s2.large.1
  22. OS string // like windows
  23. Price string // (in EUR) like 0.023
  24. RAM string // (in GB) like 2
  25. VCPU string // like 8
  26. }
  27. type OTCPVAttributes struct {
  28. Type string // like vss.ssd
  29. Price string // (in EUR/GB/h) like 0.01
  30. }
  31. // OTC pricing is either for a node, a persistent volume (or a database, network, cluster, ...)
  32. type OTCPricing struct {
  33. NodeAttributes *OTCNodeAttributes
  34. PVAttributes *OTCPVAttributes
  35. }
  36. // the main provider struct
  37. type OTC struct {
  38. Clientset clustercache.ClusterCache
  39. Pricing map[string]*OTCPricing
  40. Config models.ProviderConfig
  41. ClusterRegion string
  42. projectID string
  43. clusterManagementPrice float64
  44. BaseCPUPrice string
  45. BaseRAMPrice string
  46. BaseGPUPrice string
  47. ValidPricingKeys map[string]bool
  48. DownloadPricingDataLock sync.RWMutex
  49. }
  50. // Kubernetes to OTC OS conversion
  51. /* Note:
  52. Kubernetes cannot fill the "kubernetes.io/os" label with the variety that OTC provides
  53. because it is based on the runtime.GOOS variable (https://kubernetes.io/docs/reference/labels-annotations-taints/#kubernetes-io-os)
  54. which can only contain some os. The pricing between everything but windows differs
  55. by about 5ct - 30ct per hour, so most os get treated like Open Linux.
  56. */
  57. var kubernetesOSTypes = map[string]string{
  58. "linux": "Open Linux",
  59. "windows": "Windows",
  60. "Open Linux": "linux",
  61. "Oracle Linux": "linux",
  62. "SUSE Linux": "linux",
  63. "SUSE for SAP": "linux",
  64. "RedHat Linux": "linux",
  65. "Windows": "windows",
  66. }
  67. // Currently assumes that no GPU is present
  68. // but aws does that too, so its fine.
  69. type otcKey struct {
  70. ProviderID string
  71. Labels map[string]string
  72. }
  73. func (k *otcKey) GPUCount() int {
  74. return 0
  75. }
  76. func (k *otcKey) GPUType() string {
  77. return ""
  78. }
  79. func (k *otcKey) ID() string {
  80. return k.ProviderID
  81. }
  82. type otcPVKey struct {
  83. RegionID string
  84. Type string
  85. Size string // in GB
  86. Labels map[string]string
  87. ProviderId string
  88. StorageClassParameters map[string]string
  89. }
  90. func (k *otcPVKey) Features() string {
  91. fmt.Printf("features for pv %s", k.ID())
  92. return k.RegionID + "," + k.Type
  93. }
  94. func (k *otcKey) Features() string {
  95. instanceType, _ := util.GetInstanceType(k.Labels)
  96. operatingSystem, _ := util.GetOperatingSystem(k.Labels)
  97. ClusterRegion, _ := util.GetRegion(k.Labels)
  98. key := ClusterRegion + "," + instanceType + "," + operatingSystem
  99. return key
  100. }
  101. // Extract/generate a key that holds the data required to calculate
  102. // the cost of the given node (like s2.large.4).
  103. func (otc *OTC) GetKey(labels map[string]string, n *v1.Node) models.Key {
  104. return &otcKey{
  105. Labels: labels,
  106. ProviderID: labels["providerID"],
  107. }
  108. }
  109. // Returns the storage class for a persistent volume key.
  110. func (k *otcPVKey) GetStorageClass() string {
  111. return k.Type
  112. }
  113. // Returns the provider id for a persistent volume key.
  114. func (k *otcPVKey) ID() string {
  115. return k.ProviderId
  116. }
  117. func (otc *OTC) GetPVKey(pv *v1.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
  118. providerID := ""
  119. return &otcPVKey{
  120. Labels: pv.Labels,
  121. Type: pv.Spec.StorageClassName,
  122. StorageClassParameters: parameters,
  123. RegionID: defaultRegion,
  124. ProviderId: providerID,
  125. }
  126. }
  127. // Takes a resopnse from the otc api and the respective service name as an input
  128. // and extracts the resulting data into a product slice.
  129. func (otc *OTC) loadStructFromResponse(resp http.Response, serviceName string) ([]Product, error) {
  130. body, err := io.ReadAll(resp.Body)
  131. if err != nil {
  132. return nil, err
  133. }
  134. // Unmarshal the first bit of the response.
  135. wrapper := make(map[string]map[string]interface{})
  136. err = json.Unmarshal(body, &wrapper)
  137. if err != nil {
  138. return nil, err
  139. }
  140. // Unmarshal the second, more specific, bit of the response.
  141. data := make(map[string][]Product)
  142. tmp, err := json.Marshal(wrapper["response"]["result"])
  143. if err != nil {
  144. return nil, err
  145. }
  146. err = json.Unmarshal(tmp, &data)
  147. if err != nil {
  148. return nil, err
  149. }
  150. return data[serviceName], nil
  151. }
  152. // The product (price) data that is fetched from OTC
  153. //
  154. // If OsUnit, VCpu and Ram aren't given, the product
  155. // is a persistent volume, else it's a node.
  156. type Product struct {
  157. OpiFlavour string `json:"opiFlavour"`
  158. OsUnit string `json:"osUnit,omitempty"`
  159. PriceAmount string `json:"priceAmount"`
  160. VCpu string `json:"vCpu,omitempty"`
  161. Ram string `json:"ram,omitempty"`
  162. }
  163. /*
  164. Download the pricing data from the OTC API
  165. When a node has a specified price of e.g. 0.014 and
  166. the kubernetes node has a RAM attribute of 8232873984 Bytes.
  167. The price in Prometheus will be composed of:
  168. - the cpu/h price multiplied with the amount of VCPUs:
  169. 0.006904 * 1 => 0.006904
  170. - the RAM/h price multiplied with the amount of ram in GiB:
  171. 0.000925 * (8232873984/1024/1024/1024) => 0.0070924
  172. And the resulting node_total_hourly_price{} metric in Prometheus
  173. will approach the total node cost retrieved from OTC:
  174. ==> 0.006904 + 0.0070924 = 0.013996399999999999
  175. ~ 0.014
  176. */
  177. func (otc *OTC) DownloadPricingData() error {
  178. otc.DownloadPricingDataLock.Lock()
  179. defer otc.DownloadPricingDataLock.Unlock()
  180. // Fetch pricing data from the otc.json config in case downloading the pricing maps fails.
  181. c, err := otc.Config.GetCustomPricingData()
  182. if err != nil {
  183. log.Errorf("Error downloading default pricing data: %s", err.Error())
  184. }
  185. otc.BaseCPUPrice = c.CPU
  186. otc.BaseRAMPrice = c.RAM
  187. otc.BaseGPUPrice = c.GPU
  188. otc.clusterManagementPrice = 0.10 // TODO: What is the cluster management price?
  189. otc.projectID = c.ProjectID
  190. // Slice with all nodes currently present in the cluster.
  191. nodeList := otc.Clientset.GetAllNodes()
  192. // Slice with all storage classes.
  193. storageClasses := otc.Clientset.GetAllStorageClasses()
  194. for _, tmp := range storageClasses {
  195. fmt.Println("storage class found:")
  196. fmt.Println(tmp.Parameters)
  197. fmt.Println(tmp.Labels)
  198. fmt.Println(tmp.TypeMeta)
  199. fmt.Println(tmp.Size())
  200. }
  201. // Slice with all persistent volumes present in the cluster
  202. pvList := otc.Clientset.GetAllPersistentVolumes()
  203. // Create a slice of all existing keys in the current cluster.
  204. // (keys like "eu-de,s3.medium.1,linux" or "eu-de,s3.xlarge.2,windows")
  205. inputkeys := make(map[string]bool)
  206. tmp := []string{}
  207. for _, node := range nodeList {
  208. labels := node.GetObjectMeta().GetLabels()
  209. key := otc.GetKey(labels, node)
  210. inputkeys[key.Features()] = true
  211. tmp = append(tmp, key.Features())
  212. }
  213. for _, pv := range pvList {
  214. fmt.Println("storage class name \"" + pv.Spec.StorageClassName + "\" found")
  215. key := otc.GetPVKey(pv, map[string]string{}, "eu-de")
  216. inputkeys[key.Features()] = true
  217. tmp = append(tmp, key.Features())
  218. }
  219. otc.Pricing = make(map[string]*OTCPricing)
  220. otc.ValidPricingKeys = make(map[string]bool)
  221. // Get pricing data from API.
  222. nodePricingURL := "https://calculator.otc-service.com/de/open-telekom-price-api/?serviceName=ecs" /* + "&limitMax=200"*/ + "&columns%5B1%5D=opiFlavour" + "&columns%5B2%5D=osUnit" + "&columns%5B3%5D=vCpu" + "&columns%5B4%5D=ram" + "&columns%5B5%5D=priceAmount"
  223. pvPricingURL := "https://calculator.otc-service.com/de/open-telekom-price-api/?serviceName%5B0%5D=evs&columns%5B1%5D=opiFlavour&columns%5B2%5D=priceAmount&limitFrom=0&region%5B3%5D=eu-de"
  224. log.Info("Started downloading OTC pricing data...")
  225. resp, err := http.Get(nodePricingURL)
  226. if err != nil {
  227. return err
  228. }
  229. pvResp, err := http.Get(pvPricingURL)
  230. if err != nil {
  231. return err
  232. }
  233. log.Info("Succesfully downloaded OTC pricing data")
  234. var products []Product
  235. nodeProducts, err := otc.loadStructFromResponse(*resp, "ecs")
  236. if err != nil {
  237. return err
  238. }
  239. products = append(products, nodeProducts...)
  240. pvProducts, err := otc.loadStructFromResponse(*pvResp, "evs")
  241. if err != nil {
  242. return err
  243. }
  244. products = append(products, pvProducts...)
  245. // convert the otc-reponse product-structs to opencost-compatible node structs
  246. const ClusterRegion = "eu-de"
  247. for _, product := range products {
  248. var productPricing *OTCPricing
  249. var key string
  250. // if os is empty the product must be a persistent volume
  251. if product.OsUnit == "" {
  252. productPricing = &OTCPricing{
  253. PVAttributes: &OTCPVAttributes{
  254. Type: product.OpiFlavour,
  255. Price: strings.Split(strings.ReplaceAll(product.PriceAmount, ",", "."), " ")[0],
  256. },
  257. }
  258. key = ClusterRegion + "," + productPricing.PVAttributes.Type
  259. } else {
  260. // else it must be a node
  261. adjustedOS := kubernetesOSTypes[product.OsUnit]
  262. productPricing = &OTCPricing{
  263. NodeAttributes: &OTCNodeAttributes{
  264. Type: product.OpiFlavour,
  265. OS: adjustedOS,
  266. Price: strings.Split(strings.ReplaceAll(product.PriceAmount, ",", "."), " ")[0],
  267. RAM: strings.Split(product.Ram, " ")[0],
  268. VCPU: product.VCpu,
  269. },
  270. }
  271. key = ClusterRegion + "," + productPricing.NodeAttributes.Type + "," + productPricing.NodeAttributes.OS
  272. }
  273. // create a key similiar to the ones created with otcKey.Features()
  274. // so that the pricing data can be fetched using an otcKey
  275. log.Info("product \"" + key + "\" found")
  276. otc.Pricing[key] = productPricing
  277. otc.ValidPricingKeys[key] = true
  278. }
  279. return nil
  280. }
  281. func (otc *OTC) NetworkPricing() (*models.Network, error) {
  282. cpricing, err := otc.Config.GetCustomPricingData()
  283. if err != nil {
  284. return nil, err
  285. }
  286. znec, err := strconv.ParseFloat(cpricing.ZoneNetworkEgress, 64)
  287. if err != nil {
  288. return nil, err
  289. }
  290. rnec, err := strconv.ParseFloat(cpricing.RegionNetworkEgress, 64)
  291. if err != nil {
  292. return nil, err
  293. }
  294. inec, err := strconv.ParseFloat(cpricing.InternetNetworkEgress, 64)
  295. if err != nil {
  296. return nil, err
  297. }
  298. return &models.Network{
  299. ZoneNetworkEgressCost: znec,
  300. RegionNetworkEgressCost: rnec,
  301. InternetNetworkEgressCost: inec,
  302. }, nil
  303. }
  304. // NodePricing(Key) (*Node, PricingMetadata, error)
  305. // Read the keys features and determine the price of the Node described by
  306. // the key to construct a Pricing Node object to return and work with.
  307. func (otc *OTC) NodePricing(k models.Key) (*models.Node, models.PricingMetadata, error) {
  308. otc.DownloadPricingDataLock.RLock()
  309. defer otc.DownloadPricingDataLock.RUnlock()
  310. key := k.Features()
  311. meta := models.PricingMetadata{}
  312. log.Info("looking for pricing data of node with key features " + key)
  313. pricing, ok := otc.Pricing[key]
  314. if ok {
  315. // The pricing key was found in the pricing list of the otc provider.
  316. // Now create a pricing node from that data and return it.
  317. log.Info("pricing data found")
  318. return otc.createNode(pricing, k)
  319. } else if _, ok := otc.ValidPricingKeys[key]; ok {
  320. // The pricing key is actually valid, but somehow it could not be found.
  321. // Try re-downloading the pricing data to check for changes.
  322. log.Info("key is valid, but no associated pricing data could be found; trying to re-download pricing data")
  323. otc.DownloadPricingDataLock.RUnlock()
  324. err := otc.DownloadPricingData()
  325. otc.DownloadPricingDataLock.RLock()
  326. if err != nil {
  327. return &models.Node{
  328. Cost: otc.BaseCPUPrice,
  329. BaseCPUPrice: otc.BaseCPUPrice,
  330. BaseRAMPrice: otc.BaseRAMPrice,
  331. BaseGPUPrice: otc.BaseGPUPrice,
  332. UsesBaseCPUPrice: true,
  333. }, meta, err
  334. }
  335. pricing, ok = otc.Pricing[key]
  336. if !ok {
  337. // The given key does not exist in OTC or locally, return a default pricing node.
  338. return &models.Node{
  339. Cost: otc.BaseCPUPrice,
  340. BaseCPUPrice: otc.BaseCPUPrice,
  341. BaseRAMPrice: otc.BaseRAMPrice,
  342. BaseGPUPrice: otc.BaseGPUPrice,
  343. UsesBaseCPUPrice: true,
  344. }, meta, fmt.Errorf("unable to find any Pricing data for \"%s\"", key)
  345. }
  346. // The local pricing date was just outdated.
  347. log.Info("pricing data found after re-download")
  348. return otc.createNode(pricing, k)
  349. } else {
  350. // The given key is not valid, fall back to base pricing (handled by the costmodel)?
  351. log.Info("given key \"" + key + "\" is invalid; falling back to default pricing")
  352. return nil, meta, fmt.Errorf("invalid Pricing Key \"%s\"", key)
  353. }
  354. }
  355. // create a Pricing Node from the internal pricing struct and a key describing the kubernetes node
  356. func (otc *OTC) createNode(pricing *OTCPricing, key models.Key) (*models.Node, models.PricingMetadata, error) {
  357. // aws does some fancy stuff here, but it probably isn't that necessary
  358. // so just return the pricing node constructed directly from the internal struct
  359. meta := models.PricingMetadata{}
  360. return &models.Node{
  361. Cost: pricing.NodeAttributes.Price,
  362. VCPU: pricing.NodeAttributes.VCPU,
  363. RAM: pricing.NodeAttributes.RAM,
  364. BaseCPUPrice: otc.BaseCPUPrice,
  365. BaseRAMPrice: otc.BaseRAMPrice,
  366. BaseGPUPrice: otc.BaseGPUPrice,
  367. }, meta, nil
  368. }
  369. // give the order to read the custom provider config file
  370. func (otc *OTC) GetConfig() (*models.CustomPricing, error) {
  371. c, err := otc.Config.GetCustomPricingData()
  372. if err != nil {
  373. return nil, err
  374. }
  375. return c, nil
  376. }
  377. // load balancer cost
  378. // taken straight up from aws
  379. func (otc *OTC) LoadBalancerPricing() (*models.LoadBalancer, error) {
  380. return &models.LoadBalancer{
  381. Cost: 0.05,
  382. }, nil
  383. }
  384. // returns general info about the cluster
  385. // This method HAS to be overwritten as long as the CustomProvider
  386. // Field of the OTC struct is not set when initializing the provider
  387. // in "provider.go" (see all the other providers).
  388. func (otc *OTC) ClusterInfo() (map[string]string, error) {
  389. c, err := otc.GetConfig()
  390. if err != nil {
  391. return nil, err
  392. }
  393. m := make(map[string]string)
  394. m["name"] = "OTC Cluster #1"
  395. if clusterName := otc.getClusterName(c); clusterName != "" {
  396. m["name"] = clusterName
  397. }
  398. m["provider"] = opencost.OTCProvider
  399. m["account"] = c.ProjectID
  400. m["region"] = otc.ClusterRegion
  401. m["remoteReadEnabled"] = strconv.FormatBool(env.IsRemoteEnabled())
  402. m["id"] = env.GetClusterID()
  403. return m, nil
  404. }
  405. func (otc *OTC) getClusterName(cfg *models.CustomPricing) string {
  406. if cfg.ClusterName != "" {
  407. return cfg.ClusterName
  408. }
  409. for _, node := range otc.Clientset.GetAllNodes() {
  410. if clusterName, ok := node.Labels["name"]; ok {
  411. return clusterName
  412. }
  413. }
  414. return ""
  415. }
  416. // search for pricing data matching the given persistent volume key
  417. // in the provider's pricing list and return it
  418. func (otc *OTC) PVPricing(pvk models.PVKey) (*models.PV, error) {
  419. pricing, ok := otc.Pricing[pvk.Features()]
  420. if !ok {
  421. log.Info("Persistent Volume pricing not found for features \"" + pvk.Features() + "\"")
  422. log.Info("continuing with pricing for \"eu-de,vss.ssd\"")
  423. pricing, ok = otc.Pricing["eu-de,vss.ssd"]
  424. if !ok {
  425. log.Errorf("something went wrong, the DownloadPricing method probably didn't execute correctly")
  426. return &models.PV{}, nil
  427. }
  428. }
  429. // otc pv pricing is in the format: price per GB per month
  430. // this convertes that to: GB price per hour
  431. hourly, err := strconv.ParseFloat(pricing.PVAttributes.Price, 32)
  432. if err != nil {
  433. return &models.PV{}, err
  434. }
  435. hourly = hourly / 730
  436. return &models.PV{
  437. Cost: fmt.Sprintf("%v", hourly),
  438. Class: pricing.PVAttributes.Type,
  439. }, nil
  440. }
  441. // TODO: Implement method
  442. func (otc *OTC) GetAddresses() ([]byte, error) {
  443. return []byte{}, nil
  444. }
  445. // TODO: Implement method
  446. func (otc *OTC) GetDisks() ([]byte, error) {
  447. return []byte{}, nil
  448. }
  449. // TODO: Implement method
  450. func (otc *OTC) GetOrphanedResources() ([]models.OrphanedResource, error) {
  451. return []models.OrphanedResource{}, nil
  452. }
  453. // TODO: Implement method
  454. func (otc *OTC) AllNodePricing() (interface{}, error) {
  455. return nil, nil
  456. }
  457. // TODO: Implement method
  458. func (otc *OTC) UpdateConfig(r io.Reader, updateType string) (*models.CustomPricing, error) {
  459. return &models.CustomPricing{}, nil
  460. }
  461. // TODO: Implement method
  462. func (otc *OTC) UpdateConfigFromConfigMap(configMap map[string]string) (*models.CustomPricing, error) {
  463. return &models.CustomPricing{}, nil
  464. }
  465. // TODO: Implement method
  466. func (otc *OTC) GetManagementPlatform() (string, error) {
  467. return "", nil
  468. }
  469. // TODO: Implement method
  470. func (otc *OTC) GetLocalStorageQuery(start, end time.Duration, isPVC, isDeleted bool) string {
  471. return ""
  472. }
  473. // TODO: Implement method
  474. func (otc *OTC) ApplyReservedInstancePricing(nodes map[string]*models.Node) {
  475. }
  476. func (otc *OTC) ServiceAccountStatus() *models.ServiceAccountStatus {
  477. return &models.ServiceAccountStatus{
  478. Checks: []*models.ServiceAccountCheck{},
  479. }
  480. }
  481. // TODO: Implement method
  482. func (otc *OTC) PricingSourceStatus() map[string]*models.PricingSource {
  483. return map[string]*models.PricingSource{}
  484. }
  485. // TODO: Implement method
  486. func (otc *OTC) ClusterManagementPricing() (string, float64, error) {
  487. return "", 0.0, nil
  488. }
  489. func (otc *OTC) CombinedDiscountForNode(nodeType string, reservedInstance bool, defaultDiscount, negotiatedDiscount float64) float64 {
  490. return 1.0 - ((1.0 - defaultDiscount) * (1.0 - negotiatedDiscount))
  491. }
  492. // Regions retrieved from https://www.open-telekom-cloud.com/de/business-navigator/hochverfuegbare-rechenzentren
  493. var otcRegions = []string{
  494. "eu-de",
  495. "eu-nl",
  496. }
  497. func (otc *OTC) Regions() []string {
  498. regionOverrides := env.GetRegionOverrideList()
  499. if len(regionOverrides) > 0 {
  500. log.Debugf("Overriding OTC regions with configured region list: %+v", regionOverrides)
  501. return regionOverrides
  502. }
  503. return otcRegions
  504. }
  505. // PricingSourceSummary returns the pricing source summary for the provider.
  506. // The summary represents what was _parsed_ from the pricing source, not what
  507. // was returned from the relevant API.
  508. func (otc *OTC) PricingSourceSummary() interface{} {
  509. // encode the pricing source summary as a JSON string
  510. return otc.Pricing
  511. }