Skip to content
Open
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
15 changes: 13 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ MODAL_ENVIRONMENT=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

# --- RunPod provider -------------------------------------------------------
# One API key, from https://console.runpod.io/user/settings. It needs READ+WRITE on
# Pods: Nebula creates and deletes them, and also creates container-registry-auth
# objects when a workload uses an imagePullSecret. A read-only key registers fine
# and then fails every provision with an auth error, which blocklists the whole
# provider until it is replaced.
#
# Unlike AWS there is no ambient identity to fall back on, so leaving this blank
# skips both the Secret and the provider registration — not fatal.
RUNPOD_API_KEY=

# --- Additional providers (add as adapters land) ---------------------------
# Each provider gets its OWN secret (see hack/deploy.sh PROVIDER_SECRETS), e.g.:
# RUNPOD_API_KEY=
# Each provider gets its OWN secret; see hack/deploy.sh PROVIDER_SECRETS and the
# RunPod block above for the shape.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ metadata:
spec:
providers:
- name: modal # NeoCloud; regions omitted = place anywhere (cheapest)
- name: runpod # NeoCloud with real Spot; a region is a country code
regions: # ("us") or one data center ("us-ks-2")
- us
- name: aws # hyperscaler; "us" expands to every US region
regions:
- us
Expand Down Expand Up @@ -95,7 +98,8 @@ placement controller owns those.
> `kubectl logs` and `kubectl exec` both work on Modal, `-f`/`--tail` and `-it`
> included: the manager serves the two kubelet routes the API server proxies.
> `--timestamps`/`--previous`/`--since` and `-c` are ignored, and a terminal resize is
> not forwarded. On providers that do not support them yet, both answer NotFound.
> not forwarded. On providers that do not support them yet — AWS and RunPod — both
> answer NotFound.

## Getting started

Expand Down
14 changes: 14 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import (
awsprovider "github.com/InftyAI/Nebula/pkg/provider/aws"
"github.com/InftyAI/Nebula/pkg/provider/fake"
"github.com/InftyAI/Nebula/pkg/provider/modal"
"github.com/InftyAI/Nebula/pkg/provider/runpod"
"github.com/InftyAI/Nebula/pkg/vnode"
// +kubebuilder:scaffold:imports
)
Expand Down Expand Up @@ -505,6 +506,19 @@ func registerProviders(ctx context.Context, c client.Client) {
setupLog.Info("registered provider", "provider", p.Name())
}

// RunPod. Like Modal and unlike AWS, its credential is a single API key read from the
// environment (RUNPOD_API_KEY, delivered by the per-provider Secret) — there is no
// role/instance-identity path to fall back on, so an absent key is exactly the
// logged-and-skipped case. Also like Modal, there is no region config here: a pool's
// regions become RunPod data centers or country codes at provision time, so editing a
// NodePool changes placement without a restart.
if p, err := runpod.NewSDKClient(ctx); err != nil {
setupLog.Info("skipping RunPod provider registration", "reason", err.Error())
} else {
provider.Register(p)
setupLog.Info("registered provider", "provider", p.Name())
}

// The fake provider is an in-memory backend used only by the e2e suite to
// exercise the full control-plane loop without cloud credentials. It ships in
// the binary but registers ONLY when explicitly enabled, so it can never place
Expand Down
1 change: 1 addition & 0 deletions config/catalog/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ configMapGenerator:
files:
- modal.csv=../../pkg/provider/catalog/data/modal.csv
- aws.csv=../../pkg/provider/catalog/data/aws.csv
- runpod.csv=../../pkg/provider/catalog/data/runpod.csv

