Jelajahi Sumber

Aws fargate pricing (#3478)

Signed-off-by: motoki317 <motoki317@gmail.com>
Signed-off-by: Tomer Fisher <tomer.fisher@zesty.co>
Signed-off-by: tomerfi1210 <tomerfisher@macbookpro.local>
Co-authored-by: motoki317 <motoki317@gmail.com>
Co-authored-by: tomerfi1210 <tomerfisher@macbookpro.local>
Tomer Fisher 4 bulan lalu
induk
melakukan
5570fe3d2f

+ 172 - 0
pkg/cloud/aws/fargate.go

@@ -0,0 +1,172 @@
+package aws
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+const (
+	usageTypeFargateLinuxX86CPU    = "Fargate-vCPU-Hours:perCPU"
+	usageTypeFargateLinuxX86RAM    = "Fargate-GB-Hours"
+	usageTypeFargateLinuxArmCPU    = "Fargate-ARM-vCPU-Hours:perCPU"
+	usageTypeFargateLinuxArmRAM    = "Fargate-ARM-GB-Hours"
+	usageTypeFargateWindowsCPU     = "Fargate-Windows-vCPU-Hours:perCPU"
+	usageTypeFargateWindowsLicense = "Fargate-Windows-OS-Hours:perCPU"
+	usageTypeFargateWindowsRAM     = "Fargate-Windows-GB-Hours"
+)
+
+var fargateUsageTypes = []string{
+	usageTypeFargateLinuxX86CPU,
+	usageTypeFargateLinuxX86RAM,
+	usageTypeFargateLinuxArmCPU,
+	usageTypeFargateLinuxArmRAM,
+	usageTypeFargateWindowsCPU,
+	usageTypeFargateWindowsLicense,
+	usageTypeFargateWindowsRAM,
+}
+
+type FargateRegionPricing map[string]float64
+
+func (f FargateRegionPricing) Validate() error {
+	for _, usageType := range fargateUsageTypes {
+		if _, ok := f[usageType]; !ok {
+			return fmt.Errorf("missing pricing for usageType %s", usageType)
+		}
+	}
+	return nil
+}
+
+type FargatePricing struct {
+	regions map[string]FargateRegionPricing
+}
+
+func NewFargatePricing() *FargatePricing {
+	return &FargatePricing{
+		regions: make(map[string]FargateRegionPricing),
+	}
+}
+
+func (f *FargatePricing) Initialize(nodeList []*clustercache.Node) error {
+	url := f.getPricingURL(nodeList)
+
+	log.Infof("Downloading Fargate pricing data from %s", url)
+
+	client := &http.Client{
+		Timeout: 30 * time.Second,
+	}
+
+	resp, err := client.Get(url)
+	if err != nil {
+		return fmt.Errorf("downloading pricing data: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("pricing download failed: status=%d", resp.StatusCode)
+	}
+
+	var pricing AWSPricing
+	if err := json.NewDecoder(resp.Body).Decode(&pricing); err != nil {
+		return fmt.Errorf("parsing pricing data: %w", err)
+	}
+
+	return f.populatePricing(&pricing)
+}
+
+func (f *FargatePricing) getPricingURL(nodeList []*clustercache.Node) string {
+	// Allow override of pricing URL for air-gapped environments
+	if override := env.GetAWSECSPricingURLOverride(); override != "" {
+		return override
+	}
+	return getPricingListURL("AmazonECS", nodeList)
+}
+
+func (f *FargatePricing) populatePricing(pricing *AWSPricing) error {
+	// Populate pricing for each region
+productLoop:
+	for sku, product := range pricing.Products {
+		for _, usageType := range fargateUsageTypes {
+			if strings.HasSuffix(product.Attributes.UsageType, usageType) {
+				region := product.Attributes.RegionCode
+				if _, ok := f.regions[region]; !ok {
+					f.regions[region] = make(FargateRegionPricing)
+				}
+
+				skuPrice, err := f.getPricingOfSKU(sku, &pricing.Terms)
+				if err != nil {
+					return fmt.Errorf("error getting pricing for sku %s: %s", sku, err)
+				}
+				f.regions[region][usageType] = skuPrice
+				continue productLoop
+			}
+		}
+	}
+
+	// Validate pricing - do we have all the pricing we need?
+	for region, regionPricing := range f.regions {
+		err := regionPricing.Validate()
+		if err != nil {
+			// Be failsafe here and just log warnings
+			log.Warnf("Fargate pricing data is (partially) missing pricing for %s: %s", region, err)
+		}
+	}
+	return nil
+}
+
+func (f *FargatePricing) getPricingOfSKU(sku string, allTerms *AWSPricingTerms) (float64, error) {
+	skuTerm, ok := allTerms.OnDemand[sku]
+	if !ok {
+		return 0, fmt.Errorf("missing pricing for sku %s", sku)
+	}
+	for _, offerTerm := range skuTerm {
+		if _, isMatch := OnDemandRateCodes[offerTerm.OfferTermCode]; isMatch {
+			priceDimensionKey := strings.Join([]string{sku, offerTerm.OfferTermCode, HourlyRateCode}, ".")
+			if dimension, ok := offerTerm.PriceDimensions[priceDimensionKey]; ok {
+				return strconv.ParseFloat(dimension.PricePerUnit.USD, 64)
+			}
+		} else if _, isMatch := OnDemandRateCodesCn[offerTerm.OfferTermCode]; isMatch {
+			priceDimensionKey := strings.Join([]string{sku, offerTerm.OfferTermCode, HourlyRateCodeCn}, ".")
+			if dimension, ok := offerTerm.PriceDimensions[priceDimensionKey]; ok {
+				return strconv.ParseFloat(dimension.PricePerUnit.CNY, 64)
+			}
+		}
+	}
+	return 0, fmt.Errorf("missing pricing for sku %s", sku)
+}
+
+func (f *FargatePricing) GetHourlyPricing(region string, os, arch string) (cpu, memory float64, err error) {
+	regionPricing, ok := f.regions[region]
+	if !ok {
+		return 0, 0, fmt.Errorf("missing pricing for region %s", region)
+	}
+
+	switch os {
+	case "linux":
+		switch arch {
+		case "amd64":
+			cpu = regionPricing[usageTypeFargateLinuxX86CPU]
+			memory = regionPricing[usageTypeFargateLinuxX86RAM]
+			return
+		case "arm64":
+			cpu = regionPricing[usageTypeFargateLinuxArmCPU]
+			memory = regionPricing[usageTypeFargateLinuxArmRAM]
+			return
+		}
+	case "windows":
+		cpuOnly := regionPricing[usageTypeFargateWindowsCPU]
+		cpuLicense := regionPricing[usageTypeFargateWindowsLicense]
+		cpu = cpuOnly + cpuLicense
+		memory = regionPricing[usageTypeFargateWindowsRAM]
+		return
+	}
+
+	return 0, 0, fmt.Errorf("unknown os/arch combination: %s/%s", os, arch)
+}

+ 533 - 0
pkg/cloud/aws/fargate_test.go

@@ -0,0 +1,533 @@
+package aws
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+)
+
+var testRegionPricing = FargateRegionPricing{
+	usageTypeFargateLinuxX86CPU:    0.0404800000,
+	usageTypeFargateLinuxX86RAM:    0.0044450000,
+	usageTypeFargateLinuxArmCPU:    0.0323800000,
+	usageTypeFargateLinuxArmRAM:    0.0035600000,
+	usageTypeFargateWindowsCPU:     0.0465520000,
+	usageTypeFargateWindowsLicense: 0.0460000000,
+	usageTypeFargateWindowsRAM:     0.0051117500,
+}
+
+func TestFargatePricing_populatePricing(t *testing.T) {
+	// Load test data
+	testDataPath := "testdata/ecs-pricing-us-east-1.json"
+	data, err := os.ReadFile(testDataPath)
+	if err != nil {
+		t.Fatalf("Failed to read test data: %v", err)
+	}
+
+	var pricing AWSPricing
+	err = json.Unmarshal(data, &pricing)
+	if err != nil {
+		t.Fatalf("Failed to unmarshal test data: %v", err)
+	}
+
+	tests := []struct {
+		name    string
+		pricing *AWSPricing
+		wantErr bool
+	}{
+		{
+			name:    "valid pricing data",
+			pricing: &pricing,
+			wantErr: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f := NewFargatePricing()
+
+			err := f.populatePricing(tt.pricing)
+
+			if tt.wantErr {
+				if err == nil {
+					t.Errorf("populatePricing() expected error, got nil")
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("populatePricing() unexpected error: %v", err)
+				return
+			}
+
+			// Verify that regions were populated
+			if len(f.regions) == 0 {
+				t.Error("populatePricing() did not populate any regions")
+				return
+			}
+
+			// Check that us-east-1 pricing was populated (from test data)
+			usEast1, ok := f.regions["us-east-1"]
+			if !ok {
+				t.Error("populatePricing() did not populate us-east-1 region")
+				return
+			}
+
+			// Verify all required usage types are present
+			for _, usageType := range fargateUsageTypes {
+				if price, ok := usEast1[usageType]; !ok {
+					t.Errorf("populatePricing() missing usage type %s", usageType)
+				} else if price <= 0 {
+					t.Errorf("populatePricing() invalid price %f for usage type %s", price, usageType)
+				}
+			}
+
+			// Test specific pricing values from test data
+			for usageType, expectedPrice := range testRegionPricing {
+				if actualPrice, ok := usEast1[usageType]; ok {
+					if actualPrice != expectedPrice {
+						t.Errorf("populatePricing() price mismatch for %s: expected %f, got %f", usageType, expectedPrice, actualPrice)
+					}
+				}
+			}
+		})
+	}
+}
+
+func TestFargatePricing_GetHourlyPricing(t *testing.T) {
+	// Create a Fargate pricing instance with test data
+	f := NewFargatePricing()
+
+	// Populate test pricing data for us-east-1
+	f.regions["us-east-1"] = testRegionPricing
+
+	tests := []struct {
+		name        string
+		region      string
+		os          string
+		arch        string
+		expectedCPU float64
+		expectedRAM float64
+		expectedErr bool
+	}{
+		{
+			name:        "linux amd64",
+			region:      "us-east-1",
+			os:          "linux",
+			arch:        "amd64",
+			expectedCPU: 0.0404800000,
+			expectedRAM: 0.0044450000,
+			expectedErr: false,
+		},
+		{
+			name:        "linux arm64",
+			region:      "us-east-1",
+			os:          "linux",
+			arch:        "arm64",
+			expectedCPU: 0.0323800000,
+			expectedRAM: 0.0035600000,
+			expectedErr: false,
+		},
+		{
+			name:        "windows (any arch)",
+			region:      "us-east-1",
+			os:          "windows",
+			arch:        "amd64",
+			expectedCPU: 0.0925520000, // CPU + License: 0.0465520000 + 0.0460000000
+			expectedRAM: 0.0051117500,
+			expectedErr: false,
+		},
+		{
+			name:        "unknown region",
+			region:      "unknown-region",
+			os:          "linux",
+			arch:        "amd64",
+			expectedCPU: 0,
+			expectedRAM: 0,
+			expectedErr: true,
+		},
+		{
+			name:        "unknown os",
+			region:      "us-east-1",
+			os:          "macos",
+			arch:        "amd64",
+			expectedCPU: 0,
+			expectedRAM: 0,
+			expectedErr: true,
+		},
+		{
+			name:        "unknown arch for linux",
+			region:      "us-east-1",
+			os:          "linux",
+			arch:        "unknown",
+			expectedCPU: 0,
+			expectedRAM: 0,
+			expectedErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cpu, memory, err := f.GetHourlyPricing(tt.region, tt.os, tt.arch)
+
+			if tt.expectedErr {
+				if err == nil {
+					t.Errorf("GetHourlyPricing() expected error, got nil")
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("GetHourlyPricing() unexpected error: %v", err)
+				return
+			}
+
+			if cpu != tt.expectedCPU {
+				t.Errorf("GetHourlyPricing() CPU price mismatch: expected %f, got %f", tt.expectedCPU, cpu)
+			}
+
+			if memory != tt.expectedRAM {
+				t.Errorf("GetHourlyPricing() RAM price mismatch: expected %f, got %f", tt.expectedRAM, memory)
+			}
+		})
+	}
+}
+
+func TestFargateRegionPricing_Validate(t *testing.T) {
+	tests := []struct {
+		name    string
+		pricing FargateRegionPricing
+		wantErr bool
+	}{
+		{
+			name: "valid complete pricing",
+			pricing: FargateRegionPricing{
+				usageTypeFargateLinuxX86CPU:    0.04048,
+				usageTypeFargateLinuxX86RAM:    0.004445,
+				usageTypeFargateLinuxArmCPU:    0.03238,
+				usageTypeFargateLinuxArmRAM:    0.00356,
+				usageTypeFargateWindowsCPU:     0.046552,
+				usageTypeFargateWindowsLicense: 0.046,
+				usageTypeFargateWindowsRAM:     0.00511175,
+			},
+			wantErr: false,
+		},
+		{
+			name: "missing linux x86 CPU",
+			pricing: FargateRegionPricing{
+				usageTypeFargateLinuxX86RAM:    0.004445,
+				usageTypeFargateLinuxArmCPU:    0.03238,
+				usageTypeFargateLinuxArmRAM:    0.00356,
+				usageTypeFargateWindowsCPU:     0.046552,
+				usageTypeFargateWindowsLicense: 0.046,
+				usageTypeFargateWindowsRAM:     0.00511175,
+			},
+			wantErr: true,
+		},
+		{
+			name: "missing linux x86 RAM",
+			pricing: FargateRegionPricing{
+				usageTypeFargateLinuxX86CPU:    0.04048,
+				usageTypeFargateLinuxArmCPU:    0.03238,
+				usageTypeFargateLinuxArmRAM:    0.00356,
+				usageTypeFargateWindowsCPU:     0.046552,
+				usageTypeFargateWindowsLicense: 0.046,
+				usageTypeFargateWindowsRAM:     0.00511175,
+			},
+			wantErr: true,
+		},
+		{
+			name: "missing windows license",
+			pricing: FargateRegionPricing{
+				usageTypeFargateLinuxX86CPU: 0.04048,
+				usageTypeFargateLinuxX86RAM: 0.004445,
+				usageTypeFargateLinuxArmCPU: 0.03238,
+				usageTypeFargateLinuxArmRAM: 0.00356,
+				usageTypeFargateWindowsCPU:  0.046552,
+				usageTypeFargateWindowsRAM:  0.00511175,
+			},
+			wantErr: true,
+		},
+		{
+			name:    "empty pricing",
+			pricing: FargateRegionPricing{},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.pricing.Validate()
+			if tt.wantErr && err == nil {
+				t.Errorf("Validate() expected error, got nil")
+			}
+			if !tt.wantErr && err != nil {
+				t.Errorf("Validate() unexpected error: %v", err)
+			}
+		})
+	}
+}
+
+func TestFargatePricing_Initialize(t *testing.T) {
+	// Load test data
+	testDataPath := "testdata/ecs-pricing-us-east-1.json"
+	data, err := os.ReadFile(testDataPath)
+	if err != nil {
+		t.Fatalf("Failed to read test data: %v", err)
+	}
+
+	// Create a test HTTP server that serves the pricing data
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(data)
+	}))
+	defer server.Close()
+
+	// Set up test environment variable to use our test server
+	t.Setenv("AWS_ECS_PRICING_URL", server.URL)
+
+	tests := []struct {
+		name     string
+		nodeList []*clustercache.Node
+		wantErr  bool
+	}{
+		{
+			name: "successful initialization",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-east-1",
+					},
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name:     "empty node list",
+			nodeList: []*clustercache.Node{},
+			wantErr:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f := NewFargatePricing()
+			err := f.Initialize(tt.nodeList)
+
+			if tt.wantErr {
+				if err == nil {
+					t.Errorf("Initialize() expected error, got nil")
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("Initialize() unexpected error: %v", err)
+				return
+			}
+
+			// Verify that regions were populated
+			if len(f.regions) == 0 {
+				t.Error("Initialize() did not populate any regions")
+				return
+			}
+
+			// Check that us-east-1 pricing was populated (from test data)
+			usEast1, ok := f.regions["us-east-1"]
+			if !ok {
+				t.Error("Initialize() did not populate us-east-1 region")
+				return
+			}
+
+			// Verify all required usage types are present
+			for _, usageType := range fargateUsageTypes {
+				if price, ok := usEast1[usageType]; !ok {
+					t.Errorf("Initialize() missing usage type %s", usageType)
+				} else if price <= 0 {
+					t.Errorf("Initialize() invalid price %f for usage type %s", price, usageType)
+				}
+			}
+		})
+	}
+}
+
+func TestFargatePricing_Initialize_HTTPError(t *testing.T) {
+	// Create a test HTTP server that returns an error
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer server.Close()
+
+	// Set up test environment variable to use our test server
+	t.Setenv("AWS_ECS_PRICING_URL", server.URL)
+
+	f := NewFargatePricing()
+	nodeList := []*clustercache.Node{
+		{
+			Name: "test-node",
+			Labels: map[string]string{
+				"topology.kubernetes.io/region": "us-east-1",
+			},
+		},
+	}
+
+	err := f.Initialize(nodeList)
+	if err == nil {
+		t.Error("Initialize() expected error for HTTP 500, got nil")
+	}
+}
+
+func TestFargatePricing_Initialize_InvalidJSON(t *testing.T) {
+	// Create a test HTTP server that returns invalid JSON
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("invalid json"))
+	}))
+	defer server.Close()
+
+	// Set up test environment variable to use our test server
+	t.Setenv("AWS_ECS_PRICING_URL", server.URL)
+
+	f := NewFargatePricing()
+	nodeList := []*clustercache.Node{
+		{
+			Name: "test-node",
+			Labels: map[string]string{
+				"topology.kubernetes.io/region": "us-east-1",
+			},
+		},
+	}
+
+	err := f.Initialize(nodeList)
+	if err == nil {
+		t.Error("Initialize() expected error for invalid JSON, got nil")
+	}
+}
+
+func TestFargatePricing_getPricingURL(t *testing.T) {
+	tests := []struct {
+		name     string
+		nodeList []*clustercache.Node
+		envVar   string
+		expected string
+	}{
+		{
+			name: "with environment variable override",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-east-1",
+					},
+				},
+			},
+			envVar:   "https://custom-pricing-url.com",
+			expected: "https://custom-pricing-url.com",
+		},
+		{
+			name: "without environment variable - single region",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-west-2",
+					},
+				},
+			},
+			envVar:   "",
+			expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/us-west-2/index.json",
+		},
+		{
+			name: "without environment variable - Chinese region",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "cn-north-1",
+					},
+				},
+			},
+			envVar:   "",
+			expected: "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonECS/current/cn-north-1/index.json",
+		},
+		{
+			name:     "without environment variable - empty node list",
+			nodeList: []*clustercache.Node{},
+			envVar:   "",
+			expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/index.json",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.envVar != "" {
+				t.Setenv("AWS_ECS_PRICING_URL", tt.envVar)
+			} else {
+				t.Setenv("AWS_ECS_PRICING_URL", "")
+			}
+
+			f := NewFargatePricing()
+			result := f.getPricingURL(tt.nodeList)
+
+			if result != tt.expected {
+				t.Errorf("getPricingURL() = %v, expected %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+// TestFargatePricing_ValidateAWSPricingFormat validates that the actual AWS pricing API
+// returns data in the expected format. This test is skipped by default and only runs
+// when INTEGRATION=true to avoid hitting AWS APIs in regular CI runs.
+func TestFargatePricing_ValidateAWSPricingFormat(t *testing.T) {
+	if os.Getenv("INTEGRATION") != "true" {
+		t.Skip("Skipping integration test. Set INTEGRATION=true to run.")
+	}
+
+	nodes := []*clustercache.Node{
+		{
+			Labels: map[string]string{
+				"topology.kubernetes.io/region": "us-east-1",
+			},
+		},
+	}
+
+	url := getPricingListURL("AmazonECS", nodes)
+	t.Logf("Testing AWS pricing URL: %s", url)
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Get(url)
+	if err != nil {
+		t.Fatalf("Failed to fetch pricing data: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("Unexpected status code: %d", resp.StatusCode)
+	}
+
+	var pricing AWSPricing
+	if err := json.NewDecoder(resp.Body).Decode(&pricing); err != nil {
+		t.Fatalf("Failed to decode pricing data - AWS format may have changed: %v", err)
+	}
+
+	if len(pricing.Products) == 0 {
+		t.Fatal("Expected products in pricing data, got none - AWS format may have changed")
+	}
+
+	if len(pricing.Terms.OnDemand) == 0 {
+		t.Fatal("Expected OnDemand terms in pricing data, got none - AWS format may have changed")
+	}
+
+	t.Logf("✓ AWS pricing format validated: %d products, %d OnDemand terms", 
+		len(pricing.Products), len(pricing.Terms.OnDemand))
+}
+

+ 153 - 34
pkg/cloud/aws/provider.go

@@ -54,6 +54,7 @@ const (
 	APIPricingSource              = "Public API"
 	SpotPricingSource             = "Spot Data Feed"
 	ReservedInstancePricingSource = "Savings Plan, Reserved Instance, and Out-Of-Cluster"
+	FargatePricingSource          = "Fargate"
 
 	InUseState    = "in-use"
 	AttachedState = "attached"
@@ -61,6 +62,13 @@ const (
 	AWSHourlyPublicIPCost    = 0.005
 	EKSCapacityTypeLabel     = "eks.amazonaws.com/capacityType"
 	EKSCapacitySpotTypeValue = "SPOT"
+
+	// relevant to pricing url
+	awsPricingBaseURL      = "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/"
+	awsChinaPricingBaseURL = "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/"
+	pricingCurrentPath     = "/current/"
+	pricingIndexFile       = "index.json"
+	chinaRegionPrefix      = "cn-"
 )
 
 var (
@@ -81,7 +89,6 @@ var (
 )
 
 func (aws *AWS) PricingSourceStatus() map[string]*models.PricingSource {
-
 	sources := make(map[string]*models.PricingSource)
 
 	sps := &models.PricingSource{
@@ -122,8 +129,19 @@ func (aws *AWS) PricingSourceStatus() map[string]*models.PricingSource {
 		rps.Available = true
 	}
 	sources[ReservedInstancePricingSource] = rps
-	return sources
 
+	fs := &models.PricingSource{
+		Name:      FargatePricingSource,
+		Enabled:   true,
+		Available: true,
+	}
+	if aws.FargatePricingError != nil {
+		fs.Error = aws.FargatePricingError.Error()
+		fs.Available = false
+	}
+	sources[FargatePricingSource] = fs
+
+	return sources
 }
 
 // SpotRefreshDuration represents how much time must pass before we refresh
@@ -174,6 +192,8 @@ type AWS struct {
 	SavingsPlanDataByInstanceID map[string]*SavingsPlanData
 	SavingsPlanDataRunning      bool
 	SavingsPlanDataLock         sync.RWMutex
+	FargatePricing              *FargatePricing
+	FargatePricingError         error
 	ValidPricingKeys            map[string]bool
 	Clientset                   clustercache.ClusterCache
 	BaseCPUPrice                string
@@ -329,8 +349,10 @@ var OnDemandRateCodesCn = map[string]struct{}{
 }
 
 // HourlyRateCode is appended to a node sku
-const HourlyRateCode = "6YS6EN2CT7"
-const HourlyRateCodeCn = "Q7UJUT2CE6"
+const (
+	HourlyRateCode   = "6YS6EN2CT7"
+	HourlyRateCodeCn = "Q7UJUT2CE6"
+)
 
 // volTypes are used to map between AWS UsageTypes and
 // EBS volume types, as they would appear in K8s storage class
@@ -353,8 +375,10 @@ var volTypes = map[string]string{
 	"io2":                    "EBS:VolumeUsage.io2",
 }
 
-var loadedAWSSecret bool = false
-var awsSecret *AWSAccessKey = nil
+var (
+	loadedAWSSecret bool          = false
+	awsSecret       *AWSAccessKey = nil
+)
 
 // KubeAttrConversion maps the k8s labels for region to an AWS key
 func (aws *AWS) KubeAttrConversion(region, instanceType, operatingSystem string) string {
@@ -463,7 +487,7 @@ func (aws *AWS) GetAWSAccessKey() (*AWSAccessKey, error) {
 	if err != nil {
 		return nil, fmt.Errorf("error configuring Cloud Provider %s", err)
 	}
-	//Look for service key values in env if not present in config
+	// Look for service key values in env if not present in config
 	if config.ServiceKeyName == "" {
 		config.ServiceKeyName = env.GetAWSAccessKeyID()
 	}
@@ -596,6 +620,7 @@ func (aws *AWS) UpdateConfig(r io.Reader, updateType string) (*models.CustomPric
 }
 
 type awsKey struct {
+	Name           string
 	SpotLabelName  string
 	SpotLabelValue string
 	Labels         map[string]string
@@ -624,7 +649,6 @@ func (k *awsKey) ID() string {
 // If the node has a spot label, it will be included in the list
 // Otherwise, the list include instance type, operating system, and the region
 func (k *awsKey) Features() string {
-
 	instanceType, _ := util.GetInstanceType(k.Labels)
 	operatingSystem, _ := util.GetOperatingSystem(k.Labels)
 	region, _ := util.GetRegion(k.Labels)
@@ -644,6 +668,16 @@ func (k *awsKey) Features() string {
 	return key
 }
 
+const eksComputeTypeLabel = "eks.amazonaws.com/compute-type"
+
+func (k *awsKey) isFargateNode() bool {
+	v := k.Labels[eksComputeTypeLabel]
+	if v == "fargate" {
+		return true
+	}
+	return false
+}
+
 // getUsageType returns the usage type of the instance
 // If the instance is a spot instance, it will return PreemptibleType
 // Otherwise returns an empty string
@@ -751,6 +785,7 @@ func getStorageClassTypeFrom(provisioner string) string {
 // GetKey maps node labels to information needed to retrieve pricing data
 func (aws *AWS) GetKey(labels map[string]string, n *clustercache.Node) models.Key {
 	return &awsKey{
+		Name:           n.Name,
 		SpotLabelName:  aws.SpotLabelName,
 		SpotLabelValue: aws.SpotLabelValue,
 		Labels:         labels,
@@ -770,42 +805,50 @@ func (aws *AWS) ClusterManagementPricing() (string, float64, error) {
 	return aws.clusterProvisioner, aws.clusterManagementPrice, nil
 }
 
-// Use the pricing data from the current region. Fall back to using all region data if needed.
-func (aws *AWS) getRegionPricing(nodeList []*clustercache.Node) (*http.Response, string, error) {
-
-	pricingURL := "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/"
+func getPricingListURL(serviceCode string, nodeList []*clustercache.Node) string {
+	// See https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/using-the-aws-price-list-bulk-api-fetching-price-list-files-manually.html
 	region := ""
 	multiregion := false
+	isChina := false
+
 	for _, n := range nodeList {
-		labels := n.Labels
-		currentNodeRegion := ""
-		if r, ok := util.GetRegion(labels); ok {
-			currentNodeRegion = r
-			// Switch to Chinese endpoint for regions with the Chinese prefix
-			if strings.HasPrefix(currentNodeRegion, "cn-") {
-				pricingURL = "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonEC2/current/"
-			}
-		} else {
-			multiregion = true // We weren't able to detect the node's region, so pull all data.
+		r, ok := util.GetRegion(n.Labels)
+		if !ok {
+			multiregion = true
 			break
 		}
-		if region == "" { // We haven't set a region yet
-			region = currentNodeRegion
-		} else if region != "" && currentNodeRegion != region { // If two nodes have different regions here, we'll need to fetch all pricing data.
+		if strings.HasPrefix(r, chinaRegionPrefix) {
+			isChina = true
+		}
+
+		if region == "" {
+			region = r
+		} else if r != region {
 			multiregion = true
 			break
 		}
 	}
 
-	// Chinese multiregion endpoint only contains data for Chinese regions and Chinese regions are excluded from other endpoint
+	baseURL := awsPricingBaseURL + serviceCode + pricingCurrentPath
+	if isChina {
+		// Chinese regions are isolated and use a different pricing endpoint
+		baseURL = awsChinaPricingBaseURL + serviceCode + pricingCurrentPath
+	}
+
 	if region != "" && !multiregion {
-		pricingURL += region + "/"
+		baseURL += region + "/"
 	}
 
-	pricingURL += "index.json"
+	return baseURL + pricingIndexFile
+}
 
+// Use the pricing data from the current region. Fall back to using all region data if needed.
+func (aws *AWS) getRegionPricing(nodeList []*clustercache.Node) (*http.Response, string, error) {
+	var pricingURL string
 	if env.GetAWSPricingURL() != "" { // Allow override of pricing URL
 		pricingURL = env.GetAWSPricingURL()
+	} else {
+		pricingURL = getPricingListURL("AmazonEC2", nodeList)
 	}
 
 	log.Infof("starting download of \"%s\", which is quite large ...", pricingURL)
@@ -947,6 +990,15 @@ func (aws *AWS) DownloadPricingData() error {
 		}
 	}
 
+	// Initialize fargate pricing if it's not initialized yet
+	if aws.FargatePricing == nil {
+		aws.FargatePricing = NewFargatePricing()
+		aws.FargatePricingError = aws.FargatePricing.Initialize(nodeList)
+		if aws.FargatePricingError != nil {
+			log.Errorf("Failed to initialize fargate pricing: %s", aws.FargatePricingError.Error())
+		}
+	}
+
 	aws.ValidPricingKeys = make(map[string]bool)
 
 	resp, pricingURL, err := aws.getRegionPricing(nodeList)
@@ -1410,6 +1462,75 @@ func (aws *AWS) createNode(terms *AWSProductTerms, usageType string, k models.Ke
 	}, meta, nil
 }
 
+func (aws *AWS) getFargatePod(awsKey *awsKey) (*clustercache.Pod, bool) {
+	pods := aws.Clientset.GetAllPods()
+	for _, pod := range pods {
+		if pod.Spec.NodeName == awsKey.Name {
+			return pod, true
+		}
+	}
+	return nil, false
+}
+
+const (
+	nodeOSLabel   = "kubernetes.io/os"
+	nodeArchLabel = "kubernetes.io/arch"
+
+	fargatePodCapacityAnnotation = "CapacityProvisioned"
+)
+
+// e.g. "0.25vCPU 0.5GB"
+var fargatePodCapacityRegex = regexp.MustCompile("^([0-9.]+)vCPU ([0-9.]+)GB$")
+
+func (aws *AWS) createFargateNode(awsKey *awsKey, usageType string) (*models.Node, models.PricingMetadata, error) {
+	if aws.FargatePricing == nil {
+		return nil, models.PricingMetadata{}, fmt.Errorf("fargate pricing not initialized")
+	}
+	pod, ok := aws.getFargatePod(awsKey)
+	if !ok {
+		return nil, models.PricingMetadata{}, fmt.Errorf("could not find pod for fargate node %s", awsKey.Name)
+	}
+	capacity := pod.Annotations[fargatePodCapacityAnnotation]
+	match := fargatePodCapacityRegex.FindStringSubmatch(capacity)
+	if len(match) == 0 {
+		return nil, models.PricingMetadata{}, fmt.Errorf("could not parse pod capacity for fargate node %s", awsKey.Name)
+	}
+
+	vCPU, err := strconv.ParseFloat(match[1], 64)
+	if err != nil {
+		return nil, models.PricingMetadata{}, fmt.Errorf("could not parse vCPU capacity for fargate node %s: %v", awsKey.Name, err)
+	}
+	memory, err := strconv.ParseFloat(match[2], 64)
+	if err != nil {
+		return nil, models.PricingMetadata{}, fmt.Errorf("could not parse memory capacity for fargate node %s: %v", awsKey.Name, err)
+	}
+
+	region, ok := util.GetRegion(awsKey.Labels)
+	if !ok {
+		return nil, models.PricingMetadata{}, fmt.Errorf("could not get region for fargate node %s", awsKey.Name)
+	}
+	nodeOS := awsKey.Labels[nodeOSLabel]
+	nodeArch := awsKey.Labels[nodeArchLabel]
+	hourlyCPU, hourlyRAM, err := aws.FargatePricing.GetHourlyPricing(region, nodeOS, nodeArch)
+	if err != nil {
+		return nil, models.PricingMetadata{}, fmt.Errorf("could not get hourly pricing for fargate node %s: %v", awsKey.Name, err)
+	}
+
+	cost := hourlyCPU*vCPU + hourlyRAM*memory
+	return &models.Node{
+		Cost:         strconv.FormatFloat(cost, 'f', -1, 64),
+		VCPU:         strconv.FormatFloat(vCPU, 'f', -1, 64),
+		RAM:          strconv.FormatFloat(memory, 'f', -1, 64),
+		RAMBytes:     strconv.FormatFloat(memory*1024*1024*1024, 'f', -1, 64),
+		VCPUCost:     strconv.FormatFloat(hourlyCPU, 'f', -1, 64),
+		RAMCost:      strconv.FormatFloat(hourlyRAM, 'f', -1, 64),
+		BaseCPUPrice: aws.BaseCPUPrice,
+		BaseRAMPrice: aws.BaseRAMPrice,
+		BaseGPUPrice: aws.BaseGPUPrice,
+		UsageType:    usageType,
+	}, models.PricingMetadata{}, nil
+}
+
 // NodePricing takes in a key from GetKey and returns a Node object for use in building the cost model.
 func (aws *AWS) NodePricing(k models.Key) (*models.Node, models.PricingMetadata, error) {
 	aws.DownloadPricingDataLock.RLock()
@@ -1455,6 +1576,9 @@ func (aws *AWS) NodePricing(k models.Key) (*models.Node, models.PricingMetadata,
 			}, meta, fmt.Errorf("Unable to find any Pricing data for \"%s\"", key)
 		}
 		return aws.createNode(terms, usageType, k)
+	} else if awsKey, ok := k.(*awsKey); ok && awsKey.isFargateNode() {
+		// Since Fargate pricing is listed at AmazonECS and is different from AmazonEC2, we handle it separately here
+		return aws.createFargateNode(awsKey, usageType)
 	} else { // Fall back to base pricing if we can't find the key. Base pricing is handled at the costmodel level.
 		// we seem to have an issue where this error gets thrown during app start.
 		// somehow the ValidPricingKeys map is being accessed before all the pricing data has been downloaded
@@ -1464,7 +1588,6 @@ func (aws *AWS) NodePricing(k models.Key) (*models.Node, models.PricingMetadata,
 
 // ClusterInfo returns an object that represents the cluster. TODO: actually return the name of the cluster. Blocked on cluster federation.
 func (awsProvider *AWS) ClusterInfo() (map[string]string, error) {
-
 	c, err := awsProvider.GetConfig()
 	if err != nil {
 		return nil, err
@@ -1673,7 +1796,6 @@ func (aws *AWS) getAllAddresses() ([]*ec2Types.Address, error) {
 			a := add // duplicate to avoid pointer to iterator
 			addresses = append(addresses, &a)
 		}
-
 	}
 
 	var errs []error
@@ -1939,7 +2061,7 @@ func (aws *AWS) GetOrphanedResources() ([]models.OrphanedResource, error) {
 }
 
 func (aws *AWS) findCostForDisk(disk *ec2Types.Volume) (*float64, error) {
-	//todo: use AWS pricing from all regions
+	// todo: use AWS pricing from all regions
 	if disk.AvailabilityZone == nil {
 		return nil, fmt.Errorf("nil region")
 	}
@@ -2292,7 +2414,6 @@ type spotInfo struct {
 }
 
 func (aws *AWS) parseSpotData(bucket string, prefix string, projectID string, region string) (map[string]*spotInfo, error) {
-
 	aws.ConfigureAuth() // configure aws api authentication by setting env vars
 
 	s3Prefix := projectID
@@ -2455,7 +2576,6 @@ func (aws *AWS) parseSpotData(bucket string, prefix string, projectID string, re
 
 // ApplyReservedInstancePricing TODO
 func (aws *AWS) ApplyReservedInstancePricing(nodes map[string]*models.Node) {
-
 }
 
 func (aws *AWS) ServiceAccountStatus() *models.ServiceAccountStatus {
@@ -2468,7 +2588,6 @@ func (aws *AWS) CombinedDiscountForNode(instanceType string, isPreemptible bool,
 
 // Regions returns a predefined list of AWS regions
 func (aws *AWS) Regions() []string {
-
 	regionOverrides := env.GetRegionOverrideList()
 
 	if len(regionOverrides) > 0 {

+ 309 - 0
pkg/cloud/aws/provider_test.go

@@ -488,3 +488,312 @@ func Test_getStorageClassTypeFrom(t *testing.T) {
 		})
 	}
 }
+
+func Test_awsKey_isFargateNode(t *testing.T) {
+	tests := []struct {
+		name   string
+		labels map[string]string
+		want   bool
+	}{
+		{
+			name: "fargate node with correct label",
+			labels: map[string]string{
+				eksComputeTypeLabel: "fargate",
+			},
+			want: true,
+		},
+		{
+			name: "ec2 node with different compute type",
+			labels: map[string]string{
+				eksComputeTypeLabel: "ec2",
+			},
+			want: false,
+		},
+		{
+			name: "node without compute type label",
+			labels: map[string]string{
+				"some.other.label": "value",
+			},
+			want: false,
+		},
+		{
+			name:   "node with empty labels",
+			labels: map[string]string{},
+			want:   false,
+		},
+		{
+			name:   "node with nil labels",
+			labels: nil,
+			want:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			k := &awsKey{
+				Labels: tt.labels,
+			}
+			if got := k.isFargateNode(); got != tt.want {
+				t.Errorf("awsKey.isFargateNode() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestGetPricingListURL(t *testing.T) {
+	tests := []struct {
+		name        string
+		serviceCode string
+		nodeList    []*clustercache.Node
+		expected    string
+	}{
+		{
+			name:        "AmazonEC2 service with us-east-1 region",
+			serviceCode: "AmazonEC2",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-east-1",
+					},
+				},
+			},
+			expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/us-east-1/index.json",
+		},
+		{
+			name:        "AmazonECS service with us-west-2 region",
+			serviceCode: "AmazonECS",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-west-2",
+					},
+				},
+			},
+			expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/us-west-2/index.json",
+		},
+		{
+			name:        "Chinese region cn-north-1",
+			serviceCode: "AmazonEC2",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "cn-north-1",
+					},
+				},
+			},
+			expected: "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonEC2/current/cn-north-1/index.json",
+		},
+		{
+			name:        "Chinese region cn-northwest-1",
+			serviceCode: "AmazonECS",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "cn-northwest-1",
+					},
+				},
+			},
+			expected: "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonECS/current/cn-northwest-1/index.json",
+		},
+		{
+			name:        "empty node list - multiregion",
+			serviceCode: "AmazonEC2",
+			nodeList:    []*clustercache.Node{},
+			expected:    "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json",
+		},
+		{
+			name:        "multiple regions - multiregion",
+			serviceCode: "AmazonECS",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node-1",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-east-1",
+					},
+				},
+				{
+					Name: "test-node-2",
+					Labels: map[string]string{
+						"topology.kubernetes.io/region": "us-west-2",
+					},
+				},
+			},
+			expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/index.json",
+		},
+		{
+			name:        "node without region label",
+			serviceCode: "AmazonEC2",
+			nodeList: []*clustercache.Node{
+				{
+					Name: "test-node",
+					Labels: map[string]string{
+						"some.other.label": "value",
+					},
+				},
+			},
+			expected: "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := getPricingListURL(tt.serviceCode, tt.nodeList)
+			if result != tt.expected {
+				t.Errorf("getPricingListURL() = %v, expected %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+// Mock cluster cache for testing
+type mockClusterCache struct {
+	pods []*clustercache.Pod
+}
+
+func (m *mockClusterCache) Run()  {}
+func (m *mockClusterCache) Stop() {}
+
+func (m *mockClusterCache) GetAllPods() []*clustercache.Pod {
+	return m.pods
+}
+
+func (m *mockClusterCache) GetAllNodes() []*clustercache.Node {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllPersistentVolumes() []*clustercache.PersistentVolume {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllPersistentVolumeClaims() []*clustercache.PersistentVolumeClaim {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllStorageClasses() []*clustercache.StorageClass {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllServices() []*clustercache.Service {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllDeployments() []*clustercache.Deployment {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllDaemonSets() []*clustercache.DaemonSet {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllStatefulSets() []*clustercache.StatefulSet {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllReplicaSets() []*clustercache.ReplicaSet {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllJobs() []*clustercache.Job {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllNamespaces() []*clustercache.Namespace {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllPodDisruptionBudgets() []*clustercache.PodDisruptionBudget {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllReplicationControllers() []*clustercache.ReplicationController {
+	return nil
+}
+
+func (m *mockClusterCache) GetAllResourceQuotas() []*clustercache.ResourceQuota {
+	return nil
+}
+
+func TestAWS_getFargatePod(t *testing.T) {
+	tests := []struct {
+		name     string
+		pods     []*clustercache.Pod
+		awsKey   *awsKey
+		wantPod  *clustercache.Pod
+		wantBool bool
+	}{
+		{
+			name: "pod found for node",
+			pods: []*clustercache.Pod{
+				{
+					Name: "test-pod",
+					Spec: clustercache.PodSpec{
+						NodeName: "fargate-node-1",
+					},
+				},
+			},
+			awsKey: &awsKey{
+				Name: "fargate-node-1",
+			},
+			wantPod: &clustercache.Pod{
+				Name: "test-pod",
+				Spec: clustercache.PodSpec{
+					NodeName: "fargate-node-1",
+				},
+			},
+			wantBool: true,
+		},
+		{
+			name: "pod not found for node",
+			pods: []*clustercache.Pod{
+				{
+					Name: "test-pod",
+					Spec: clustercache.PodSpec{
+						NodeName: "different-node",
+					},
+				},
+			},
+			awsKey: &awsKey{
+				Name: "fargate-node-1",
+			},
+			wantPod:  nil,
+			wantBool: false,
+		},
+		{
+			name: "no pods in cluster",
+			pods: []*clustercache.Pod{},
+			awsKey: &awsKey{
+				Name: "fargate-node-1",
+			},
+			wantPod:  nil,
+			wantBool: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			aws := &AWS{
+				Clientset: &mockClusterCache{pods: tt.pods},
+			}
+
+			gotPod, gotBool := aws.getFargatePod(tt.awsKey)
+
+			if gotBool != tt.wantBool {
+				t.Errorf("AWS.getFargatePod() gotBool = %v, want %v", gotBool, tt.wantBool)
+			}
+
+			if tt.wantPod == nil && gotPod != nil {
+				t.Errorf("AWS.getFargatePod() gotPod = %v, want nil", gotPod)
+			} else if tt.wantPod != nil && gotPod == nil {
+				t.Errorf("AWS.getFargatePod() gotPod = nil, want %v", tt.wantPod)
+			} else if tt.wantPod != nil && gotPod != nil {
+				if gotPod.Name != tt.wantPod.Name || gotPod.Spec.NodeName != tt.wantPod.Spec.NodeName {
+					t.Errorf("AWS.getFargatePod() gotPod = %v, want %v", gotPod, tt.wantPod)
+				}
+			}
+		})
+	}
+}

+ 462 - 0
pkg/cloud/aws/testdata/ecs-pricing-us-east-1.json

@@ -0,0 +1,462 @@
+{
+  "formatVersion" : "v1.0",
+  "disclaimer" : "This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://aws.amazon.com. All Free Tier prices are also subject to the terms included at https://aws.amazon.com/free/",
+  "offerCode" : "AmazonECS",
+  "version" : "20250828172723",
+  "publicationDate" : "2025-08-28T17:27:23Z",
+  "products" : {
+    "74HXSAMY4YHFPXQQ" : {
+      "sku" : "74HXSAMY4YHFPXQQ",
+      "productFamily" : "Compute Metering",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "usagetype" : "USE1-ECS-EC2-GB-Hours",
+        "operation" : "",
+        "memorytype" : "perGB",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "RF2BUTAD289DREDC" : {
+      "sku" : "RF2BUTAD289DREDC",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "operatingSystem" : "Windows",
+        "usagetype" : "USE1-Fargate-Windows-vCPU-Hours:perCPU",
+        "operation" : "",
+        "cputype" : "perCPU",
+        "regionCode" : "us-east-1",
+        "resource" : "per vCPU per hour",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "9D3ZWXRS2VXMZET6" : {
+      "sku" : "9D3ZWXRS2VXMZET6",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "operatingSystem" : "Windows",
+        "usagetype" : "USE1-Fargate-Windows-OS-Hours:perCPU",
+        "operation" : "",
+        "cputype" : "perCPU OS License Fee",
+        "regionCode" : "us-east-1",
+        "resource" : "OS License Fee per vCPU per hour",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "WTG8RAG79KP7K3N8" : {
+      "sku" : "WTG8RAG79KP7K3N8",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "usagetype" : "USE1-ECS-Anywhere-Instance-hours",
+        "operation" : "AddExternalInstance",
+        "externalInstanceType" : "Default",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "7KPDPTDSCT4J3Z64" : {
+      "sku" : "7KPDPTDSCT4J3Z64",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "usagetype" : "USE1-Fargate-EphemeralStorage-GB-Hours",
+        "operation" : "",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service",
+        "storagetype" : "default"
+      }
+    },
+    "UNH9KPQP7W7C66C9" : {
+      "sku" : "UNH9KPQP7W7C66C9",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "usagetype" : "USE1-Fargate-ARM-GB-Hours",
+        "operation" : "",
+        "cpuArchitecture" : "ARM",
+        "memorytype" : "perGB",
+        "regionCode" : "us-east-1",
+        "resource" : "per GB per hour",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "8QDMJPGQCM368Z6X" : {
+      "sku" : "8QDMJPGQCM368Z6X",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "operatingSystem" : "Windows",
+        "usagetype" : "USE1-Fargate-Windows-GB-Hours",
+        "operation" : "",
+        "memorytype" : "perGB",
+        "regionCode" : "us-east-1",
+        "resource" : "per GB per hour",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "PBZNQUSEXZUC34C9" : {
+      "sku" : "PBZNQUSEXZUC34C9",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "usagetype" : "USE1-Fargate-GB-Hours",
+        "operation" : "",
+        "memorytype" : "perGB",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "XSZATS4VYMDC9CYN" : {
+      "sku" : "XSZATS4VYMDC9CYN",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "usagetype" : "USE1-Fargate-ARM-vCPU-Hours:perCPU",
+        "operation" : "",
+        "cpuArchitecture" : "ARM",
+        "cputype" : "perCPU",
+        "regionCode" : "us-east-1",
+        "resource" : "per vCPU per hour",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "VUZADW4HBG7NT6R7" : {
+      "sku" : "VUZADW4HBG7NT6R7",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "usagetype" : "USE1-ECS-Anywhere-Instance-hours-WithFree",
+        "operation" : "AddExternalInstance",
+        "externalInstanceType" : "With Free",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "DQ8NT47B7YY2CGC5" : {
+      "sku" : "DQ8NT47B7YY2CGC5",
+      "productFamily" : "Compute Metering",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "usagetype" : "USE1-ECS-EC2-vCPU-Hours",
+        "operation" : "",
+        "cputype" : "perCPU",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    },
+    "8CESGAFWKAJ98PME" : {
+      "sku" : "8CESGAFWKAJ98PME",
+      "productFamily" : "Compute",
+      "attributes" : {
+        "servicecode" : "AmazonECS",
+        "location" : "US East (N. Virginia)",
+        "locationType" : "AWS Region",
+        "tenancy" : "Shared",
+        "usagetype" : "USE1-Fargate-vCPU-Hours:perCPU",
+        "operation" : "",
+        "cputype" : "perCPU",
+        "regionCode" : "us-east-1",
+        "servicename" : "Amazon Elastic Container Service"
+      }
+    }
+  },
+  "terms" : {
+    "OnDemand" : {
+      "RF2BUTAD289DREDC" : {
+        "RF2BUTAD289DREDC.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "RF2BUTAD289DREDC",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "RF2BUTAD289DREDC.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "RF2BUTAD289DREDC.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - Windows - vCPU - US East (N.Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0465520000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "74HXSAMY4YHFPXQQ" : {
+        "74HXSAMY4YHFPXQQ.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "74HXSAMY4YHFPXQQ",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "74HXSAMY4YHFPXQQ.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "74HXSAMY4YHFPXQQ.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "USD 0.0 per GB-Hours for ECS-EC2-GB-Hours in US East (N. Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "GB-Hours",
+              "pricePerUnit" : {
+                "USD" : "0.0000000000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "XSZATS4VYMDC9CYN" : {
+        "XSZATS4VYMDC9CYN.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "XSZATS4VYMDC9CYN",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "XSZATS4VYMDC9CYN.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "XSZATS4VYMDC9CYN.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - ARM - vCPU - US East (N. Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0323800000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "UNH9KPQP7W7C66C9" : {
+        "UNH9KPQP7W7C66C9.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "UNH9KPQP7W7C66C9",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "UNH9KPQP7W7C66C9.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "UNH9KPQP7W7C66C9.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - ARM - Memory - US East (N. Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0035600000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "WTG8RAG79KP7K3N8" : {
+        "WTG8RAG79KP7K3N8.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "WTG8RAG79KP7K3N8",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "WTG8RAG79KP7K3N8.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "WTG8RAG79KP7K3N8.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "$0.01025 per hour for ECS-Anywhere-Instance-Hours",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0102500000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "VUZADW4HBG7NT6R7" : {
+        "VUZADW4HBG7NT6R7.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "VUZADW4HBG7NT6R7",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "VUZADW4HBG7NT6R7.JRTCKXETXF.4TES4JTS8Q" : {
+              "rateCode" : "VUZADW4HBG7NT6R7.JRTCKXETXF.4TES4JTS8Q",
+              "description" : "$0 per hour for ECS-Anywhere-Instance-Hours",
+              "beginRange" : "0",
+              "endRange" : "2200",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0000000000"
+              },
+              "appliesTo" : [ ]
+            },
+            "VUZADW4HBG7NT6R7.JRTCKXETXF.2A2Z7VTUXW" : {
+              "rateCode" : "VUZADW4HBG7NT6R7.JRTCKXETXF.2A2Z7VTUXW",
+              "description" : "$0.01025 per hour for ECS-Anywhere-Instance-Hours",
+              "beginRange" : "2200",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0102500000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "9D3ZWXRS2VXMZET6" : {
+        "9D3ZWXRS2VXMZET6.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "9D3ZWXRS2VXMZET6",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "9D3ZWXRS2VXMZET6.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "9D3ZWXRS2VXMZET6.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - Windows - OS - US East (N.Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0460000000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "DQ8NT47B7YY2CGC5" : {
+        "DQ8NT47B7YY2CGC5.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "DQ8NT47B7YY2CGC5",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "DQ8NT47B7YY2CGC5.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "DQ8NT47B7YY2CGC5.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "USD 0.0 per vCPU-Hours for ECS-EC2-vCPU-Hours in US East (N. Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "vCPU-Hours",
+              "pricePerUnit" : {
+                "USD" : "0.0000000000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "PBZNQUSEXZUC34C9" : {
+        "PBZNQUSEXZUC34C9.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "PBZNQUSEXZUC34C9",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "PBZNQUSEXZUC34C9.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "PBZNQUSEXZUC34C9.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - Memory  - US East (N.Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0044450000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "7KPDPTDSCT4J3Z64" : {
+        "7KPDPTDSCT4J3Z64.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "7KPDPTDSCT4J3Z64",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "7KPDPTDSCT4J3Z64.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "7KPDPTDSCT4J3Z64.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - Ephemeral Storage - US East (N. Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "GB-Hours",
+              "pricePerUnit" : {
+                "USD" : "0.0001110000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "8CESGAFWKAJ98PME" : {
+        "8CESGAFWKAJ98PME.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "8CESGAFWKAJ98PME",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "8CESGAFWKAJ98PME.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "8CESGAFWKAJ98PME.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - vCPU - US East (N.Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0404800000"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      },
+      "8QDMJPGQCM368Z6X" : {
+        "8QDMJPGQCM368Z6X.JRTCKXETXF" : {
+          "offerTermCode" : "JRTCKXETXF",
+          "sku" : "8QDMJPGQCM368Z6X",
+          "effectiveDate" : "2025-08-01T00:00:00Z",
+          "priceDimensions" : {
+            "8QDMJPGQCM368Z6X.JRTCKXETXF.6YS6EN2CT7" : {
+              "rateCode" : "8QDMJPGQCM368Z6X.JRTCKXETXF.6YS6EN2CT7",
+              "description" : "AWS Fargate - Windows - Memory  - US East (N.Virginia)",
+              "beginRange" : "0",
+              "endRange" : "Inf",
+              "unit" : "hours",
+              "pricePerUnit" : {
+                "USD" : "0.0051117500"
+              },
+              "appliesTo" : [ ]
+            }
+          },
+          "termAttributes" : { }
+        }
+      }
+    }
+  },
+  "attributesList" : { }
+}

+ 4 - 4
pkg/costmodel/costmodel.go

@@ -957,18 +957,18 @@ func (cm *CostModel) GetNodeCost() (map[string]*costAnalyzerCloud.Node, error) {
 			cpu = 0
 		}
 
-		var ram float64
 		if newCnode.RAM == "" {
 			newCnode.RAM = n.Status.Capacity.Memory().String()
 		}
-		ram = float64(n.Status.Capacity.Memory().Value())
+		if newCnode.RAMBytes == "" {
+			newCnode.RAMBytes = fmt.Sprintf("%v", n.Status.Capacity.Memory().Value())
+		}
+		ram, _ := strconv.ParseFloat(newCnode.RAMBytes, 64)
 		if math.IsNaN(ram) {
 			log.Warnf("ram parsed as NaN. Setting to 0.")
 			ram = 0
 		}
 
-		newCnode.RAMBytes = fmt.Sprintf("%f", ram)
-
 		gpuc, err := strconv.ParseFloat(newCnode.GPU, 64)
 		if err != nil {
 			gpuc = 0.0

+ 6 - 1
pkg/env/costmodel.go

@@ -25,6 +25,7 @@ const (
 	AWSAccessKeySecretEnvVar = "AWS_SECRET_ACCESS_KEY"
 	AWSClusterIDEnvVar       = "AWS_CLUSTER_ID"
 	AWSPricingURL            = "AWS_PRICING_URL"
+	AWSECSPricingURLOverride = "AWS_ECS_PRICING_URL"
 
 	AlibabaAccessKeyIDEnvVar     = "ALIBABA_ACCESS_KEY_ID"
 	AlibabaAccessKeySecretEnvVar = "ALIBABA_SECRET_ACCESS_KEY"
@@ -189,6 +190,11 @@ func GetAWSPricingURL() string {
 	return env.Get(AWSPricingURL, "")
 }
 
+// GetAWSECSPricingURLOverride returns an optional alternative URL to fetch AmazonECS pricing data from; for use in airgapped environments
+func GetAWSECSPricingURLOverride() string {
+	return env.Get(AWSECSPricingURLOverride, "")
+}
+
 // GetAlibabaAccessKeyID returns the environment variable value for AlibabaAccessKeyIDEnvVar which represents
 // the Alibaba access key for authentication
 func GetAlibabaAccessKeyID() string {
@@ -366,7 +372,6 @@ func GetMetricConfigFile() string {
 func GetLocalCollectorDirectory() string {
 	dir := env.Get(LocalCollectorDirectoryEnvVar, DefaultLocalCollectorDir)
 	return env.GetPathFromConfig(dir)
-
 }
 
 func GetDOKSPricingURL() string {