|
|
@@ -0,0 +1,390 @@
|
|
|
+package stackit
|
|
|
+
|
|
|
+import (
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
+ "sync"
|
|
|
+
|
|
|
+ coreenv "github.com/opencost/opencost/core/pkg/env"
|
|
|
+ "github.com/opencost/opencost/pkg/cloud/models"
|
|
|
+ "github.com/opencost/opencost/pkg/cloud/utils"
|
|
|
+
|
|
|
+ "github.com/opencost/opencost/core/pkg/clustercache"
|
|
|
+ "github.com/opencost/opencost/core/pkg/opencost"
|
|
|
+ "github.com/opencost/opencost/core/pkg/util"
|
|
|
+ "github.com/opencost/opencost/core/pkg/util/json"
|
|
|
+ "github.com/opencost/opencost/pkg/env"
|
|
|
+
|
|
|
+ "github.com/opencost/opencost/core/pkg/log"
|
|
|
+)
|
|
|
+
|
|
|
+const (
|
|
|
+ StackitPIMPricingSource = "STACKIT PIM API Pricing"
|
|
|
+)
|
|
|
+
|
|
|
+type STACKIT struct {
|
|
|
+ Clientset clustercache.ClusterCache
|
|
|
+ Config models.ProviderConfig
|
|
|
+ ClusterRegion string
|
|
|
+ ClusterAccountID string
|
|
|
+ DownloadPricingDataLock sync.RWMutex
|
|
|
+
|
|
|
+ // PIM API pricing cache (protected by DownloadPricingDataLock)
|
|
|
+ pimFlavors map[string]*pimFlavorPricing
|
|
|
+ pimStorage map[string]*pimStoragePricing
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) PricingSourceSummary() interface{} {
|
|
|
+ s.DownloadPricingDataLock.RLock()
|
|
|
+ defer s.DownloadPricingDataLock.RUnlock()
|
|
|
+ return s.pimFlavors
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) DownloadPricingData() error {
|
|
|
+ s.DownloadPricingDataLock.Lock()
|
|
|
+ defer s.DownloadPricingDataLock.Unlock()
|
|
|
+
|
|
|
+ flavors, storage, err := downloadPIMPricing()
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("STACKIT: failed to download pricing from PIM API: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(flavors) == 0 {
|
|
|
+ return fmt.Errorf("STACKIT: PIM API returned no VM flavor pricing data")
|
|
|
+ }
|
|
|
+
|
|
|
+ s.pimFlavors = flavors
|
|
|
+ s.pimStorage = storage
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) AllNodePricing() (interface{}, error) {
|
|
|
+ s.DownloadPricingDataLock.RLock()
|
|
|
+ defer s.DownloadPricingDataLock.RUnlock()
|
|
|
+ return s.pimFlavors, nil
|
|
|
+}
|
|
|
+
|
|
|
+type stackitKey struct {
|
|
|
+ Labels map[string]string
|
|
|
+}
|
|
|
+
|
|
|
+func (k *stackitKey) Features() string {
|
|
|
+ instanceType, _ := util.GetInstanceType(k.Labels)
|
|
|
+ zone, _ := util.GetZone(k.Labels)
|
|
|
+ return zone + "," + instanceType
|
|
|
+}
|
|
|
+
|
|
|
+func (k *stackitKey) GPUCount() int {
|
|
|
+ return 0
|
|
|
+}
|
|
|
+
|
|
|
+func (k *stackitKey) GPUType() string {
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
+func (k *stackitKey) ID() string {
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) NodePricing(key models.Key) (*models.Node, models.PricingMetadata, error) {
|
|
|
+ s.DownloadPricingDataLock.RLock()
|
|
|
+ defer s.DownloadPricingDataLock.RUnlock()
|
|
|
+
|
|
|
+ meta := models.PricingMetadata{}
|
|
|
+
|
|
|
+ // Extract instance type from key features ("zone,instanceType")
|
|
|
+ features := key.Features()
|
|
|
+ parts := strings.Split(features, ",")
|
|
|
+ instanceType := ""
|
|
|
+ if len(parts) >= 2 {
|
|
|
+ instanceType = parts[1]
|
|
|
+ }
|
|
|
+
|
|
|
+ pf, ok := s.pimFlavors[instanceType]
|
|
|
+ if !ok {
|
|
|
+ return nil, meta, fmt.Errorf("STACKIT: no pricing data found for instance type %q", instanceType)
|
|
|
+ }
|
|
|
+
|
|
|
+ ramBytes := int64(pf.RAMGB * 1024 * 1024 * 1024)
|
|
|
+ return &models.Node{
|
|
|
+ Cost: pf.HourlyCost,
|
|
|
+ VCPU: fmt.Sprintf("%d", pf.VCPU),
|
|
|
+ RAM: fmt.Sprintf("%g", pf.RAMGB),
|
|
|
+ RAMBytes: fmt.Sprintf("%d", ramBytes),
|
|
|
+ GPU: fmt.Sprintf("%d", pf.GPUCount),
|
|
|
+ GPUName: pf.GPUType,
|
|
|
+ InstanceType: instanceType,
|
|
|
+ Region: s.ClusterRegion,
|
|
|
+ PricingType: models.DefaultPrices,
|
|
|
+ }, meta, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) LoadBalancerPricing() (*models.LoadBalancer, error) {
|
|
|
+ config, err := s.GetConfig()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("unable to get config: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ lbPrice := 0.0
|
|
|
+ if config.DefaultLBPrice != "" {
|
|
|
+ lbPrice, _ = strconv.ParseFloat(config.DefaultLBPrice, 64)
|
|
|
+ }
|
|
|
+
|
|
|
+ return &models.LoadBalancer{
|
|
|
+ Cost: lbPrice,
|
|
|
+ }, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) NetworkPricing() (*models.Network, error) {
|
|
|
+ config, err := s.GetConfig()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("unable to get config: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ zoneEgress, _ := strconv.ParseFloat(config.ZoneNetworkEgress, 64)
|
|
|
+ regionEgress, _ := strconv.ParseFloat(config.RegionNetworkEgress, 64)
|
|
|
+ internetEgress, _ := strconv.ParseFloat(config.InternetNetworkEgress, 64)
|
|
|
+ natEgress, _ := strconv.ParseFloat(config.NatGatewayEgress, 64)
|
|
|
+ natIngress, _ := strconv.ParseFloat(config.NatGatewayIngress, 64)
|
|
|
+
|
|
|
+ return &models.Network{
|
|
|
+ ZoneNetworkEgressCost: zoneEgress,
|
|
|
+ RegionNetworkEgressCost: regionEgress,
|
|
|
+ InternetNetworkEgressCost: internetEgress,
|
|
|
+ NatGatewayEgressCost: natEgress,
|
|
|
+ NatGatewayIngressCost: natIngress,
|
|
|
+ }, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) GetKey(l map[string]string, n *clustercache.Node) models.Key {
|
|
|
+ return &stackitKey{
|
|
|
+ Labels: l,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type stackitPVKey struct {
|
|
|
+ Labels map[string]string
|
|
|
+ StorageClassName string
|
|
|
+ StorageClassParameters map[string]string
|
|
|
+ Name string
|
|
|
+ Zone string
|
|
|
+}
|
|
|
+
|
|
|
+func (key *stackitPVKey) ID() string {
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
+func (key *stackitPVKey) GetStorageClass() string {
|
|
|
+ return key.StorageClassName
|
|
|
+}
|
|
|
+
|
|
|
+func (key *stackitPVKey) Features() string {
|
|
|
+ return key.Zone
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) GetPVKey(pv *clustercache.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
|
|
|
+ zone := defaultRegion
|
|
|
+ if pv.Spec.CSI != nil {
|
|
|
+ parts := strings.Split(pv.Spec.CSI.VolumeHandle, "/")
|
|
|
+ if len(parts) >= 2 && parts[0] != "" {
|
|
|
+ zone = parts[0]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return &stackitPVKey{
|
|
|
+ Labels: pv.Labels,
|
|
|
+ StorageClassName: pv.Spec.StorageClassName,
|
|
|
+ StorageClassParameters: parameters,
|
|
|
+ Name: pv.Name,
|
|
|
+ Zone: zone,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) GpuPricing(nodeLabels map[string]string) (string, error) {
|
|
|
+ s.DownloadPricingDataLock.RLock()
|
|
|
+ defer s.DownloadPricingDataLock.RUnlock()
|
|
|
+
|
|
|
+ instanceType, _ := util.GetInstanceType(nodeLabels)
|
|
|
+ pf, ok := s.pimFlavors[instanceType]
|
|
|
+ if !ok || pf.GPUCount == 0 || pf.HourlyCost == "" {
|
|
|
+ return "", nil
|
|
|
+ }
|
|
|
+
|
|
|
+ hourlyCost, err := strconv.ParseFloat(pf.HourlyCost, 64)
|
|
|
+ if err != nil {
|
|
|
+ return "", fmt.Errorf("parsing STACKIT GPU hourly cost %q for %q: %w", pf.HourlyCost, instanceType, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ perGPUCost := hourlyCost / float64(pf.GPUCount)
|
|
|
+ return strconv.FormatFloat(perGPUCost, 'f', -1, 64), nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) PVPricing(pvk models.PVKey) (*models.PV, error) {
|
|
|
+ s.DownloadPricingDataLock.RLock()
|
|
|
+ defer s.DownloadPricingDataLock.RUnlock()
|
|
|
+
|
|
|
+ storageClass := pvk.GetStorageClass()
|
|
|
+
|
|
|
+ if len(s.pimStorage) > 0 {
|
|
|
+ // Exact storage class match
|
|
|
+ if sp, ok := s.pimStorage[storageClass]; ok {
|
|
|
+ return &models.PV{
|
|
|
+ Cost: sp.CostPerGBHr,
|
|
|
+ Class: storageClass,
|
|
|
+ }, nil
|
|
|
+ }
|
|
|
+ // Default to cheapest capacity-based storage
|
|
|
+ if sp, ok := s.pimStorage["default"]; ok {
|
|
|
+ return &models.PV{
|
|
|
+ Cost: sp.CostPerGBHr,
|
|
|
+ Class: storageClass,
|
|
|
+ }, nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ log.Debugf("STACKIT: no PV pricing found for storage class %q", storageClass)
|
|
|
+ return &models.PV{}, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) ServiceAccountStatus() *models.ServiceAccountStatus {
|
|
|
+ return &models.ServiceAccountStatus{
|
|
|
+ Checks: []*models.ServiceAccountCheck{},
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (*STACKIT) ClusterManagementPricing() (string, float64, error) {
|
|
|
+ return "", 0.0, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) CombinedDiscountForNode(instanceType string, isPreemptible bool, defaultDiscount, negotiatedDiscount float64) float64 {
|
|
|
+ return 1.0 - ((1.0 - defaultDiscount) * (1.0 - negotiatedDiscount))
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) Regions() []string {
|
|
|
+ regionOverrides := env.GetRegionOverrideList()
|
|
|
+ if len(regionOverrides) > 0 {
|
|
|
+ log.Debugf("Overriding STACKIT regions with configured region list: %+v", regionOverrides)
|
|
|
+ return regionOverrides
|
|
|
+ }
|
|
|
+ return []string{"eu01"}
|
|
|
+}
|
|
|
+
|
|
|
+func (*STACKIT) ApplyReservedInstancePricing(map[string]*models.Node) {}
|
|
|
+
|
|
|
+func (*STACKIT) GetAddresses() ([]byte, error) {
|
|
|
+ return nil, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (*STACKIT) GetDisks() ([]byte, error) {
|
|
|
+ return nil, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (*STACKIT) GetOrphanedResources() ([]models.OrphanedResource, error) {
|
|
|
+ return nil, errors.New("not implemented")
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) ClusterInfo() (map[string]string, error) {
|
|
|
+ remoteEnabled := env.IsRemoteEnabled()
|
|
|
+
|
|
|
+ m := make(map[string]string)
|
|
|
+ m["name"] = "STACKIT Cluster #1"
|
|
|
+ c, err := s.GetConfig()
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if c.ClusterName != "" {
|
|
|
+ m["name"] = c.ClusterName
|
|
|
+ }
|
|
|
+ m["provider"] = opencost.STACKITProvider
|
|
|
+ m["region"] = s.ClusterRegion
|
|
|
+ m["account"] = s.ClusterAccountID
|
|
|
+ m["remoteReadEnabled"] = strconv.FormatBool(remoteEnabled)
|
|
|
+ m["id"] = coreenv.GetClusterID()
|
|
|
+ return m, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) UpdateConfigFromConfigMap(a map[string]string) (*models.CustomPricing, error) {
|
|
|
+ return s.Config.UpdateFromMap(a)
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) UpdateConfig(r io.Reader, updateType string) (*models.CustomPricing, error) {
|
|
|
+ cp, err := s.Config.Update(func(c *models.CustomPricing) error {
|
|
|
+ a := make(map[string]interface{})
|
|
|
+ err := json.NewDecoder(r).Decode(&a)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ for k, v := range a {
|
|
|
+ kUpper := utils.ToTitle.String(k)
|
|
|
+ vstr, ok := v.(string)
|
|
|
+ if ok {
|
|
|
+ err := models.SetCustomPricingField(c, kUpper, vstr)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("error setting custom pricing field: %w", err)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ return fmt.Errorf("type error while updating config for %s", kUpper)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if env.IsRemoteEnabled() {
|
|
|
+ err := utils.UpdateClusterMeta(coreenv.GetClusterID(), c.ClusterName)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return cp, err
|
|
|
+ }
|
|
|
+
|
|
|
+ if refreshErr := s.DownloadPricingData(); refreshErr != nil {
|
|
|
+ log.Warnf("STACKIT: failed to refresh pricing after config update: %v", refreshErr)
|
|
|
+ }
|
|
|
+
|
|
|
+ return cp, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) GetConfig() (*models.CustomPricing, error) {
|
|
|
+ c, err := s.Config.GetCustomPricingData()
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if c.Discount == "" {
|
|
|
+ c.Discount = "0%"
|
|
|
+ }
|
|
|
+ if c.NegotiatedDiscount == "" {
|
|
|
+ c.NegotiatedDiscount = "0%"
|
|
|
+ }
|
|
|
+ if c.CurrencyCode == "" {
|
|
|
+ c.CurrencyCode = "EUR"
|
|
|
+ }
|
|
|
+ return c, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) GetManagementPlatform() (string, error) {
|
|
|
+ nodes := s.Clientset.GetAllNodes()
|
|
|
+ if len(nodes) > 0 {
|
|
|
+ n := nodes[0]
|
|
|
+ if _, ok := n.Labels["node.stackit.cloud/ske"]; ok {
|
|
|
+ return "ske", nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "", nil
|
|
|
+}
|
|
|
+
|
|
|
+func (s *STACKIT) PricingSourceStatus() map[string]*models.PricingSource {
|
|
|
+ s.DownloadPricingDataLock.RLock()
|
|
|
+ defer s.DownloadPricingDataLock.RUnlock()
|
|
|
+ return map[string]*models.PricingSource{
|
|
|
+ StackitPIMPricingSource: {
|
|
|
+ Name: StackitPIMPricingSource,
|
|
|
+ Enabled: true,
|
|
|
+ Available: len(s.pimFlavors) > 0,
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|