|
|
@@ -2,12 +2,14 @@ package aws
|
|
|
|
|
|
import (
|
|
|
"encoding/json"
|
|
|
+ "errors"
|
|
|
"io"
|
|
|
"net/http"
|
|
|
"net/url"
|
|
|
"os"
|
|
|
"reflect"
|
|
|
"testing"
|
|
|
+ "time"
|
|
|
|
|
|
"github.com/opencost/opencost/core/pkg/clustercache"
|
|
|
"github.com/opencost/opencost/pkg/cloud/models"
|
|
|
@@ -867,7 +869,7 @@ func (f *fakeProviderConfig) ConfigFileManager() *config.ConfigFileManager {
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
-func TestAWS_SpotRefreshEnabled(t *testing.T) {
|
|
|
+func TestAWS_SpotFeedRefreshEnabled(t *testing.T) {
|
|
|
tests := []struct {
|
|
|
name string
|
|
|
spotDataBucket string
|
|
|
@@ -955,9 +957,9 @@ func TestAWS_SpotRefreshEnabled(t *testing.T) {
|
|
|
},
|
|
|
}
|
|
|
|
|
|
- got := aws.SpotRefreshEnabled()
|
|
|
+ got := aws.SpotFeedRefreshEnabled()
|
|
|
if got != tt.want {
|
|
|
- t.Errorf("AWS.SpotRefreshEnabled() = %v, want %v", got, tt.want)
|
|
|
+ t.Errorf("AWS.SpotFeedRefreshEnabled() = %v, want %v", got, tt.want)
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
@@ -971,10 +973,10 @@ func TestAWS_SpotRefreshEnabled(t *testing.T) {
|
|
|
Config: nil, // nil Config should not cause panic
|
|
|
}
|
|
|
|
|
|
- got := aws.SpotRefreshEnabled()
|
|
|
+ got := aws.SpotFeedRefreshEnabled()
|
|
|
want := true // Should fall back to field-based check
|
|
|
if got != want {
|
|
|
- t.Errorf("AWS.SpotRefreshEnabled() with nil Config = %v, want %v", got, want)
|
|
|
+ t.Errorf("AWS.SpotFeedRefreshEnabled() with nil Config = %v, want %v", got, want)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -986,10 +988,378 @@ func TestAWS_SpotRefreshEnabled(t *testing.T) {
|
|
|
Config: nil, // nil Config should not cause panic
|
|
|
}
|
|
|
|
|
|
- got := aws.SpotRefreshEnabled()
|
|
|
+ got := aws.SpotFeedRefreshEnabled()
|
|
|
want := false // No fields set, should return false
|
|
|
if got != want {
|
|
|
- t.Errorf("AWS.SpotRefreshEnabled() with nil Config and no fields = %v, want %v", got, want)
|
|
|
+ t.Errorf("AWS.SpotFeedRefreshEnabled() with nil Config and no fields = %v, want %v", got, want)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func TestAWS_spotPricingFromHistory(t *testing.T) {
|
|
|
+ t.Run("nil cache returns false", func(t *testing.T) {
|
|
|
+ aws := &AWS{}
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ "kubernetes.io/os": "linux",
|
|
|
+ "eks.amazonaws.com/capacityType": "SPOT",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ _, ok := aws.spotPricingFromHistory(key)
|
|
|
+ if ok {
|
|
|
+ t.Error("Expected false when cache is nil")
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("missing region label returns false", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{}
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ }
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ _, ok := aws.spotPricingFromHistory(key)
|
|
|
+ if ok {
|
|
|
+ t.Error("Expected false when region label is missing")
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("missing instance type label returns false", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{}
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ }
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ _, ok := aws.spotPricingFromHistory(key)
|
|
|
+ if ok {
|
|
|
+ t.Error("Expected false when instance type label is missing")
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("missing zone label returns false", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{}
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ }
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ _, ok := aws.spotPricingFromHistory(key)
|
|
|
+ if ok {
|
|
|
+ t.Error("Expected false when zone label is missing")
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("fetcher error returns false", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{
|
|
|
+ fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
|
|
|
+ return nil, errors.New("api error")
|
|
|
+ },
|
|
|
+ }
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ }
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ _, ok := aws.spotPricingFromHistory(key)
|
|
|
+ if ok {
|
|
|
+ t.Error("Expected false when fetcher returns error")
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("successful lookup returns entry", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{
|
|
|
+ fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
|
|
|
+ if key.Region != "us-east-1" || key.InstanceType != "m5.large" || key.AvailabilityZone != "us-east-1a" {
|
|
|
+ t.Errorf("Unexpected key: %v", key)
|
|
|
+ }
|
|
|
+ return &SpotPriceHistoryEntry{
|
|
|
+ SpotPrice: 0.042,
|
|
|
+ Timestamp: time.Now(),
|
|
|
+ RetrievedAt: time.Now(),
|
|
|
+ }, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ }
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ entry, ok := aws.spotPricingFromHistory(key)
|
|
|
+ if !ok {
|
|
|
+ t.Fatal("Expected true for successful lookup")
|
|
|
+ }
|
|
|
+ if entry.SpotPrice != 0.042 {
|
|
|
+ t.Errorf("Expected spot price 0.042, got %f", entry.SpotPrice)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func TestAWS_createNode_spotHistoryFallback(t *testing.T) {
|
|
|
+ // Helper to build AWSProductTerms with on-demand pricing
|
|
|
+ makeTerms := func(sku, offerTermCode, cost string) *AWSProductTerms {
|
|
|
+ priceKey := sku + "." + offerTermCode + "." + HourlyRateCode
|
|
|
+ return &AWSProductTerms{
|
|
|
+ Sku: sku,
|
|
|
+ OnDemand: &AWSOfferTerm{
|
|
|
+ Sku: sku,
|
|
|
+ OfferTermCode: offerTermCode,
|
|
|
+ PriceDimensions: map[string]*AWSRateCode{
|
|
|
+ priceKey: {
|
|
|
+ Unit: "Hrs",
|
|
|
+ PricePerUnit: AWSCurrencyCode{USD: cost},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ VCpu: "4",
|
|
|
+ Memory: "16",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ t.Run("preemptible node uses spot history when available", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{
|
|
|
+ fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
|
|
|
+ return &SpotPriceHistoryEntry{
|
|
|
+ SpotPrice: 0.035,
|
|
|
+ Timestamp: time.Now(),
|
|
|
+ RetrievedAt: time.Now(),
|
|
|
+ }, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ BaseCPUPrice: "0.04",
|
|
|
+ BaseRAMPrice: "0.01",
|
|
|
+ BaseGPUPrice: "0.95",
|
|
|
+ }
|
|
|
+ terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
|
|
|
+ // Key with PreemptibleType suffix to trigger isPreemptible
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ SpotLabelName: "eks.amazonaws.com/capacityType",
|
|
|
+ SpotLabelValue: "SPOT",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ "kubernetes.io/os": "linux",
|
|
|
+ "eks.amazonaws.com/capacityType": "SPOT",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ node, meta, err := aws.createNode(terms, PreemptibleType, key)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if node.Cost != "0.035000" {
|
|
|
+ t.Errorf("Expected spot history cost 0.035000, got %s", node.Cost)
|
|
|
+ }
|
|
|
+ if node.UsageType != PreemptibleType {
|
|
|
+ t.Errorf("Expected usage type %s, got %s", PreemptibleType, node.UsageType)
|
|
|
+ }
|
|
|
+ if meta.Source != SpotPriceHistorySource {
|
|
|
+ t.Errorf("Expected source %s, got %s", SpotPriceHistorySource, meta.Source)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("preemptible node falls back to on-demand when history unavailable", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{
|
|
|
+ fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
|
|
|
+ return nil, errors.New("no data")
|
|
|
+ },
|
|
|
+ }
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ BaseCPUPrice: "0.04",
|
|
|
+ BaseRAMPrice: "0.01",
|
|
|
+ BaseGPUPrice: "0.95",
|
|
|
+ }
|
|
|
+ terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ SpotLabelName: "eks.amazonaws.com/capacityType",
|
|
|
+ SpotLabelValue: "SPOT",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ "kubernetes.io/os": "linux",
|
|
|
+ "eks.amazonaws.com/capacityType": "SPOT",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ node, _, err := aws.createNode(terms, PreemptibleType, key)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if node.Cost != "0.096" {
|
|
|
+ t.Errorf("Expected on-demand cost 0.096, got %s", node.Cost)
|
|
|
+ }
|
|
|
+ if node.UsageType != PreemptibleType {
|
|
|
+ t.Errorf("Expected usage type %s, got %s", PreemptibleType, node.UsageType)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("preemptible node with nil cache falls back to on-demand", func(t *testing.T) {
|
|
|
+ aws := &AWS{
|
|
|
+ BaseCPUPrice: "0.04",
|
|
|
+ BaseRAMPrice: "0.01",
|
|
|
+ BaseGPUPrice: "0.95",
|
|
|
+ }
|
|
|
+ terms := makeTerms("SKU123", "JRTCKXETXF", "0.096")
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ SpotLabelName: "eks.amazonaws.com/capacityType",
|
|
|
+ SpotLabelValue: "SPOT",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ "kubernetes.io/os": "linux",
|
|
|
+ "eks.amazonaws.com/capacityType": "SPOT",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ node, _, err := aws.createNode(terms, PreemptibleType, key)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if node.Cost != "0.096" {
|
|
|
+ t.Errorf("Expected on-demand cost 0.096, got %s", node.Cost)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("preemptible node uses base spot prices when no public pricing", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{
|
|
|
+ fetchFunc: func(key SpotPriceHistoryKey) (*SpotPriceHistoryEntry, error) {
|
|
|
+ return nil, errors.New("no data")
|
|
|
+ },
|
|
|
+ }
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ BaseCPUPrice: "0.04",
|
|
|
+ BaseRAMPrice: "0.01",
|
|
|
+ BaseGPUPrice: "0.95",
|
|
|
+ BaseSpotCPUPrice: "0.02",
|
|
|
+ BaseSpotRAMPrice: "0.005",
|
|
|
+ }
|
|
|
+ // Terms without valid pricing dimensions
|
|
|
+ terms := &AWSProductTerms{
|
|
|
+ Sku: "SKU123",
|
|
|
+ OnDemand: &AWSOfferTerm{
|
|
|
+ Sku: "SKU123",
|
|
|
+ OfferTermCode: "JRTCKXETXF",
|
|
|
+ PriceDimensions: map[string]*AWSRateCode{},
|
|
|
+ },
|
|
|
+ VCpu: "4",
|
|
|
+ Memory: "16",
|
|
|
+ }
|
|
|
+ key := &awsKey{
|
|
|
+ ProviderID: "aws:///us-east-1a/i-0123456789abcdef0",
|
|
|
+ SpotLabelName: "eks.amazonaws.com/capacityType",
|
|
|
+ SpotLabelValue: "SPOT",
|
|
|
+ Labels: map[string]string{
|
|
|
+ "topology.kubernetes.io/region": "us-east-1",
|
|
|
+ "topology.kubernetes.io/zone": "us-east-1a",
|
|
|
+ "node.kubernetes.io/instance-type": "m5.large",
|
|
|
+ "kubernetes.io/os": "linux",
|
|
|
+ "eks.amazonaws.com/capacityType": "SPOT",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ node, _, err := aws.createNode(terms, PreemptibleType, key)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("Unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if node.VCPUCost != "0.02" {
|
|
|
+ t.Errorf("Expected base spot CPU price 0.02, got %s", node.VCPUCost)
|
|
|
+ }
|
|
|
+ if node.RAMCost != "0.005" {
|
|
|
+ t.Errorf("Expected base spot RAM price 0.005, got %s", node.RAMCost)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func TestAWS_PricingSourceStatus_spotPriceHistory(t *testing.T) {
|
|
|
+ t.Run("not yet initialized", func(t *testing.T) {
|
|
|
+ aws := &AWS{
|
|
|
+ Config: &fakeProviderConfig{
|
|
|
+ customPricing: &models.CustomPricing{},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ sources := aws.PricingSourceStatus()
|
|
|
+ sphs, ok := sources[SpotPriceHistorySource]
|
|
|
+ if !ok {
|
|
|
+ t.Fatal("Expected SpotPriceHistorySource in sources")
|
|
|
+ }
|
|
|
+ if sphs.Available {
|
|
|
+ t.Error("Expected Available=false when cache not initialized")
|
|
|
+ }
|
|
|
+ if sphs.Error != "Not yet initialized" {
|
|
|
+ t.Errorf("Expected 'Not yet initialized' error, got %q", sphs.Error)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("initialization error", func(t *testing.T) {
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryError: errors.New("no cluster region configured"),
|
|
|
+ Config: &fakeProviderConfig{
|
|
|
+ customPricing: &models.CustomPricing{},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ sources := aws.PricingSourceStatus()
|
|
|
+ sphs := sources[SpotPriceHistorySource]
|
|
|
+ if sphs.Available {
|
|
|
+ t.Error("Expected Available=false on error")
|
|
|
+ }
|
|
|
+ if sphs.Error != "no cluster region configured" {
|
|
|
+ t.Errorf("Expected error message, got %q", sphs.Error)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("successfully initialized", func(t *testing.T) {
|
|
|
+ mockFetcher := &mockSpotPriceHistoryFetcher{}
|
|
|
+ aws := &AWS{
|
|
|
+ SpotPriceHistoryCache: NewSpotPriceHistoryCache(mockFetcher),
|
|
|
+ Config: &fakeProviderConfig{
|
|
|
+ customPricing: &models.CustomPricing{},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ sources := aws.PricingSourceStatus()
|
|
|
+ sphs := sources[SpotPriceHistorySource]
|
|
|
+ if !sphs.Available {
|
|
|
+ t.Error("Expected Available=true when cache initialized")
|
|
|
}
|
|
|
})
|
|
|
}
|