Răsfoiți Sursa

feat(cloud): add OVH cloud provider (#3627)

Signed-off-by: Jordan Labrosse <jordan.labrosse@gameverse.app>
Jordan Labrosse 2 luni în urmă
părinte
comite
d521538744

+ 13 - 0
configs/ovh.json

@@ -0,0 +1,13 @@
+{
+    "provider": "OVH",
+    "description": "OVH fallback prices: CPU/RAM from b2-7, GPU from t2-45 per-GPU-hour, storage from high-speed-gen2",
+    "CPU": "0.03405",
+    "spotCPU": "0.03405",
+    "RAM": "0.009729",
+    "spotRAM": "0.009729",
+    "GPU": "1.80",
+    "storage": "0.000119",
+    "zoneNetworkEgress": "0.0",
+    "regionNetworkEgress": "0.0",
+    "internetNetworkEgress": "0.01"
+}

+ 5 - 0
core/pkg/opencost/assetprops.go

@@ -193,6 +193,9 @@ const OTCProvider = "OTC"
 // DigitalOceanProvider describes the provider DigitalOcean
 const DigitalOceanProvider = "DigitalOcean"
 
+// OVHProvider describes the provider OVH
+const OVHProvider = "OVH"
+
 // NilProvider describes unknown provider
 const NilProvider = "-"
 
@@ -215,6 +218,8 @@ func ParseProvider(str string) string {
 		return OracleProvider
 	case "digitalocean", "doks", "do":
 		return DigitalOceanProvider
+	case "ovh", "ovhcloud", "ovh-mks":
+		return OVHProvider
 	default:
 		return NilProvider
 	}

+ 685 - 0
pkg/cloud/ovh/provider.go

@@ -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
+}

+ 419 - 0
pkg/cloud/ovh/provider_test.go

