Преглед изворни кода

Fix pagination in sizes API for DigitalOcean provider (#3804)

Signed-off-by: svenkitesh <svenkitesh@digitalocean.com>
Sreeram Venkitesh пре 1 дан
родитељ
комит
f71615cda8
4 измењених фајлова са 405 додато и 60 уклоњено
  1. 4 0
      go.mod
  2. 9 0
      go.sum
  3. 81 44
      pkg/cloud/digitalocean/provider.go
  4. 311 16
      pkg/cloud/digitalocean/provider_test.go

+ 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")
+	}
+}