generatorOptions:
# Stable name (no content-hash suffix) so `kubectl edit` and the volume
Expand Down
12 changes: 8 additions & 4 deletions config/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,14 @@ spec:
- secretRef:
name: nebula-aws-credentials
optional: true
# Add one secretRef per provider as adapters land, e.g.:
# - secretRef:
# name: nebula-runpod-credentials
# optional: true
# RunPod: a single API key (RUNPOD_API_KEY). Unlike AWS there is no ambient
# identity to fall back on, so an absent Secret means the provider is simply
# skipped at registration. Regions come from the NodePool, so this is the only
# RunPod config here.
- secretRef:
name: nebula-runpod-credentials
optional: true
# Add one secretRef per provider as adapters land, following the pattern above.
ports:
# The kubelet API the API server dials for `kubectl logs` (10250, like a real
# kubelet). Declaring it is documentation and NetworkPolicy surface; the
Expand Down
14 changes: 13 additions & 1 deletion config/samples/nodepool.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ spec:
- us
- eu
- ap-melbourne
# - name: runpod
- name: runpod
# RunPod takes a region as either an ISO country code (US, SE) or one exact data
# center (US-KS-2, EU-RO-1) — no group tokens: "eu" is not a country code, so it
# would be forwarded and rejected at provision time. Each entry is one failover
# candidate, so a shortage in one blocklists only that one.
regions:
- US
- EU-RO-1
# Outer axis: try OnDemand on every provider first, fall back to Spot.
capacityTypes:
- OnDemand
Expand All @@ -32,6 +39,11 @@ spec:
# sandbox stays reachable through its connect URL and token under every mode — it just
# cannot call out.
#
# Setting ANY mode other than Open narrows this pool to the providers that can enforce
# it: placement skips a provider whose Capabilities report SupportsEgressPolicy=false
# (RunPod, which exposes no outbound knob at all), rather than provisioning something
# with open internet access under a policy that says otherwise.
#
# Blocked permits nothing:
# egress:
# mode: Blocked
Expand Down
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ itself at startup (see [Webhook TLS](#webhook-tls-no-cert-manager)).
| `MODAL_ENVIRONMENT` | Modal | no | Modal Environment to create sandboxes in. Blank omits the key and the SDK uses the token profile's default. See [Modal Environments](#modal-environments). |
| `AWS_ACCESS_KEY_ID` | AWS | dev only | Prefer IRSA / instance role in production and leave blank — the SDK's default credential chain finds the role. Set only for local/dev. |
| `AWS_SECRET_ACCESS_KEY` | AWS | dev only | Pairs with `AWS_ACCESS_KEY_ID`; both required together or both blank. |
| `RUNPOD_API_KEY` | RunPod | yes | From [console.runpod.io/user/settings](https://console.runpod.io/user/settings). Needs **read+write on Pods**: a read-only key registers fine and then fails every provision with an auth error, which blocklists the whole provider. Unlike AWS there is no ambient identity, so blank skips RunPod entirely. |

Non-secret config, passed as `make` variables:

Expand Down
48 changes: 46 additions & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ enters the system.
- [Provider mappings](#provider-mappings)
- [AWS](#aws)
- [Modal](#modal)
- [RunPod](#runpod)
- [fake](#fake)
- [Logs and exec](#logs-and-exec)
- [What is not observable](#what-is-not-observable)
Expand Down Expand Up @@ -79,8 +80,9 @@ therefore emits without storing, and stores only once `Provision` returns.

`Provision` returns `(id, reserved, error)`. `reserved` means the provider committed
capacity, not merely accepted the request: AWS always does (`CreateFleet` with
`FleetTypeInstant` is synchronous), a fresh Modal sandbox never does (the GPU may
still be queued). Only a reserved instance advances to `Initializing`; an unreserved
`FleetTypeInstant` is synchronous), RunPod always does for the same reason (`POST
/pods` allocates a host before it answers, and a shortage comes back as an error), a
fresh Modal sandbox never does (the GPU may still be queued). Only a reserved instance advances to `Initializing`; an unreserved
id holds at `Provisioning`, which is still exactly true — the id is real and must be
reclaimed, but nothing is allocated. Either way the Pod is now tracked: `reserved`
constrains what the status may claim, not what is owed, and says nothing about
Expand Down Expand Up @@ -232,6 +234,48 @@ only two signals and has to record a third fact itself.
first poll tick. An *adopted* sandbox has been observed, so a `running` one is
known to be reserved. See below for why queued is not reported distinctly.

### RunPod

One RunPod Pod per NodeClaim. The single signal is `desiredStatus`, and the name says
what the trap is: it is the state RunPod *intends*, not the one it has reached.

| `desiredStatus` | `lastStartedAt` | `InstanceState` | Pod |
|---|---|---|---|
| `RUNNING` | set | `Running` | `Running` / `Ready=True` |
| `RUNNING` | empty | `Pending` | `Pending` / `Initializing` |
| `EXITED`, `TERMINATED` | — | `Terminated` | `Failed` / `Terminated` |
| anything else | — | `Pending` | `Pending` / `Initializing` |
| absent from `List` | — | `Terminated` | `Failed` / `Terminated` |

- **`RUNNING` alone is not running.** RunPod reports it from the moment it accepts the
Pod, while the image may still be pulling. `lastStartedAt` is the one field that
appears only once the container has actually started, so it is the gate — the same
role AWS's 2/2 reachability checks play. Without it a Deployment's replica would read
ready before anything was listening.
- **There is no readiness concept beyond that.** RunPod has no probe, so "started" is
the strongest signal available; a container that is up but not yet serving reads
`Running`. Contrast Modal, which has a real probe and latches it.
- **There is no queueing**, as with AWS: `POST /pods` allocates a host machine before
it answers, and a capacity shortfall is a synchronous error
(`ErrNoCapacity`, plus `ErrSpotCapacity` on the interruptible tier) that drives
region/tier failover. So `Provision` always returns `reserved` and the Pod goes
straight to `Initializing`.
- **No `Failed` case.** `EXITED` covers a clean exit and a crash alike — RunPod does
not distinguish them here and exposes no exit code — so a workload that died reads
as `Terminated`, indistinguishable from teardown. A spot reclaim arrives the same
way (`TERMINATED`, no notice), which is why the poll interval is 10s.
- **Identity rides the Pod name**, not tags: RunPod Pods have none, so `List` filters
on the `nebula-` prefix and the claim name is recovered by stripping it. A Pod whose
name would exceed RunPod's 191-character cap is refused at `Provision` rather than
truncated — two truncated claims would collide onto one Pod.
- The endpoint is **derived, not read back**: `https://<podID>-<port>.proxy.runpod.net`
is known at create time, so it is published from `CreatePod` like Modal's, but with
no token — that proxy is unauthenticated. A Pod with a public IP and an assigned
`/tcp` port mapping reports that direct address instead, once the poll loop sees it.
- **Neither `kubectl logs` nor `kubectl exec` works.** RunPod's REST v1 surface has no
pod-log endpoint and its only way into a container is SSH, so the adapter implements
neither optional half and both routes answer NotFound.

### fake

The in-memory e2e provider reports `InstanceRunning` as soon as an instance is
Expand Down
7 changes: 6 additions & 1 deletion hack/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ PROVIDER_SECRETS=(
# instance role (the preferred path). Region is NON-SECRET (on the manager
# Deployment); the adapter self-configures the rest (GPU AMI + subnets).
"nebula-aws-credentials|AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY|"
# "nebula-runpod-credentials|RUNPOD_API_KEY|"
# RunPod: one API key, and the only credential it has — there is no ambient identity to
# fall back on as AWS has, so a blank key skips the Secret AND the provider. Mint it at
# https://console.runpod.io/user/settings with read+write on Pods; a read-only key
# registers fine and then fails every create with an auth error, which blocklists the
# whole provider.
"nebula-runpod-credentials|RUNPOD_API_KEY|"
)

# create_provider_secret <secret-name> <required-keys> <optional-keys>
Expand Down
69 changes: 69 additions & 0 deletions pkg/provider/catalog/data/runpod.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# RunPod price/availability catalog — community-maintained.
#
# RunPod exposes GPU prices through its API, but only per GPU TYPE and without the
# canonical-name mapping Nebula needs, so these are SECURE-cloud list prices
# transcribed from https://runpod.io/pricing by hand. Treat them as a starting
# point, not a billing source, and refresh via `make update-catalog`
# (see hack/refresh.go).
#
# Prices are per GPU-HOUR, as Modal's are — not per instance-hour like aws.csv.
# RunPod bills per GPU, and the adapter passes the count as a runtime parameter.
#
# Only SECURE cloud is priced here. RunPod's COMMUNITY cloud rents the same GPUs
# from peer hosts for roughly 30-50% less, but this file has no cloud-type column
# to say which tier a row prices, so listing both would make the number the
# optimizer reads a coin flip. The adapter pins cloudType=SECURE to match; adding
# COMMUNITY means adding that column first (see pkg/provider/runpod's package doc).
#
# Columns (shared header across all provider CSVs; unused cells left blank):
# accelerator_type canonical Nebula accelerator type, matched case-insensitively
# against the nebula.inftyai.com/accelerator-type label
# accelerator_id ALWAYS SET here, unlike modal.csv: RunPod's ids are marketing
# strings ("NVIDIA H100 80GB HBM3") that share nothing with the
# canonical names, so every row carries its own translation.
#
# SEVERAL rows may share one accelerator_type, and the order
# matters: MapAccelerator returns them in file order, so the
# FIRST is the primary and the rest are interchangeable
# alternates. RunPod's create takes the whole list and picks by
# availability, so alternates widen a single launch — they never
# widen what a failure blocklists, which keys on the canonical
# pool. Put the variant with the best interconnect first (SXM
# before NVL before PCIe); RunPod falls back only if it must.
# gpu_count BLANK. RunPod takes the GPU count as a request parameter, so it
# is not a lookup dimension (contrast aws.csv, where the count is
# baked into the instance type). A blank row matches any count.
# capacity_type OnDemand | Spot. Spot is RunPod's `interruptible` tier: real,
# abruptly reclaimed, and with no bid to name in the REST v1 API.
# price_per_hour approximate USD per GPU-hour on SECURE cloud
# available whether Nebula may schedule onto it. Flipping a row to false
# removes it from placement everywhere without touching Go — the
# escape hatch for a GPU type RunPod has stopped offering.
# region BLANK. RunPod's prices are not partitioned by data center, so
# one row prices every region. Region is still a real placement
# axis for this provider (a pool's regions become dataCenterIds);
# it is just not a pricing one.
# updated documentation only, ignored by the parser
accelerator_type,accelerator_id,gpu_count,capacity_type,price_per_hour,available,region,updated
L4,NVIDIA L4,,OnDemand,0.43,true,,2026-08-29
L4,NVIDIA L4,,Spot,0.25,true,,2026-08-29
A40,NVIDIA A40,,OnDemand,0.40,true,,2026-08-29
A40,NVIDIA A40,,Spot,0.23,true,,2026-08-29
RTX4090,NVIDIA GeForce RTX 4090,,OnDemand,0.69,true,,2026-08-29
RTX4090,NVIDIA GeForce RTX 4090,,Spot,0.35,true,,2026-08-29
L40S,NVIDIA L40S,,OnDemand,0.86,true,,2026-08-29
L40S,NVIDIA L40S,,Spot,0.49,true,,2026-08-29
A100-40GB,NVIDIA A100-PCIE-40GB,,OnDemand,1.19,true,,2026-08-29
A100-40GB,NVIDIA A100-PCIE-40GB,,Spot,0.69,true,,2026-08-29
A100-80GB,NVIDIA A100-SXM4-80GB,,OnDemand,1.74,true,,2026-08-29
A100-80GB,NVIDIA A100-SXM4-80GB,,Spot,0.99,true,,2026-08-29
A100-80GB,NVIDIA A100 80GB PCIe,,OnDemand,1.64,true,,2026-08-29
A100-80GB,NVIDIA A100 80GB PCIe,,Spot,0.94,true,,2026-08-29
H100,NVIDIA H100 80GB HBM3,,OnDemand,2.99,true,,2026-08-29
H100,NVIDIA H100 80GB HBM3,,Spot,1.65,true,,2026-08-29
H100,NVIDIA H100 NVL,,OnDemand,2.79,true,,2026-08-29
H100,NVIDIA H100 NVL,,Spot,1.55,true,,2026-08-29
H100,NVIDIA H100 PCIe,,OnDemand,2.39,true,,2026-08-29
H100,NVIDIA H100 PCIe,,Spot,1.35,true,,2026-08-29
H200,NVIDIA H200,,OnDemand,3.99,true,,2026-08-29
H200,NVIDIA H200,,Spot,2.19,true,,2026-08-29
19 changes: 13 additions & 6 deletions pkg/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,16 @@ type Offering struct {
PricePerHour float64
Available bool
// Region is the provider region this row prices, in the provider's own
// vocabulary (e.g. AWS "us-east-1"). Empty for region-simple providers whose
// catalog is not region-partitioned (Modal, RunPod); a region-aware provider
// emits one row per {accelerator, capacityType, region}.
// vocabulary (e.g. AWS "us-east-1"). Empty when a provider's catalog is not
// region-partitioned; a region-aware provider emits one row per {accelerator,
// capacityType, region}.
//
// Empty here is about PRICING, and says nothing about whether the provider has a
// region axis at all — the two are independent. Modal has neither. RunPod prices
// every data center alike, so its rows carry no region, yet region IS a real
// placement axis for it (a pool's regions become RunPod dataCenterIds). AWS's rows
// are blank for a third reason: its per-region truth is probed live rather than
// hand-maintained.
Region string
// AcceleratorID is this provider's own name for what serves the canonical
// AcceleratorType (AWS "p5.48xlarge" for H100) — the lookup data MapAccelerator
Expand Down Expand Up @@ -458,9 +465,9 @@ type BlockScope struct {
Accelerator *string
// CapacityType empty => blocks all capacity types.
CapacityType nebulav1alpha1.CapacityType
// Region: nil => the provider has no region axis (Modal/RunPod, whose candidates
// carry an empty region too); &"us-east-1" => that region only, so a shortage there
// does not disqualify us-west-2.
// Region: nil => the provider has no region axis (Modal, whose candidates carry an
// empty region too); &"us-east-1" => that region only, so a shortage there does not
// disqualify us-west-2.
Region *string
// DenyAll true => block everything on this provider (auth/quota errors), ignoring the
// fields above. Still scoped to this one provider; it never spans providers.
Expand Down
Loading
Loading