Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ metadata:
nebula.inftyai.com/enabled: "true" # opt in
nebula.inftyai.com/nodepool: gpu # which NodePool to place against
nebula.inftyai.com/accelerator-type: h100 # GPU type (case-insensitive)
annotations:
nebula.inftyai.com/regions: eu,uk # optional: narrow within the pool
spec:
containers:
- name: workload
Expand All @@ -99,12 +101,6 @@ spec:
nvidia.com/gpu: "8" # GPU count
```

The accelerator **type** rides on the label and is matched case-insensitively
against the provider catalog (`pkg/provider/catalog/data`); the **count** rides on
the standard `nvidia.com/gpu` resource limit, so scheduling and provisioning read
the same number. Do not set `nodeName` or a provider `nodeSelector` yourself — the
placement controller owns those.

## Quick start

```bash
Expand All @@ -126,10 +122,7 @@ One virtual node appears per provider whose credentials are present:
kubectl get nodes -l nebula.inftyai.com/provider
```

Then define a [NodePool](#defining-a-nodepool) and [opt a workload in](#opting-a-workload-in).

To build and deploy from source instead, see [docs/deploy.md](docs/deploy.md). The
[docs](docs/README.md) cover the rest.
See [docs](docs/README.md) for more detailed instructions.

## License

Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ const (
// via util.AcceleratorRequest.
AcceleratorTypeLabel = "nebula.inftyai.com/accelerator-type"

// RegionsAnnotation narrows ONE workload to a comma-separated list of broad
// geographies ("eu", or "eu,uk"), which is how a Pod expresses data residency without
// an operator carving out a NodePool per jurisdiction. Case and spacing are free.
RegionsAnnotation = "nebula.inftyai.com/regions"

// EndpointAnnotation carries the reachable address of the external instance (a DNS
// name, an IP, or a URL, in the provider's own form). It is the only way to reach
// the workload, and PodIP cannot hold it — the API server validates PodIP as a
Expand Down
24 changes: 6 additions & 18 deletions api/v1alpha1/nodepool_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,27 +147,15 @@ type ProviderSpec struct {
// +optional
Weight *int32 `json:"weight,omitempty"`

// Regions CONSTRAINS where this provider may place, in the provider's own
// vocabulary. It lives here per provider because region names are
// provider-namespaced. Three levels:
// Regions CONSTRAINS where this provider may place. Three levels:
// - omitted/empty => every region the provider serves. For a region-simple
// provider (Modal) this sends no region at all, its widest and cheapest mode.
// - a geography GROUP token ("us", "eu", "ap", ...) => that geography's regions.
// The recommended way to ask for breadth with a residency boundary.
// - a GEOGRAPHY ("us", "eu", "ap", ...) => that geography's regions here. The
// recommended way to ask for breadth with a residency boundary, and the only
// level a workload can also request per-Pod.
// - a literal region name ("us-east-1" on AWS, "us-east" on Modal) => just that.
// Only the provider knows its own geography, so it resolves which level a value is
// (see provider.Provider's ExpandRegions). Group tokens are shared across
// providers; the regions behind them are not.
//
// A non-group value is passed through UNVALIDATED, because region names change
// faster than Nebula ships: a bad one fails at provision time with the provider's
// own error, which beats refusing a region that launched last week. It is also the
// escape hatch for AWS opt-in regions, which no group contains.
//
// Unconstrained is the widest and costliest setting: every region becomes a
// failover candidate and gets swept by the poll loop. Prefer a group unless the
// workload needs global reach. Entry count is uncapped (a group already expands to
// many); maxLength bounds each entry.
// Geographies are shared across providers; the regions behind them are not, so only
// the provider resolves which level a value is (provider.Provider's ExpandRegions).
// +optional
// +kubebuilder:validation:items:MaxLength=32
Regions []string `json:"regions,omitempty"`
Expand Down
4 changes: 2 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,8 +678,8 @@ func awsRegionSource(c client.Client) awsprovider.RegionSource {
// unioning the raw lists first would collapse that to "nothing" —
// the swept set would miss regions placement provisions into, and
// List's absence is reported as Terminated on live instances.
// This is the same expansion regionsFor applies on the placement
// side; both must agree, so both call this one function.
// This is the same expansion selectPlacement applies through the
// adapter's ExpandRegions; both must agree, so both call this one function.
regions = append(regions, awsprovider.ExpandRegions(ps.Regions)...)
}
}
Expand Down
24 changes: 6 additions & 18 deletions config/crd/bases/nebula.inftyai.com_nodepools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,27 +169,15 @@ spec:
type: string
regions:
description: |-
Regions CONSTRAINS where this provider may place, in the provider's own
vocabulary. It lives here per provider because region names are
provider-namespaced. Three levels:
Regions CONSTRAINS where this provider may place. Three levels:
- omitted/empty => every region the provider serves. For a region-simple
provider (Modal) this sends no region at all, its widest and cheapest mode.
- a geography GROUP token ("us", "eu", "ap", ...) => that geography's regions.
The recommended way to ask for breadth with a residency boundary.
- a GEOGRAPHY ("us", "eu", "ap", ...) => that geography's regions here. The
recommended way to ask for breadth with a residency boundary, and the only
level a workload can also request per-Pod.
- a literal region name ("us-east-1" on AWS, "us-east" on Modal) => just that.
Only the provider knows its own geography, so it resolves which level a value is
(see provider.Provider's ExpandRegions). Group tokens are shared across
providers; the regions behind them are not.

A non-group value is passed through UNVALIDATED, because region names change
faster than Nebula ships: a bad one fails at provision time with the provider's
own error, which beats refusing a region that launched last week. It is also the
escape hatch for AWS opt-in regions, which no group contains.

Unconstrained is the widest and costliest setting: every region becomes a
failover candidate and gets swept by the poll loop. Prefer a group unless the
workload needs global reach. Entry count is uncapped (a group already expands to
many); maxLength bounds each entry.
Geographies are shared across providers; the regions behind them are not, so only
the provider resolves which level a value is (provider.Provider's ExpandRegions).
items:
maxLength: 32
type: string
Expand Down
2 changes: 1 addition & 1 deletion docs/add-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Create `pkg/provider/<name>/` and implement `provider.Provider`
| `Offerings(ctx)` | Price/availability rows for the optimizer (see the catalog below). |
| `MapAccelerator(canonical, count)` | Translate a canonical accelerator (type + count) to the provider's own id; `ok=false` if unsupported. |
| `ClassifyProvisionError(err, accel, region)` | Map a Provision failure to the `BlockScope` failover should blocklist. Only an **auth** error widens to the whole provider (`DenyAll`); capacity, quota, and unrecognized errors are all scoped to that {accel, tier, region} so failover can route around one candidate instead of fencing off the provider. Delegate to `provider.ClassifyError` for the shared part and decorate only what is provider-specific (e.g. the region axis). |
| `ExpandRegions(declared)` | Turn a pool's `regions` into the region candidates placement will walk. `catalog.Base` passes them through unchanged — one candidate per declared region, token used verbatim. Override for **either** of two independent reasons: the tokens are not callable (`pkg/provider/aws` expands the group `us` into every US EC2 region via a static table, since `us` is not a region you can call), or the provider's create **cannot fail over**, in which case splitting shrinks the capacity pool instead of widening it (`pkg/provider/modal` collapses every declared region into ONE candidate). Note Modal's own names already include the group tokens, so it overrides for the *second* reason alone — the two axes are orthogonal. |
| `ExpandRegions(declared, narrowTo)` | Turn a pool's `regions` into the region candidates placement will walk, optionally narrowed to the geographies one workload asked for. |

The Pod is the single source of truth for the workload shape; `ProvisionRequest`
carries only what the Pod cannot express (the optimizer's capacity tier and the
Expand Down
23 changes: 16 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,10 @@ mapping), see [docs/status.md](status.md).

**Non-goals in the current implementation**

- Provider-neutral geography. `ProviderSpec.Regions` accepts shared *group* tokens
(`us`, `eu`, `ap`), but the regions behind them are per-provider and the narrower
names are each cloud's own vocabulary — there is no global region namespace. Which
level a value is, is resolved by the provider (`ExpandRegions`); an omitted list
means every region it serves.
- Uniform geographic coverage. Regions come in two levels: broad geographies
(`provider.Geographies`: `us`, `eu`, `ap`, `uk`, `ca`, `me`, `sa`, `af`, `mx`) and a
provider's own region names (`us-east-1`). A NodePool may declare either; a Pod's
`regions` annotation takes geographies only.
- Price-ranked region choice. Within a capacity tier the expanded regions are walked
in order, not ranked: the catalog carries no per-region prices, so a wide
declaration cannot yet prefer the cheapest region. Modal is the sharper case — a
Expand Down Expand Up @@ -147,6 +146,11 @@ Follow one GPU Pod from creation to teardown:
`nvidia.com/gpu` resource. The Pod remains the source of truth for image,
command, env, ports, CPU, memory, accelerator type, and accelerator count.

A workload that cares where it runs adds `nebula.inftyai.com/regions`, a
comma-separated list of `provider.Geographies` tokens. It only ever narrows
what the pool already allows — a Pod cannot reach a geography its NodePool
left out.

2. **Gate at admission.** The mutating webhook adds the scheduling gate
`nebula.inftyai.com/provider-selection` and a key-only `Exists` toleration for
the virtual-node taint `nebula.inftyai.com/provider:NoSchedule`. The webhook
Expand All @@ -160,10 +164,11 @@ Follow one GPU Pod from creation to teardown:
```text
for each capacityType in pool.spec.capacityTypes: # outer axis
for each provider in pool.spec.providers: # listed order today
for each region in ExpandRegions(provider.regions): # provider-local axis
for each region in ExpandRegions(provider.regions, podGeographies):
skip unregistered providers
skip providers that do not offer the accelerator type/count
skip providers that cannot serve the tier (Modal has no Spot)
skip providers with no region in the requested geographies
skip candidates blocked by failover blocklist
choose the first remaining candidate
```
Expand All @@ -173,7 +178,9 @@ Follow one GPU Pod from creation to teardown:
while Modal collapses every declared region into a single candidate carrying them
all (so its inner loop always runs exactly once, and the chosen `region` may be a
joined token rather than one region name). An empty expansion still yields one
unconstrained `""` candidate so the walk runs.
unconstrained `""` candidate so the walk runs — but only when the Pod requested
no geography. Under a narrowing request an empty expansion means *this provider
cannot reach there*, so the candidate is skipped rather than run unconstrained.

`Ordered` is the only strategy the API accepts, and the inner ranking is listed
order. `LowestPrice` and `Weighted` exist as constants but are deliberately kept
Expand Down Expand Up @@ -301,6 +308,8 @@ Responsibilities:
- resolve the selected NodePool from the Pod's `nebula.inftyai.com/nodepool`
label;
- parse the accelerator type/count from Pod label plus `nvidia.com/gpu`;
- parse the requested geographies from `nebula.inftyai.com/regions` and narrow
each provider's regions to them;
- select the first currently usable candidate across capacity tier, provider,
and provider-local region;
- consult the shared failover blocklist before selecting a candidate;
Expand Down
14 changes: 4 additions & 10 deletions docs/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,15 @@ which is the whole reason for splitting them:
| `reason` | Means | Clears when |
| --- | --- | --- |
| `no_pool` | The Pod names a NodePool that does not exist, or carries no pool label. | A human fixes the Pod (or the workload generating it). |
| `invalid_request` | The accelerator request is malformed — e.g. `nvidia.com/gpu` with no accelerator-type label. It is *not* treated as CPU-only. | A human fixes the Pod spec. |
| `invalid_request` | The request is malformed — `nvidia.com/gpu` with no accelerator-type label (*not* treated as CPU-only), or a `nebula.inftyai.com/regions` annotation naming no known geography. | A human fixes the Pod spec. |
| `all_blocked` | A servable candidate exists, but failover is holding every one of them off. | By itself — the Pod is already requeued for the block's expiry. |
| `no_candidate` | No provider in the pool can serve this request at all. | An operator adds a provider, or a provider registers. |
| `stale_claim` | A NodeClaim from a prior same-named Pod has not been reaped yet. | By itself, in seconds. A sustained rate means the NodeClaim backstop is stuck. |

The skip `reason` is likewise closed: `provider_unregistered`,
`capacity_type_unsupported`, `accelerator_unsupported`, `blocked`. Only `blocked` clears
on its own. One reconcile can file several skips — the walk visits every candidate before
giving up.

`nebula_placement_deferrals_total` counts **deferrals, not Pods**. A gated Pod is
reconciled again on every requeue and resync, so one Pod stuck for an hour contributes
many increments. The rate is therefore a measure of placement pressure, not a population:
for "how many Pods are stuck right now" read the SchedulingGated Pod count from
kube-state-metrics, and use this series to explain *why*.
`capacity_type_unsupported`, `accelerator_unsupported`, `egress_policy_unsupported`,
`no_available_regions`, `blocked`. Only `blocked` clears on its own. One reconcile can file
several skips — the walk visits every candidate before giving up.

## Provisioning

Expand Down
32 changes: 25 additions & 7 deletions internal/controller/nodeclaim_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package controller

import (
"context"
"slices"
"testing"
"time"

Expand Down Expand Up @@ -85,11 +86,26 @@ func (f *fakeProvider) MapAccelerator(c string, _ int32) ([]string, bool) {
return nil, false
}

// ExpandRegions passes the declaration through, matching catalog.Base's default (the
// region-simple behaviour). Tests that need group expansion set expandRegions.
func (f *fakeProvider) ExpandRegions(declared []string) []string {
// ExpandRegions is region-simple like Modal: declared tokens are their own geographies, an
// unconstrained pool takes the narrowing as its constraint, and no declaration at all is
// one unpinned candidate. Tests that need group expansion set expandRegions.
func (f *fakeProvider) ExpandRegions(declared, narrowTo []string) []string {
if f.expandRegions != nil {
return f.expandRegions(declared)
declared = f.expandRegions(declared)
}
switch {
case len(narrowTo) > 0 && len(declared) == 0:
return narrowTo
case len(narrowTo) > 0:
var out []string
for _, d := range declared {
if slices.Contains(narrowTo, d) {
out = append(out, d)
}
}
return out
case len(declared) == 0:
return []string{""}
}
return declared
}
Expand All @@ -98,10 +114,12 @@ func (f *fakeProvider) ClassifyProvisionError(error, string, string) provider.Bl
}

// resolver returns a Providers func that resolves only the given provider.
func resolver(provs ...*fakeProvider) func(string) (provider.Provider, bool) {
// It takes the interface, not *fakeProvider, so a test can register a REAL adapter where the
// thing under test is that adapter's own behaviour (see the region-narrowing tests).
func resolver(provs ...provider.Provider) func(string) (provider.Provider, bool) {
return func(name string) (provider.Provider, bool) {
for _, p := range provs {
if p.name == name {
if p.Name() == name {
return p, true
}
}
Expand All @@ -125,7 +143,7 @@ func testScheme(t *testing.T) *runtime.Scheme {
// newClaimReconciler wires a NodeClaimReconciler over a fake client seeded with
// objs. Any fakeProviders passed are registered as the reconciler's resolver so
// the teardown backstop can reach them.
func newClaimReconciler(t *testing.T, objs []client.Object, provs ...*fakeProvider) (*NodeClaimReconciler, client.Client) {
func newClaimReconciler(t *testing.T, objs []client.Object, provs ...provider.Provider) (*NodeClaimReconciler, client.Client) {
t.Helper()
s := testScheme(t)
c := fake.NewClientBuilder().
Expand Down
Loading
Loading