alexsouthard 1 неделя назад
Родитель
Сommit
2f1af7f0c2

+ 67 - 7
modules/pricing/public/gcp/gcppricingsource.go

@@ -46,9 +46,10 @@ func (g *GCPPricingSource) GetPricing() (*pricing.PricingSet, error) {
 		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 	}
 	}
 
 
-	// Maps to accumulate CPU and RAM costs per node key
+	// Maps to accumulate CPU, RAM, and per-GPU costs.
 	nodeCPUCosts := make(map[nodeKey]float64)
 	nodeCPUCosts := make(map[nodeKey]float64)
 	nodeRAMCosts := make(map[nodeKey]float64)
 	nodeRAMCosts := make(map[nodeKey]float64)
+	nodeGPUCosts := make(map[gpuKey]float64)
 
 
 	// Track volume pricing
 	// Track volume pricing
 	volumeCosts := make(map[volumeKey]float64)
 	volumeCosts := make(map[volumeKey]float64)
@@ -73,7 +74,7 @@ func (g *GCPPricingSource) GetPricing() (*pricing.PricingSet, error) {
 			return nil, fmt.Errorf("PricingSource (GCP): unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
 			return nil, fmt.Errorf("PricingSource (GCP): unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
 		}
 		}
 
 
-		nextToken, err := g.parsePage(resp.Body, nodeCPUCosts, nodeRAMCosts, volumeCosts)
+		nextToken, err := g.parsePage(resp.Body, nodeCPUCosts, nodeRAMCosts, nodeGPUCosts, volumeCosts)
 		closeErr := resp.Body.Close()
 		closeErr := resp.Body.Close()
 		if closeErr != nil {
 		if closeErr != nil {
 			log.Warnf("failed to close response body: %v", closeErr)
 			log.Warnf("failed to close response body: %v", closeErr)
@@ -91,8 +92,8 @@ func (g *GCPPricingSource) GetPricing() (*pricing.PricingSet, error) {
 		nextPageToken = nextToken
 		nextPageToken = nextToken
 	}
 	}
 
 
-	// Build node pricing from accumulated CPU and RAM costs
-	g.buildNodePricing(ps, nodeCPUCosts, nodeRAMCosts)
+	// Build node pricing from accumulated CPU and RAM costs and GPU-qualified copies
+	g.buildNodePricing(ps, nodeCPUCosts, nodeRAMCosts, nodeGPUCosts)
 
 
 	// Build volume pricing
 	// Build volume pricing
 	g.buildVolumePricing(ps, volumeCosts)
 	g.buildVolumePricing(ps, volumeCosts)
@@ -114,7 +115,7 @@ func (g *GCPPricingSource) buildURL(pageToken string) string {
 }
 }
 
 
 func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]float64, nodeRAMCosts map[nodeKey]float64,
 func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]float64, nodeRAMCosts map[nodeKey]float64,
-	volumeCosts map[volumeKey]float64,
+	nodeGPUCosts map[gpuKey]float64, volumeCosts map[volumeKey]float64,
 ) (nextPageToken string, err error) {
 ) (nextPageToken string, err error) {
 
 
 	data, err := io.ReadAll(body)
 	data, err := io.ReadAll(body)
@@ -132,6 +133,10 @@ func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]fl
 			continue
 			continue
 		}
 		}
 
 
+		if isCommitmentOrReservedSKU(sku.Description) {
+			continue
+		}
+
 		category := sku.Category
 		category := sku.Category
 		resourceGroup := category.ResourceGroup
 		resourceGroup := category.ResourceGroup
 		usageType := strings.ToLower(category.UsageType)
 		usageType := strings.ToLower(category.UsageType)
@@ -146,7 +151,9 @@ func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]fl
 			continue
 			continue
 		}
 		}
 
 
-		// TODO: Add GPU pricing support
+		if isGPUResource(resourceGroup) {
+			g.parseGPUSKU(sku, usageType, nodeGPUCosts)
+		}
 	}
 	}
 
 
 	return page.NextPageToken, nil
 	return page.NextPageToken, nil
@@ -216,6 +223,34 @@ func (g *GCPPricingSource) parseComputeSKU(sku *GCPPricing, usageType string, no
 	}
 	}
 }
 }
 
 
