Просмотр исходного кода

Fix providerID set to node name in idle allocations (#3403)

Signed-off-by: Robert Kessler <rbtkess@users.noreply.github.com>
Signed-off-by: Robert Kessler <rob@cake.ai>
Robert Kessler 7 месяцев назад
Родитель
Сommit
e7eb25b169

+ 12 - 4
core/pkg/opencost/totals.go

@@ -209,6 +209,7 @@ type AssetTotals struct {
 	End                             time.Time `json:"end"`
 	Cluster                         string    `json:"cluster"`
 	Node                            string    `json:"node"`
+	ProviderID                      string    `json:"providerID,omitempty"`
 	Count                           int       `json:"count"`
 	AttachedVolumeCost              float64   `json:"attachedVolumeCost"`
 	AttachedVolumeCostAdjustment    float64   `json:"attachedVolumeCostAdjustment"`
@@ -245,6 +246,7 @@ func (art *AssetTotals) Clone() *AssetTotals {
 		End:                             art.End,
 		Cluster:                         art.Cluster,
 		Node:                            art.Node,
+		ProviderID:                      art.ProviderID,
 		Count:                           art.Count,
 		AttachedVolumeCost:              art.AttachedVolumeCost,
 		AttachedVolumeCostAdjustment:    art.AttachedVolumeCostAdjustment,
@@ -378,12 +380,18 @@ func ComputeAssetTotals(as *AssetSet, byAsset bool) map[string]*AssetTotals {
 		adjustedGPUCost := node.GPUCost * adjustmentRate
 		gpuCostAdjustment := adjustedGPUCost - node.GPUCost
 
+		var providerID string
+		if byAsset && node.Properties.ProviderID != "" {
+			providerID = node.Properties.ProviderID
+		}
+
 		if _, ok := arts[key]; !ok {
 			arts[key] = &AssetTotals{
-				Start:   node.Start,
-				End:     node.End,
-				Cluster: node.Properties.Cluster,
-				Node:    node.Properties.Name,
+				Start:      node.Start,
+				End:        node.End,
+				Cluster:    node.Properties.Cluster,
+				Node:       node.Properties.Name,
+				ProviderID: providerID,
 			}
 		}
 

+ 229 - 0
core/pkg/opencost/totals_providerid_test.go

@@ -0,0 +1,229 @@
+package opencost
+
+import (
+	"testing"
+	"time"
+)
+
+// TestComputeAssetTotals_ProviderID_ByNode verifies that when computing
+// AssetTotals by node (byAsset=true), the ProviderID field is correctly
+// populated from the node's ProviderID.
+func TestComputeAssetTotals_ProviderID_ByNode(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := NewClosedWindow(start, end)
+
+	// Create AssetSet with nodes that have ProviderIDs
+	as := NewAssetSet(start, end)
+
+	// Node 1: AWS EC2 instance
+	node1 := NewNode("node1", "cluster1", "aws:///us-east-1a/i-0abc123def456789", start, end, window)
+	node1.CPUCost = 10.0
+	node1.RAMCost = 5.0
+
+	// Node 2: GCP instance
+	node2 := NewNode("node2", "cluster1", "gce://my-project/us-central1-a/instance-456", start, end, window)
+	node2.CPUCost = 8.0
+	node2.RAMCost = 4.0
+
+	// Node 3: Azure VM
+	node3 := NewNode("node3", "cluster1", "azure:///subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm-789", start, end, window)
+	node3.CPUCost = 12.0
+	node3.RAMCost = 6.0
+
+	as.Insert(node1, nil)
+	as.Insert(node2, nil)
+	as.Insert(node3, nil)
+
+	// Compute AssetTotals by node (byAsset=true)
+	totals := ComputeAssetTotals(as, true)
+
+	// Verify that each node's totals includes the correct ProviderID
+	expectedProviderIDs := map[string]string{
+		"cluster1/node1": "aws:///us-east-1a/i-0abc123def456789",
+		"cluster1/node2": "gce://my-project/us-central1-a/instance-456",
+		"cluster1/node3": "azure:///subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm-789",
+	}
+
+	for key, expectedProviderID := range expectedProviderIDs {
+		total, ok := totals[key]
+		if !ok {
+			t.Errorf("Expected to find totals for key %s", key)
+			continue
+		}
+
+		if total.ProviderID != expectedProviderID {
+			t.Errorf("For key %s: expected ProviderID %q, got %q", key, expectedProviderID, total.ProviderID)
+		}
+
+		// Verify Node field is also set correctly
+		expectedNodeName := key[len("cluster1/"):]
+		if total.Node != expectedNodeName {
+			t.Errorf("For key %s: expected Node %q, got %q", key, expectedNodeName, total.Node)
+		}
+	}
+}
+
+// TestComputeAssetTotals_ProviderID_ByCluster verifies that when computing
+// AssetTotals by cluster (byAsset=false), the ProviderID field is empty
+// because there's no single instance ID that represents all nodes.
+func TestComputeAssetTotals_ProviderID_ByCluster(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := NewClosedWindow(start, end)
+
+	// Create AssetSet with nodes that have ProviderIDs
+	as := NewAssetSet(start, end)
+
+	node1 := NewNode("node1", "cluster1", "aws:///us-east-1a/i-0abc123def456789", start, end, window)
+	node1.CPUCost = 10.0
+	node1.RAMCost = 5.0
+
+	node2 := NewNode("node2", "cluster1", "gce://my-project/us-central1-a/instance-456", start, end, window)
+	node2.CPUCost = 8.0
+	node2.RAMCost = 4.0
+
+	as.Insert(node1, nil)
+	as.Insert(node2, nil)
+
+	// Compute AssetTotals by cluster (byAsset=false)
+	totals := ComputeAssetTotals(as, false)
+
+	// Verify that cluster-level totals have an empty ProviderID
+	total, ok := totals["cluster1"]
+	if !ok {
+		t.Fatal("Expected to find totals for cluster1")
+	}
+
+	if total.ProviderID != "" {
+		t.Errorf("Expected empty ProviderID for cluster-level totals, got %q", total.ProviderID)
+	}
+
+	// Verify Node field is also empty at cluster level
+	if total.Node != "" {
+		t.Errorf("Expected empty Node for cluster-level totals, got %q", total.Node)
+	}
+
+	// Verify costs are aggregated correctly
+	expectedCPUCost := 18.0 // 10.0 + 8.0
+	expectedRAMCost := 9.0  // 5.0 + 4.0
+
+	if total.CPUCost != expectedCPUCost {
+		t.Errorf("Expected CPUCost %f, got %f", expectedCPUCost, total.CPUCost)
+	}
+
+	if total.RAMCost != expectedRAMCost {
+		t.Errorf("Expected RAMCost %f, got %f", expectedRAMCost, total.RAMCost)
+	}
+}
+
+// TestComputeAssetTotals_ProviderID_EmptyProviderID verifies that nodes
+// without a ProviderID still work correctly and result in an empty string
+// in the AssetTotals.
+func TestComputeAssetTotals_ProviderID_EmptyProviderID(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := NewClosedWindow(start, end)
+
+	// Create AssetSet with a node that has no ProviderID
+	as := NewAssetSet(start, end)
+
+	// Node without ProviderID (e.g., local/bare-metal cluster)
+	node := NewNode("node1", "cluster1", "", start, end, window)
+	node.CPUCost = 10.0
+	node.RAMCost = 5.0
+
+	as.Insert(node, nil)
+
+	// Compute AssetTotals by node (byAsset=true)
+	totals := ComputeAssetTotals(as, true)
+
+	// Verify that the node's totals has an empty ProviderID
+	total, ok := totals["cluster1/node1"]
+	if !ok {
+		t.Fatal("Expected to find totals for cluster1/node1")
+	}
+
+	if total.ProviderID != "" {
+		t.Errorf("Expected empty ProviderID for node without ProviderID, got %q", total.ProviderID)
+	}
+
+	// Verify other fields are still populated correctly
+	if total.Node != "node1" {
+		t.Errorf("Expected Node %q, got %q", "node1", total.Node)
+	}
+
+	if total.CPUCost != 10.0 {
+		t.Errorf("Expected CPUCost %f, got %f", 10.0, total.CPUCost)
+	}
+}
+
+// TestComputeAssetTotals_ProviderID_MultipleNodesAggregation tests that
+// when multiple nodes with different ProviderIDs are aggregated at the
+// cluster level, the ProviderID remains empty.
+func TestComputeAssetTotals_ProviderID_MultipleNodesAggregation(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := NewClosedWindow(start, end)
+
+	// Create AssetSet with multiple nodes across different clusters
+	as := NewAssetSet(start, end)
+
+	// Cluster 1 nodes
+	node1 := NewNode("node1", "cluster1", "aws:///us-east-1a/i-0abc123", start, end, window)
+	node1.CPUCost = 10.0
+	node1.RAMCost = 5.0
+
+	node2 := NewNode("node2", "cluster1", "aws:///us-east-1b/i-0def456", start, end, window)
+	node2.CPUCost = 8.0
+	node2.RAMCost = 4.0
+
+	// Cluster 2 node
+	node3 := NewNode("node3", "cluster2", "gce://project/zone/instance", start, end, window)
+	node3.CPUCost = 12.0
+	node3.RAMCost = 6.0
+
+	as.Insert(node1, nil)
+	as.Insert(node2, nil)
+	as.Insert(node3, nil)
+
+	// Compute AssetTotals by node (byAsset=true)
+	nodeTotal := ComputeAssetTotals(as, true)
+
+	// Verify each node has its own ProviderID
+	if nodeTotal["cluster1/node1"].ProviderID != "aws:///us-east-1a/i-0abc123" {
+		t.Errorf("Node1 ProviderID mismatch")
+	}
+	if nodeTotal["cluster1/node2"].ProviderID != "aws:///us-east-1b/i-0def456" {
+		t.Errorf("Node2 ProviderID mismatch")
+	}
+	if nodeTotal["cluster2/node3"].ProviderID != "gce://project/zone/instance" {
+		t.Errorf("Node3 ProviderID mismatch")
+	}
+
+	// Compute AssetTotals by cluster (byAsset=false)
+	clusterTotals := ComputeAssetTotals(as, false)
+
+	// Verify cluster-level totals have empty ProviderID
+	for clusterKey, total := range clusterTotals {
+		if total.ProviderID != "" {
+			t.Errorf("Cluster %s should have empty ProviderID, got %q", clusterKey, total.ProviderID)
+		}
+	}
+
+	// Verify cluster1 aggregates both nodes correctly
+	cluster1Total := clusterTotals["cluster1"]
+	expectedCPU := 18.0 // 10.0 + 8.0
+	expectedRAM := 9.0  // 5.0 + 4.0
+
+	if cluster1Total.CPUCost != expectedCPU {
+		t.Errorf("Cluster1 CPUCost: expected %f, got %f", expectedCPU, cluster1Total.CPUCost)
+	}
+	if cluster1Total.RAMCost != expectedRAM {
+		t.Errorf("Cluster1 RAMCost: expected %f, got %f", expectedRAM, cluster1Total.RAMCost)
+	}
+}

+ 1 - 1
pkg/costmodel/costmodel.go

@@ -1824,7 +1824,7 @@ func computeIdleAllocations(allocSet *opencost.AllocationSet, assetSet *opencost
 			Properties: &opencost.AllocationProperties{
 				Cluster:    assetTotal.Cluster,
 				Node:       assetTotal.Node,
-				ProviderID: assetTotal.Node,
+				ProviderID: assetTotal.ProviderID,
 			},
 			Start:   assetTotal.Start,
 			End:     assetTotal.End,

+ 346 - 0
pkg/costmodel/idle_providerid_test.go

@@ -0,0 +1,346 @@
+package costmodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// TestComputeIdleAllocations_ProviderID_ByNode verifies that when computing
+// idle allocations by node, each idle allocation correctly contains the
+// cloud provider instance ID from AssetTotals.ProviderID, not the node name.
+func TestComputeIdleAllocations_ProviderID_ByNode(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := opencost.NewClosedWindow(start, end)
+
+	// Create AssetSet with nodes that have ProviderIDs
+	assetSet := opencost.NewAssetSet(start, end)
+
+	node1 := opencost.NewNode("ip-10-2-1-100.ec2.internal", "cluster1", "aws:///us-east-1a/i-0abc123def456789", start, end, window)
+	node1.CPUCost = 10.0
+	node1.RAMCost = 5.0
+	node1.GPUCost = 0.0
+
+	node2 := opencost.NewNode("gke-cluster-default-pool-node-456", "cluster1", "gce://my-project/us-central1-a/instance-456", start, end, window)
+	node2.CPUCost = 8.0
+	node2.RAMCost = 4.0
+	node2.GPUCost = 0.0
+
+	assetSet.Insert(node1, nil)
+	assetSet.Insert(node2, nil)
+
+	// Create AllocationSet with some utilization (not 100% to ensure idle exists)
+	allocSet := opencost.NewAllocationSet(start, end)
+
+	// Create allocations that use 50% of each node's resources
+	alloc1 := &opencost.Allocation{
+		Name:    "namespace1/pod1/container1",
+		Start:   start,
+		End:     end,
+		Window:  window.Clone(),
+		CPUCost: 5.0, // 50% of node1's CPU
+		RAMCost: 2.5, // 50% of node1's RAM
+		GPUCost: 0.0,
+		Properties: &opencost.AllocationProperties{
+			Cluster: "cluster1",
+			Node:    "ip-10-2-1-100.ec2.internal",
+		},
+	}
+
+	alloc2 := &opencost.Allocation{
+		Name:    "namespace2/pod2/container2",
+		Start:   start,
+		End:     end,
+		Window:  window.Clone(),
+		CPUCost: 4.0, // 50% of node2's CPU
+		RAMCost: 2.0, // 50% of node2's RAM
+		GPUCost: 0.0,
+		Properties: &opencost.AllocationProperties{
+			Cluster: "cluster1",
+			Node:    "gke-cluster-default-pool-node-456",
+		},
+	}
+
+	allocSet.Insert(alloc1)
+	allocSet.Insert(alloc2)
+
+	// Compute idle allocations by node (idleByNode=true)
+	idleSet, err := computeIdleAllocations(allocSet, assetSet, true)
+	if err != nil {
+		t.Fatalf("Error computing idle allocations: %v", err)
+	}
+
+	// Expected idle allocations with ProviderIDs
+	expectedIdles := map[string]struct {
+		providerID string
+		nodeName   string
+	}{
+		"cluster1/ip-10-2-1-100.ec2.internal/__idle__": {
+			providerID: "aws:///us-east-1a/i-0abc123def456789",
+			nodeName:   "ip-10-2-1-100.ec2.internal",
+		},
+		"cluster1/gke-cluster-default-pool-node-456/__idle__": {
+			providerID: "gce://my-project/us-central1-a/instance-456",
+			nodeName:   "gke-cluster-default-pool-node-456",
+		},
+	}
+
+	// Verify each idle allocation has the correct ProviderID
+	foundCount := 0
+	for _, alloc := range idleSet.Allocations {
+		if !alloc.IsIdle() {
+			continue
+		}
+
+		expected, ok := expectedIdles[alloc.Name]
+		if !ok {
+			t.Errorf("Unexpected idle allocation: %s", alloc.Name)
+			continue
+		}
+
+		foundCount++
+
+		// Verify ProviderID is the cloud instance ID, not the node name
+		if alloc.Properties.ProviderID != expected.providerID {
+			t.Errorf("Allocation %s: expected ProviderID %q, got %q",
+				alloc.Name, expected.providerID, alloc.Properties.ProviderID)
+		}
+
+		// Verify ProviderID is NOT the node name (the bug we're fixing)
+		if alloc.Properties.ProviderID == expected.nodeName {
+			t.Errorf("Allocation %s: ProviderID should not be node name %q",
+				alloc.Name, expected.nodeName)
+		}
+
+		// Verify Node field still contains the node name
+		if alloc.Properties.Node != expected.nodeName {
+			t.Errorf("Allocation %s: expected Node %q, got %q",
+				alloc.Name, expected.nodeName, alloc.Properties.Node)
+		}
+
+		// Verify costs are non-zero (idle exists)
+		if alloc.CPUCost <= 0 && alloc.RAMCost <= 0 {
+			t.Errorf("Allocation %s: expected non-zero idle costs", alloc.Name)
+		}
+	}
+
+	if foundCount != len(expectedIdles) {
+		t.Errorf("Expected %d idle allocations, found %d", len(expectedIdles), foundCount)
+	}
+}
+
+// TestComputeIdleAllocations_ProviderID_ByCluster verifies that when computing
+// idle allocations by cluster, the idle allocation has an empty ProviderID
+// because there's no single instance ID for the entire cluster.
+func TestComputeIdleAllocations_ProviderID_ByCluster(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := opencost.NewClosedWindow(start, end)
+
+	// Create AssetSet with nodes that have ProviderIDs
+	assetSet := opencost.NewAssetSet(start, end)
+
+	node1 := opencost.NewNode("node1", "cluster1", "aws:///us-east-1a/i-0abc123", start, end, window)
+	node1.CPUCost = 10.0
+	node1.RAMCost = 5.0
+	node1.GPUCost = 0.0
+
+	node2 := opencost.NewNode("node2", "cluster1", "aws:///us-east-1b/i-0def456", start, end, window)
+	node2.CPUCost = 8.0
+	node2.RAMCost = 4.0
+	node2.GPUCost = 0.0
+
+	assetSet.Insert(node1, nil)
+	assetSet.Insert(node2, nil)
+
+	// Create AllocationSet with some utilization
+	allocSet := opencost.NewAllocationSet(start, end)
+
+	alloc := &opencost.Allocation{
+		Name:    "namespace1/pod1/container1",
+		Start:   start,
+		End:     end,
+		Window:  window.Clone(),
+		CPUCost: 9.0, // Uses 50% of total cluster CPU (18.0 total)
+		RAMCost: 4.5, // Uses 50% of total cluster RAM (9.0 total)
+		GPUCost: 0.0,
+		Properties: &opencost.AllocationProperties{
+			Cluster: "cluster1",
+			Node:    "node1",
+		},
+	}
+
+	allocSet.Insert(alloc)
+
+	// Compute idle allocations by cluster (idleByNode=false)
+	idleSet, err := computeIdleAllocations(allocSet, assetSet, false)
+	if err != nil {
+		t.Fatalf("Error computing idle allocations: %v", err)
+	}
+
+	// Find the cluster-level idle allocation
+	var clusterIdle *opencost.Allocation
+	for _, alloc := range idleSet.Allocations {
+		if alloc.IsIdle() && alloc.Name == "cluster1/__idle__" {
+			clusterIdle = alloc
+			break
+		}
+	}
+
+	if clusterIdle == nil {
+		t.Fatal("Expected to find cluster-level idle allocation")
+	}
+
+	// Verify ProviderID is empty for cluster-level idle
+	if clusterIdle.Properties.ProviderID != "" {
+		t.Errorf("Cluster-level idle allocation should have empty ProviderID, got %q",
+			clusterIdle.Properties.ProviderID)
+	}
+
+	// Verify Node is also empty for cluster-level idle
+	if clusterIdle.Properties.Node != "" {
+		t.Errorf("Cluster-level idle allocation should have empty Node, got %q",
+			clusterIdle.Properties.Node)
+	}
+
+	// Verify costs are non-zero
+	if clusterIdle.CPUCost <= 0 && clusterIdle.RAMCost <= 0 {
+		t.Error("Expected non-zero idle costs for cluster-level idle")
+	}
+}
+
+// TestComputeIdleAllocations_ProviderID_NoProviderID verifies that nodes
+// without a ProviderID (e.g., bare-metal, local clusters) result in idle
+// allocations with an empty ProviderID field.
+func TestComputeIdleAllocations_ProviderID_NoProviderID(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := opencost.NewClosedWindow(start, end)
+
+	// Create AssetSet with a node that has no ProviderID
+	assetSet := opencost.NewAssetSet(start, end)
+
+	node := opencost.NewNode("bare-metal-node-1", "cluster1", "", start, end, window)
+	node.CPUCost = 10.0
+	node.RAMCost = 5.0
+	node.GPUCost = 0.0
+
+	assetSet.Insert(node, nil)
+
+	// Create AllocationSet with partial utilization
+	allocSet := opencost.NewAllocationSet(start, end)
+
+	alloc := &opencost.Allocation{
+		Name:    "namespace1/pod1/container1",
+		Start:   start,
+		End:     end,
+		Window:  window.Clone(),
+		CPUCost: 5.0,
+		RAMCost: 2.5,
+		GPUCost: 0.0,
+		Properties: &opencost.AllocationProperties{
+			Cluster: "cluster1",
+			Node:    "bare-metal-node-1",
+		},
+	}
+
+	allocSet.Insert(alloc)
+
+	// Compute idle allocations by node
+	idleSet, err := computeIdleAllocations(allocSet, assetSet, true)
+	if err != nil {
+		t.Fatalf("Error computing idle allocations: %v", err)
+	}
+
+	// Find the idle allocation
+	var idle *opencost.Allocation
+	for _, alloc := range idleSet.Allocations {
+		if alloc.IsIdle() {
+			idle = alloc
+			break
+		}
+	}
+
+	if idle == nil {
+		t.Fatal("Expected to find idle allocation")
+	}
+
+	// Verify ProviderID is empty
+	if idle.Properties.ProviderID != "" {
+		t.Errorf("Node without ProviderID should result in empty ProviderID in idle allocation, got %q",
+			idle.Properties.ProviderID)
+	}
+
+	// Verify Node field is still populated
+	if idle.Properties.Node != "bare-metal-node-1" {
+		t.Errorf("Expected Node %q, got %q", "bare-metal-node-1", idle.Properties.Node)
+	}
+}
+
+// TestComputeIdleAllocations_ProviderID_AzureFormat tests that Azure VM
+// ProviderIDs are correctly propagated to idle allocations.
+func TestComputeIdleAllocations_ProviderID_AzureFormat(t *testing.T) {
+	// Create test window
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
+	window := opencost.NewClosedWindow(start, end)
+
+	// Create AssetSet with Azure VM
+	assetSet := opencost.NewAssetSet(start, end)
+
+	azureProviderID := "azure:///subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/aks-nodepool1-12345678-vmss000000"
+	node := opencost.NewNode("aks-nodepool1-12345678-vmss000000", "cluster1", azureProviderID, start, end, window)
+	node.CPUCost = 10.0
+	node.RAMCost = 5.0
+	node.GPUCost = 0.0
+
+	assetSet.Insert(node, nil)
+
+	// Create AllocationSet
+	allocSet := opencost.NewAllocationSet(start, end)
+
+	alloc := &opencost.Allocation{
+		Name:    "namespace1/pod1/container1",
+		Start:   start,
+		End:     end,
+		Window:  window.Clone(),
+		CPUCost: 5.0,
+		RAMCost: 2.5,
+		GPUCost: 0.0,
+		Properties: &opencost.AllocationProperties{
+			Cluster: "cluster1",
+			Node:    "aks-nodepool1-12345678-vmss000000",
+		},
+	}
+
+	allocSet.Insert(alloc)
+
+	// Compute idle allocations
+	idleSet, err := computeIdleAllocations(allocSet, assetSet, true)
+	if err != nil {
+		t.Fatalf("Error computing idle allocations: %v", err)
+	}
+
+	// Find the idle allocation
+	var idle *opencost.Allocation
+	for _, alloc := range idleSet.Allocations {
+		if alloc.IsIdle() {
+			idle = alloc
+			break
+		}
+	}
+
+	if idle == nil {
+		t.Fatal("Expected to find idle allocation")
+	}
+
+	// Verify Azure ProviderID is correctly set
+	if idle.Properties.ProviderID != azureProviderID {
+		t.Errorf("Expected ProviderID %q, got %q", azureProviderID, idle.Properties.ProviderID)
+	}
+}