Browse Source

Sth/kcm 5744 (#3826)

Signed-off-by: Arindam Bandyopadhyay <arindam.bandyopadhyay@oracle.com>
Signed-off-by: svenkitesh <svenkitesh@digitalocean.com>
Signed-off-by: Ahmed Soliman <dev.soliman@gmail.com>
Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Co-authored-by: Christian Petersen <Christian.Petersen2@ibm.com>
Co-authored-by: Arindam Bandyopadhyay <arindam.bandyopadhyay@oracle.com>
Co-authored-by: Sreeram Venkitesh <40194401+sreeram-venkitesh@users.noreply.github.com>
Co-authored-by: Ahmed Soliman <46458090+ahmd-soliman@users.noreply.github.com>
Sean Holcomb 1 week ago
parent
commit
bbecdbd55b

+ 6 - 0
.github/actions/sign-image/action.yaml

@@ -73,8 +73,14 @@ runs:
         run: |
           set -euo pipefail
           if [[ -z "${STARTED_ON:-}" ]]; then
+            echo "inputs.run-started-at is empty; using fallback ${FALLBACK_STARTED_ON}"
             STARTED_ON="$FALLBACK_STARTED_ON"
           fi
+          if [[ -z "${STARTED_ON:-}" ]]; then
+            echo "::error::startedOn resolved empty after fallback; predicate would be unparseable"
+            exit 1
+          fi
+          echo "Using startedOn=${STARTED_ON}"
           RESOLVED_GIT_COMMIT="$(git rev-parse HEAD)"
           export RESOLVED_GIT_COMMIT STARTED_ON
           python3 - <<'PY' > predicate.json

+ 8 - 7
core/pkg/model/kubemodel/daemonset.go

@@ -8,13 +8,14 @@ import (
 // @bingen:generate:DaemonSet
 // DaemonSet represents a Kubernetes DaemonSet resource
 type DaemonSet struct {
-	UID          string            `json:"uid"`
-	NamespaceUID string            `json:"namespaceUid"`
-	Name         string            `json:"name"`
-	Labels       map[string]string `json:"labels,omitempty"`
-	Annotations  map[string]string `json:"annotations,omitempty"`
-	Start        time.Time         `json:"start,omitempty"`
-	End          time.Time         `json:"end,omitempty"`
+	UID              string            `json:"uid"`
+	NamespaceUID     string            `json:"namespaceUid"`
+	Name             string            `json:"name"`
+	Labels           map[string]string `json:"labels,omitempty"`
+	Annotations      map[string]string `json:"annotations,omitempty"`
+	DevicePluginInfo map[string]string `json:"devicePluginInfo"`
+	Start            time.Time         `json:"start,omitempty"`
+	End              time.Time         `json:"end,omitempty"`
 }
 
 func (d *DaemonSet) ValidateDaemonSet(window Window) error {

+ 9 - 9
core/pkg/model/kubemodel/dcgm.go

@@ -13,23 +13,23 @@ import (
 // container unique identifiers
 // @bingen:generate:DCGMDevice
 type DCGMDevice struct {
-	UUID      string
-	Start     time.Time
-	End       time.Time
-	Device    string
-	ModelName string
-	PodUsage  map[string]DCGMPod
+	UUID      string             `json:"uuid"`
+	Start     time.Time          `json:"start"`
+	End       time.Time          `json:"end"`
+	Device    string             `json:"device"`
+	ModelName string             `json:"modelName"`
+	PodUsages map[string]DCGMPod `json:"podUsages"`
 }
 
 // @bingen:generate:DCGMPod
 type DCGMPod struct {
-	ContainerUsage map[string]DCGMContainer
+	ContainerUsages map[string]DCGMContainer `json:"container-usages"`
 }
 
 // @bingen:generate:DCGMContainer
 type DCGMContainer struct {
-	UsageAvg float64
-	UsageMax float64
+	UsageAvg float64 `json:"usageAvg"`
+	UsageMax float64 `json:"usageMax"`
 }
 
 func (d *DCGMDevice) ValidateDCGMDevice(window Window) error {

+ 4 - 4
core/pkg/model/kubemodel/kubemodel.go

@@ -12,7 +12,7 @@ type KubeModelSet struct {
 	Cluster                *Cluster                          `json:"cluster"`           // @bingen:field[version=1]
 	Namespaces             map[string]*Namespace             `json:"namespaces"`        // @bingen:field[version=1]
 	ResourceQuotas         map[string]*ResourceQuota         `json:"resourceQuotas"`    // @bingen:field[version=1]
-	Containers             map[string]*Container             `json:"containers"`        // @bingen:field[version=2]
+	Services               map[string]*Service               `json:"services"`          // @bingen:field[version=2]
 	Deployments            map[string]*Deployment            `json:"deployments"`       // @bingen:field[version=2]
 	StatefulSets           map[string]*StatefulSet           `json:"statefulSets"`      // @bingen:field[version=2]
 	DaemonSets             map[string]*DaemonSet             `json:"daemonSets"`        // @bingen:field[version=2]
@@ -20,10 +20,10 @@ type KubeModelSet struct {
 	CronJobs               map[string]*CronJob               `json:"cronJobs"`          // @bingen:field[version=2]
 	ReplicaSets            map[string]*ReplicaSet            `json:"replicaSets"`       // @bingen:field[version=2]
 	Nodes                  map[string]*Node                  `json:"nodes"`             // @bingen:field[version=2]
-	Pods                   map[string]*Pod                   `json:"pods"`              // @bingen:field[version=2]
-	PersistentVolumeClaims map[string]*PersistentVolumeClaim `json:"pvcs"`              // @bingen:field[version=2]
-	Services               map[string]*Service               `json:"services"`          // @bingen:field[version=2]
 	PersistentVolumes      map[string]*PersistentVolume      `json:"persistentVolumes"` // @bingen:field[version=2]
+	PersistentVolumeClaims map[string]*PersistentVolumeClaim `json:"pvcs"`              // @bingen:field[version=2]
+	Pods                   map[string]*Pod                   `json:"pods"`              // @bingen:field[version=2]
+	Containers             map[string]*Container             `json:"containers"`        // @bingen:field[version=2]
 	DCGMDevices            map[string]*DCGMDevice            `json:"dcgmDevices"`       // @bingen:field[version=2]
 	idx                    *kubeModelSetIndexes              // @bingen:field[ignore]
 }

File diff suppressed because it is too large
+ 261 - 217
core/pkg/model/kubemodel/kubemodel_codecs.go


+ 7 - 9
core/pkg/model/kubemodel/mock.go

@@ -64,12 +64,10 @@ func NewMockKubeModelSet(start, end time.Time) *KubeModelSet {
 
 	// --- Node ---
 	kms.RegisterNode(&Node{
-		UID:          "node-uid",
-		ProviderID:   "aws:///us-east-1a/i-0abc123def456",
-		Name:         "node-1",
-		Labels:       map[string]string{"node.kubernetes.io/instance-type": "m5.large"},
-		InstanceType: "m5.large",
-		Preemptible:  false,
+		UID:        "node-uid",
+		ProviderID: "aws:///us-east-1a/i-0abc123def456",
+		Name:       "node-1",
+		Labels:     map[string]string{"node.kubernetes.io/instance-type": "m5.large"},
 		ResourceCapacities: ResourceQuantities{
 			ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 2000, StatMax: 2000}},
 			ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 8e9, StatMax: 8e9}},
@@ -94,7 +92,7 @@ func NewMockKubeModelSet(start, end time.Time) *KubeModelSet {
 		NodeUID:      "node-uid",
 		Name:         "my-pod-abc12",
 		Owners:       []Owner{{UID: "dep-uid", Kind: OwnerKindDeployment, Controller: true}},
-		PVCVolumes:   []PodPVCVolumes{{Name: "data", PersistentVolumeClaimUID: "pvc-uid"}},
+		PVCVolumes:   []PodPVCVolume{{Name: "data", PersistentVolumeClaimUID: "pvc-uid"}},
 		Labels:       map[string]string{"app": "my-app", "version": "v1"},
 		Annotations:  map[string]string{"prometheus.io/scrape": "true"},
 		NetworkTrafficDetails: []NetworkTrafficDetail{
@@ -243,9 +241,9 @@ func NewMockKubeModelSet(start, end time.Time) *KubeModelSet {
 		UUID:      "GPU-abc123def-456-789",
 		Device:    "0",
 		ModelName: "Tesla T4",
-		PodUsage: map[string]DCGMPod{
+		PodUsages: map[string]DCGMPod{
 			"pod-uid": {
-				ContainerUsage: map[string]DCGMContainer{
+				ContainerUsages: map[string]DCGMContainer{
 					"app": {UsageAvg: 0.65, UsageMax: 0.92},
 				},
 			},

+ 0 - 2
core/pkg/model/kubemodel/node.go

@@ -14,8 +14,6 @@ type Node struct {
 	ProviderID           string             `json:"providerId"`
 	Name                 string             `json:"name"`
 	Labels               map[string]string  `json:"labels"`
-	InstanceType         string             `json:"instanceType"`
-	Preemptible          bool               `json:"preemptible"` // TODO unpopulated
 	ResourceCapacities   ResourceQuantities `json:"resourceCapacities"`
 	ResourcesAllocatable ResourceQuantities `json:"resourcesAllocatable"`
 	FileSystem           FileSystem         `json:"fileSystem"`

+ 3 - 3
core/pkg/model/kubemodel/owner.go

@@ -40,7 +40,7 @@ func ParseOwnerKind(kind string) OwnerKind {
 
 // @bingen:generate:Owner
 type Owner struct {
-	UID        string
-	Controller bool
-	Kind       OwnerKind
+	UID        string    `json:"uid"`
+	Controller bool      `json:"controller"`
+	Kind       OwnerKind `json:"kind"`
 }

+ 4 - 4
core/pkg/model/kubemodel/pod.go

@@ -5,10 +5,10 @@ import (
 	"time"
 )
 
-// @bingen:generate:PodPVCVolumes
-type PodPVCVolumes struct {
+// @bingen:generate:PodPVCVolume
+type PodPVCVolume struct {
 	Name                     string `json:"name"`
-	PersistentVolumeClaimUID string `json:"persistentVolumeClaimUID"`
+	PersistentVolumeClaimUID string `json:"persistentVolumeClaimUid"`
 }
 
 // @bingen:generate:Pod
@@ -18,7 +18,7 @@ type Pod struct {
 	NodeUID               string                 `json:"nodeUid"`
 	Name                  string                 `json:"name"`
 	Owners                []Owner                `json:"owners"`
-	PVCVolumes            []PodPVCVolumes        `json:"pvcVolumes,omitempty"`
+	PVCVolumes            []PodPVCVolume         `json:"pvcVolumes,omitempty"`
 	Labels                map[string]string      `json:"labels,omitempty"`
 	Annotations           map[string]string      `json:"annotations,omitempty"`
 	NetworkTrafficDetails []NetworkTrafficDetail `json:"networkTrafficDetails,omitempty"`

+ 5 - 5
core/pkg/model/kubemodel/resource.go

@@ -4,13 +4,13 @@ package kubemodel
 type Resource string
 
 const (
-	ResourceCPU    Resource = "cpu"
-	ResourceMemory Resource = "memory"
-	ResourceNvidia Resource = "nvidia.com/gpu"
+	ResourceCPU          Resource = "cpu"
+	ResourceMemory       Resource = "memory"
+	ResourceNvidia       Resource = "nvidia.com/gpu"
+	ResourceNvidiaShared Resource = "nvidia.com/gpu.shared"
+	ResourceAWSVGPU      Resource = "k8s.amazonaws.com/vgpu"
 )
 
-var GPUResources = []Resource{ResourceNvidia}
-
 // @bingen:generate:ResourceQuantity
 type ResourceQuantity struct {
 	Resource Resource `json:"resource"` // @bingen:field[version=1]

+ 0 - 8
core/pkg/util/compat.go

@@ -1,13 +1,10 @@
 package util
 
 import (
-	"net"
-
 	v1 "k8s.io/api/core/v1"
 )
 
 // See https://kubernetes.io/docs/reference/labels-annotations-taints/
-
 func GetZone(labels map[string]string) (string, bool) {
 	if _, ok := labels[v1.LabelTopologyZone]; ok { // Label as of 1.17
 		return labels[v1.LabelTopologyZone], true
@@ -57,8 +54,3 @@ func GetArchType(labels map[string]string) (string, bool) {
 		return "", false
 	}
 }
-
-func PrivateIPCheck(ip string) bool {
-	ipAddress := net.ParseIP(ip)
-	return ipAddress.IsPrivate()
-}

+ 4 - 0
go.mod

@@ -31,6 +31,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/sts v1.42.1
 	github.com/aws/smithy-go v1.25.1
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
+	github.com/digitalocean/godo v1.192.0
 	github.com/go-playground/validator/v10 v10.30.1
 	github.com/google/martian v2.1.0+incompatible
 	github.com/google/uuid v1.6.0
@@ -95,7 +96,10 @@ require (
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/gofrs/flock v0.13.0 // indirect
+	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/jsonschema-go v0.4.3 // indirect
+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
 	github.com/klauspost/crc32 v1.3.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/minio/crc64nvme v1.1.1 // indirect

+ 9 - 0
go.sum

@@ -151,6 +151,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/digitalocean/godo v1.192.0 h1:It3AcVa123/Eh/Ol+F9CXXBBlTsWyUIYe8yZhhFZ9Q4=
+github.com/digitalocean/godo v1.192.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -255,9 +257,12 @@ github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9U
 github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
 github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
 github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
 github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
@@ -278,12 +283,16 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
 github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
 github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
+github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
+github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=

+ 81 - 44
pkg/cloud/digitalocean/provider.go

@@ -1,11 +1,10 @@
 package digitalocean
 
 import (
-	"encoding/json"
+	"context"
 	"fmt"
 	"io"
 	"math"
-	"net/http"
 	"regexp"
 	"sort"
 	"strconv"
@@ -13,6 +12,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/digitalocean/godo"
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/pkg/cloud/models"
@@ -33,6 +33,7 @@ type DOKS struct {
 	Config                models.ProviderConfig
 	Clientset             clustercache.ClusterCache
 	ClusterManagementCost float64
+	client                *godo.Client // DigitalOcean API client for fetching sizes
 }
 
 type PricingCache struct {
@@ -108,10 +109,18 @@ type DOMeta struct {
 }
 
 func NewDOKSProvider(pricingURL string) *DOKS {
+	// Create a godo client for API requests
+	var client *godo.Client
+	token := env.GetDigitalOceanAccessToken()
+	if token != "" {
+		client = godo.NewFromToken(token)
+	}
+
 	return &DOKS{
 		PricingURL: pricingURL,
 		Cache:      &PricingCache{},
 		Sizes:      make(map[string]*DOSize),
+		client:     client,
 	}
 }
 
@@ -132,66 +141,94 @@ func (do *DOKS) fetchPricingData() (*DOResponse, error) {
 		return do.Cache.data, nil
 	}
 
-	pricingURL := do.PricingURL
-	if pricingURL == "" {
-		pricingURL = env.GetDOKSPricingURL()
+	// Check if godo client is available
+	if do.client == nil {
+		log.Errorf("DigitalOcean API client is not initialized. Set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY before creating the provider")
+		return nil, fmt.Errorf("digitalocean client not initialized: set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY before provider initialization")
 	}
-	log.Infof("Fetching DigitalOcean sizes from: %s", pricingURL)
 
-	// Create request with authentication
-	req, err := http.NewRequest("GET", pricingURL, nil)
-	if err != nil {
-		log.Warnf("Failed to create request: %v", err)
-		return nil, fmt.Errorf("failed to create request: %w", err)
-	}
+	log.Infof("Fetching DigitalOcean sizes using godo client")
 
-	// Authentication is required for the DigitalOcean sizes API
-	token := env.GetDigitalOceanAccessToken()
-	if token == "" {
-		log.Errorf("DigitalOcean API requires authentication. Set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY environment variable with your DigitalOcean Personal Access Token")
-		return nil, fmt.Errorf("DigitalOcean authentication required: set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY environment variable")
-	}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
 
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-	req.Header.Set("Content-Type", "application/json")
-	log.Debugf("Using authenticated DigitalOcean API request")
+	// Fetch all sizes with pagination
+	var allSizes []godo.Size
+	opt := &godo.ListOptions{}
 
-	client := &http.Client{Timeout: 30 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		log.Warnf("Failed to fetch sizes from DigitalOcean: %v", err)
-		return nil, fmt.Errorf("sizes API fetch error: %w", err)
-	}
-	defer resp.Body.Close()
+	for {
+		sizes, resp, err := do.client.Sizes.List(ctx, opt)
+		if err != nil {
+			log.Warnf("Failed to fetch sizes from DigitalOcean: %v", err)
+			return nil, fmt.Errorf("sizes API fetch error: %w", err)
+		}
 
-	if resp.StatusCode != http.StatusOK {
-		log.Warnf("Sizes API returned unexpected status: %d", resp.StatusCode)
-		return nil, fmt.Errorf("sizes API returned status: %d", resp.StatusCode)
-	}
+		// Append current page's sizes to our list
+		allSizes = append(allSizes, sizes...)
+		log.Debugf("Fetched page %d with %d sizes", opt.Page, len(sizes))
 
-	var data DOResponse
-	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
-		log.Errorf("Failed to decode sizes JSON: %v", err)
-		return nil, fmt.Errorf("failed to decode sizes response: %w", err)
-	}
+		// Check if we're at the last page
+		if resp.Links == nil || resp.Links.IsLastPage() {
+			break
+		}
 
-	// TODO: handle pagination
+		// Get the next page number
+		page, err := resp.Links.CurrentPage()
+		if err != nil {
+			log.Warnf("Failed to get current page number: %v", err)
+			return nil, fmt.Errorf("sizes API pagination: %w", err)
+		}
+
+		// Set the page for the next request
+		opt.Page = page + 1
+	}
 
 	// Index sizes by slug for quick lookup
 	sizesMap := make(map[string]*DOSize)
-	for i := range data.Sizes {
-		size := &data.Sizes[i]
-		sizesMap[size.Slug] = size
+	cachedResponse := &DOResponse{
+		Sizes: make([]DOSize, 0, len(allSizes)),
+	}
+	for _, godoSize := range allSizes {
+		doSize := &DOSize{
+			Slug:         godoSize.Slug,
+			Memory:       godoSize.Memory,
+			VCPUs:        godoSize.Vcpus,
+			Disk:         godoSize.Disk,
+			Transfer:     godoSize.Transfer,
+			PriceMonthly: godoSize.PriceMonthly,
+			PriceHourly:  godoSize.PriceHourly,
+			Regions:      godoSize.Regions,
+			Available:    godoSize.Available,
+			Description:  godoSize.Description,
+		}
+
+		// Convert GPU info if present
+		if godoSize.GPUInfo != nil {
+			doSize.GPUInfo = DOGPUInfo{
+				Count: godoSize.GPUInfo.Count,
+				Model: godoSize.GPUInfo.Model,
+			}
+
+			if godoSize.GPUInfo.VRAM != nil {
+				doSize.GPUInfo.VRAM = DOGPUVRAM{
+					Amount: godoSize.GPUInfo.VRAM.Amount,
+					Unit:   godoSize.GPUInfo.VRAM.Unit,
+				}
+			}
+		}
+
+		sizesMap[doSize.Slug] = doSize
+		cachedResponse.Sizes = append(cachedResponse.Sizes, *doSize)
 		log.Debugf("Indexing size: Slug=%s, VCPUs=%d, Memory=%dMB, PriceHourly=$%.5f",
-			size.Slug, size.VCPUs, size.Memory, size.PriceHourly)
+			doSize.Slug, doSize.VCPUs, doSize.Memory, doSize.PriceHourly)
 	}
 
 	// Cache and return
 	do.Sizes = sizesMap
-	do.Cache.data = &data
+	do.Cache.data = cachedResponse
 	do.Cache.lastUpdate = time.Now()
 
-	log.Infof("Successfully updated DigitalOcean pricing cache (%d sizes)", len(data.Sizes))
+	log.Infof("Successfully updated DigitalOcean pricing cache (%d sizes)", len(allSizes))
 	return do.Cache.data, nil
 }
 

+ 311 - 16
pkg/cloud/digitalocean/provider_test.go

@@ -1,11 +1,13 @@
 package digitalocean
 
 import (
-	"net/http"
-	"net/http/httptest"
+	"context"
+	"encoding/json"
+	"fmt"
 	"os"
 	"testing"
 
+	"github.com/digitalocean/godo"
 	"github.com/opencost/opencost/pkg/cloud/models"
 )
 
@@ -17,20 +19,79 @@ func newTestProviderWithFile(t *testing.T, filename string) (*DOKS, func() int)
 		t.Fatalf("Failed to read file: %v", err)
 	}
 
+	// Parse the JSON data to get sizes
+	var response DOResponse
+	if err := json.Unmarshal(data, &response); err != nil {
+		t.Fatalf("Failed to parse JSON: %v", err)
+	}
+
 	// Set a fake token for testing
 	t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
 
-	var count int
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		count++
-		w.Header().Set("Content-Type", "application/json")
-		_, _ = w.Write(data)
-	}))
+	// Convert DOSize to godo.Size for mock
+	var godoSizes []godo.Size
+	for _, doSize := range response.Sizes {
+		godoSize := godo.Size{
+			Slug:         doSize.Slug,
+			Memory:       doSize.Memory,
+			Vcpus:        doSize.VCPUs,
+			Disk:         doSize.Disk,
+			Transfer:     doSize.Transfer,
+			PriceMonthly: doSize.PriceMonthly,
+			PriceHourly:  doSize.PriceHourly,
+			Regions:      doSize.Regions,
+			Available:    doSize.Available,
+			Description:  doSize.Description,
+		}
+		// Convert GPU info if present
+		if doSize.GPUInfo.Count > 0 {
+			godoSize.GPUInfo = &godo.GPUInfo{
+				Count: doSize.GPUInfo.Count,
+				Model: doSize.GPUInfo.Model,
+				VRAM: &godo.VRAM{
+					Amount: doSize.GPUInfo.VRAM.Amount,
+					Unit:   doSize.GPUInfo.VRAM.Unit,
+				},
+			}
+		}
+		godoSizes = append(godoSizes, godoSize)
+	}
+
+	// Create a mock godo client with all sizes on a single page
+	var callCount int
+	mockService := &testMockSizesService{
+		sizes:     godoSizes,
+		callCount: &callCount,
+	}
+
+	provider := &DOKS{
+		PricingURL: "https://api.digitalocean.com/v2/sizes",
+		Cache:      &PricingCache{},
+		Sizes:      make(map[string]*DOSize),
+		client:     &godo.Client{Sizes: mockService},
+	}
+
+	return provider, func() int { return callCount }
+}
+
+// testMockSizesService is a simple mock that returns all sizes on a single page
+type testMockSizesService struct {
+	sizes     []godo.Size
+	callCount *int
+}
 
-	t.Cleanup(server.Close)
+func (m *testMockSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
+	*m.callCount++
+	// Return all sizes on page 1 (no pagination for simple tests)
+	return m.sizes, &godo.Response{
+		Links: &godo.Links{
+			Pages: &godo.Pages{},
+		},
+	}, nil
+}
 
-	provider := NewDOKSProvider(server.URL)
-	return provider, func() int { return count }
+func (m *testMockSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
+	return nil, nil, nil
 }
 
 func newTestProviderWith404(t *testing.T) *DOKS {
@@ -39,16 +100,30 @@ func newTestProviderWith404(t *testing.T) *DOKS {
 	// Set a fake token for testing
 	t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
 
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusNotFound)
-	}))
+	// Create a mock service that returns an error
+	errorService := &testErrorSizesService{}
 
-	t.Cleanup(server.Close)
+	provider := &DOKS{
+		PricingURL: "https://api.digitalocean.com/v2/sizes",
+		Cache:      &PricingCache{},
+		Sizes:      make(map[string]*DOSize),
+		client:     &godo.Client{Sizes: errorService},
+	}
 
-	provider := NewDOKSProvider(server.URL)
 	return provider
 }
 
+// testErrorSizesService returns an error for testing error handling
+type testErrorSizesService struct{}
+
+func (m *testErrorSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
+	return nil, nil, fmt.Errorf("API error: 404 Not Found")
+}
+
+func (m *testErrorSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
+	return nil, nil, nil
+}
+
 func TestNodePricing_APIMatches(t *testing.T) {
 	provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
 
@@ -621,3 +696,223 @@ func TestNodePricing_GPU(t *testing.T) {
 		t.Errorf("expected 1 API call, got %d", c)
 	}
 }
+
+// mockSizesService implements the godo.SizesService interface for testing pagination
+type mockSizesService struct {
+	pages [][]godo.Size
+}
+
+func (m *mockSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
+	if opt == nil {
+		opt = &godo.ListOptions{}
+	}
+
+	// Pages are 1-indexed in godo
+	page := opt.Page
+	if page == 0 {
+		page = 1
+	}
+
+	// Check if page is within range
+	if page > len(m.pages) {
+		// Return last page indicator
+		return []godo.Size{}, &godo.Response{
+			Links: &godo.Links{
+				Pages: &godo.Pages{}, // No Next link = last page
+			},
+		}, nil
+	}
+
+	sizes := m.pages[page-1]
+
+	// Create response with pagination links
+	// godo.Pages has: First, Last, Next, Prev
+	resp := &godo.Response{
+		Links: &godo.Links{
+			Pages: &godo.Pages{
+				// Set First link (required for CurrentPage to parse)
+				First: "https://api.digitalocean.com/v2/sizes?page=1&per_page=20",
+			},
+		},
+	}
+
+	// Set Last link - always set for godo to work
+	resp.Links.Pages.Last = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", len(m.pages))
+
+	// Set Next link if not on the last page
+	if page < len(m.pages) {
+		resp.Links.Pages.Next = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", page+1)
+	}
+
+	// Set Prev link if not on the first page
+	if page > 1 {
+		resp.Links.Pages.Prev = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", page-1)
+	}
+
+	return sizes, resp, nil
+}
+
+func (m *mockSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
+	return nil, nil, nil
+}
+
+// createMockGodoClient creates a godo client with a mock SizesService for pagination testing
+func createMockGodoClient(t *testing.T) *godo.Client {
+	t.Helper()
+
+	page1 := []godo.Size{
+		{
+			Slug:        "s-1vcpu-2gb",
+			Memory:      2048,
+			Vcpus:       1,
+			Disk:        50,
+			PriceHourly: 0.01786,
+			Available:   true,
+		},
+		{
+			Slug:        "s-2vcpu-4gb",
+			Memory:      4096,
+			Vcpus:       2,
+			Disk:        80,
+			PriceHourly: 0.03571,
+			Available:   true,
+		},
+	}
+
+	page2 := []godo.Size{
+		{
+			Slug:        "m-4vcpu-32gb",
+			Memory:      32768,
+			Vcpus:       4,
+			Disk:        160,
+			PriceHourly: 0.25000,
+			Available:   true,
+		},
+		{
+			Slug:        "m-8vcpu-64gb",
+			Memory:      65536,
+			Vcpus:       8,
+			Disk:        320,
+			PriceHourly: 0.50000,
+			Available:   true,
+		},
+	}
+
+	page3 := []godo.Size{
+		{
+			Slug:        "c-8-intel",
+			Memory:      16384,
+			Vcpus:       8,
+			Disk:        160,
+			PriceHourly: 0.32440,
+			Available:   true,
+		},
+		{
+			Slug:        "c-16-intel",
+			Memory:      32768,
+			Vcpus:       16,
+			Disk:        320,
+			PriceHourly: 0.64880,
+			Available:   true,
+		},
+	}
+
+	// Create a client (we'll replace its Sizes service)
+	client := godo.NewFromToken("test_token")
+
+	// Replace the Sizes service with our mock
+	client.Sizes = &mockSizesService{
+		pages: [][]godo.Size{page1, page2, page3},
+	}
+
+	return client
+}
+
+// TestFetchPricingData_Pagination verifies that pagination is correctly handled when fetching sizes
+func TestFetchPricingData_Pagination(t *testing.T) {
+	t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
+
+	// Create a provider with a mock client that simulates pagination
+	provider := &DOKS{
+		PricingURL: "https://api.digitalocean.com/v2/sizes",
+		Cache:      &PricingCache{},
+		Sizes:      make(map[string]*DOSize),
+		client:     createMockGodoClient(t),
+	}
+
+	// Fetch pricing data which triggers pagination
+	response, err := provider.fetchPricingData()
+	if err != nil {
+		t.Fatalf("expected no error, got: %v", err)
+	}
+
+	if response == nil {
+		t.Fatal("expected non-nil response")
+	}
+
+	// Verify that all sizes from all pages were collected
+	expectedSizes := map[string]bool{
+		"s-1vcpu-2gb":  true,
+		"s-2vcpu-4gb":  true,
+		"m-4vcpu-32gb": true,
+		"m-8vcpu-64gb": true,
+		"c-8-intel":    true,
+		"c-16-intel":   true,
+	}
+
+	// Check that all expected sizes are in the provider's sizes map
+	for slug := range expectedSizes {
+		if _, exists := provider.Sizes[slug]; !exists {
+			t.Errorf("expected size %q to be indexed, but it was not", slug)
+		}
+	}
+
+	// Verify the total count
+	if len(provider.Sizes) != len(expectedSizes) {
+		t.Errorf("expected %d sizes, got %d", len(expectedSizes), len(provider.Sizes))
+	}
+
+	// Verify specific size details
+	testCases := []struct {
+		slug           string
+		expectedVCPUs  int
+		expectedMemory int
+	}{
+		{"s-1vcpu-2gb", 1, 2048},
+		{"s-2vcpu-4gb", 2, 4096},
+		{"m-4vcpu-32gb", 4, 32768},
+		{"m-8vcpu-64gb", 8, 65536},
+		{"c-8-intel", 8, 16384},
+		{"c-16-intel", 16, 32768},
+	}
+
+	for _, tc := range testCases {
+		size, exists := provider.Sizes[tc.slug]
+		if !exists {
+			t.Fatalf("expected size %q to exist", tc.slug)
+		}
+
+		if size.VCPUs != tc.expectedVCPUs {
+			t.Errorf("size %q: expected %d vCPUs, got %d", tc.slug, tc.expectedVCPUs, size.VCPUs)
+		}
+
+		if size.Memory != tc.expectedMemory {
+			t.Errorf("size %q: expected %d MB memory, got %d", tc.slug, tc.expectedMemory, size.Memory)
+		}
+	}
+
+	// Verify caching works (second fetch should use cached data)
+	response2, err := provider.fetchPricingData()
+	if err != nil {
+		t.Fatalf("expected no error on second fetch, got: %v", err)
+	}
+
+	if response2 == nil {
+		t.Fatal("expected non-nil response on second fetch")
+	}
+
+	// Verify cache timestamp was updated
+	if provider.Cache.lastUpdate.IsZero() {
+		t.Error("expected cache to have a non-zero timestamp")
+	}
+}

+ 41 - 0
pkg/cloud/oracle/product.go

@@ -64,6 +64,47 @@ type instanceProduct map[string]Product
 // instanceProducts maps instance types to associated part numbers.
 var instanceProducts instanceProduct
 
+// normalizeOCIInstanceShape parses synthetic flex shape labels in the form
+// <base-shape>.<ocpus>o.<memory>g.<baseline>b. Supported baselines are 1_1,
+// 1_2, and 1_8, which map to the burstable CPU price multipliers below.
+func normalizeOCIInstanceShape(shape string) (string, float64, bool) {
+	const defaultCPUPriceMultiplier = 1.0
+
+	parts := strings.Split(shape, ".")
+	if len(parts) < 4 {
+		return "", defaultCPUPriceMultiplier, false
+	}
+
+	ocpuPart := parts[len(parts)-3]
+	memoryPart := parts[len(parts)-2]
+	baselinePart := parts[len(parts)-1]
+
+	if !strings.HasSuffix(ocpuPart, "o") || !strings.HasSuffix(memoryPart, "g") || !strings.HasSuffix(baselinePart, "b") {
+		return "", defaultCPUPriceMultiplier, false
+	}
+	if _, err := strconv.ParseFloat(strings.TrimSuffix(ocpuPart, "o"), 64); err != nil {
+		return "", defaultCPUPriceMultiplier, false
+	}
+	if _, err := strconv.ParseFloat(strings.TrimSuffix(memoryPart, "g"), 64); err != nil {
+		return "", defaultCPUPriceMultiplier, false
+	}
+
+	var cpuPriceMultiplier float64
+	switch strings.TrimSuffix(baselinePart, "b") {
+	case "1_1":
+		cpuPriceMultiplier = 1.0
+	case "1_2":
+		cpuPriceMultiplier = 0.5
+	case "1_8":
+		cpuPriceMultiplier = 0.125
+	default:
+		return "", defaultCPUPriceMultiplier, false
+	}
+
+	baseShape := strings.Join(parts[:len(parts)-3], ".")
+	return baseShape, cpuPriceMultiplier, true
+}
+
 func (i instanceProduct) get(shape string) Product {
 	if product, ok := i[shape]; ok {
 		return product

+ 4 - 0
pkg/cloud/oracle/provider.go

@@ -21,6 +21,7 @@ const nodePoolIdAnnotation = "oci.oraclecloud.com/node-pool-id"
 const virtualPoolIdAnnotation = "oci.oraclecloud.com/virtual-node-pool-id"
 const virtualNodeLabel = "node-role.kubernetes.io/virtual-node"
 const preemptibleLabel = "oci.oraclecloud.com/oke-is-preemptible"
+const ociInstanceShapeLabel = "oci.oraclecloud.com/instance-shape"
 const managementPlatformOKE = "oke"
 const currencyCodeUSD = "USD"
 
@@ -147,6 +148,9 @@ func (o *Oracle) GetKey(labels map[string]string, n *clustercache.Node) models.K
 		gpuType = "nvidia.com/gpu"
 	}
 	instanceType, _ := util.GetInstanceType(labels)
+	if instanceType == "" {
+		instanceType = labels[ociInstanceShapeLabel]
+	}
 	return &oracleKey{
 		providerID:   n.SpecProviderID,
 		instanceType: instanceType,

+ 25 - 0
pkg/cloud/oracle/provider_test.go

@@ -54,6 +54,31 @@ func TestGetKey(t *testing.T) {
 	}
 }
 
+func TestGetKeyFallsBackToOCIInstanceShapeLabel(t *testing.T) {
+	labels := map[string]string{
+		ociInstanceShapeLabel: "VM.Standard.E3.Flex",
+	}
+
+	key := (&Oracle{}).GetKey(labels, testNode(0))
+	features := strings.Split(key.Features(), ",")
+
+	assert.Len(t, features, 3)
+	assert.Equal(t, "VM.Standard.E3.Flex", features[0])
+}
+
+func TestGetKeyPrefersKubernetesInstanceTypeLabel(t *testing.T) {
+	labels := map[string]string{
+		v1.LabelInstanceTypeStable: "VM.Standard.E3.Flex.2o.32g.1_1b",
+		ociInstanceShapeLabel:      "VM.Standard.E3.Flex",
+	}
+
+	key := (&Oracle{}).GetKey(labels, testNode(0))
+	features := strings.Split(key.Features(), ",")
+
+	assert.Len(t, features, 3)
+	assert.Equal(t, "VM.Standard.E3.Flex.2o.32g.1_1b", features[0])
+}
+
 func TestGetPVKey(t *testing.T) {
 	storageClass := "xyz"
 	providerID := "ocid.abc"

+ 12 - 2
pkg/cloud/oracle/ratecard.go

@@ -126,7 +126,17 @@ func (rcs *RateCardStore) ForPVK(pvk models.PVKey, defaultPricing DefaultPricing
 // ForKey retrieves costing metadata for a key.
 func (rcs *RateCardStore) ForKey(key models.Key, defaultPricing DefaultPricing) (*models.Node, models.PricingMetadata, error) {
 	features := strings.Split(key.Features(), ",")
-	product := instanceProducts.get(features[0])
+	shape := features[0]
+	cpuPriceMultiplier := 1.0
+	product := instanceProducts.get(shape)
+	if baseShape, multiplier, ok := normalizeOCIInstanceShape(shape); ok {
+		baseProduct := instanceProducts.get(baseShape)
+		if !baseProduct.isEmpty() {
+			shape = baseShape
+			cpuPriceMultiplier = multiplier
+			product = baseProduct
+		}
+	}
 	var node *models.Node
 	// Use the default pricing if the instance product is unknown
 	if product.isEmpty() {
@@ -151,7 +161,7 @@ func (rcs *RateCardStore) ForKey(key models.Key, defaultPricing DefaultPricing)
 			GPU:      defaultPricing.GPU,
 		}
 	} else {
-		ocpuPrice := rcs.prices[product.OCPU].UnitPrice
+		ocpuPrice := rcs.prices[product.OCPU].UnitPrice * cpuPriceMultiplier
 		if !isARMArch(features) {
 			// Non-ARM architectures have 2 VCPU per OCPU
 			ocpuPrice /= 2

+ 71 - 7
pkg/cloud/oracle/ratecard_test.go

@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestRCSForKey(t *testing.T) {
@@ -38,6 +39,10 @@ func TestRCSForKey(t *testing.T) {
 			"0.014000",
 			false,
 		},
+		"unknown-shape.2o.32g.1_2b": {
+			"0.600000",
+			false,
+		},
 		"unknown-shape": {
 			"0.600000",
 			false,
@@ -58,12 +63,63 @@ func TestRCSForKey(t *testing.T) {
 				Memory: "0.1",
 				GPU:    "0.3",
 			})
-			assert.NoError(t, err)
+			require.NoError(t, err)
 			assertFloatStrings(t, testCase.cost, node.Cost, 0.001)
 		})
 	}
 }
 
+func TestRCSForKey_KarpenterFlexShape(t *testing.T) {
+	rcs, server := testSetupRateCardStore(t)
+	defer server.Close()
+
+	defaultPricing := DefaultPricing{
+		OCPU:   "0.2",
+		Memory: "0.1",
+		GPU:    "0.3",
+	}
+
+	testCases := map[string]struct {
+		baseShape     string
+		flexShape     string
+		cpuMultiplier float64
+		assertCost    bool
+	}{
+		"baseline-1_1": {
+			baseShape:     "VM.Standard.E3.Flex",
+			flexShape:     "VM.Standard.E3.Flex.2o.32g.1_1b",
+			cpuMultiplier: 1,
+			assertCost:    true,
+		},
+		"baseline-1_2": {
+			baseShape:     "VM.Standard.E4.Flex",
+			flexShape:     "VM.Standard.E4.Flex.8o.32g.1_2b",
+			cpuMultiplier: 0.5,
+		},
+		"baseline-1_8": {
+			baseShape:     "VM.Standard.E4.Flex",
+			flexShape:     "VM.Standard.E4.Flex.8o.32g.1_8b",
+			cpuMultiplier: 0.125,
+		},
+	}
+
+	for name, testCase := range testCases {
+		t.Run(name, func(t *testing.T) {
+			baseNode, _, err := rcs.ForKey(&oracleKey{instanceType: testCase.baseShape, labels: make(map[string]string)}, defaultPricing)
+			require.NoError(t, err)
+
+			flexNode, _, err := rcs.ForKey(&oracleKey{instanceType: testCase.flexShape, labels: make(map[string]string)}, defaultPricing)
+			require.NoError(t, err)
+
+			assertFloatStrings(t, baseNode.RAMCost, flexNode.RAMCost, 0.000001)
+			assert.InDelta(t, mustParseFloat(t, baseNode.VCPUCost)*testCase.cpuMultiplier, mustParseFloat(t, flexNode.VCPUCost), 0.000001)
+			if testCase.assertCost {
+				assertFloatStrings(t, baseNode.Cost, flexNode.Cost, 0.000001)
+			}
+		})
+	}
+}
+
 func TestRCSForPVK(t *testing.T) {
 	rcs, server := testSetupRateCardStore(t)
 	defer server.Close()
@@ -90,7 +146,7 @@ func TestRCSForPVK(t *testing.T) {
 			pv, err := rcs.ForPVK(pvk, DefaultPricing{
 				Storage: "0.25",
 			})
-			assert.NoError(t, err)
+			require.NoError(t, err)
 			assertFloatStrings(t, testCase.cost, pv.Cost, 0.00001)
 		})
 	}
@@ -150,7 +206,7 @@ func TestRCSEgressForRegion(t *testing.T) {
 
 func testSetupRateCardStore(t *testing.T) (*RateCardStore, *httptest.Server) {
 	pricesUSDBytes, err := os.ReadFile("test/prices_usd.json")
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusOK)
 		w.Write(pricesUSDBytes)
@@ -158,15 +214,23 @@ func testSetupRateCardStore(t *testing.T) (*RateCardStore, *httptest.Server) {
 
 	rcs := NewRateCardStore(server.URL, currencyCodeUSD)
 	store, err := rcs.Refresh()
-	assert.NoError(t, err)
-	assert.True(t, len(store) > 0)
+	require.NoError(t, err)
+	require.NotEmpty(t, store)
 	return rcs, server
 }
 
 func assertFloatStrings(t *testing.T, s1, s2 string, delta float64) {
+	t.Helper()
 	f1, err := strconv.ParseFloat(s1, 64)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	f2, err := strconv.ParseFloat(s2, 64)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.InDelta(t, f1, f2, delta)
 }
+
+func mustParseFloat(t *testing.T, s string) float64 {
+	t.Helper()
+	f, err := strconv.ParseFloat(s, 64)
+	require.NoError(t, err)
+	return f
+}

+ 13 - 7
pkg/cloud/oracle/usageapiintegration.go

@@ -108,14 +108,9 @@ func (uai *UsageApiIntegration) GetCloudCost(start time.Time, end time.Time) (*o
 			listRate = float64(*item.ListRate)
 		}
 
-		attrCostToParse := ""
-		if item.AttributedCost != nil {
-			attrCostToParse = *item.AttributedCost
-		}
-
-		attrCost, err := strconv.ParseFloat(attrCostToParse, 64)
+		attrCost, err := parseAttributedCost(item.AttributedCost)
 		if err != nil {
-			return nil, fmt.Errorf("unable to parse float '%s': %s", attrCostToParse, err.Error())
+			return nil, err
 		}
 
 		computedAmt := 0.0
@@ -164,6 +159,17 @@ func (uai *UsageApiIntegration) RefreshStatus() cloud.ConnectionStatus {
 	return uai.ConnectionStatus
 }
 
+func parseAttributedCost(s *string) (float64, error) {
+	if s == nil || *s == "" {
+		return 0, nil
+	}
+	f, err := strconv.ParseFloat(*s, 64)
+	if err != nil {
+		return 0, fmt.Errorf("unable to parse float '%s': %s", *s, err.Error())
+	}
+	return f, nil
+}
+
 func SelectOCICategory(service string) string {
 	if service == "Compute" {
 		return opencost.ComputeCategory

+ 28 - 0
pkg/cloud/oracle/usageapiintegration_test.go

@@ -9,6 +9,34 @@ import (
 	"github.com/opencost/opencost/core/pkg/util/timeutil"
 )
 
+func TestParseAttributedCost(t *testing.T) {
+	strPtr := func(s string) *string { return &s }
+
+	cases := map[string]struct {
+		input   *string
+		want    float64
+		wantErr bool
+	}{
+		"nil":        {nil, 0, false},
+		"empty":      {strPtr(""), 0, false},
+		"zero":       {strPtr("0"), 0, false},
+		"valid":      {strPtr("1.23"), 1.23, false},
+		"negative":   {strPtr("-0.5"), -0.5, false},
+		"unparsable": {strPtr("abc"), 0, true},
+	}
+	for name, c := range cases {
+		t.Run(name, func(t *testing.T) {
+			got, err := parseAttributedCost(c.input)
+			if (err != nil) != c.wantErr {
+				t.Fatalf("err = %v, wantErr = %v", err, c.wantErr)
+			}
+			if got != c.want {
+				t.Errorf("got %v, want %v", got, c.want)
+			}
+		})
+	}
+}
+
 func TestUsageAPIIntegration_GetCloudCost(t *testing.T) {
 	usageApiConfigPath := os.Getenv("USAGEAPI_CONFIGURATION")
 	if usageApiConfigPath == "" {

+ 12 - 26
pkg/kubemodel/kubemodel.go

@@ -197,7 +197,6 @@ func (km *KubeModel) computeNodes(kms *kubemodel.KubeModelSet, start, end time.T
 	nodeInfoResultFuture := source.WithGroup(grp, metrics.QueryNodeInfo(start, end))
 	nodeUptimeResultFuture := source.WithGroup(grp, metrics.QueryNodeUptime(start, end))
 	nodeLabelsResultFuture := source.WithGroup(grp, metrics.QueryNodeLabels(start, end))
-	nodeIsSpotResultFuture := source.WithGroup(grp, metrics.QueryNodeIsSpot(start, end))
 	nodeResourceCapacitiesFuture := source.WithGroup(grp, metrics.QueryNodeResourceCapacities(start, end))
 	nodeResourcesAllocatableFuture := source.WithGroup(grp, metrics.QueryNodeResourcesAllocatable(start, end))
 
@@ -213,7 +212,6 @@ func (km *KubeModel) computeNodes(kms *kubemodel.KubeModelSet, start, end time.T
 			UID:                  res.UID,
 			ProviderID:           res.ProviderID,
 			Name:                 res.Node,
-			InstanceType:         res.InstanceType,
 			ResourceCapacities:   make(kubemodel.ResourceQuantities),
 			ResourcesAllocatable: make(kubemodel.ResourceQuantities),
 		}
@@ -253,18 +251,6 @@ func (km *KubeModel) computeNodes(kms *kubemodel.KubeModelSet, start, end time.T
 		node.ResourcesAllocatable.Set(resource, unit, kubemodel.StatAvg, value)
 	}
 
-	nodeIsSpotResult, _ := nodeIsSpotResultFuture.Await()
-	for _, res := range nodeIsSpotResult {
-		node, ok := nodeMap[res.UID]
-		if !ok {
-			log.Warnf("node with UID '%s' has not been initialized to add spot status", res.UID)
-			continue
-		}
-		if len(res.Data) > 0 {
-			node.Preemptible = res.Data[0].Value > 0
-		}
-	}
-
 	nodeLabelsResult, _ := nodeLabelsResultFuture.Await()
 	for _, res := range nodeLabelsResult {
 		node, ok := nodeMap[res.UID]
@@ -444,7 +430,7 @@ func (km *KubeModel) computePods(kms *kubemodel.KubeModelSet, start, end time.Ti
 			log.Warnf("pod with UID '%s' has not been initialized to add PVC volumes", res.UID)
 			continue
 		}
-		pod.PVCVolumes = append(pod.PVCVolumes, kubemodel.PodPVCVolumes{
+		pod.PVCVolumes = append(pod.PVCVolumes, kubemodel.PodPVCVolume{
 			Name:                     res.PodVolumeName,
 			PersistentVolumeClaimUID: res.PVCUID,
 		})
@@ -1524,7 +1510,7 @@ func (km *KubeModel) computeDCGMDevices(kms *kubemodel.KubeModelSet, start, end
 			UUID:      res.UUID,
 			Device:    res.Device,
 			ModelName: res.ModelName,
-			PodUsage:  make(map[string]kubemodel.DCGMPod),
+			PodUsages: make(map[string]kubemodel.DCGMPod),
 		}
 	}
 
@@ -1546,14 +1532,14 @@ func (km *KubeModel) computeDCGMDevices(kms *kubemodel.KubeModelSet, start, end
 		if !ok || res.PodUID == "" || res.Container == "" {
 			continue
 		}
-		pod, ok := device.PodUsage[res.PodUID]
+		pod, ok := device.PodUsages[res.PodUID]
 		if !ok {
-			pod = kubemodel.DCGMPod{ContainerUsage: make(map[string]kubemodel.DCGMContainer)}
+			pod = kubemodel.DCGMPod{ContainerUsages: make(map[string]kubemodel.DCGMContainer)}
 		}
-		c := pod.ContainerUsage[res.Container]
+		c := pod.ContainerUsages[res.Container]
 		c.UsageAvg = res.Value
-		pod.ContainerUsage[res.Container] = c
-		device.PodUsage[res.PodUID] = pod
+		pod.ContainerUsages[res.Container] = c
+		device.PodUsages[res.PodUID] = pod
 	}
 
 	dcgmUsageMaxResult, _ := dcgmUsageMaxFuture.Await()
@@ -1562,14 +1548,14 @@ func (km *KubeModel) computeDCGMDevices(kms *kubemodel.KubeModelSet, start, end
 		if !ok || res.PodUID == "" || res.Container == "" {
 			continue
 		}
-		pod, ok := device.PodUsage[res.PodUID]
+		pod, ok := device.PodUsages[res.PodUID]
 		if !ok {
-			pod = kubemodel.DCGMPod{ContainerUsage: make(map[string]kubemodel.DCGMContainer)}
+			pod = kubemodel.DCGMPod{ContainerUsages: make(map[string]kubemodel.DCGMContainer)}
 		}
-		c := pod.ContainerUsage[res.Container]
+		c := pod.ContainerUsages[res.Container]
 		c.UsageMax = res.Value
-		pod.ContainerUsage[res.Container] = c
-		device.PodUsage[res.PodUID] = pod
+		pod.ContainerUsages[res.Container] = c
+		device.PodUsages[res.PodUID] = pod
 	}
 
 	for _, device := range deviceMap {

Some files were not shown because too many files changed in this diff