Explorar el Código

feat: Use custom PV price from annotations (#3075)

Signed-off-by: Maksim Paskal <paskal.maksim@gmail.com>
Maksim Paskal hace 3 días
padre
commit
e0d9594b98
Se han modificado 2 ficheros con 250 adiciones y 0 borrados
  1. 54 0
      pkg/costmodel/costmodel.go
  2. 196 0
      pkg/costmodel/costmodel_test.go

+ 54 - 0
pkg/costmodel/costmodel.go

@@ -33,6 +33,12 @@ const (
 	profileThreshold = 1000 * 1000 * 1000 // 1s (in ns)
 
 	unmountedPVsContainer = "unmounted-pvs"
+
+	annotationDomain = "opencost.io"
+
+	annotationStorageCost = annotationDomain + "/storage-hourly-cost"
+	annotationNodeCPUCost = annotationDomain + "/node-cpu-hourly-cost"
+	annotationNodeRAMCost = annotationDomain + "/node-ram-hourly-cost"
 )
 
 // isCron matches a CronJob name and captures the non-timestamp name
@@ -820,6 +826,11 @@ func (cm *CostModel) addPVData(pvClaimMapping map[string]*PersistentVolumeClaimD
 			storageClassMap["default"] = params
 			storageClassMap[""] = params
 		}
+
+		// Add custom cost annotation to storage class map
+		if key, found := storageClass.Annotations[annotationStorageCost]; found && params != nil {
+			params[annotationStorageCost] = key
+		}
 	}
 
 	pvs := cache.GetAllPersistentVolumes()
@@ -862,6 +873,24 @@ func (cm *CostModel) addPVData(pvClaimMapping map[string]*PersistentVolumeClaimD
 	return nil
 }
 
+// Checks if the provided cost string can be parsed into a finite, non-negative float64.
+// If the cost is invalid, it logs a warning with the cost value and the reason.
+func (cm *CostModel) costIsValid(cost string) bool {
+	parsedCost, err := strconv.ParseFloat(cost, 64)
+	if err != nil {
+		log.Warnf("Invalid cost value: %s. Error: %s", cost, err.Error())
+		return false
+	}
+
+	// Check if the parsed cost is a valid number (not NaN, not Inf, and non-negative)
+	if math.IsNaN(parsedCost) || math.IsInf(parsedCost, 0) || parsedCost < 0 {
+		log.Warnf("Invalid cost value: %s. Error: cost must be a finite, non-negative number", cost)
+		return false
+	}
+
+	return true
+}
+
 func (cm *CostModel) GetPVCost(pv *costAnalyzerCloud.PV, kpv *clustercache.PersistentVolume, defaultRegion string) error {
 	cp := cm.Provider
 	cfg, err := cp.GetConfig()
@@ -870,6 +899,21 @@ func (cm *CostModel) GetPVCost(pv *costAnalyzerCloud.PV, kpv *clustercache.Persi
 	}
 	key := cp.GetPVKey(kpv, pv.Parameters, defaultRegion)
 	pv.ProviderID = key.ID()
+
+	// If PV has a custom cost annotation, use that to mandate the cost
+	if cost, found := kpv.Annotations[annotationStorageCost]; found && cm.costIsValid(cost) {
+		log.Infof("Found custom cost from annotation for PV %s: %s", kpv.Name, cost)
+		pv.Cost = cost
+		return nil
+	}
+
+	// If SC has a custom cost annotation, use that to mandate the cost
+	if cost, found := pv.Parameters[annotationStorageCost]; found && cm.costIsValid(cost) {
+		log.Infof("Found custom cost from Storage Class annotation for PV %s: %s", kpv.Name, cost)
+		pv.Cost = cost
+		return nil
+	}
+
 	pvWithCost, err := cp.PVPricing(key)
 	if err != nil {
 		pv.Cost = cfg.Storage
@@ -931,6 +975,16 @@ func (cm *CostModel) GetNodeCost() (map[string]*costAnalyzerCloud.Node, error) {
 			}
 		}
 
+		if cost, found := n.Annotations[annotationNodeCPUCost]; found && cm.costIsValid(cost) {
+			log.Infof("Found custom CPU cost from annotation for Node %s: %s", n.Name, cost)
+			cnode.VCPUCost = cost
+		}
+
+		if cost, found := n.Annotations[annotationNodeRAMCost]; found && cm.costIsValid(cost) {
+			log.Infof("Found custom RAM cost from annotation for Node %s: %s", n.Name, cost)
+			cnode.RAMCost = cost
+		}
+
 		pmd.PricingTypeCounts[cnode.PricingType]++
 
 		// newCnode builds upon cnode but populates/overrides certain fields.

+ 196 - 0
pkg/costmodel/costmodel_test.go

@@ -8,8 +8,13 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/storage"
 	"github.com/opencost/opencost/core/pkg/util"
+	"github.com/opencost/opencost/pkg/cloud/models"
+	"github.com/opencost/opencost/pkg/cloud/provider"
+	"github.com/opencost/opencost/pkg/config"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 )
@@ -350,3 +355,194 @@ func TestGetContainerAllocation(t *testing.T) {
 		})
 	}
 }