+// parseGPUSKU accumulates an hourly price for one GPU. The product label is
+// part of the key because GPU SKUs are not tied to a single machine type.
+func (g *GCPPricingSource) parseGPUSKU(sku *GCPPricing, usageType string, nodeGPUCosts map[gpuKey]float64) {
+	if nodeGPUCosts == nil {
+		return
+	}
+
+	product := normalizeGPUProduct(sku.Description)
+	if product == "" {
+		log.Debugf("PricingSource (GCP): skipping GPU SKU with unrecognized product label: %q", sku.Description)
+		return
+	}
+
+	hourlyPrice, err := g.extractHourlyPrice(sku)
+	if err != nil || hourlyPrice == 0 {
+		return
+	}
+
+	for _, region := range sku.ServiceRegions {
+		key := gpuKey{
+			Region:    region,
+			Product:   product,
+			UsageType: usageType,
+		}
+		nodeGPUCosts[key] = hourlyPrice
+	}
+}
+
 // expandInstanceTypes handles special cases like E2 and A2 families that map to multiple instance types
 // expandInstanceTypes handles special cases like E2 and A2 families that map to multiple instance types
 func (g *GCPPricingSource) expandInstanceTypes(instanceType, resourceGroup string) []string {
 func (g *GCPPricingSource) expandInstanceTypes(instanceType, resourceGroup string) []string {
 	resourceGroupLower := strings.ToLower(resourceGroup)
 	resourceGroupLower := strings.ToLower(resourceGroup)
@@ -262,8 +297,9 @@ func (g *GCPPricingSource) extractHourlyPrice(sku *GCPPricing) (float64, error)
 }
 }
 
 
 func (g *GCPPricingSource) buildNodePricing(ps *pricing.PricingSet, nodeCPUCosts map[nodeKey]float64,
 func (g *GCPPricingSource) buildNodePricing(ps *pricing.PricingSet, nodeCPUCosts map[nodeKey]float64,
-	nodeRAMCosts map[nodeKey]float64,
+	nodeRAMCosts map[nodeKey]float64, nodeGPUCosts map[gpuKey]float64,
 ) {
 ) {
+
 	// Combine CPU and RAM costs into complete node pricing
 	// Combine CPU and RAM costs into complete node pricing
 	processedKeys := make(map[nodeKey]bool)
 	processedKeys := make(map[nodeKey]bool)
 
 
@@ -318,6 +354,30 @@ func (g *GCPPricingSource) buildNodePricing(ps *pricing.PricingSet, nodeCPUCosts
 		}
 		}
 
 
 		ps.NodePricing = append(ps.NodePricing, nodePricing)
 		ps.NodePricing = append(ps.NodePricing, nodePricing)
+
+		// A GPU-qualified record must repeat CPU/RAM pricing
+		for gpuKey, gpuCost := range nodeGPUCosts {
+			if gpuKey.Region != key.Region || gpuKey.UsageType != key.UsageType {
+				continue
+			}
+
+			gpuNodePricing := &pricing.NodePricing{
+				Properties: nodePricing.Properties,
+				Prices: pricing.Prices{
+					pricing.ResourceCPU: nodePricing.Prices[pricing.ResourceCPU],
+					pricing.ResourceRAM: nodePricing.Prices[pricing.ResourceRAM],
+					pricing.ResourceGPU: {
+						Unit:  unit.GPUHour,
+						Price: gpuCost,
+					},
+				},
+			}
+			gpuNodePricing.Properties.Labels = map[string]string{
+				gpuProductLabel: gpuKey.Product,
+			}
+
+			ps.NodePricing = append(ps.NodePricing, gpuNodePricing)
+		}
 	}
 	}
 }
 }
 
 

+ 65 - 5
modules/pricing/public/gcp/gcppricingsource_test.go

@@ -365,6 +365,38 @@ func TestParsePage(t *testing.T) {
 			wantNextPageToken: "",
 			wantNextPageToken: "",
 			wantErr:           false,
 			wantErr:           false,
 		},
 		},