@@ -0,0 +1,419 @@
+package ovh
+
+import (
+	"math"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strconv"
+	"testing"
+
+	v1 "k8s.io/api/core/v1"
+)
+
+func newTestProvider(t *testing.T, filename string) *OVH {
+	t.Helper()
+
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		t.Fatalf("failed to read test fixture %s: %v", filename, err)
+	}
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write(data)
+	}))
+	t.Cleanup(srv.Close)
+
+	provider := &OVH{
+		catalogURL: srv.URL,
+	}
+	if err := provider.DownloadPricingData(); err != nil {
+		t.Fatalf("DownloadPricingData failed: %v", err)
+	}
+	return provider
+}
+
+func assertIntEqual(t *testing.T, name string, got, want int) {
+	t.Helper()
+	if got != want {
+		t.Errorf("%s: got %d, want %d", name, got, want)
+	}
+}
+
+func assertFloatClose(t *testing.T, name string, got, want, tolerance float64) {
+	t.Helper()
+	if math.Abs(got-want) > tolerance {
+		t.Errorf("%s: got %f, want %f (tolerance %f)", name, got, want, tolerance)
+	}
+}
+
+func parseFloat(t *testing.T, s string) float64 {
+	t.Helper()
+	v, err := strconv.ParseFloat(s, 64)
+	if err != nil {
+		t.Fatalf("parseFloat(%q) failed: %v", s, err)
+	}
+	return v
+}
+
+func TestParseCatalog(t *testing.T) {
+	data, err := os.ReadFile("testdata/ovh_catalog.json")
+	if err != nil {
+		t.Fatalf("failed to read catalog fixture: %v", err)
+	}
+
+	pricing, volumePricing, err := parseCatalog(data)
+	if err != nil {
+		t.Fatalf("parseCatalog failed: %v", err)
+	}
+
+	// b2-7 instance: hourly and monthly
+	b2, ok := pricing["b2-7"]
+	if !ok {
+		t.Fatal("b2-7 flavor not found")
+	}
+	// Hourly: 6810000 microcents / 100_000_000 = 0.0681
+	assertFloatClose(t, "b2-7 hourly", b2.HourlyPrice, 0.0681, 0.0001)
+	// Monthly: 2420000000 / 100_000_000 / 730 = 24.2 / 730
+	assertFloatClose(t, "b2-7 monthly", b2.MonthlyPrice, 24.2/730.0, 0.0001)
+	assertIntEqual(t, "b2-7 VCPU", b2.VCPU, 2)
+	assertIntEqual(t, "b2-7 RAM", b2.RAM, 7)
+	assertIntEqual(t, "b2-7 Disk", b2.Disk, 50)
+	assertIntEqual(t, "b2-7 GPU", b2.GPU, 0)
+
+	// t2-45 GPU instance
+	t2, ok := pricing["t2-45"]
+	if !ok {
+		t.Fatal("t2-45 flavor not found")
+	}
+	// Hourly: 180000000 / 100_000_000 = 1.8
+	assertFloatClose(t, "t2-45 hourly", t2.HourlyPrice, 1.8, 0.0001)
+	// Monthly: 63800000000 / 100_000_000 / 730 = 638 / 730
+	assertFloatClose(t, "t2-45 monthly", t2.MonthlyPrice, 638.0/730.0, 0.0001)
+	assertIntEqual(t, "t2-45 VCPU", t2.VCPU, 15)
+	assertIntEqual(t, "t2-45 RAM", t2.RAM, 45)
+	assertIntEqual(t, "t2-45 Disk", t2.Disk, 400)
+	assertIntEqual(t, "t2-45 GPU", t2.GPU, 1)
+	if t2.GPUName != "Tesla V100S" {
+		t.Errorf("t2-45 GPUName: got %q, want %q", t2.GPUName, "Tesla V100S")
+	}
+
+	// Volume pricing
+	// high-speed-gen2: 11900 / 100_000_000 = 0.000119
+	hsGen2, ok := volumePricing["high-speed-gen2"]
+	if !ok {
+		t.Fatal("high-speed-gen2 volume type not found")
+	}
+	assertFloatClose(t, "high-speed-gen2", hsGen2, 0.000119, 0.000001)
+
+	hs, ok := volumePricing["high-speed"]
+	if !ok {
+		t.Fatal("high-speed volume type not found")
+	}
+	assertFloatClose(t, "high-speed", hs, 0.000119, 0.000001)
+
+	// classic: 5900 / 100_000_000 = 0.000059
+	classic, ok := volumePricing["classic"]
+	if !ok {
+		t.Fatal("classic volume type not found")
+	}
+	assertFloatClose(t, "classic", classic, 0.000059, 0.000001)
+}
+
+func TestOVHKey(t *testing.T) {
+	key := &ovhKey{
+		Labels: map[string]string{
+			v1.LabelTopologyRegion:     "GRA7",
+			v1.LabelInstanceTypeStable: "b2-7",
+		},
+	}
+
+	if got := key.Features(); got != "GRA7,b2-7" {
+		t.Errorf("Features(): got %q, want %q", got, "GRA7,b2-7")
+	}
+	if got := key.GPUType(); got != "" {
+		t.Errorf("GPUType(): got %q, want empty", got)
+	}
+	if got := key.GPUCount(); got != 0 {
+		t.Errorf("GPUCount(): got %d, want 0", got)
+	}
+	if got := key.ID(); got != "" {
+		t.Errorf("ID(): got %q, want empty", got)
+	}
+}
+
+func TestOVHKeyGPU(t *testing.T) {
+	tests := []struct {
+		instanceType string
+		wantGPU      string
+	}{
+		{"t2-45", "t2-45"},
+		{"l4-24", "l4-24"},
+		{"l40s-48", "l40s-48"},
+		{"a10-96", "a10-96"},
+		{"a100-180", "a100-180"},
+		{"b2-7", ""},
+		{"d2-4", ""},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.instanceType, func(t *testing.T) {
+			key := &ovhKey{
+				Labels: map[string]string{
+					v1.LabelInstanceTypeStable: tc.instanceType,
+				},
+			}
+			if got := key.GPUType(); got != tc.wantGPU {
+				t.Errorf("GPUType(%s): got %q, want %q", tc.instanceType, got, tc.wantGPU)
+			}
+		})
+	}
+}
+
+func TestOVHPVKey(t *testing.T) {
+	tests := []struct {
+		name         string
+		storageClass string
+		zone         string
+		wantFeatures string
+	}{
+		{"high-speed-gen2", "csi-cinder-high-speed-gen2", "GRA7", "GRA7,high-speed-gen2"},
+		{"high-speed", "csi-cinder-high-speed", "GRA9", "GRA9,high-speed"},
+		{"classic", "csi-cinder-classic", "BHS5", "BHS5,classic"},
+		{"unknown", "unknown-class", "GRA7", "GRA7,"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key := &ovhPVKey{
+				StorageClassName: tc.storageClass,
+				Zone:             tc.zone,
+			}
+			if got := key.Features(); got != tc.wantFeatures {
+				t.Errorf("Features(): got %q, want %q", got, tc.wantFeatures)
+			}
+			if got := key.GetStorageClass(); got != tc.storageClass {
+				t.Errorf("GetStorageClass(): got %q, want %q", got, tc.storageClass)
+			}
+			if got := key.ID(); got != "" {
+				t.Errorf("ID(): got %q, want empty", got)
+			}
+		})
+	}
+}
+
+func TestIsMonthlyBilling(t *testing.T) {
+	tests := []struct {
+		name         string
+		labels       map[string]string
+		monthlyPools []string
+		want         bool
+	}{
+		{
+			name:   "default hourly",
+			labels: map[string]string{},
+			want:   false,
+		},
+		{
+			name:   "label monthly",
+			labels: map[string]string{BillingLabel: "monthly"},
+			want:   true,
+		},
+		{
+			name:   "label hourly",
+			labels: map[string]string{BillingLabel: "hourly"},
+			want:   false,
+		},
+		{
+			name:         "env monthly",
+			labels:       map[string]string{NodepoolLabel: "pool-monthly"},
+			monthlyPools: []string{"pool-monthly", "other-pool"},
+			want:         true,
+		},
+		{
+			name:         "env miss",
+			labels:       map[string]string{NodepoolLabel: "pool-hourly"},
+			monthlyPools: []string{"pool-monthly"},
+			want:         false,
+		},
+		{
+			name:         "label overrides env",
+			labels:       map[string]string{BillingLabel: "hourly", NodepoolLabel: "pool-monthly"},
+			monthlyPools: []string{"pool-monthly"},
+			want:         false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := isMonthlyBilling(tc.labels, tc.monthlyPools)
+			if got != tc.want {
+				t.Errorf("isMonthlyBilling(): got %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestNodePricing_Hourly(t *testing.T) {
+	provider := newTestProvider(t, "testdata/ovh_catalog.json")
+
+	key := &ovhKey{
+		Labels: map[string]string{
+			v1.LabelTopologyRegion:     "GRA7",
+			v1.LabelInstanceTypeStable: "b2-7",
+		},
+	}
+
+	node, meta, err := provider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("NodePricing failed: %v", err)
+	}
+
+	if meta.Source != "ovh" {
+		t.Errorf("Source: got %q, want %q", meta.Source, "ovh")
+	}
+	assertFloatClose(t, "cost", parseFloat(t, node.Cost), 0.0681, 0.0001)
+	assertIntEqual(t, "VCPU", int(parseFloat(t, node.VCPU)), 2)
+	assertIntEqual(t, "RAM", int(parseFloat(t, node.RAM)), 7)
+	assertIntEqual(t, "Storage", int(parseFloat(t, node.Storage)), 50)
+	if node.Region != "GRA7" {
+		t.Errorf("Region: got %q, want %q", node.Region, "GRA7")
+	}
+	if node.InstanceType != "b2-7" {
+		t.Errorf("InstanceType: got %q, want %q", node.InstanceType, "b2-7")
+	}
+}
+
+func TestNodePricing_Monthly(t *testing.T) {
+	provider := newTestProvider(t, "testdata/ovh_catalog.json")
+
+	key := &ovhKey{
+		Labels: map[string]string{
+			v1.LabelTopologyRegion:     "GRA7",
+			v1.LabelInstanceTypeStable: "b2-7",
+			BillingLabel:               "monthly",
+		},
+	}
+
+	node, _, err := provider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("NodePricing failed: %v", err)
+	}
+
+	// Monthly price: 24.2 / 730
+	assertFloatClose(t, "cost", parseFloat(t, node.Cost), 24.2/730.0, 0.0001)
+}
+
+func TestNodePricing_MonthlyViaEnv(t *testing.T) {
+	provider := newTestProvider(t, "testdata/ovh_catalog.json")
+	provider.monthlyNodepools = []string{"my-monthly-pool"}
+
+	key := &ovhKey{
+		Labels: map[string]string{
+			v1.LabelTopologyRegion:     "GRA7",
+			v1.LabelInstanceTypeStable: "b2-7",
+			NodepoolLabel:              "my-monthly-pool",
+		},
+	}
+
+	node, _, err := provider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("NodePricing failed: %v", err)
+	}
+
+	assertFloatClose(t, "cost", parseFloat(t, node.Cost), 24.2/730.0, 0.0001)
+}
+
+func TestNodePricing_GPU(t *testing.T) {
+	provider := newTestProvider(t, "testdata/ovh_catalog.json")
+
+	key := &ovhKey{
+		Labels: map[string]string{
+			v1.LabelTopologyRegion:     "GRA7",
+			v1.LabelInstanceTypeStable: "t2-45",
+		},
+	}
+
+	node, _, err := provider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("NodePricing failed: %v", err)
+	}
+
+	assertFloatClose(t, "cost", parseFloat(t, node.Cost), 1.8, 0.0001)
+	assertIntEqual(t, "GPU", int(parseFloat(t, node.GPU)), 1)
+	if node.GPUName != "Tesla V100S" {
+		t.Errorf("GPUName: got %q, want %q", node.GPUName, "Tesla V100S")
+	}
+	assertIntEqual(t, "VCPU", int(parseFloat(t, node.VCPU)), 15)
+	assertIntEqual(t, "RAM", int(parseFloat(t, node.RAM)), 45)
+}
+
+func TestNodePricing_NotFound(t *testing.T) {
+	provider := newTestProvider(t, "testdata/ovh_catalog.json")
+
+	key := &ovhKey{
+		Labels: map[string]string{
+			v1.LabelTopologyRegion:     "GRA7",
+			v1.LabelInstanceTypeStable: "unknown-flavor",
+		},
+	}
+
+	_, _, err := provider.NodePricing(key)
+	if err == nil {
+		t.Fatal("expected error for unknown flavor, got nil")
+	}
+}
+
+func TestPVPricing(t *testing.T) {
+	provider := newTestProvider(t, "testdata/ovh_catalog.json")
+
+	key := &ovhPVKey{
+		StorageClassName: "csi-cinder-high-speed-gen2",
+		Zone:             "GRA7",
+	}
+
+	pv, err := provider.PVPricing(key)
+	if err != nil {
+		t.Fatalf("PVPricing failed: %v", err)
+	}
+
+	assertFloatClose(t, "cost", parseFloat(t, pv.Cost), 0.000119, 0.000001)
+	if pv.Class != "csi-cinder-high-speed-gen2" {
+		t.Errorf("Class: got %q, want %q", pv.Class, "csi-cinder-high-speed-gen2")
+	}
+}
+
+func TestNetworkPricing(t *testing.T) {
+	provider := &OVH{}
+
+	net, err := provider.NetworkPricing()
+	if err != nil {
+		t.Fatalf("NetworkPricing failed: %v", err)
+	}
+
+	if net.ZoneNetworkEgressCost != 0 {
+		t.Errorf("ZoneNetworkEgressCost: got %f, want 0", net.ZoneNetworkEgressCost)
+	}
+	if net.RegionNetworkEgressCost != 0 {
+		t.Errorf("RegionNetworkEgressCost: got %f, want 0", net.RegionNetworkEgressCost)
+	}
+	assertFloatClose(t, "InternetNetworkEgressCost", net.InternetNetworkEgressCost, 0.01, 0.0001)
+	if net.NatGatewayEgressCost != 0 {
+		t.Errorf("NatGatewayEgressCost: got %f, want 0", net.NatGatewayEgressCost)
+	}
+	if net.NatGatewayIngressCost != 0 {
+		t.Errorf("NatGatewayIngressCost: got %f, want 0", net.NatGatewayIngressCost)
+	}
+}
+
+func TestLoadBalancerPricing(t *testing.T) {
+	provider := &OVH{}
+
+	lb, err := provider.LoadBalancerPricing()
+	if err != nil {
+		t.Fatalf("LoadBalancerPricing failed: %v", err)
+	}
+
+	assertFloatClose(t, "LB cost", lb.Cost, 0.012, 0.0001)
+}