+
+func TestStorageCostAnnotations(t *testing.T) {
+	t.Parallel()
+
+	confMan := config.NewConfigFileManager(storage.NewFileStorage("../../"))
+
+	customProvider := &provider.CSVProvider{
+		CSVLocation: "../../configs/pricing_schema_pv.csv",
+		CustomProvider: &provider.CustomProvider{
+			Config: provider.NewProviderConfig(confMan, "../../configs/default.json"),
+		},
+	}
+	err := customProvider.DownloadPricingData()
+	assert.NoError(t, err)
+
+	costModel := &CostModel{
+		Provider: customProvider,
+	}
+
+	providerConfig, err := customProvider.GetConfig()
+	assert.NoError(t, err)
+	assert.NotNil(t, providerConfig)
+
+	type testCase struct {
+		name         string
+		pv           *models.PV
+		pvc          *clustercache.PersistentVolume
+		expectedCost string
+	}
+
+	testCases := []testCase{
+		{
+			name: "Cost from provider",
+			pv:   &models.PV{},
+			pvc: &clustercache.PersistentVolume{
+				Name: "pvc-08e1f205-d7a9-4430-90fc-7b3965a18c4d",
+			},
+			expectedCost: "0.1337",
+		},
+		{
+			name: "Cost from custom provider config",
+			pv:   &models.PV{},
+			pvc: &clustercache.PersistentVolume{
+				Name: "fake-name",
+			},
+			expectedCost: providerConfig.Storage,
+		},
+		{
+			name: "Cost from annotations",
+			pv:   &models.PV{},
+			pvc: &clustercache.PersistentVolume{
+				Name: "pvc-08e1f205-d7a9-4430-90fc-7b3965a18c4d",
+				Annotations: map[string]string{
+					annotationStorageCost: "123.123",
+				},
+			},
+			expectedCost: "123.123",
+		},
+		{
+			name: "Cost from storage class and with no annotations",
+			pv: &models.PV{
+				Parameters: map[string]string{
+					annotationStorageCost: "123.124",
+				},
+			},
+			pvc: &clustercache.PersistentVolume{
+				Name: "pvc-08e1f205-d7a9-4430-90fc-7b3965a18c4d",
+			},
+			expectedCost: "123.124",
+		},
+		{
+			name: "Cost from storage class and with annotations",
+			pv: &models.PV{
+				Parameters: map[string]string{
+					annotationStorageCost: "123.124",
+				},
+			},
+			pvc: &clustercache.PersistentVolume{
+				Name: "pvc-08e1f205-d7a9-4430-90fc-7b3965a18c4d",
+				Annotations: map[string]string{
+					annotationStorageCost: "123.125",
+				},
+			},
+			expectedCost: "123.125",
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			err := costModel.GetPVCost(testCase.pv, testCase.pvc, "default-region")
+			assert.NoError(t, err)
+
+			assert.Equal(t, testCase.expectedCost, testCase.pv.Cost)
+		})
+	}
+}
+
+func TestNodeCostAnnotations(t *testing.T) {
+	t.Parallel()
+
+	confMan := config.NewConfigFileManager(storage.NewFileStorage("../../"))
+
+	customProvider := &provider.CSVProvider{
+		CSVLocation: "../../configs/pricing_schema_region.csv",
+		CustomProvider: &provider.CustomProvider{
+			Config: provider.NewProviderConfig(confMan, "../../configs/default.json"),
+		},
+	}
+	err := customProvider.DownloadPricingData()
+	assert.NoError(t, err)
+
+	costModel := &CostModel{
+		Provider: customProvider,
+		Cache: NewFakeNodeCache([]*clustercache.Node{
+			{
+				Name: "test-node-001",
+				Labels: map[string]string{
+					"topology.kubernetes.io/region": "regionone",
+				},
+			},
+			{
+				Name: "test-node-002",
+				Labels: map[string]string{
+					"topology.kubernetes.io/region": "regionone",
+				},
+				Annotations: map[string]string{
+					"opencost.io/node-cpu-hourly-cost": "111",
+					"opencost.io/node-ram-hourly-cost": "222",
+				},
+			},
+		}),
+	}
+	assert.NotNil(t, costModel)
+
+	providerConfig, err := customProvider.GetConfig()
+	assert.NoError(t, err)
+	assert.NotNil(t, providerConfig)
+
+	nodeCost, err := costModel.GetNodeCost()
+	assert.NoError(t, err)
+	assert.NotNil(t, nodeCost)
+	assert.NotEmpty(t, nodeCost)
+
+	type testCase struct {
+		node     string
+		VCPUCost string
+		RAMCost  string
+	}
+	testCases := []testCase{
+		{
+			node:     "test-node-001",
+			VCPUCost: "+Inf",
+			RAMCost:  "+Inf",
+		},
+		{
+			node:     "test-node-002",
+			VCPUCost: "111",
+			RAMCost:  "222",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.node, func(t *testing.T) {
+			t.Parallel()
+
+			nodeCost, ok := nodeCost[tc.node]
+			require.True(t, ok)
+
+			assert.Equal(t, tc.VCPUCost, nodeCost.VCPUCost)
+			assert.Equal(t, tc.RAMCost, nodeCost.RAMCost)
+		})
+	}
+}
+
+// FakeNodeCache implements ClusterCache interface for testing
+type FakeNodeCache struct {
+	clustercache.ClusterCache
+	nodes []*clustercache.Node
+}
+
+func (f FakeNodeCache) GetAllNodes() []*clustercache.Node {
+	return f.nodes
+}
+
+func NewFakeNodeCache(nodes []*clustercache.Node) FakeNodeCache {
+	return FakeNodeCache{
+		nodes: nodes,
+	}
+}