|
@@ -0,0 +1,685 @@
|
|
|
|
|
+package ovh
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "errors"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "net/url"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "sync"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ coreenv "github.com/opencost/opencost/core/pkg/env"
|
|
|
|
|
+ "github.com/opencost/opencost/core/pkg/log"
|
|
|
|
|
+ "github.com/opencost/opencost/core/pkg/opencost"
|
|
|
|
|
+ "github.com/opencost/opencost/core/pkg/util"
|
|
|
|
|
+ coreJSON "github.com/opencost/opencost/core/pkg/util/json"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/opencost/opencost/core/pkg/clustercache"
|
|
|
|
|
+ "github.com/opencost/opencost/pkg/cloud/models"
|
|
|
|
|
+ "github.com/opencost/opencost/pkg/cloud/utils"
|
|
|
|
|
+ "github.com/opencost/opencost/pkg/env"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const (
|
|
|
|
|
+ OVHCatalogPricing = "OVH Catalog Pricing"
|
|
|
|
|
+
|
|
|
|
|
+ BillingLabel = "ovh.opencost.io/billing"
|
|
|
|
|
+ NodepoolLabel = "nodepool"
|
|
|
|
|
+
|
|
|
|
|
+ microcentsPerUnit = 100_000_000.0
|
|
|
|
|
+ hoursPerMonth = 730.0
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// GPU instance prefixes on OVH
|
|
|
|
|
+var gpuPrefixes = []string{"t2-", "l4-", "l40s-", "a10-", "a100-"}
|
|
|
|
|
+
|
|
|
|
|
+// OVH MKS regions — update periodically or use REGION_OVERRIDE_LIST env var.
|
|
|
|
|
+// Source: https://us.ovhcloud.com/public-cloud/regions-availability/
|
|
|
|
|
+var ovhRegions = []string{
|
|
|
|
|
+ "BHS5", "DE1", "GRA5", "GRA7", "GRA9", "GRA11",
|
|
|
|
|
+ "OR1", "SBG5", "SGP1", "SYD1", "UK1", "VA1", "WAW1",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Storage class to OVH volume type mapping
|
|
|
|
|
+var storageClassToVolumeType = map[string]string{
|
|
|
|
|
+ "csi-cinder-high-speed-gen2": "high-speed-gen2",
|
|
|
|
|
+ "csi-cinder-high-speed": "high-speed",
|
|
|
|
|
+ "csi-cinder-classic": "classic",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// OVH implements the models.Provider interface for OVHcloud.
|
|
|
|
|
+type OVH struct {
|
|
|
|
|
+ Clientset clustercache.ClusterCache
|
|
|
|
|
+ Config models.ProviderConfig
|
|
|
|
|
+ Pricing map[string]*OVHFlavorPricing
|
|
|
|
|
+ VolumePricing map[string]float64
|
|
|
|
|
+ ClusterRegion string
|
|
|
|
|
+ ClusterAccountID string
|
|
|
|
|
+ DownloadLock sync.RWMutex
|
|
|
|
|
+ catalogURL string
|
|
|
|
|
+ monthlyNodepools []string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// OVHFlavorPricing holds pricing and specs for an OVH instance flavor.
|
|
|
|
|
+type OVHFlavorPricing struct {
|
|
|
|
|
+ HourlyPrice float64
|
|
|
|
|
+ MonthlyPrice float64 // monthly price converted to hourly (/730)
|
|
|
|
|
+ PlanCode string
|
|
|
|
|
+ VCPU int
|
|
|
|
|
+ RAM int // GB
|
|
|
|
|
+ Disk int // GB
|
|
|
|
|
+ GPU int
|
|
|
|
|
+ GPUName string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Catalog JSON types
|
|
|
|
|
+
|
|
|
|
|
+type ovhCatalog struct {
|
|
|
|
|
+ Plans []ovhPlan `json:"plans"`
|
|
|
|
|
+ Addons []ovhAddon `json:"addons"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhPlan struct {
|
|
|
|
|
+ PlanCode string `json:"planCode"`
|
|
|
|
|
+ AddonFamilies []ovhAddonFamily `json:"addonFamilies"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhAddonFamily struct {
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ Addons []string `json:"addons"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhAddon struct {
|
|
|
|
|
+ PlanCode string `json:"planCode"`
|
|
|
|
|
+ Product string `json:"product"`
|
|
|
|
|
+ Pricings []ovhPricing `json:"pricings"`
|
|
|
|
|
+ Blobs *ovhBlobs `json:"blobs"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhPricing struct {
|
|
|
|
|
+ Price int64 `json:"price"`
|
|
|
|
|
+ Type string `json:"type"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhBlobs struct {
|
|
|
|
|
+ Technical *ovhTechnical `json:"technical"`
|
|
|
|
|
+ Commercial *ovhCommercial `json:"commercial"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhTechnical struct {
|
|
|
|
|
+ CPU *ovhCPU `json:"cpu"`
|
|
|
|
|
+ Memory *ovhMemory `json:"memory"`
|
|
|
|
|
+ Storage *ovhStorage `json:"storage"`
|
|
|
|
|
+ GPU *ovhGPU `json:"gpu"`
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhCommercial struct {
|
|
|
|
|
+ BrickSubtype string `json:"brickSubtype"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhCPU struct {
|
|
|
|
|
+ Cores float64 `json:"cores"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhMemory struct {
|
|
|
|
|
+ Size float64 `json:"size"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhStorage struct {
|
|
|
|
|
+ Disks []ovhDisk `json:"disks"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhDisk struct {
|
|
|
|
|
+ Capacity float64 `json:"capacity"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ovhGPU struct {
|
|
|
|
|
+ Number int `json:"number"`
|
|
|
|
|
+ Model string `json:"model"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// parseCatalog extracts instance and volume pricing from the OVH public cloud catalog.
|
|
|
|
|
+func parseCatalog(data []byte) (map[string]*OVHFlavorPricing, map[string]float64, error) {
|
|
|
|
|
+ var catalog ovhCatalog
|
|
|
|
|
+ if err := json.Unmarshal(data, &catalog); err != nil {
|
|
|
|
|
+ return nil, nil, fmt.Errorf("failed to unmarshal OVH catalog: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Find the project.2018 plan and collect addon planCodes
|
|
|
|
|
+ instanceAddons := make(map[string]bool)
|
|
|
|
|
+ volumeAddons := make(map[string]bool)
|
|
|
|
|
+
|
|
|
|
|
+ var projectPlan *ovhPlan
|
|
|
|
|
+ for i := range catalog.Plans {
|
|
|
|
|
+ if catalog.Plans[i].PlanCode == "project.2018" {
|
|
|
|
|
+ projectPlan = &catalog.Plans[i]
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if projectPlan == nil {
|
|
|
|
|
+ return nil, nil, fmt.Errorf("project.2018 plan not found in OVH catalog")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, family := range projectPlan.AddonFamilies {
|
|
|
|
|
+ switch family.Name {
|
|
|
|
|
+ case "instance":
|
|
|
|
|
+ for _, a := range family.Addons {
|
|
|
|
|
+ instanceAddons[a] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ case "volume":
|
|
|
|
|
+ for _, a := range family.Addons {
|
|
|
|
|
+ volumeAddons[a] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ pricing := make(map[string]*OVHFlavorPricing)
|
|
|
|
|
+ volumePricing := make(map[string]float64)
|
|
|
|
|
+
|
|
|
|
|
+ for _, addon := range catalog.Addons {
|
|
|
|
|
+ if instanceAddons[addon.PlanCode] {
|
|
|
|
|
+ parseInstanceAddon(addon, pricing)
|
|
|
|
|
+ } else if volumeAddons[addon.PlanCode] {
|
|
|
|
|
+ parseVolumeAddon(addon, volumePricing)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return pricing, volumePricing, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// parseInstanceAddon extracts flavor pricing from an instance addon entry.
|
|
|
|
|
+func parseInstanceAddon(addon ovhAddon, pricing map[string]*OVHFlavorPricing) {
|
|
|
|
|
+ planCode := addon.PlanCode
|
|
|
|
|
+ isMonthly := strings.Contains(planCode, ".monthly.")
|
|
|
|
|
+
|
|
|
|
|
+ // Extract flavor name: strip .consumption or .monthly.postpaid suffix
|
|
|
|
|
+ flavorName := planCode
|
|
|
|
|
+ if idx := strings.Index(planCode, ".consumption"); idx > 0 {
|
|
|
|
|
+ flavorName = planCode[:idx]
|
|
|
|
|
+ } else if idx := strings.Index(planCode, ".monthly."); idx > 0 {
|
|
|
|
|
+ flavorName = planCode[:idx]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if len(addon.Pricings) == 0 {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Select pricing entry by type: "consumption" for hourly, "monthly.postpaid" for monthly
|
|
|
|
|
+ targetType := "consumption"
|
|
|
|
|
+ if isMonthly {
|
|
|
|
|
+ targetType = "monthly.postpaid"
|
|
|
|
|
+ }
|
|
|
|
|
+ var rawPrice float64
|
|
|
|
|
+ matched := false
|
|
|
|
|
+ for _, p := range addon.Pricings {
|
|
|
|
|
+ if p.Type == targetType {
|
|
|
|
|
+ rawPrice = float64(p.Price) / microcentsPerUnit
|
|
|
|
|
+ matched = true
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if !matched {
|
|
|
|
|
+ rawPrice = float64(addon.Pricings[0].Price) / microcentsPerUnit
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ entry, exists := pricing[flavorName]
|
|
|
|
|
+ if !exists {
|
|
|
|
|
+ entry = &OVHFlavorPricing{PlanCode: planCode}
|
|
|
|
|
+ pricing[flavorName] = entry
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if isMonthly {
|
|
|
|
|
+ entry.MonthlyPrice = rawPrice / hoursPerMonth
|
|
|
|
|
+ } else {
|
|
|
|
|
+ entry.HourlyPrice = rawPrice
|
|
|
|
|
+ // Extract specs from blobs
|
|
|
|
|
+ if addon.Blobs != nil && addon.Blobs.Technical != nil {
|
|
|
|
|
+ tech := addon.Blobs.Technical
|
|
|
|
|
+ if tech.CPU != nil {
|
|
|
|
|
+ entry.VCPU = int(tech.CPU.Cores)
|
|
|
|
|
+ }
|
|
|
|
|
+ if tech.Memory != nil {
|
|
|
|
|
+ entry.RAM = int(tech.Memory.Size)
|
|
|
|
|
+ }
|
|
|
|
|
+ if tech.Storage != nil && len(tech.Storage.Disks) > 0 {
|
|
|
|
|
+ entry.Disk = int(tech.Storage.Disks[0].Capacity)
|
|
|
|
|
+ }
|
|
|
|
|
+ if tech.GPU != nil {
|
|
|
|
|
+ entry.GPU = tech.GPU.Number
|
|
|
|
|
+ entry.GPUName = tech.GPU.Model
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// parseVolumeAddon extracts volume pricing from a volume addon entry.
|
|
|
|
|
+func parseVolumeAddon(addon ovhAddon, volumePricing map[string]float64) {
|
|
|
|
|
+ planCode := addon.PlanCode
|
|
|
|
|
+ // Extract volume type: volume.high-speed-gen2.consumption -> high-speed-gen2
|
|
|
|
|
+ parts := strings.SplitN(planCode, ".", 3)
|
|
|
|
|
+ if len(parts) < 3 {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ volumeType := parts[1]
|
|
|
|
|
+
|
|
|
|
|
+ if len(addon.Pricings) == 0 {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Only use consumption (hourly) pricing
|
|
|
|
|
+ for _, p := range addon.Pricings {
|
|
|
|
|
+ if p.Type == "consumption" {
|
|
|
|
|
+ volumePricing[volumeType] = float64(p.Price) / microcentsPerUnit
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ volumePricing[volumeType] = float64(addon.Pricings[0].Price) / microcentsPerUnit
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ovhKey implements models.Key for OVH nodes.
|
|
|
|
|
+type ovhKey struct {
|
|
|
|
|
+ Labels map[string]string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (k *ovhKey) Features() string {
|
|
|
|
|
+ region, _ := util.GetRegion(k.Labels)
|
|
|
|
|
+ instanceType, _ := util.GetInstanceType(k.Labels)
|
|
|
|
|
+ return region + "," + instanceType
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (k *ovhKey) GPUType() string {
|
|
|
|
|
+ instanceType, _ := util.GetInstanceType(k.Labels)
|
|
|
|
|
+ for _, prefix := range gpuPrefixes {
|
|
|
|
|
+ if strings.HasPrefix(instanceType, prefix) {
|
|
|
|
|
+ return instanceType
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return ""
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GPUCount returns 0 as GPU count is derived from the flavor lookup in NodePricing,
|
|
|
|
|
+// not from node labels. This is consistent with other providers.
|
|
|
|
|
+func (k *ovhKey) GPUCount() int {
|
|
|
|
|
+ return 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (k *ovhKey) ID() string {
|
|
|
|
|
+ return ""
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ovhPVKey implements models.PVKey for OVH persistent volumes.
|
|
|
|
|
+type ovhPVKey struct {
|
|
|
|
|
+ StorageClassName string
|
|
|
|
|
+ StorageClassParameters map[string]string
|
|
|
|
|
+ Zone string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (k *ovhPVKey) Features() string {
|
|
|
|
|
+ // First try the StorageClass name mapping
|
|
|
|
|
+ volumeType := storageClassToVolumeType[k.StorageClassName]
|
|
|
|
|
+ // Fallback to the "type" parameter from StorageClass (e.g. "high-speed-gen2")
|
|
|
|
|
+ if volumeType == "" && k.StorageClassParameters != nil {
|
|
|
|
|
+ volumeType = k.StorageClassParameters["type"]
|
|
|
|
|
+ }
|
|
|
|
|
+ return k.Zone + "," + volumeType
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (k *ovhPVKey) GetStorageClass() string {
|
|
|
|
|
+ return k.StorageClassName
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (k *ovhPVKey) ID() string {
|
|
|
|
|
+ return ""
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// isMonthlyBilling determines whether a node uses monthly billing.
|
|
|
|
|
+func isMonthlyBilling(labels map[string]string, monthlyPools []string) bool {
|
|
|
|
|
+ if v, ok := labels[BillingLabel]; ok {
|
|
|
|
|
+ if v == "monthly" {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ if v == "hourly" {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if pool, ok := labels[NodepoolLabel]; ok {
|
|
|
|
|
+ for _, mp := range monthlyPools {
|
|
|
|
|
+ if pool == mp {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (c *OVH) getCatalogURL() string {
|
|
|
|
|
+ if c.catalogURL != "" {
|
|
|
|
|
+ return c.catalogURL
|
|
|
|
|
+ }
|
|
|
|
|
+ u, _ := url.Parse("https://eu.api.ovh.com/v1/order/catalog/public/cloud")
|
|
|
|
|
+ q := u.Query()
|
|
|
|
|
+ q.Set("ovhSubsidiary", env.GetOVHSubsidiary())
|
|
|
|
|
+ u.RawQuery = q.Encode()
|
|
|
|
|
+ return u.String()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// DownloadPricingData fetches the OVH public cloud catalog and parses pricing.
|
|
|
|
|
+func (c *OVH) DownloadPricingData() error {
|
|
|
|
|
+ c.DownloadLock.Lock()
|
|
|
|
|
+ defer c.DownloadLock.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ c.monthlyNodepools = env.GetOVHMonthlyNodepools()
|
|
|
|
|
+
|
|
|
|
|
+ if c.Pricing != nil {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ catalogURL := c.getCatalogURL()
|
|
|
|
|
+ log.Infof("Downloading OVH pricing data from %s", catalogURL)
|
|
|
|
|
+
|
|
|
|
|
+ client := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
|
+ resp, err := client.Get(catalogURL)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return fmt.Errorf("failed to fetch OVH catalog: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer resp.Body.Close()
|
|
|
|
|
+
|
|
|
|
|
+ if resp.StatusCode != http.StatusOK {
|
|
|
|
|
+ return fmt.Errorf("OVH catalog returned status %d", resp.StatusCode)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ body, err := io.ReadAll(resp.Body)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return fmt.Errorf("failed to read OVH catalog response: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ pricing, volumePricing, err := parseCatalog(body)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ c.Pricing = pricing
|
|
|
|
|
+ c.VolumePricing = volumePricing
|
|
|
|
|
+
|
|
|
|
|
+ log.Infof("Loaded OVH pricing: %d flavors, %d volume types", len(pricing), len(volumePricing))
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// NodePricing returns pricing for a specific node based on its key.
|
|
|
|
|
+func (c *OVH) NodePricing(key models.Key) (*models.Node, models.PricingMetadata, error) {
|
|
|
|
|
+ c.DownloadLock.RLock()
|
|
|
|
|
+ defer c.DownloadLock.RUnlock()
|
|
|
|
|
+
|
|
|
|
|
+ meta := models.PricingMetadata{Source: "ovh"}
|
|
|
|
|
+
|
|
|
|
|
+ features := strings.Split(key.Features(), ",")
|
|
|
|
|
+ if len(features) < 2 {
|
|
|
|
|
+ return nil, meta, fmt.Errorf("invalid key features: %s", key.Features())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ region := features[0]
|
|
|
|
|
+ instanceType := features[1]
|
|
|
|
|
+
|
|
|
|
|
+ flavor, ok := c.Pricing[instanceType]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return nil, meta, fmt.Errorf("flavor not found in OVH pricing: %s", instanceType)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Determine billing mode
|
|
|
|
|
+ var labels map[string]string
|
|
|
|
|
+ if k, ok := key.(*ovhKey); ok {
|
|
|
|
|
+ labels = k.Labels
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ price := flavor.HourlyPrice
|
|
|
|
|
+ if isMonthlyBilling(labels, c.monthlyNodepools) && flavor.MonthlyPrice > 0 {
|
|
|
|
|
+ price = flavor.MonthlyPrice
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return &models.Node{
|
|
|
|
|
+ Cost: fmt.Sprintf("%f", price),
|
|
|
|
|
+ VCPU: fmt.Sprintf("%d", flavor.VCPU),
|
|
|
|
|
+ RAM: fmt.Sprintf("%d", flavor.RAM),
|
|
|
|
|
+ Storage: fmt.Sprintf("%d", flavor.Disk),
|
|
|
|
|
+ GPU: fmt.Sprintf("%d", flavor.GPU),
|
|
|
|
|
+ GPUName: flavor.GPUName,
|
|
|
|
|
+ InstanceType: instanceType,
|
|
|
|
|
+ Region: region,
|
|
|
|
|
+ // DefaultPrices for both hourly and monthly; monthly prices are pre-amortized to hourly (/730).
|
|
|
|
|
+ PricingType: models.DefaultPrices,
|
|
|
|
|
+ }, meta, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// PVPricing returns pricing for a persistent volume.
|
|
|
|
|
+func (c *OVH) PVPricing(pvk models.PVKey) (*models.PV, error) {
|
|
|
|
|
+ c.DownloadLock.RLock()
|
|
|
|
|
+ defer c.DownloadLock.RUnlock()
|
|
|
|
|
+
|
|
|
|
|
+ features := strings.Split(pvk.Features(), ",")
|
|
|
|
|
+ volumeType := ""
|
|
|
|
|
+ if len(features) > 1 {
|
|
|
|
|
+ volumeType = features[1]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cost, ok := c.VolumePricing[volumeType]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ log.Debugf("Volume pricing not found for storage class %s (type: %s)", pvk.GetStorageClass(), volumeType)
|
|
|
|
|
+ return &models.PV{}, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return &models.PV{
|
|
|
|
|
+ Cost: fmt.Sprintf("%f", cost),
|
|
|
|
|
+ Class: pvk.GetStorageClass(),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// NetworkPricing returns static network pricing for OVH.
|
|
|
|
|
+func (c *OVH) NetworkPricing() (*models.Network, error) {
|
|
|
|
|
+ return &models.Network{
|
|
|
|
|
+ ZoneNetworkEgressCost: 0,
|
|
|
|
|
+ RegionNetworkEgressCost: 0,
|
|
|
|
|
+ InternetNetworkEgressCost: 0.01,
|
|
|
|
|
+ NatGatewayEgressCost: 0,
|
|
|
|
|
+ NatGatewayIngressCost: 0,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// LoadBalancerPricing returns static load balancer pricing for OVH.
|
|
|
|
|
+func (c *OVH) LoadBalancerPricing() (*models.LoadBalancer, error) {
|
|
|
|
|
+ return &models.LoadBalancer{
|
|
|
|
|
+ Cost: 0.012,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GpuPricing returns GPU-specific pricing (not used for OVH).
|
|
|
|
|
+func (c *OVH) GpuPricing(nodeLabels map[string]string) (string, error) {
|
|
|
|
|
+ return "", nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ClusterInfo returns metadata about the cluster.
|
|
|
|
|
+func (c *OVH) ClusterInfo() (map[string]string, error) {
|
|
|
|
|
+ remoteEnabled := env.IsRemoteEnabled()
|
|
|
|
|
+
|
|
|
|
|
+ m := make(map[string]string)
|
|
|
|
|
+ m["name"] = "OVH Cluster #1"
|
|
|
|
|
+
|
|
|
|
|
+ conf, err := c.GetConfig()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if conf.ClusterName != "" {
|
|
|
|
|
+ m["name"] = conf.ClusterName
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ m["provider"] = opencost.OVHProvider
|
|
|
|
|
+ m["region"] = c.ClusterRegion
|
|
|
|
|
+ m["account"] = c.ClusterAccountID
|
|
|
|
|
+ m["remoteReadEnabled"] = strconv.FormatBool(remoteEnabled)
|
|
|
|
|
+ m["id"] = coreenv.GetClusterID()
|
|
|
|
|
+ return m, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetManagementPlatform detects the management platform from node labels.
|
|
|
|
|
+func (c *OVH) GetManagementPlatform() (string, error) {
|
|
|
|
|
+ nodes := c.Clientset.GetAllNodes()
|
|
|
|
|
+ if len(nodes) > 0 {
|
|
|
|
|
+ n := nodes[0]
|
|
|
|
|
+ if _, ok := n.Labels[NodepoolLabel]; ok {
|
|
|
|
|
+ return "mks", nil
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "", nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetKey returns a Key for matching node pricing.
|
|
|
|
|
+func (c *OVH) GetKey(labels map[string]string, n *clustercache.Node) models.Key {
|
|
|
|
|
+ return &ovhKey{Labels: labels}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetPVKey returns a PVKey for matching persistent volume pricing.
|
|
|
|
|
+func (c *OVH) GetPVKey(pv *clustercache.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
|
|
|
|
|
+ zone := ""
|
|
|
|
|
+ if pv.Spec.CSI != nil {
|
|
|
|
|
+ parts := strings.Split(pv.Spec.CSI.VolumeHandle, "/")
|
|
|
|
|
+ if len(parts) > 0 {
|
|
|
|
|
+ zone = parts[0]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return &ovhPVKey{
|
|
|
|
|
+ StorageClassName: pv.Spec.StorageClassName,
|
|
|
|
|
+ StorageClassParameters: parameters,
|
|
|
|
|
+ Zone: zone,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetAddresses is not implemented for OVH.
|
|
|
|
|
+func (c *OVH) GetAddresses() ([]byte, error) {
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetDisks is not implemented for OVH.
|
|
|
|
|
+func (c *OVH) GetDisks() ([]byte, error) {
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetOrphanedResources is not implemented for OVH.
|
|
|
|
|
+func (c *OVH) GetOrphanedResources() ([]models.OrphanedResource, error) {
|
|
|
|
|
+ return nil, errors.New("not implemented")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// AllNodePricing returns all cached node pricing data.
|
|
|
|
|
+func (c *OVH) AllNodePricing() (interface{}, error) {
|
|
|
|
|
+ c.DownloadLock.RLock()
|
|
|
|
|
+ defer c.DownloadLock.RUnlock()
|
|
|
|
|
+ return c.Pricing, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// UpdateConfigFromConfigMap updates config from a ConfigMap.
|
|
|
|
|
+func (c *OVH) UpdateConfigFromConfigMap(a map[string]string) (*models.CustomPricing, error) {
|
|
|
|
|
+ return c.Config.UpdateFromMap(a)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// UpdateConfig updates custom pricing from a JSON reader.
|
|
|
|
|
+func (c *OVH) UpdateConfig(r io.Reader, updateType string) (*models.CustomPricing, error) {
|
|
|
|
|
+ defer c.DownloadPricingData()
|
|
|
|
|
+
|
|
|
|
|
+ return c.Config.Update(func(cp *models.CustomPricing) error {
|
|
|
|
|
+ a := make(map[string]interface{})
|
|
|
|
|
+ err := coreJSON.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(cp, 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(), cp.ClusterName)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nil
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetConfig returns the custom pricing configuration with OVH defaults.
|
|
|
|
|
+func (c *OVH) GetConfig() (*models.CustomPricing, error) {
|
|
|
|
|
+ cp, err := c.Config.GetCustomPricingData()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if cp.Discount == "" {
|
|
|
|
|
+ cp.Discount = "0%"
|
|
|
|
|
+ }
|
|
|
|
|
+ if cp.NegotiatedDiscount == "" {
|
|
|
|
|
+ cp.NegotiatedDiscount = "0%"
|
|
|
|
|
+ }
|
|
|
|
|
+ if cp.CurrencyCode == "" {
|
|
|
|
|
+ cp.CurrencyCode = "EUR"
|
|
|
|
|
+ }
|
|
|
|
|
+ return cp, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ClusterManagementPricing returns the management cost for the cluster.
|
|
|
|
|
+func (c *OVH) ClusterManagementPricing() (string, float64, error) {
|
|
|
|
|
+ return "", 0.0, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// CombinedDiscountForNode calculates the combined discount for a node.
|
|
|
|
|
+func (c *OVH) CombinedDiscountForNode(instanceType string, isPreemptible bool, defaultDiscount, negotiatedDiscount float64) float64 {
|
|
|
|
|
+ return 1.0 - ((1.0 - defaultDiscount) * (1.0 - negotiatedDiscount))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Regions returns the list of supported OVH regions.
|
|
|
|
|
+func (c *OVH) Regions() []string {
|
|
|
|
|
+ regionOverrides := env.GetRegionOverrideList()
|
|
|
|
|
+ if len(regionOverrides) > 0 {
|
|
|
|
|
+ log.Debugf("Overriding OVH regions with configured region list: %+v", regionOverrides)
|
|
|
|
|
+ return regionOverrides
|
|
|
|
|
+ }
|
|
|
|
|
+ return ovhRegions
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ApplyReservedInstancePricing is a no-op for OVH.
|
|
|
|
|
+func (c *OVH) ApplyReservedInstancePricing(nodes map[string]*models.Node) {}
|
|
|
|
|
+
|
|
|
|
|
+// ServiceAccountStatus returns the service account status.
|
|
|
|
|
+func (c *OVH) ServiceAccountStatus() *models.ServiceAccountStatus {
|
|
|
|
|
+ return &models.ServiceAccountStatus{
|
|
|
|
|
+ Checks: []*models.ServiceAccountCheck{},
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// PricingSourceStatus returns the status of the pricing data source.
|
|
|
|
|
+func (c *OVH) PricingSourceStatus() map[string]*models.PricingSource {
|
|
|
|
|
+ return map[string]*models.PricingSource{
|
|
|
|
|
+ OVHCatalogPricing: {
|
|
|
|
|
+ Name: OVHCatalogPricing,
|
|
|
|
|
+ Enabled: true,
|
|
|
|
|
+ Available: true,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// PricingSourceSummary returns the parsed pricing data.
|
|
|
|
|
+func (c *OVH) PricingSourceSummary() interface{} {
|
|
|
|
|
+ return c.Pricing
|
|
|
|
|
+}
|