+ 213 - 0
pkg/cloud/ovh/testdata/ovh_catalog.json

@@ -0,0 +1,213 @@
+{
+  "catalogId": "test",
+  "locale": {
+    "currencyCode": "EUR",
+    "subsidiary": "FR"
+  },
+  "plans": [
+    {
+      "planCode": "project.2018",
+      "addonFamilies": [
+        {
+          "name": "instance",
+          "addons": [
+            "b2-7.consumption",
+            "b2-7.monthly.postpaid",
+            "t2-45.consumption",
+            "t2-45.monthly.postpaid"
+          ]
+        },
+        {
+          "name": "volume",
+          "addons": [
+            "volume.high-speed-gen2.consumption",
+            "volume.high-speed.consumption",
+            "volume.classic.consumption"
+          ]
+        }
+      ]
+    }
+  ],
+  "addons": [
+    {
+      "planCode": "b2-7.consumption",
+      "invoiceName": "b2-7 - Hourly",
+      "product": "publiccloud-instance",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 6810000,
+          "type": "consumption"
+        }
+      ],
+      "blobs": {
+        "technical": {
+          "cpu": {
+            "cores": 2
+          },
+          "memory": {
+            "size": 7
+          },
+          "storage": {
+            "disks": [
+              {
+                "capacity": 50
+              }
+            ]
+          },
+          "name": "b2-7"
+        },
+        "commercial": {
+          "brickSubtype": "general-purpose",
+          "name": "b2-7"
+        }
+      }
+    },
+    {
+      "planCode": "b2-7.monthly.postpaid",
+      "invoiceName": "b2-7 - Monthly",
+      "product": "publiccloud-instance-monthly",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 2420000000,
+          "type": "monthly.postpaid"
+        }
+      ],
+      "blobs": {
+        "technical": {
+          "cpu": {
+            "cores": 2
+          },
+          "memory": {
+            "size": 7
+          },
+          "storage": {
+            "disks": [
+              {
+                "capacity": 50
+              }
+            ]
+          },
+          "name": "b2-7"
+        },
+        "commercial": {
+          "brickSubtype": "general-purpose",
+          "name": "b2-7"
+        }
+      }
+    },
+    {
+      "planCode": "t2-45.consumption",
+      "invoiceName": "t2-45 - Hourly",
+      "product": "publiccloud-instance",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 180000000,
+          "type": "consumption"
+        }
+      ],
+      "blobs": {
+        "technical": {
+          "cpu": {
+            "cores": 15
+          },
+          "memory": {
+            "size": 45
+          },
+          "storage": {
+            "disks": [
+              {
+                "capacity": 400
+              }
+            ]
+          },
+          "gpu": {
+            "number": 1,
+            "model": "Tesla V100S"
+          },
+          "name": "t2-45"
+        },
+        "commercial": {
+          "brickSubtype": "gpu",
+          "name": "t2-45"
+        }
+      }
+    },
+    {
+      "planCode": "t2-45.monthly.postpaid",
+      "invoiceName": "t2-45 - Monthly",
+      "product": "publiccloud-instance-monthly",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 63800000000,
+          "type": "monthly.postpaid"
+        }
+      ],
+      "blobs": {
+        "technical": {
+          "cpu": {
+            "cores": 15
+          },
+          "memory": {
+            "size": 45
+          },
+          "storage": {
+            "disks": [
+              {
+                "capacity": 400
+              }
+            ]
+          },
+          "gpu": {
+            "number": 1,
+            "model": "Tesla V100S"
+          },
+          "name": "t2-45"
+        },
+        "commercial": {
+          "brickSubtype": "gpu",
+          "name": "t2-45"
+        }
+      }
+    },
+    {
+      "planCode": "volume.high-speed-gen2.consumption",
+      "invoiceName": "Volume High Speed Gen2 - Hourly",
+      "product": "publiccloud-volume",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 11900,
+          "type": "consumption"
+        }
+      ]
+    },
+    {
+      "planCode": "volume.high-speed.consumption",
+      "invoiceName": "Volume High Speed - Hourly",
+      "product": "publiccloud-volume",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 11900,
+          "type": "consumption"
+        }
+      ]
+    },
+    {
+      "planCode": "volume.classic.consumption",
+      "invoiceName": "Volume Classic - Hourly",
+      "product": "publiccloud-volume",
+      "pricingType": "consumption",
+      "pricings": [
+        {
+          "price": 5900,
+          "type": "consumption"
+        }
+      ]
+    }
+  ]
+}

