diff --git a/docs/deploy.md b/docs/deploy.md index ba53f71..254d4e8 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -172,6 +172,26 @@ identity, the `system:nodes` organization, and the current manager Pod IP as the --- +## AWS IAM permissions + +The AWS adapter uses the SDK's default credential chain (IRSA or an instance role in production). +That identity needs these EC2 actions: + +| Action | Used for | +|---|---| +| `ec2:CreateFleet` | Launching an instance (an *instant* fleet across the region's AZs) | +| `ec2:RunInstances`, `ec2:CreateTags` | Required by `CreateFleet` to launch and tag the instance | +| `ec2:CreateLaunchTemplate`, `ec2:DeleteLaunchTemplate`, `ec2:DescribeLaunchTemplates` | The ephemeral launch template each launch creates, and sweeping leaked ones | +| `ec2:TerminateInstances` | Deleting an instance | +| `ec2:DescribeInstances` | Finding and listing Nebula's instances | +| `ec2:DescribeInstanceStatus` | The 2/2 status checks that gate `Running` (a separate grant from `DescribeInstances`) | +| `ec2:DescribeInstanceTypeOfferings` | Which instance types a region and each AZ offer | +| `ec2:DescribeImages`, `ec2:DescribeSubnets` | The region's GPU AMI and default-VPC subnets | +| `ec2:DescribeSpotPriceHistory` | The live Spot price a Spot claim is priced at | + +Without `ec2:DescribeSpotPriceHistory`, Spot claims are priced at the catalog's seed rate (a +us-east-1 snapshot) instead of the region's current price. Placement and launches still work. + ## Modal Environments Optional, and off unless you set it. A Modal **Environment** is a named partition of diff --git a/internal/controller/nodeclaim_controller.go b/internal/controller/nodeclaim_controller.go index 6f09310..f473236 100644 --- a/internal/controller/nodeclaim_controller.go +++ b/internal/controller/nodeclaim_controller.go @@ -409,6 +409,7 @@ func (r *NodeClaimReconciler) recordPrice(ctx context.Context, nc *nebulav1alpha AcceleratorType: accelerator, Count: count, CapacityType: nc.Spec.CapacityType, + Region: nc.Spec.Region, CPUCores: cpuCores, MemoryMiB: memoryMiB, DiskGiB: util.PodEphemeralStorageGiB(pod), diff --git a/internal/controller/nodeclaim_price_test.go b/internal/controller/nodeclaim_price_test.go index a918f32..1540067 100644 --- a/internal/controller/nodeclaim_price_test.go +++ b/internal/controller/nodeclaim_price_test.go @@ -75,6 +75,7 @@ func TestRecordPrice_WritesRateAndRequest(t *testing.T) { pod := gpuPod("H100", 2, "4", "8Gi") claim := newClaim("c1", "p1", "default", "uid-1", "fake") claim.Spec.CapacityType = nebulav1alpha1.CapacityOnDemand + claim.Spec.Region = "us-east-1" pp := &pricedProvider{fakeProvider: &fakeProvider{name: "fake"}, rate: 7.9} r, c := newPricedReconciler(t, []client.Object{pod, claim}, pp) @@ -90,6 +91,8 @@ func TestRecordPrice_WritesRateAndRequest(t *testing.T) { want := provider.PriceRequest{ AcceleratorType: "H100", Count: 2, CapacityType: nebulav1alpha1.CapacityOnDemand, + // The claim's region, for a provider whose price varies by region (AWS Spot). + Region: "us-east-1", // The Pod's own reservation, which only a provider metering CPU/memory apart from // the accelerator charges for — but the request carries it either way. CPUCores: 4, MemoryMiB: 8192, diff --git a/pkg/provider/aws/aws.go b/pkg/provider/aws/aws.go index 7eac846..b6f2f79 100644 --- a/pkg/provider/aws/aws.go +++ b/pkg/provider/aws/aws.go @@ -134,6 +134,11 @@ type Client interface { // region offers is AWS-authoritative and changes over time, so it is queried // live rather than hand-maintained in the catalog. AvailableInstanceTypes(ctx context.Context) (map[string]bool, error) + // SpotPrice returns the current Spot price (USD/hour) of one instance type in the + // client's region: the LOWEST across its AZs, since the launch fleet's default Spot + // allocation strategy is lowest-price. Spot prices move hourly and differ per region + // and AZ, so they are read live rather than from the catalog's seed rows. + SpotPrice(ctx context.Context, instanceType string) (float64, error) } // InstanceSpec is the resolved, EC2-shaped request the Client turns into a @@ -236,8 +241,34 @@ type Provider struct { mu sync.Mutex clients map[string]Client // region -> Client, populated lazily by clientFor + + // spotMu guards spotPrices, the live Spot price cache PricePerHour reads (see spotRate). + spotMu sync.Mutex + spotPrices map[spotPriceKey]spotPriceEntry +} + +// spotPriceKey is one cached Spot price: an instance type in a region. +type spotPriceKey struct{ region, instanceType string } + +// spotPriceEntry is a cached Spot price, or the failure to read one (so a missing IAM grant +// or throttle is not retried on every reconcile). +type spotPriceEntry struct { + price float64 + err error + at time.Time } +const ( + // spotPriceTTL is how long a live Spot price is reused. Spot prices move gradually, at + // most every hour or so, so this bounds staleness without a call per reconcile. + spotPriceTTL = time.Hour + // spotPriceErrTTL is how long a failed lookup is remembered before retrying. + spotPriceErrTTL = 5 * time.Minute + // spotPriceTimeout caps one DescribeSpotPriceHistory lookup. PricePerHour has no context + // of its own, and a slow lookup only delays a cost figure. + spotPriceTimeout = 10 * time.Second +) + // New returns an EC2 Provider backed by a client factory and price catalog. // regionSource supplies the NodePool declarations the List/Offerings fan-out // sweeps (nil is tolerated — the sweep then uses only the regions already @@ -254,6 +285,7 @@ func New(newClient ClientFactory, cat catalog.Lookup, regionSource RegionSource) newClient: newClient, regionSource: regionSource, clients: make(map[string]Client), + spotPrices: make(map[spotPriceKey]spotPriceEntry), } } @@ -454,7 +486,7 @@ func (p *Provider) Offerings(ctx context.Context) ([]provider.Offering, error) { log.Error(err, "probe instance-type availability failed; skipping region", "region", region) continue } - for _, o := range rows { + for _, o := range regionRows(rows, region) { o.Region = region o.Available = o.Available && avail[o.AcceleratorID] out = append(out, o) @@ -466,6 +498,36 @@ func (p *Provider) Offerings(ctx context.Context) ([]provider.Offering, error) { return out, nil } +// regionRows returns the catalog rows that price region: its own rows, plus a blank-region +// (default) row for each offering the region has no row of its own for. Rows for other +// regions are dropped. +func regionRows(rows []provider.Offering, region string) []provider.Offering { + type offeringKey struct { + id string + count int32 + tier nebulav1alpha1.CapacityType + } + key := func(o provider.Offering) offeringKey { + return offeringKey{id: o.AcceleratorID, count: o.GPUCount, tier: o.CapacityType} + } + own := map[offeringKey]bool{} + for _, o := range rows { + if o.Region == region { + own[key(o)] = true + } + } + out := make([]provider.Offering, 0, len(rows)) + for _, o := range rows { + switch { + case o.Region == region: + out = append(out, o) + case o.Region == "" && !own[key(o)]: + out = append(out, o) + } + } + return out +} + // Provision implements provider.Provider. The Pod is the source of truth for the // workload; req carries the claim identity, capacity tier, and region. // @@ -760,9 +822,62 @@ func (p *Provider) PricePerHour(req provider.PriceRequest) (float64, error) { if err != nil { return 0, err } + if req.CapacityType == nebulav1alpha1.CapacitySpot && req.Region != "" { + rate = p.spotRate(req, rate) + } return rate + data.AWSRootVolumeCostPerHour(awsAMIRootGiB+req.DiskGiB), nil } +// spotRate is the live Spot price of the request's primary instance type in its region, or +// seed (the catalog's Spot row) when the live price cannot be read. The catalog's Spot rows +// are a single us-east-1 snapshot; the live price is what the region charges now. +func (p *Provider) spotRate(req provider.PriceRequest, seed float64) float64 { + ids, ok := p.MapAccelerator(req.AcceleratorType, req.Count) + if !ok || len(ids) == 0 { + return seed + } + key := spotPriceKey{region: req.Region, instanceType: ids[0]} + log := logf.Log.WithName("aws-spot-price").WithValues("region", key.region, "instanceType", key.instanceType) + + p.spotMu.Lock() + e, cached := p.spotPrices[key] + p.spotMu.Unlock() + if !cached || e.expired(time.Now()) { + e = p.fetchSpotPrice(key) + p.spotMu.Lock() + p.spotPrices[key] = e + p.spotMu.Unlock() + } + if e.err != nil { + log.V(1).Info("live spot price unavailable; using the catalog seed", "seed", seed, "error", e.err.Error()) + return seed + } + return e.price +} + +// fetchSpotPrice reads one live Spot price through the region's client. +func (p *Provider) fetchSpotPrice(key spotPriceKey) spotPriceEntry { + ctx, cancel := context.WithTimeout(context.Background(), spotPriceTimeout) + defer cancel() + now := time.Now() + client, err := p.clientFor(ctx, key.region) + if err != nil { + return spotPriceEntry{err: err, at: now} + } + price, err := client.SpotPrice(ctx, key.instanceType) + return spotPriceEntry{price: price, err: err, at: now} +} + +// expired reports whether the entry must be re-read: a price after spotPriceTTL, a failure +// after the shorter spotPriceErrTTL. +func (e spotPriceEntry) expired(now time.Time) bool { + ttl := spotPriceTTL + if e.err != nil { + ttl = spotPriceErrTTL + } + return now.Sub(e.at) > ttl +} + // instanceSpecFromPod reads the workload off the Pod (source of truth) and the // accelerator type (from the AcceleratorTypeLabel), maps it to an EC2 instance // type via the catalog, and stamps the claim tag, capacity tier, and region. diff --git a/pkg/provider/aws/aws_test.go b/pkg/provider/aws/aws_test.go index ceea8d2..f602bd2 100644 --- a/pkg/provider/aws/aws_test.go +++ b/pkg/provider/aws/aws_test.go @@ -55,6 +55,11 @@ type fakeClient struct { // Offerings call on a bare fake reports every row unavailable but still present. available map[string]bool availErr error + // spotPrices is what SpotPrice returns per instance type; spotErr injects a failure. + // spotCalls counts lookups so a test can assert the cache. + spotPrices map[string]float64 + spotErr error + spotCalls int } func (f *fakeClient) RunInstance(_ context.Context, spec InstanceSpec) (string, error) { @@ -107,6 +112,18 @@ func (f *fakeClient) FindInstance(_ context.Context, claimName string, states [] return nil, nil } +func (f *fakeClient) SpotPrice(_ context.Context, instanceType string) (float64, error) { + f.spotCalls++ + if f.spotErr != nil { + return 0, f.spotErr + } + price, ok := f.spotPrices[instanceType] + if !ok { + return 0, errNoSpotPrice + } + return price, nil +} + func (f *fakeClient) AvailableInstanceTypes(_ context.Context) (map[string]bool, error) { if f.availErr != nil { return nil, f.availErr @@ -123,9 +140,10 @@ func (c fakeCatalog) Offerings(_ string) []provider.Offering { return c.rows } const testRegion = "us-east-1" -// offering is a compact constructor for a region-aware test catalog row (the -// full struct literal is too wide to read inline). count is the gpu_count, the -// AWS lookup key that (with the accelerator type) selects the instance type. +// offering is a compact constructor for a test catalog row (the full struct literal +// is too wide to read inline). count is the gpu_count, the AWS lookup key that (with +// the accelerator type) selects the instance type. The region is blank, as in the real +// catalog's default rows; Offerings stamps it per region. func offering(accel, id string, count int32, tier nebulav1alpha1.CapacityType, price float64) provider.Offering { return provider.Offering{ AcceleratorType: accel, @@ -134,7 +152,6 @@ func offering(accel, id string, count int32, tier nebulav1alpha1.CapacityType, p CapacityType: tier, PricePerHour: price, Available: true, - Region: testRegion, } } @@ -1126,3 +1143,94 @@ func TestProvision_RefusesDiskAboveVolumeCap(t *testing.T) { t.Fatalf("RunInstance called %d times for a refused disk", f.runCnt) } } + +func TestPricePerHour_SpotUsesLivePrice(t *testing.T) { + f := &fakeClient{spotPrices: map[string]float64{"p5.48xlarge": 30.692}} + p := newTestProvider(f) + req := provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: nebulav1alpha1.CapacitySpot, Region: testRegion} + want := 30.692 + data.AWSRootVolumeCostPerHour(awsAMIRootGiB) + + got, err := p.PricePerHour(req) + if err != nil || got != want { + t.Fatalf("PricePerHour(spot) = %v, %v; want the live price + root volume %v, not the 34.41 seed", got, err, want) + } + if _, _ = p.PricePerHour(req); f.spotCalls != 1 { + t.Fatalf("spot lookups = %d after two calls, want 1 (cached)", f.spotCalls) + } + + // Past the TTL the price is read again. + key := spotPriceKey{region: testRegion, instanceType: "p5.48xlarge"} + e := p.spotPrices[key] + e.at = time.Now().Add(-spotPriceTTL - time.Minute) + p.spotPrices[key] = e + f.spotPrices["p5.48xlarge"] = 31.0 + if got, _ := p.PricePerHour(req); f.spotCalls != 2 || got != 31.0+data.AWSRootVolumeCostPerHour(awsAMIRootGiB) { + t.Fatalf("after TTL: price %v, lookups %d; want the refreshed 31.0 from a second lookup", got, f.spotCalls) + } +} + +func TestPricePerHour_SpotFallsBackToSeed(t *testing.T) { + seed := 34.41 + data.AWSRootVolumeCostPerHour(awsAMIRootGiB) + req := provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: nebulav1alpha1.CapacitySpot, Region: testRegion} + + f := &fakeClient{spotErr: errors.New("UnauthorizedOperation")} + p := newTestProvider(f) + if got, err := p.PricePerHour(req); err != nil || got != seed { + t.Fatalf("PricePerHour(lookup fails) = %v, %v; want the seed %v, never an error", got, err, seed) + } + if _, _ = p.PricePerHour(req); f.spotCalls != 1 { + t.Fatalf("spot lookups = %d after two failing calls, want 1 (the failure is cached)", f.spotCalls) + } + + // No live price for the type (e.g. not sold on Spot in the region) is the same fallback. + p = newTestProvider(&fakeClient{spotPrices: map[string]float64{}}) + if got, err := p.PricePerHour(req); err != nil || got != seed { + t.Fatalf("PricePerHour(no live price) = %v, %v; want the seed %v", got, err, seed) + } +} + +func TestPricePerHour_LivePriceOnlyForSpotWithRegion(t *testing.T) { + f := &fakeClient{spotPrices: map[string]float64{"p5.48xlarge": 30.692}} + p := newTestProvider(f) + root := data.AWSRootVolumeCostPerHour(awsAMIRootGiB) + + // Spot with no region: nothing to look up. + got, err := p.PricePerHour(provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: nebulav1alpha1.CapacitySpot}) + if err != nil || got != 34.41+root { + t.Fatalf("PricePerHour(spot, no region) = %v, %v; want the seed %v", got, err, 34.41+root) + } + // On-demand: the catalog's list price, untouched. + got, err = p.PricePerHour(provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: nebulav1alpha1.CapacityOnDemand, Region: testRegion}) + if err != nil || got != 98.32+root { + t.Fatalf("PricePerHour(on-demand) = %v, %v; want the catalog price %v", got, err, 98.32+root) + } + if f.spotCalls != 0 { + t.Fatalf("spot lookups = %d, want 0 for these requests", f.spotCalls) + } +} + +func TestRegionRows(t *testing.T) { + od, spot := nebulav1alpha1.CapacityOnDemand, nebulav1alpha1.CapacitySpot + withRegion := func(o provider.Offering, region string) provider.Offering { o.Region = region; return o } + rows := []provider.Offering{ + offering("H100", "p5.48xlarge", 8, od, 55.040), + offering("H100", "p5.48xlarge", 8, spot, 29.168), + withRegion(offering("H100", "p5.48xlarge", 8, od, 58.893), "eu-north-1"), + withRegion(offering("H100", "p5.48xlarge", 8, od, 71.552), "eu-west-2"), + } + got := regionRows(rows, "eu-north-1") + if len(got) != 2 { + t.Fatalf("regionRows(eu-north-1) = %d rows, want 2 (its own on-demand + the default spot): %+v", len(got), got) + } + for _, o := range got { + switch { + case o.CapacityType == od && (o.Region != "eu-north-1" || o.PricePerHour != 58.893): + t.Errorf("on-demand row = %+v, want eu-north-1's own 58.893, not the default or eu-west-2's", o) + case o.CapacityType == spot && o.Region != "": + t.Errorf("spot row = %+v, want the default (eu-north-1 has none of its own)", o) + } + } + if got := regionRows(rows, "us-east-2"); len(got) != 2 || got[0].Region != "" || got[1].Region != "" { + t.Fatalf("regionRows(us-east-2) = %+v, want just the two defaults", got) + } +} diff --git a/pkg/provider/aws/client.go b/pkg/provider/aws/client.go index 0f51e7f..c88b8da 100644 --- a/pkg/provider/aws/client.go +++ b/pkg/provider/aws/client.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "strconv" "sync" "time" @@ -56,6 +57,11 @@ type ec2API interface { DescribeInstanceTypeOfferings( ctx context.Context, in *ec2.DescribeInstanceTypeOfferingsInput, optFns ...func(*ec2.Options), ) (*ec2.DescribeInstanceTypeOfferingsOutput, error) + // DescribeSpotPriceHistory reads the current Spot price per AZ (see SpotPrice). + // Needs the ec2:DescribeSpotPriceHistory IAM permission. + DescribeSpotPriceHistory( + ctx context.Context, in *ec2.DescribeSpotPriceHistoryInput, optFns ...func(*ec2.Options), + ) (*ec2.DescribeSpotPriceHistoryOutput, error) // DescribeImages resolves the region's GPU AMI at construction (see // resolveGPUAMI); DescribeSubnets discovers the default VPC's per-AZ subnets // RunInstance fails over across (see discoverDefaultSubnets). @@ -933,6 +939,52 @@ func (c *sdkClient) AvailableInstanceTypes(ctx context.Context) (map[string]bool return out, nil } +// spotProductDescription is the only Linux product Spot prices are published under since +// EC2-Classic was retired (August 2023); the plain "Linux/UNIX" variant priced Classic. +const spotProductDescription = "Linux/UNIX (Amazon VPC)" + +// errNoSpotPrice reports that the region publishes no current Spot price for a type, e.g. one +// it does not offer on Spot. The caller falls back to the catalog's seed price. +var errNoSpotPrice = errors.New("no spot price") + +// SpotPrice implements Client via DescribeSpotPriceHistory. A StartTime of now returns the +// price in effect in each AZ; the lowest is returned, matching the fleet's lowest-price Spot +// allocation. +func (c *sdkClient) SpotPrice(ctx context.Context, instanceType string) (float64, error) { + in := &ec2.DescribeSpotPriceHistoryInput{ + InstanceTypes: []ec2types.InstanceType{ec2types.InstanceType(instanceType)}, + ProductDescriptions: []string{spotProductDescription}, + StartTime: awssdk.Time(time.Now()), + } + lowest := -1.0 + for { + page, err := c.ec2.DescribeSpotPriceHistory(ctx, in) + if err != nil { + return 0, err + } + for _, h := range page.SpotPriceHistory { + if h.SpotPrice == nil { + continue + } + price, err := strconv.ParseFloat(*h.SpotPrice, 64) + if err != nil || price <= 0 { + continue + } + if lowest < 0 || price < lowest { + lowest = price + } + } + if page.NextToken == nil || *page.NextToken == "" { + break + } + in.NextToken = page.NextToken + } + if lowest < 0 { + return 0, fmt.Errorf("aws: %s in %s: %w", instanceType, c.region, errNoSpotPrice) + } + return lowest, nil +} + // instanceTypeAZs returns, for each of the requested types, the set of AZs in this // region that offer it — used by RunInstance to prune fleet overrides targeting an // AZ that does not offer the type. Offerings are stable per region, so a resolved diff --git a/pkg/provider/aws/client_test.go b/pkg/provider/aws/client_test.go index ac340a6..76625df 100644 --- a/pkg/provider/aws/client_test.go +++ b/pkg/provider/aws/client_test.go @@ -59,6 +59,13 @@ type fakeEC2 struct { // assert which instance ids were queried. lastStatusIn *ec2.DescribeInstanceStatusInput + // spotPages seeds DescribeSpotPriceHistory; spotErr injects a failure; lastSpotIn + // records the last input so a test can assert the filters. + spotPages []*ec2.DescribeSpotPriceHistoryOutput + spotErr error + spotIdx int + lastSpotIn *ec2.DescribeSpotPriceHistoryInput + offeringPages []*ec2.DescribeInstanceTypeOfferingsOutput offeringErr error offeringIdx int @@ -235,6 +242,21 @@ func (f *fakeEC2) DescribeInstanceTypeOfferings( return page, nil } +func (f *fakeEC2) DescribeSpotPriceHistory( + _ context.Context, in *ec2.DescribeSpotPriceHistoryInput, _ ...func(*ec2.Options), +) (*ec2.DescribeSpotPriceHistoryOutput, error) { + f.lastSpotIn = in + if f.spotErr != nil { + return nil, f.spotErr + } + if f.spotIdx >= len(f.spotPages) { + return &ec2.DescribeSpotPriceHistoryOutput{}, nil + } + page := f.spotPages[f.spotIdx] + f.spotIdx++ + return page, nil +} + func apiErr(code string) error { return &smithy.GenericAPIError{Code: code, Message: code} } @@ -1311,3 +1333,46 @@ func TestSDKRunInstance_SizesRootVolume(t *testing.T) { }) } } + +func TestSpotPrice_LowestAcrossAZs(t *testing.T) { + spot := func(az, price string) ec2types.SpotPrice { + return ec2types.SpotPrice{AvailabilityZone: awssdk.String(az), SpotPrice: awssdk.String(price)} + } + f := &fakeEC2{spotPages: []*ec2.DescribeSpotPriceHistoryOutput{ + { + SpotPriceHistory: []ec2types.SpotPrice{spot("us-east-1a", "31.9467"), {AvailabilityZone: awssdk.String("us-east-1b")}}, + NextToken: awssdk.String("page-2"), + }, + {SpotPriceHistory: []ec2types.SpotPrice{spot("us-east-1c", "29.168"), spot("us-east-1d", "37.378")}}, + }} + c := &sdkClient{ec2: f, region: testRegion} + + got, err := c.SpotPrice(context.Background(), "p5.48xlarge") + if err != nil || got != 29.168 { + t.Fatalf("SpotPrice = %v, %v; want the lowest AZ price 29.168 across both pages", got, err) + } + in := f.lastSpotIn + if !reflect.DeepEqual(in.InstanceTypes, []ec2types.InstanceType{"p5.48xlarge"}) { + t.Errorf("InstanceTypes = %v, want only p5.48xlarge", in.InstanceTypes) + } + if !reflect.DeepEqual(in.ProductDescriptions, []string{spotProductDescription}) { + t.Errorf("ProductDescriptions = %v, want %q", in.ProductDescriptions, spotProductDescription) + } + if in.StartTime == nil { + t.Error("StartTime unset: without it AWS returns the whole history, not the current price") + } +} + +func TestSpotPrice_NoPrice(t *testing.T) { + c := &sdkClient{ec2: &fakeEC2{}, region: testRegion} + if _, err := c.SpotPrice(context.Background(), "p5.48xlarge"); !errors.Is(err, errNoSpotPrice) { + t.Fatalf("SpotPrice(no history) err = %v, want errNoSpotPrice", err) + } +} + +func TestSpotPrice_APIError(t *testing.T) { + c := &sdkClient{ec2: &fakeEC2{spotErr: apiErr("UnauthorizedOperation")}, region: testRegion} + if _, err := c.SpotPrice(context.Background(), "p5.48xlarge"); err == nil { + t.Fatal("SpotPrice swallowed the API error") + } +} diff --git a/pkg/provider/catalog/base.go b/pkg/provider/catalog/base.go index 6abafa6..8328631 100644 --- a/pkg/provider/catalog/base.go +++ b/pkg/provider/catalog/base.go @@ -132,9 +132,13 @@ func (b Base) MapAccelerator(canonical string, count int32) (providerAccelerator // adds them. // // Rows match as MapAccelerator matches them, plus capacity type: the one dimension -// MapAccelerator can ignore and pricing cannot (AWS p5.48xlarge is $20.839 Spot against -// $55.040 OnDemand). Among interchangeable alternates the FIRST row wins, so the price -// describes the id a launch actually tries first. +// MapAccelerator can ignore and pricing cannot (AWS p5.48xlarge is about $29.17 Spot +// against $55.040 OnDemand in us-east-1). Among interchangeable alternates the FIRST row +// wins, so the price describes the id a launch actually tries first. +// +// Region: a row priced for the request's region wins over a blank-region row, which is the +// default for every region without one of its own. A row for another region never matches. +// Providers whose rows all have a blank region (Modal, RunPod) price as before. func (b Base) PricePerHour(req provider.PriceRequest) (float64, error) { // A CPU-only request. No row can match an empty type, so this is the same ErrNoPrice the // loop below would reach; it is stated here for a clearer message, and because it MUST @@ -156,7 +160,10 @@ func (b Base) PricePerHour(req provider.PriceRequest) (float64, error) { tier = nebulav1alpha1.CapacityOnDemand } - for _, o := range b.Catalog.Offerings(b.ProviderName) { + var regional, fallback *provider.Offering + offerings := b.Catalog.Offerings(b.ProviderName) + for i := range offerings { + o := &offerings[i] if !strings.EqualFold(o.AcceleratorType, req.AcceleratorType) || !o.Available { continue } @@ -170,14 +177,30 @@ func (b Base) PricePerHour(req provider.PriceRequest) (float64, error) { if o.PricePerHour <= 0 { continue } - rate := o.PricePerHour - // GPUCount 0 => the row prices ONE accelerator and the count is a runtime knob, so - // the rate scales with it. A row carrying a count prices the whole offering already. - if o.GPUCount == 0 { - rate *= float64(req.Count) + switch { + case o.Region == "": + if fallback == nil { + fallback = o + } + case req.Region != "" && o.Region == req.Region: + if regional == nil { + regional = o + } } - return rate, nil } - return 0, fmt.Errorf("catalog: %s has no available %s x%d row on %s: %w", - b.ProviderName, req.AcceleratorType, req.Count, tier, provider.ErrNoPrice) + row := regional + if row == nil { + row = fallback + } + if row == nil { + return 0, fmt.Errorf("catalog: %s has no available %s x%d row on %s: %w", + b.ProviderName, req.AcceleratorType, req.Count, tier, provider.ErrNoPrice) + } + rate := row.PricePerHour + // GPUCount 0 => the row prices ONE accelerator and the count is a runtime knob, so the + // rate scales with it. A row carrying a count prices the whole offering already. + if row.GPUCount == 0 { + rate *= float64(req.Count) + } + return rate, nil } diff --git a/pkg/provider/catalog/catalog_test.go b/pkg/provider/catalog/catalog_test.go index b94cb3b..136da62 100644 --- a/pkg/provider/catalog/catalog_test.go +++ b/pkg/provider/catalog/catalog_test.go @@ -21,6 +21,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "testing" nebulav1alpha1 "github.com/InftyAI/Nebula/api/v1alpha1" @@ -72,13 +73,31 @@ func TestLoad_EmbeddedAWS(t *testing.T) { } // AWS requests by instance type, so every row must carry an accelerator_id (the - // EC2 instance type). The embedded catalog is REGION-LESS — region is stamped by - // the adapter's live probe, not hand-maintained here — so rows have no region. - // Both capacity tiers appear. + // EC2 instance type). Blank-region rows are the defaults; a row with a region prices + // that region only, so every regional offering needs a default for the regions + // without one. Both capacity tiers appear. + type offeringKey struct { + id string + count int32 + tier nebulav1alpha1.CapacityType + } + defaults := map[offeringKey]bool{} + for _, o := range offs { + if o.Region == "" { + defaults[offeringKey{o.AcceleratorID, o.GPUCount, o.CapacityType}] = true + } + } + regionName := regexp.MustCompile(`^[a-z]{2}-[a-z]+-[0-9]$`) var haveSpot, haveOnDemand bool for _, o := range offs { if o.Region != "" { - t.Fatalf("embedded aws catalog must be region-less, got region %q for %q", o.Region, o.AcceleratorType) + if !regionName.MatchString(o.Region) { + t.Fatalf("aws row %s has malformed region %q", o.AcceleratorID, o.Region) + } + if !defaults[offeringKey{o.AcceleratorID, o.GPUCount, o.CapacityType}] { + t.Fatalf("aws row %s x%d %s in %s has no default (blank-region) row", + o.AcceleratorID, o.GPUCount, o.CapacityType, o.Region) + } } if o.AcceleratorID == "" { t.Fatalf("aws offering %q has no accelerator_id (EC2 instance type)", o.AcceleratorType) @@ -406,6 +425,44 @@ func TestBasePricePerHour_EmbeddedCatalog(t *testing.T) { if got != 55.040 { t.Fatalf("aws H100 x8 = %v, want 55.040 (whole-instance rate, unscaled)", got) } + + // A region with rows of its own is priced from them; one without uses the default. + got, err = aws.PricePerHour(provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: od, Region: "eu-north-1"}) + if err != nil || got != 58.893 { + t.Fatalf("aws H100 x8 in eu-north-1 = %v, %v; want its own row, 58.893", got, err) + } + got, err = aws.PricePerHour(provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: od, Region: "il-central-1"}) + if err != nil || got != 55.040 { + t.Fatalf("aws H100 x8 in a region without rows = %v, %v; want the default 55.040", got, err) + } +} + +func TestBasePricePerHour_RegionPreference(t *testing.T) { + od := nebulav1alpha1.CapacityOnDemand + row := func(region string, price float64) provider.Offering { + return provider.Offering{AcceleratorType: "H100", AcceleratorID: "p5.48xlarge", GPUCount: 8, + CapacityType: od, PricePerHour: price, Available: true, Region: region} + } + b := Base{ProviderName: "aws", Catalog: fakeLookup{rows: []provider.Offering{ + row("eu-west-2", 71.552), row("", 55.040), row("eu-north-1", 58.893), + }}} + for region, want := range map[string]float64{ + "eu-north-1": 58.893, // its own row, though listed after the default + "eu-west-2": 71.552, + "us-east-2": 55.040, // no row of its own: the default + "": 55.040, // no region: the default, never another region's row + } { + got, err := b.PricePerHour(provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: od, Region: region}) + if err != nil || got != want { + t.Errorf("PricePerHour(region %q) = %v, %v; want %v", region, got, err, want) + } + } + + // Only other regions' rows: no price, rather than borrowing one. + b.Catalog = fakeLookup{rows: []provider.Offering{row("eu-west-2", 71.552)}} + if _, err := b.PricePerHour(provider.PriceRequest{AcceleratorType: "H100", Count: 8, CapacityType: od, Region: "us-east-2"}); !errors.Is(err, provider.ErrNoPrice) { + t.Errorf("PricePerHour(only another region's row) err = %v, want ErrNoPrice", err) + } } func TestOfferings_CopyIsolation(t *testing.T) { diff --git a/pkg/provider/catalog/data/aws.csv b/pkg/provider/catalog/data/aws.csv index 8ffafd4..98499d1 100644 --- a/pkg/provider/catalog/data/aws.csv +++ b/pkg/provider/catalog/data/aws.csv @@ -7,52 +7,373 @@ # serves that pair, and the AWS adapter selects it by matching BOTH the canonical # accelerator and the Pod's requested GPU count. # -# The `region` column is left BLANK here on purpose. This file holds only the -# durable facts — the (accelerator, count) -> instance-type mapping and a seed -# on-demand price — one row per {accelerator, count, capacity_type}. Region truth -# (which types a region actually offers, and live spot prices) is NOT -# hand-maintained: AWS exposes it at runtime, so the adapter's Offerings(ctx) -# probes DescribeInstanceTypeOfferings per configured region and stamps/filters -# these rows. That keeps this file small and stops per-region availability from -# drifting out of date. +# Rows with a BLANK region are the defaults, priced at us-east-1 rates: the +# (accelerator, count) -> instance-type mapping, plus a price for any region +# without rows of its own. Rows with a region price that region; PricePerHour +# prefers them over the default. Which types a region offers is not +# hand-maintained: the adapter's Offerings(ctx) probes +# DescribeInstanceTypeOfferings per configured region and stamps/filters these +# rows. # -# On-demand prices are approximate USD per INSTANCE-hour transcribed by hand from -# https://aws.amazon.com/ec2/pricing/on-demand/ (us-east-1 rates as the seed); -# treat them as a starting point, not a billing source. Spot prices vary by -# region and minute and are left to the live probe (the seed spot rows carry a -# recent us-east-1 typical rate only as a fallback). +# On-demand prices are USD per INSTANCE-hour (Linux), from AWS's published +# on-demand price list (https://aws.amazon.com/ec2/pricing/on-demand/); treat +# them as a starting point, not a billing source. Spot prices vary by region, AZ +# and hour, so PricePerHour prices a Spot claim at its region's live price (the +# lowest across AZs, cached for an hour). The Spot rows here are the same figure, +# the lowest AZ price in that region on the `updated` date, used only when the +# live price cannot be read. # # Columns (shared header across all provider CSVs; unused cells left blank): # accelerator_type canonical Nebula accelerator type # accelerator_id EC2 instance type that serves {accelerator_type, gpu_count} # gpu_count accelerators the instance type provides (the lookup key) # capacity_type Spot | OnDemand -# price_per_hour approximate USD/instance-hour (seed; live probe may override) -# available seed availability (the live probe is authoritative per region) -# region BLANK — stamped per configured region by the live probe +# price_per_hour approximate USD/instance-hour (Spot: fallback for the live price) +# available seed availability (the per-region probe is authoritative) +# region the AWS region this row prices; BLANK = default for any region # updated YYYY-MM-DD the row was last verified accelerator_type,accelerator_id,gpu_count,capacity_type,price_per_hour,available,region,updated -T4,g4dn.xlarge,1,OnDemand,0.526,true,,2026-09-30 -T4,g4dn.xlarge,1,Spot,0.274,true,,2026-09-30 -T4,g4dn.12xlarge,4,OnDemand,3.912,true,,2026-09-30 -T4,g4dn.12xlarge,4,Spot,1.561,true,,2026-09-30 -T4,g4dn.metal,8,OnDemand,7.824,true,,2026-09-30 -T4,g4dn.metal,8,Spot,5.683,true,,2026-09-30 -A10G,g5.xlarge,1,OnDemand,1.006,true,,2026-09-30 -A10G,g5.xlarge,1,Spot,0.466,true,,2026-09-30 -A10G,g5.12xlarge,4,OnDemand,5.672,true,,2026-09-30 -A10G,g5.12xlarge,4,Spot,4.576,true,,2026-09-30 -A10G,g5.48xlarge,8,OnDemand,16.288,true,,2026-09-30 -A10G,g5.48xlarge,8,Spot,8.402,true,,2026-09-30 -L4,g6.xlarge,1,OnDemand,0.805,true,,2026-09-30 -L4,g6.xlarge,1,Spot,0.601,true,,2026-09-30 -L4,g6.12xlarge,4,OnDemand,4.602,true,,2026-09-30 -L4,g6.12xlarge,4,Spot,3.300,true,,2026-09-30 -L4,g6.48xlarge,8,OnDemand,13.350,true,,2026-09-30 -L4,g6.48xlarge,8,Spot,8.292,true,,2026-09-30 -A100-40GB,p4d.24xlarge,8,OnDemand,21.958,true,,2026-09-30 -A100-40GB,p4d.24xlarge,8,Spot,17.861,true,,2026-09-30 -A100-80GB,p4de.24xlarge,8,OnDemand,27.447,true,,2026-09-30 -A100-80GB,p4de.24xlarge,8,Spot,21.514,true,,2026-09-30 -H100,p5.48xlarge,8,OnDemand,55.040,true,,2026-09-30 -H100,p5.48xlarge,8,Spot,20.839,true,,2026-09-30 +T4,g4dn.xlarge,1,OnDemand,0.526,true,,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.297,true,,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,3.912,true,,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.594,true,,2026-10-19 +T4,g4dn.metal,8,OnDemand,7.824,true,,2026-10-19 +T4,g4dn.metal,8,Spot,4.658,true,,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.006,true,,2026-10-19 +A10G,g5.xlarge,1,Spot,0.439,true,,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,5.672,true,,2026-10-19 +A10G,g5.12xlarge,4,Spot,2.471,true,,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,16.288,true,,2026-10-19 +A10G,g5.48xlarge,8,Spot,4.640,true,,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.805,true,,2026-10-19 +L4,g6.xlarge,1,Spot,0.487,true,,2026-10-19 +L4,g6.12xlarge,4,OnDemand,4.602,true,,2026-10-19 +L4,g6.12xlarge,4,Spot,1.445,true,,2026-10-19 +L4,g6.48xlarge,8,OnDemand,13.350,true,,2026-10-19 +L4,g6.48xlarge,8,Spot,4.435,true,,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,21.958,true,,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,16.721,true,,2026-10-19 +A100-80GB,p4de.24xlarge,8,OnDemand,27.447,true,,2026-10-19 +A100-80GB,p4de.24xlarge,8,Spot,21.387,true,,2026-10-19 +H100,p5.48xlarge,8,OnDemand,55.040,true,,2026-10-19 +H100,p5.48xlarge,8,Spot,29.168,true,,2026-10-19 + +# Per-region rows. A region without a row for an offering uses the default +# (blank-region, us-east-1) row above. +# us-east-2 +T4,g4dn.xlarge,1,OnDemand,0.526,true,us-east-2,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.283,true,us-east-2,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,3.912,true,us-east-2,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.948,true,us-east-2,2026-10-19 +T4,g4dn.metal,8,OnDemand,7.824,true,us-east-2,2026-10-19 +T4,g4dn.metal,8,Spot,4.066,true,us-east-2,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.006,true,us-east-2,2026-10-19 +A10G,g5.xlarge,1,Spot,0.542,true,us-east-2,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,5.672,true,us-east-2,2026-10-19 +A10G,g5.12xlarge,4,Spot,2.901,true,us-east-2,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,16.288,true,us-east-2,2026-10-19 +A10G,g5.48xlarge,8,Spot,7.418,true,us-east-2,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.805,true,us-east-2,2026-10-19 +L4,g6.xlarge,1,Spot,0.414,true,us-east-2,2026-10-19 +L4,g6.12xlarge,4,OnDemand,4.602,true,us-east-2,2026-10-19 +L4,g6.12xlarge,4,Spot,2.469,true,us-east-2,2026-10-19 +L4,g6.48xlarge,8,OnDemand,13.350,true,us-east-2,2026-10-19 +L4,g6.48xlarge,8,Spot,6.181,true,us-east-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,21.958,true,us-east-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,4.104,true,us-east-2,2026-10-19 +H100,p5.48xlarge,8,OnDemand,55.040,true,us-east-2,2026-10-19 +H100,p5.48xlarge,8,Spot,22.768,true,us-east-2,2026-10-19 +# us-west-1 +T4,g4dn.xlarge,1,OnDemand,0.631,true,us-west-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.295,true,us-west-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.694,true,us-west-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,2.240,true,us-west-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,9.389,true,us-west-1,2026-10-19 +T4,g4dn.metal,8,Spot,2.015,true,us-west-1,2026-10-19 +H100,p5.48xlarge,8,OnDemand,68.800,true,us-west-1,2026-10-19 +H100,p5.48xlarge,8,Spot,23.705,true,us-west-1,2026-10-19 +# us-west-2 +T4,g4dn.xlarge,1,OnDemand,0.526,true,us-west-2,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.239,true,us-west-2,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,3.912,true,us-west-2,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.555,true,us-west-2,2026-10-19 +T4,g4dn.metal,8,OnDemand,7.824,true,us-west-2,2026-10-19 +T4,g4dn.metal,8,Spot,4.548,true,us-west-2,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.006,true,us-west-2,2026-10-19 +A10G,g5.xlarge,1,Spot,0.505,true,us-west-2,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,5.672,true,us-west-2,2026-10-19 +A10G,g5.12xlarge,4,Spot,1.878,true,us-west-2,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,16.288,true,us-west-2,2026-10-19 +A10G,g5.48xlarge,8,Spot,4.617,true,us-west-2,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.805,true,us-west-2,2026-10-19 +L4,g6.xlarge,1,Spot,0.414,true,us-west-2,2026-10-19 +L4,g6.12xlarge,4,OnDemand,4.602,true,us-west-2,2026-10-19 +L4,g6.12xlarge,4,Spot,3.012,true,us-west-2,2026-10-19 +L4,g6.48xlarge,8,OnDemand,13.350,true,us-west-2,2026-10-19 +L4,g6.48xlarge,8,Spot,5.747,true,us-west-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,21.958,true,us-west-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,12.266,true,us-west-2,2026-10-19 +A100-80GB,p4de.24xlarge,8,OnDemand,27.447,true,us-west-2,2026-10-19 +A100-80GB,p4de.24xlarge,8,Spot,15.849,true,us-west-2,2026-10-19 +H100,p5.48xlarge,8,OnDemand,55.040,true,us-west-2,2026-10-19 +H100,p5.48xlarge,8,Spot,28.707,true,us-west-2,2026-10-19 +# ca-central-1 +T4,g4dn.xlarge,1,OnDemand,0.584,true,ca-central-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.226,true,ca-central-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.343,true,ca-central-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.618,true,ca-central-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,8.687,true,ca-central-1,2026-10-19 +T4,g4dn.metal,8,Spot,4.460,true,ca-central-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.117,true,ca-central-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.399,true,ca-central-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,6.298,true,ca-central-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,2.029,true,ca-central-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,18.085,true,ca-central-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,18.085,true,ca-central-1,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.894,true,ca-central-1,2026-10-19 +L4,g6.xlarge,1,Spot,0.181,true,ca-central-1,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.109,true,ca-central-1,2026-10-19 +L4,g6.12xlarge,4,Spot,5.109,true,ca-central-1,2026-10-19 +L4,g6.48xlarge,8,OnDemand,14.823,true,ca-central-1,2026-10-19 +L4,g6.48xlarge,8,Spot,14.823,true,ca-central-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,25.252,true,ca-central-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,11.110,true,ca-central-1,2026-10-19 +H100,p5.48xlarge,8,OnDemand,63.296,true,ca-central-1,2026-10-19 +H100,p5.48xlarge,8,Spot,17.672,true,ca-central-1,2026-10-19 +# sa-east-1 +T4,g4dn.xlarge,1,OnDemand,0.894,true,sa-east-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.089,true,sa-east-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,6.649,true,sa-east-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.579,true,sa-east-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,13.298,true,sa-east-1,2026-10-19 +T4,g4dn.metal,8,Spot,1.330,true,sa-east-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.710,true,sa-east-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.365,true,sa-east-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,9.641,true,sa-east-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,8.036,true,sa-east-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,27.686,true,sa-east-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,23.064,true,sa-east-1,2026-10-19 +L4,g6.xlarge,1,OnDemand,1.368,true,sa-east-1,2026-10-19 +L4,g6.xlarge,1,Spot,0.243,true,sa-east-1,2026-10-19 +L4,g6.12xlarge,4,OnDemand,7.822,true,sa-east-1,2026-10-19 +L4,g6.12xlarge,4,Spot,0.995,true,sa-east-1,2026-10-19 +L4,g6.48xlarge,8,OnDemand,22.693,true,sa-east-1,2026-10-19 +L4,g6.48xlarge,8,Spot,3.280,true,sa-east-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,35.557,true,sa-east-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,29.621,true,sa-east-1,2026-10-19 +H100,p5.48xlarge,8,OnDemand,92.467,true,sa-east-1,2026-10-19 +H100,p5.48xlarge,8,Spot,50.433,true,sa-east-1,2026-10-19 +# eu-central-1 +T4,g4dn.xlarge,1,OnDemand,0.658,true,eu-central-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.320,true,eu-central-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.890,true,eu-central-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,2.021,true,eu-central-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,9.780,true,eu-central-1,2026-10-19 +T4,g4dn.metal,8,Spot,4.446,true,eu-central-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.258,true,eu-central-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.594,true,eu-central-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,7.093,true,eu-central-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,2.108,true,eu-central-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,20.368,true,eu-central-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,20.368,true,eu-central-1,2026-10-19 +L4,g6.xlarge,1,OnDemand,1.006,true,eu-central-1,2026-10-19 +L4,g6.xlarge,1,Spot,0.469,true,eu-central-1,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.754,true,eu-central-1,2026-10-19 +L4,g6.12xlarge,4,Spot,2.835,true,eu-central-1,2026-10-19 +L4,g6.48xlarge,8,OnDemand,16.695,true,eu-central-1,2026-10-19 +L4,g6.48xlarge,8,Spot,4.149,true,eu-central-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,27.433,true,eu-central-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,15.729,true,eu-central-1,2026-10-19 +A100-80GB,p4de.24xlarge,8,OnDemand,34.291,true,eu-central-1,2026-10-19 +A100-80GB,p4de.24xlarge,8,Spot,12.976,true,eu-central-1,2026-10-19 +# eu-north-1 +T4,g4dn.xlarge,1,OnDemand,0.558,true,eu-north-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.077,true,eu-north-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.150,true,eu-north-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,0.645,true,eu-north-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,8.300,true,eu-north-1,2026-10-19 +T4,g4dn.metal,8,Spot,1.762,true,eu-north-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.067,true,eu-north-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.265,true,eu-north-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,6.016,true,eu-north-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,1.190,true,eu-north-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,17.276,true,eu-north-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,3.299,true,eu-north-1,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.854,true,eu-north-1,2026-10-19 +L4,g6.xlarge,1,Spot,0.150,true,eu-north-1,2026-10-19 +L4,g6.12xlarge,4,OnDemand,4.881,true,eu-north-1,2026-10-19 +L4,g6.12xlarge,4,Spot,1.335,true,eu-north-1,2026-10-19 +L4,g6.48xlarge,8,OnDemand,14.160,true,eu-north-1,2026-10-19 +L4,g6.48xlarge,8,Spot,2.545,true,eu-north-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,23.716,true,eu-north-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,7.810,true,eu-north-1,2026-10-19 +H100,p5.48xlarge,8,OnDemand,58.893,true,eu-north-1,2026-10-19 +H100,p5.48xlarge,8,Spot,38.691,true,eu-north-1,2026-10-19 +# eu-west-1 +T4,g4dn.xlarge,1,OnDemand,0.587,true,eu-west-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.337,true,eu-west-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.362,true,eu-west-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.899,true,eu-west-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,8.724,true,eu-west-1,2026-10-19 +T4,g4dn.metal,8,Spot,4.323,true,eu-west-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.123,true,eu-west-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.622,true,eu-west-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,6.332,true,eu-west-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,2.603,true,eu-west-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,18.182,true,eu-west-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,4.447,true,eu-west-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,23.716,true,eu-west-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,15.787,true,eu-west-1,2026-10-19 +# eu-west-2 +T4,g4dn.xlarge,1,OnDemand,0.615,true,eu-west-2,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.191,true,eu-west-2,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.577,true,eu-west-2,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.581,true,eu-west-2,2026-10-19 +T4,g4dn.metal,8,OnDemand,9.154,true,eu-west-2,2026-10-19 +T4,g4dn.metal,8,Spot,1.260,true,eu-west-2,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.277,true,eu-west-2,2026-10-19 +A10G,g5.xlarge,1,Spot,0.588,true,eu-west-2,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,7.200,true,eu-west-2,2026-10-19 +A10G,g5.12xlarge,4,Spot,4.100,true,eu-west-2,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,20.676,true,eu-west-2,2026-10-19 +A10G,g5.48xlarge,8,Spot,20.676,true,eu-west-2,2026-10-19 +L4,g6.xlarge,1,OnDemand,1.022,true,eu-west-2,2026-10-19 +L4,g6.xlarge,1,Spot,0.369,true,eu-west-2,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.841,true,eu-west-2,2026-10-19 +L4,g6.12xlarge,4,Spot,2.547,true,eu-west-2,2026-10-19 +L4,g6.48xlarge,8,OnDemand,16.947,true,eu-west-2,2026-10-19 +L4,g6.48xlarge,8,Spot,4.683,true,eu-west-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,28.545,true,eu-west-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,11.194,true,eu-west-2,2026-10-19 +H100,p5.48xlarge,8,OnDemand,71.552,true,eu-west-2,2026-10-19 +H100,p5.48xlarge,8,Spot,20.340,true,eu-west-2,2026-10-19 +# eu-west-3 +T4,g4dn.xlarge,1,OnDemand,0.615,true,eu-west-3,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.188,true,eu-west-3,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.574,true,eu-west-3,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.339,true,eu-west-3,2026-10-19 +T4,g4dn.metal,8,OnDemand,9.148,true,eu-west-3,2026-10-19 +T4,g4dn.metal,8,Spot,3.333,true,eu-west-3,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.277,true,eu-west-3,2026-10-19 +A10G,g5.xlarge,1,Spot,0.392,true,eu-west-3,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,7.200,true,eu-west-3,2026-10-19 +A10G,g5.12xlarge,4,Spot,3.864,true,eu-west-3,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,20.676,true,eu-west-3,2026-10-19 +A10G,g5.48xlarge,8,Spot,20.676,true,eu-west-3,2026-10-19 +L4,g6.xlarge,1,OnDemand,1.022,true,eu-west-3,2026-10-19 +L4,g6.xlarge,1,Spot,0.355,true,eu-west-3,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.841,true,eu-west-3,2026-10-19 +L4,g6.12xlarge,4,Spot,1.819,true,eu-west-3,2026-10-19 +L4,g6.48xlarge,8,OnDemand,16.947,true,eu-west-3,2026-10-19 +L4,g6.48xlarge,8,Spot,5.268,true,eu-west-3,2026-10-19 +# ap-northeast-1 +T4,g4dn.xlarge,1,OnDemand,0.710,true,ap-northeast-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.342,true,ap-northeast-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,5.281,true,ap-northeast-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,2.631,true,ap-northeast-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,10.562,true,ap-northeast-1,2026-10-19 +T4,g4dn.metal,8,Spot,5.407,true,ap-northeast-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.459,true,ap-northeast-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.708,true,ap-northeast-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,8.226,true,ap-northeast-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,5.041,true,ap-northeast-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,23.622,true,ap-northeast-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,21.260,true,ap-northeast-1,2026-10-19 +L4,g6.xlarge,1,OnDemand,1.167,true,ap-northeast-1,2026-10-19 +L4,g6.xlarge,1,Spot,0.563,true,ap-northeast-1,2026-10-19 +L4,g6.12xlarge,4,OnDemand,6.674,true,ap-northeast-1,2026-10-19 +L4,g6.12xlarge,4,Spot,3.071,true,ap-northeast-1,2026-10-19 +L4,g6.48xlarge,8,OnDemand,19.362,true,ap-northeast-1,2026-10-19 +L4,g6.48xlarge,8,Spot,8.765,true,ap-northeast-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,30.098,true,ap-northeast-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,7.065,true,ap-northeast-1,2026-10-19 +A100-80GB,p4de.24xlarge,8,OnDemand,37.622,true,ap-northeast-1,2026-10-19 +A100-80GB,p4de.24xlarge,8,Spot,14.447,true,ap-northeast-1,2026-10-19 +H100,p5.48xlarge,8,OnDemand,68.800,true,ap-northeast-1,2026-10-19 +H100,p5.48xlarge,8,Spot,25.740,true,ap-northeast-1,2026-10-19 +# ap-northeast-2 +T4,g4dn.xlarge,1,OnDemand,0.647,true,ap-northeast-2,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.316,true,ap-northeast-2,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.812,true,ap-northeast-2,2026-10-19 +T4,g4dn.12xlarge,4,Spot,2.454,true,ap-northeast-2,2026-10-19 +T4,g4dn.metal,8,OnDemand,9.624,true,ap-northeast-2,2026-10-19 +T4,g4dn.metal,8,Spot,4.331,true,ap-northeast-2,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.237,true,ap-northeast-2,2026-10-19 +A10G,g5.xlarge,1,Spot,0.567,true,ap-northeast-2,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,6.974,true,ap-northeast-2,2026-10-19 +A10G,g5.12xlarge,4,Spot,3.498,true,ap-northeast-2,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,20.028,true,ap-northeast-2,2026-10-19 +A10G,g5.48xlarge,8,Spot,9.492,true,ap-northeast-2,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.990,true,ap-northeast-2,2026-10-19 +L4,g6.xlarge,1,Spot,0.456,true,ap-northeast-2,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.658,true,ap-northeast-2,2026-10-19 +L4,g6.12xlarge,4,Spot,2.581,true,ap-northeast-2,2026-10-19 +L4,g6.48xlarge,8,OnDemand,16.416,true,ap-northeast-2,2026-10-19 +L4,g6.48xlarge,8,Spot,7.420,true,ap-northeast-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,30.410,true,ap-northeast-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,8.978,true,ap-northeast-2,2026-10-19 +H100,p5.48xlarge,8,OnDemand,75.955,true,ap-northeast-2,2026-10-19 +H100,p5.48xlarge,8,Spot,62.219,true,ap-northeast-2,2026-10-19 +# ap-northeast-3 +T4,g4dn.xlarge,1,OnDemand,0.710,true,ap-northeast-3,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.197,true,ap-northeast-3,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,5.281,true,ap-northeast-3,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.389,true,ap-northeast-3,2026-10-19 +T4,g4dn.metal,8,OnDemand,10.562,true,ap-northeast-3,2026-10-19 +T4,g4dn.metal,8,Spot,4.026,true,ap-northeast-3,2026-10-19 +# ap-south-1 +T4,g4dn.xlarge,1,OnDemand,0.579,true,ap-south-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.213,true,ap-south-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,4.306,true,ap-south-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.770,true,ap-south-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,8.612,true,ap-south-1,2026-10-19 +T4,g4dn.metal,8,Spot,2.677,true,ap-south-1,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.208,true,ap-south-1,2026-10-19 +A10G,g5.xlarge,1,Spot,0.555,true,ap-south-1,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,6.811,true,ap-south-1,2026-10-19 +A10G,g5.12xlarge,4,Spot,1.987,true,ap-south-1,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,19.559,true,ap-south-1,2026-10-19 +A10G,g5.48xlarge,8,Spot,8.952,true,ap-south-1,2026-10-19 +L4,g6.xlarge,1,OnDemand,0.966,true,ap-south-1,2026-10-19 +L4,g6.xlarge,1,Spot,0.528,true,ap-south-1,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.526,true,ap-south-1,2026-10-19 +L4,g6.12xlarge,4,Spot,2.369,true,ap-south-1,2026-10-19 +L4,g6.48xlarge,8,OnDemand,16.031,true,ap-south-1,2026-10-19 +L4,g6.48xlarge,8,Spot,16.031,true,ap-south-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,26.344,true,ap-south-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,26.344,true,ap-south-1,2026-10-19 +H100,p5.48xlarge,8,OnDemand,66.048,true,ap-south-1,2026-10-19 +H100,p5.48xlarge,8,Spot,21.223,true,ap-south-1,2026-10-19 +# ap-southeast-1 +T4,g4dn.xlarge,1,OnDemand,0.736,true,ap-southeast-1,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.316,true,ap-southeast-1,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,5.474,true,ap-southeast-1,2026-10-19 +T4,g4dn.12xlarge,4,Spot,1.716,true,ap-southeast-1,2026-10-19 +T4,g4dn.metal,8,OnDemand,10.948,true,ap-southeast-1,2026-10-19 +T4,g4dn.metal,8,Spot,3.313,true,ap-southeast-1,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,26.349,true,ap-southeast-1,2026-10-19 +A100-80GB,p4de.24xlarge,8,OnDemand,32.936,true,ap-southeast-1,2026-10-19 +A100-80GB,p4de.24xlarge,8,Spot,32.937,true,ap-southeast-1,2026-10-19 +# ap-southeast-2 +T4,g4dn.xlarge,1,OnDemand,0.684,true,ap-southeast-2,2026-10-19 +T4,g4dn.xlarge,1,Spot,0.268,true,ap-southeast-2,2026-10-19 +T4,g4dn.12xlarge,4,OnDemand,5.087,true,ap-southeast-2,2026-10-19 +T4,g4dn.12xlarge,4,Spot,2.111,true,ap-southeast-2,2026-10-19 +T4,g4dn.metal,8,OnDemand,10.174,true,ap-southeast-2,2026-10-19 +T4,g4dn.metal,8,Spot,4.837,true,ap-southeast-2,2026-10-19 +A10G,g5.xlarge,1,OnDemand,1.308,true,ap-southeast-2,2026-10-19 +A10G,g5.xlarge,1,Spot,0.663,true,ap-southeast-2,2026-10-19 +A10G,g5.12xlarge,4,OnDemand,7.375,true,ap-southeast-2,2026-10-19 +A10G,g5.12xlarge,4,Spot,7.375,true,ap-southeast-2,2026-10-19 +A10G,g5.48xlarge,8,OnDemand,21.178,true,ap-southeast-2,2026-10-19 +A10G,g5.48xlarge,8,Spot,21.178,true,ap-southeast-2,2026-10-19 +L4,g6.xlarge,1,OnDemand,1.046,true,ap-southeast-2,2026-10-19 +L4,g6.xlarge,1,Spot,0.556,true,ap-southeast-2,2026-10-19 +L4,g6.12xlarge,4,OnDemand,5.983,true,ap-southeast-2,2026-10-19 +L4,g6.12xlarge,4,Spot,0.598,true,ap-southeast-2,2026-10-19 +L4,g6.48xlarge,8,OnDemand,17.358,true,ap-southeast-2,2026-10-19 +L4,g6.48xlarge,8,Spot,2.797,true,ap-southeast-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,OnDemand,28.545,true,ap-southeast-2,2026-10-19 +A100-40GB,p4d.24xlarge,8,Spot,12.890,true,ap-southeast-2,2026-10-19 +H100,p5.48xlarge,8,OnDemand,71.552,true,ap-southeast-2,2026-10-19 +H100,p5.48xlarge,8,Spot,26.005,true,ap-southeast-2,2026-10-19 diff --git a/pkg/provider/pricing.go b/pkg/provider/pricing.go index 64b2252..1ee5362 100644 --- a/pkg/provider/pricing.go +++ b/pkg/provider/pricing.go @@ -49,8 +49,12 @@ type PriceRequest struct { // matched row's GPUCount rather than hardcoded per provider — see Offering.GPUCount. Count int32 // CapacityType selects between a row's Spot and OnDemand prices, which differ - // sharply (AWS p5.48xlarge: $20.839 Spot vs $55.040 OnDemand). + // sharply (AWS p5.48xlarge in us-east-1: about $29.17 Spot vs $55.040 OnDemand). CapacityType nebulav1alpha1.CapacityType + // Region is where the instance runs (NodeClaim.Spec.Region), in the provider's own + // vocabulary. Empty when unknown or when the provider has no region axis. Only a provider + // whose price varies by region reads it (AWS Spot, priced live per region). + Region string // CPUCores (vCPUs) and MemoryMiB are the workload's priced size (see util.PodReservation), // priced only by providers that meter them apart from the accelerator. CPUCores float64