+		{
+			name: "Commitment SKU is skipped",
+			response: GCPPricingResponse{
+				Skus: []*GCPPricing{
+					{
+						Description: "Commitment v1: T2D AMD CPU running in Americas for 1 Year",
+						Category: &GCPResourceInfo{
+							ResourceGroup: "CPU",
+							UsageType:     "OnDemand",
+						},
+						ServiceRegions: []string{"us-central1"},
+						PricingInfo: []*PricingInfo{
+							{
+								PricingExpression: &PricingExpression{
+									TieredRates: []*TieredRates{
+										{
+											UnitPrice: &UnitPriceInfo{
+												Units: "0",
+												Nanos: 19801600,
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				NextPageToken: "",
+			},
+			wantNextPageToken: "",
+			wantErr:           false,
+		},
 	}
 	}
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
@@ -383,9 +415,10 @@ func TestParsePage(t *testing.T) {
 
 
 			nodeCPUCosts := make(map[nodeKey]float64)
 			nodeCPUCosts := make(map[nodeKey]float64)
 			nodeRAMCosts := make(map[nodeKey]float64)
 			nodeRAMCosts := make(map[nodeKey]float64)
+			nodeGPUCosts := make(map[gpuKey]float64)
 			volumeCosts := make(map[volumeKey]float64)
 			volumeCosts := make(map[volumeKey]float64)
 
 
-			nextToken, err := source.parsePage(bytes.NewReader(data), nodeCPUCosts, nodeRAMCosts, volumeCosts)
+			nextToken, err := source.parsePage(bytes.NewReader(data), nodeCPUCosts, nodeRAMCosts, nodeGPUCosts, volumeCosts)
 
 
 			if (err != nil) != tt.wantErr {
 			if (err != nil) != tt.wantErr {
 				t.Errorf("parsePage() error = %v, wantErr %v", err, tt.wantErr)
 				t.Errorf("parsePage() error = %v, wantErr %v", err, tt.wantErr)
@@ -693,7 +726,7 @@ func TestBuildNodePricing(t *testing.T) {
 				PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 				PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 			}
 			}
 
 
-			source.buildNodePricing(ps, tt.cpuCosts, tt.ramCosts)
+			source.buildNodePricing(ps, tt.cpuCosts, tt.ramCosts, map[gpuKey]float64{})
 
 
 			if len(ps.NodePricing) != tt.wantNodes {
 			if len(ps.NodePricing) != tt.wantNodes {
 				t.Errorf("buildNodePricing() created %d nodes, want %d", len(ps.NodePricing), tt.wantNodes)
 				t.Errorf("buildNodePricing() created %d nodes, want %d", len(ps.NodePricing), tt.wantNodes)
@@ -723,7 +756,7 @@ func TestBuildNodePricing_SpotProvisioning(t *testing.T) {
 		{Region: "us-central1", InstanceType: "n2-standard", UsageType: "preemptible"}: 0.001017,
 		{Region: "us-central1", InstanceType: "n2-standard", UsageType: "preemptible"}: 0.001017,
 	}
 	}
 
 
-	source.buildNodePricing(ps, cpuCosts, ramCosts)
+	source.buildNodePricing(ps, cpuCosts, ramCosts, map[gpuKey]float64{})
 
 
 	if len(ps.NodePricing) != 2 {
 	if len(ps.NodePricing) != 2 {
 		t.Fatalf("buildNodePricing() created %d nodes, want 2", len(ps.NodePricing))
 		t.Fatalf("buildNodePricing() created %d nodes, want 2", len(ps.NodePricing))
@@ -1188,10 +1221,37 @@ func TestIsStorageResource(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestNormalizeGPUProduct(t *testing.T) {
+	tests := []struct {
+		desc string
+		want string
+	}{
+		{"Nvidia Tesla T4 GPU running in Americas", "Tesla-T4"},
+		{"Tesla T4 GPU", "Tesla-T4"},
+		{"Nvidia Tesla V100 GPU running in Americas", "Tesla-V100"},
+		{"Nvidia Tesla P100 GPU running in Melbourne", "Tesla-P100"},
+		{"Nvidia Tesla P4 GPU", "Tesla-P4"},
+		{"Nvidia Tesla K80 GPU", "Tesla-K80"},
+		{"Nvidia Tesla A100 80GB GPU (SXM4) in region us-central1", "NVIDIA-A100-80GB-PCIe"},
+		{"Nvidia Tesla A100 GPU attached", "Tesla-A100"},
+		{"Nvidia Tesla A100 40GB GPU", "Tesla-A100"},
+		{"Nvidia L4 GPU running in Americas", "NVIDIA-L4"},
+		{"Unknown GPU Device", ""},
+		{"N2 Instance Core running in Americas", ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			got := normalizeGPUProduct(tt.desc)
+			if got != tt.want {
+				t.Errorf("normalizeGPUProduct(%q) = %q, want %q", tt.desc, got, tt.want)
+			}
+		})
+	}
+}
+
 // Helper function to check if a string contains a substring
 // Helper function to check if a string contains a substring
 func contains(s, substr string) bool {
 func contains(s, substr string) bool {
 	return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
 	return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
 		(len(s) > 0 && (s[0:len(substr)] == substr || contains(s[1:], substr))))
 		(len(s) > 0 && (s[0:len(substr)] == substr || contains(s[1:], substr))))
 }
 }
-
-// Made with Bob

+ 58 - 0
modules/pricing/public/gcp/types.go

@@ -69,6 +69,15 @@ type nodeKey struct {
 	UsageType    string // OnDemand, Preemptible, Spot
 	UsageType    string // OnDemand, Preemptible, Spot
 }
 }
 
 
+// gpuKey identifies the hourly price of one GPU product in a GCP region and
+// purchase option. GPU SKUs do not include a machine type, so they are joined
+// with completed CPU/RAM node prices when the PricingSet is built.
+type gpuKey struct {
+	Region    string
+	Product   string
+	UsageType string // OnDemand, Preemptible, Spot
+}
+
 // volumeKey is used internally to track volume metadata during parsing
 // volumeKey is used internally to track volume metadata during parsing
 type volumeKey struct {
 type volumeKey struct {
 	Region     string
 	Region     string
@@ -166,6 +175,12 @@ func normalizeInstanceType(resourceGroup, description string) string {
 	return resourceGroupLower
 	return resourceGroupLower
 }
 }
 
 
+// isCommitmentOrReservedSKU checks whether a SKU is for committed use discounts (CUD) or reserved instances
+func isCommitmentOrReservedSKU(description string) bool {
+	d := strings.ToUpper(description)
+	return strings.Contains(d, "COMMITMENT") || strings.Contains(d, "RESERVATION")
+}
+
 // isComputeResource checks if a SKU is for compute resources (CPU/RAM)
 // isComputeResource checks if a SKU is for compute resources (CPU/RAM)
 func isComputeResource(resourceGroup string) bool {
 func isComputeResource(resourceGroup string) bool {
 	resourceGroupLower := strings.ToLower(resourceGroup)
 	resourceGroupLower := strings.ToLower(resourceGroup)
@@ -181,3 +196,46 @@ func isStorageResource(resourceGroup string) bool {
 		resourceGroupLower == "pdextreme" ||
 		resourceGroupLower == "pdextreme" ||
 		strings.HasPrefix(resourceGroupLower, "hyperdisk")
 		strings.HasPrefix(resourceGroupLower, "hyperdisk")
 }
 }
+
+// isGPUResource checks whether a Catalog SKU is priced per attached GPU.
+func isGPUResource(resourceGroup string) bool {
+	return strings.EqualFold(resourceGroup, "GPU")
+}
+
+const gpuProductLabel = "nvidia.com/gpu.product"
+
+// normalizeGPUProduct maps recognized Catalog SKU descriptions to the
+// nvidia.com/gpu.product node-label values used by the KCM GPU-pricing path.
+// The returned string must match the exact case-sensitive value stamped on
+// nodes by the NVIDIA device plugin (e.g. "Tesla-T4", "NVIDIA-A100-SXM4-40GB")
+// because ClickHouse stage 02 derivation matches via exact FNV-32a bitmap hashes.
+// Unknown descriptions are intentionally skipped rather than emitting a price
+// that could match the wrong GPU model.
+func normalizeGPUProduct(description string) string {
+	desc := strings.ToLower(description)
+
+	// A100 must be checked first: the 80 GB variant has a distinct product string.
+	if strings.Contains(desc, "a100") {
+		if strings.Contains(desc, "80gb") || strings.Contains(desc, "80 gb") {
+			return "NVIDIA-A100-80GB-PCIe"
+		}
+		return "Tesla-A100"
+	}
+
+	switch {
+	case strings.Contains(desc, "l4"):
+		return "NVIDIA-L4"
+	case strings.Contains(desc, "t4"):
+		return "Tesla-T4"
+	case strings.Contains(desc, "v100"):
+		return "Tesla-V100"
+	case strings.Contains(desc, "p100"):
+		return "Tesla-P100"
+	case strings.Contains(desc, "p4"):
+		return "Tesla-P4"
+	case strings.Contains(desc, "k80"):
+		return "Tesla-K80"
+	default:
+		return ""
+	}
+}

Разница между файлами не показана из-за своего большого размера
+ 432 - 432
modules/pricing/public/usd/nodes.jsonl


Некоторые файлы не были показаны из-за большого количества измененных файлов