+ 15 - 0
pkg/cloud/provider/provider.go

@@ -20,6 +20,7 @@ import (
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
 	"github.com/opencost/opencost/pkg/cloud/otc"
+	"github.com/opencost/opencost/pkg/cloud/ovh"
 	"github.com/opencost/opencost/pkg/cloud/scaleway"
 
 	"github.com/opencost/opencost/core/pkg/opencost"
@@ -112,6 +113,8 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 			cp.configFileName = "scaleway.json"
 		case opencost.OTCProvider:
 			cp.configFileName = "otc.json"
+		case opencost.OVHProvider:
+			cp.configFileName = "ovh.json"
 		case opencost.CSVProvider:
 			cp.configFileName = "default.json"
 		}
@@ -209,6 +212,14 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 			Config:        NewProviderConfig(config, cp.configFileName),
 			ClusterRegion: cp.region,
 		}, nil
+	case opencost.OVHProvider:
+		log.Info("Found node label \"node.k8s.ovh/type\", using OVH Provider")
+		return &ovh.OVH{
+			Clientset:        cache,
+			ClusterRegion:    cp.region,
+			ClusterAccountID: cp.accountID,
+			Config:           NewProviderConfig(config, cp.configFileName),
+		}, nil
 	case opencost.DigitalOceanProvider:
 		log.Info("Detected DigitalOcean, using DOKS")
 		return &digitalocean.DOKS{
@@ -293,6 +304,10 @@ func getClusterProperties(node *clustercache.Node) clusterProperties {
 		log.Debug("using OTC provider")
 		cp.provider = opencost.OTCProvider
 		cp.configFileName = "otc.json"
+	} else if _, ok := node.Labels["node.k8s.ovh/type"]; ok {
+		log.Debug("using OVH provider")
+		cp.provider = opencost.OVHProvider
+		cp.configFileName = "ovh.json"
 	} else if strings.HasPrefix(providerID, "digitalocean") {
 		log.Debug("using DigitalOcean provider")
 		cp.provider = opencost.DigitalOceanProvider

+ 23 - 0
pkg/env/costmodel.go

@@ -1,6 +1,7 @@
 package env
 
 import (
+	"strings"
 	"time"
 
 	"github.com/opencost/opencost/core/pkg/env"
@@ -43,6 +44,9 @@ const (
 	// Currently being used for OCI and DigitalOcean
 	ProviderPricingURL = "PROVIDER_PRICING_URL"
 
+	OVHSubsidiaryEnvVar    = "OVH_SUBSIDIARY"
+	OVHMonthlyNodepoolsVar = "OVH_MONTHLY_NODEPOOLS"
+
 	ClusterProfileEnvVar    = "CLUSTER_PROFILE"
 	RemoteEnabledEnvVar     = "REMOTE_WRITE_ENABLED"
 	RemotePWEnvVar          = "REMOTE_WRITE_PASSWORD"
@@ -400,6 +404,25 @@ func GetDOKSPricingURL() string {
 	return env.Get(ProviderPricingURL, "https://api.digitalocean.com/v2/billing/pricing")
 }
 
+func GetOVHSubsidiary() string {
+	return strings.ToUpper(strings.TrimSpace(env.Get(OVHSubsidiaryEnvVar, "FR")))
+}
+
+func GetOVHMonthlyNodepools() []string {
+	val := env.Get(OVHMonthlyNodepoolsVar, "")
+	if val == "" {
+		return nil
+	}
+	var pools []string
+	for _, p := range strings.Split(val, ",") {
+		p = strings.TrimSpace(p)
+		if p != "" {
+			pools = append(pools, p)
+		}
+	}
+	return pools
+}
+
 // IsMCPServerEnabled returns the environment variable value for MCPServerEnabledEnvVar which represents
 // whether or not the MCP server is enabled.
 func IsMCPServerEnabled() bool {