| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685 |
- 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
- }
|