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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

# Nebula

**The control plane for GPUaaS**
**The Control Plane for GPUaaS**

[![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/7WTUuFqyS6)
![Go Version](https://img.shields.io/badge/go-1.24-00ADD8?logo=go&logoColor=white)
[![Go Reference](https://pkg.go.dev/badge/github.com/InftyAI/Nebula.svg)](https://pkg.go.dev/github.com/InftyAI/Nebula)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
![Go Version](https://img.shields.io/badge/go-1.24-00ADD8?logo=go&logoColor=white)
[![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/7WTUuFqyS6)

Run GPU workloads on any NeoCloud or hyperscaler or your own infrastructure through one Kubernetes API.

Expand Down
16 changes: 10 additions & 6 deletions api/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,19 @@ const (
// default TTL.
BlocklistTTLAnnotation = "nebula.inftyai.com/blocklist-ttl"

// EndpointAnnotation carries the reachable address of the external instance
// once it is running (a public DNS name or IP, in the provider's own form).
// EndpointAnnotation carries the reachable address of the external instance (a
// public DNS name, an IP, or a URL, in the provider's own form).
// It is the ONLY way to reach the workload, so it must be visible on the Pod:
// PodIP cannot hold it because the API server validates PodIP as a literal IP
// and rejects a DNS name (the common AWS case), so the endpoint rides an
// annotation instead. Written by the virtual kubelet when it first observes the
// instance running; absent until then. Unlike the provisioning-input
// annotations above (which the placement controller stamps and VK reads), this
// flows the other way — VK writes it for operators/tooling to read.
// annotation instead. Written by the virtual kubelet as soon as it knows the
// address, which is provider-dependent and NOT tied to the phase: a provider
// that mints a connect URL at create time (Modal) publishes it from CreatePod,
// before the instance is Running; one whose address only exists after boot (AWS)
// publishes it from the poll loop. Absent until then, and never cleared once
// written. Unlike the provisioning-input annotations above (which the placement
// controller stamps and VK reads), this flows the other way — VK writes it for
// operators/tooling to read.
EndpointAnnotation = "nebula.inftyai.com/endpoint"

// TerminateInstanceFinalizer is held by every NodeClaim to guarantee teardown.
Expand Down
18 changes: 13 additions & 5 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,19 @@ teardown.
| `Failed` | `Terminated` | `applyState` ← `InstanceTerminated` | gone | `Terminated` |
| `Succeeded` | `Terminated` | `DeletePod` | gone | `Terminated` |

`Running` also sets `Ready=True` and the endpoint annotation. A Pod carrying a
`DeletionTimestamp` maps to `Terminating` from any non-terminal phase. When the
served Pod is ABSENT: after `Bound`/`Terminating`, the claim deletes itself and
the terminate finalizer runs; before `Bound`, it waits `placementGracePeriod`
first.
`Running` also sets `Ready=True`. A Pod carrying a `DeletionTimestamp` maps to
`Terminating` from any non-terminal phase. When the served Pod is ABSENT: after
`Bound`/`Terminating`, the claim deletes itself and the terminate finalizer runs;
before `Bound`, it waits `placementGracePeriod` first.

The endpoint annotation is written on whichever path first knows the address, which
differs by provider and is independent of phase. A provider that mints a connect URL
at create time (Modal) publishes it from `CreatePod`, alongside the Secret holding
the matching bearer token — so the endpoint is on the Pod before it is `Running`. A
provider whose address only exists once the instance boots (AWS's public DNS name)
reports it through `List()`, so the poll loop publishes it. Nothing ever clears the
annotation: once written it stays for the Pod's life, which is what lets the two
paths coexist and what makes the address survive a manager restart.

Note the two writers. `CreatePod` writes the rows around the `Provision` call;
every other non-terminal row comes from `applyState`, driven by the poll loop, and
Expand Down
4 changes: 2 additions & 2 deletions internal/controller/nodeclaim_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ type fakeProvider struct {

func (f *fakeProvider) Name() string { return f.name }
func (f *fakeProvider) Capabilities() provider.Capabilities { return provider.Capabilities{} }
func (f *fakeProvider) Provision(context.Context, *corev1.Pod, provider.ProvisionRequest) (string, bool, error) {
return "", false, nil
func (f *fakeProvider) Provision(context.Context, *corev1.Pod, provider.ProvisionRequest) (provider.ProvisionResult, error) {
return provider.ProvisionResult{}, nil
}
func (f *fakeProvider) Terminate(_ context.Context, id string) error {
f.terminated = append(f.terminated, id)
Expand Down
24 changes: 15 additions & 9 deletions pkg/provider/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,20 +388,26 @@ func (p *Provider) Offerings(ctx context.Context) ([]provider.Offering, error) {
// is booting on real hardware — or the reason it could not launch, which becomes an
// error driving AZ/region/tier failover. There is no queued state for an EC2
// instance to sit in, so the reserved return is unconditionally true on success.
//
// No connect credential is returned. EC2 has nothing to mint: an instance is reached
// at its public DNS name or IP, which EC2 does not know until the instance boots, so
// the address is reported the level-triggered way — observed by List/Get into
// Instance.Endpoint — and access is authenticated by the key pair and security group,
// not a bearer token.
func (p *Provider) Provision(
ctx context.Context, pod *corev1.Pod, req provider.ProvisionRequest,
) (string, bool, error) {
) (provider.ProvisionResult, error) {
if pod == nil {
return "", false, errors.New("aws: nil pod")
return provider.ProvisionResult{}, errors.New("aws: nil pod")
}
if req.ClaimName == "" {
return "", false, errors.New("aws: empty ClaimName in ProvisionRequest")
return provider.ProvisionResult{}, errors.New("aws: empty ClaimName in ProvisionRequest")
}

region := req.Region
client, err := p.clientFor(ctx, region)
if err != nil {
return "", false, err
return provider.ProvisionResult{}, err
}

// Idempotency: if an instance already carries this claim tag IN THIS REGION,
Expand All @@ -410,23 +416,23 @@ func (p *Provider) Provision(
// target region's client is sufficient. It is reserved for the same reason a
// fresh launch is: it only exists because some earlier instant fleet succeeded.
if existing, err := findByClaim(ctx, client, req.ClaimName); err != nil {
return "", false, err
return provider.ProvisionResult{}, err
} else if existing != nil {
return existing.ID, true, nil
return provider.ProvisionResult{InstanceID: existing.ID, Reserved: true}, nil
}

spec, err := p.instanceSpecFromPod(pod, req)
if err != nil {
return "", false, err
return provider.ProvisionResult{}, err
}
// The Provision deadline is enforced generically by the vnode handler (from
// Capabilities.ProvisionTimeout), so RunInstance simply honors ctx as it fails
// over across zones — no adapter-local WithTimeout here.
id, err := client.RunInstance(ctx, spec)
if err != nil {
return "", false, err
return provider.ProvisionResult{}, err
}
return id, true, nil
return provider.ProvisionResult{InstanceID: id, Reserved: true}, nil
}

// Terminate implements provider.Provider. Idempotent by the Client contract. The
Expand Down
27 changes: 18 additions & 9 deletions pkg/provider/aws/aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,22 @@ func TestProvision_MapsAcceleratorToInstanceType(t *testing.T) {
f := &fakeClient{runID: "i-1"}
p := newTestProvider(f)

id, reserved, err := p.Provision(context.Background(), gpuPod("H100", 8), provider.ProvisionRequest{
res, err := p.Provision(context.Background(), gpuPod("H100", 8), provider.ProvisionRequest{
ClaimName: "claim-a",
CapacityType: nebulav1alpha1.CapacityOnDemand,
Region: "us-west-2",
})
if err != nil {
t.Fatalf("Provision: %v", err)
}
id, reserved := res.InstanceID, res.Reserved
// EC2 mints no bearer credential: an instance is reached at the public DNS name or
// IP that only exists once it boots, so the address comes from the observed
// endpoint, not from this call.
if res.ConnectURL != "" || res.ConnectToken != "" {
t.Fatalf("expected no credential from AWS, got url=%q token set=%t",
res.ConnectURL, res.ConnectToken != "")
}
// The returned id is the raw EC2 id (no region prefix); Terminate/Get re-locate
// it by sweeping regions.
if id != "i-1" {
Expand Down Expand Up @@ -216,7 +224,7 @@ func TestProvision_LowercaseAcceleratorLabel(t *testing.T) {

// A user may write the accelerator-type label in any case; it must resolve to
// the canonical catalog row (and thus the right instance type).
if _, _, err := p.Provision(context.Background(), gpuPod("h100", 8), provider.ProvisionRequest{
if _, err := p.Provision(context.Background(), gpuPod("h100", 8), provider.ProvisionRequest{
ClaimName: "claim-lc",
Region: testRegion,
}); err != nil {
Expand Down Expand Up @@ -251,7 +259,7 @@ func TestProvision_CountSelectsInstanceType(t *testing.T) {
f := &fakeClient{runID: "i-t4"}
p := newTestProvider(f)
req := provider.ProvisionRequest{ClaimName: "claim-t4", Region: testRegion}
if _, _, err := p.Provision(context.Background(), gpuPod("T4", tc.count), req); err != nil {
if _, err := p.Provision(context.Background(), gpuPod("T4", tc.count), req); err != nil {
t.Fatalf("Provision(T4 x%d): %v", tc.count, err)
}
if got := primaryType(f.lastSpec); got != tc.wantType {
Expand All @@ -266,7 +274,7 @@ func TestProvision_UnsupportedCountIsError(t *testing.T) {
// T4 x2 has no instance type (there is no 2-GPU T4 shape): must error rather
// than silently picking the x1 or x8 row.
req := provider.ProvisionRequest{ClaimName: "claim-t4x2", Region: testRegion}
if _, _, err := p.Provision(context.Background(), gpuPod("T4", 2), req); err == nil {
if _, err := p.Provision(context.Background(), gpuPod("T4", 2), req); err == nil {
t.Fatal("expected an error for an unsupported (accelerator, count) pair")
}
if f.runCnt != 0 {
Expand All @@ -278,7 +286,7 @@ func TestProvision_SpotSetsMarketOption(t *testing.T) {
f := &fakeClient{runID: "i-spot"}
p := newTestProvider(f)

if _, _, err := p.Provision(context.Background(), gpuPod("H100", 8), provider.ProvisionRequest{
if _, err := p.Provision(context.Background(), gpuPod("H100", 8), provider.ProvisionRequest{
ClaimName: "claim-spot",
CapacityType: nebulav1alpha1.CapacitySpot,
Region: testRegion,
Expand All @@ -298,7 +306,7 @@ func TestProvision_EmptyRegionIsError(t *testing.T) {
// Provision errors rather than silently guessing. In production every request
// carries a region (admission requires each aws pool to list ≥1; placement stamps
// it), so this only guards a malformed request.
if _, _, err := p.Provision(context.Background(), gpuPod("H100", 8), provider.ProvisionRequest{
if _, err := p.Provision(context.Background(), gpuPod("H100", 8), provider.ProvisionRequest{
ClaimName: "claim-def",
}); err == nil {
t.Fatal("expected an error for a request with no region")
Expand All @@ -315,7 +323,7 @@ func TestProvision_NoAcceleratorIsError(t *testing.T) {
// EC2 GPU provisioning is by instance type; a Pod with no accelerator has no
// instance type to launch, so it must error rather than silently guessing.
req := provider.ProvisionRequest{ClaimName: "claim-cpu", Region: testRegion}
if _, _, err := p.Provision(context.Background(), gpuPod("", 0), req); err == nil {
if _, err := p.Provision(context.Background(), gpuPod("", 0), req); err == nil {
t.Fatal("expected an error for a Pod requesting no accelerator")
}
if f.runCnt != 0 {
Expand All @@ -334,11 +342,12 @@ func TestProvision_Idempotent(t *testing.T) {
}
p := newTestProvider(f)

id, _, err := p.Provision(context.Background(), gpuPod("H100", 8),
res, err := p.Provision(context.Background(), gpuPod("H100", 8),
provider.ProvisionRequest{ClaimName: "claim-a", Region: testRegion})
if err != nil {
t.Fatalf("Provision: %v", err)
}
id := res.InstanceID
// Raw EC2 id, since idempotent reuse returns the same clean id a fresh launch would.
if id != "i-existing" {
t.Fatalf("id = %q, want i-existing (idempotent reuse)", id)
Expand All @@ -352,7 +361,7 @@ func TestProvision_UnsupportedAccelerator(t *testing.T) {
f := &fakeClient{}
p := newTestProvider(f)
req := provider.ProvisionRequest{ClaimName: "claim-x", Region: testRegion}
if _, _, err := p.Provision(context.Background(), gpuPod("TPU-v4", 1), req); err == nil {
if _, err := p.Provision(context.Background(), gpuPod("TPU-v4", 1), req); err == nil {
t.Fatal("expected error for unsupported accelerator")
}
}
Expand Down
16 changes: 11 additions & 5 deletions pkg/provider/fake/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,26 @@ func (p *Provider) Capabilities() provider.Capabilities {
// It reports the instance RESERVED, which is honest for an in-memory backend: the
// instance is Running the moment it is recorded, so there is no queueing to model
// (and none of the capacity the reserved flag exists to describe).
func (p *Provider) Provision(_ context.Context, pod *corev1.Pod, req provider.ProvisionRequest) (string, bool, error) {
//
// No connect credential is minted: there is nothing to authenticate against. The
// endpoint is reported the level-triggered way, through the recorded instance.
func (p *Provider) Provision(
_ context.Context, pod *corev1.Pod, req provider.ProvisionRequest,
) (provider.ProvisionResult, error) {
if pod == nil {
return "", false, fmt.Errorf("fake: nil pod")
return provider.ProvisionResult{}, fmt.Errorf("fake: nil pod")
}
if req.ClaimName == "" {
return "", false, fmt.Errorf("fake: empty ClaimName in ProvisionRequest")
return provider.ProvisionResult{}, fmt.Errorf("fake: empty ClaimName in ProvisionRequest")
}

p.mu.Lock()
defer p.mu.Unlock()

for _, inst := range p.instances {
if inst.ClaimName == req.ClaimName {
return inst.ID, true, nil // idempotent reuse
// Idempotent reuse.
return provider.ProvisionResult{InstanceID: inst.ID, Reserved: true}, nil
}
}

Expand All @@ -114,7 +120,7 @@ func (p *Provider) Provision(_ context.Context, pod *corev1.Pod, req provider.Pr
Endpoint: fmt.Sprintf("fake://%s", id),
CapacityType: req.CapacityType,
}
return id, true, nil
return provider.ProvisionResult{InstanceID: id, Reserved: true}, nil
}

// Terminate forgets the instance. Idempotent: terminating an already-gone (or
Expand Down
19 changes: 14 additions & 5 deletions pkg/provider/fake/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,27 @@ func TestProvisionReportsRunningAndLists(t *testing.T) {
p := New()
ctx := context.Background()

id, reserved, err := p.Provision(ctx, testPod(), provider.ProvisionRequest{
res, err := p.Provision(ctx, testPod(), provider.ProvisionRequest{
ClaimName: "claim-a",
CapacityType: nebulav1alpha1.CapacityOnDemand,
})
if err != nil {
t.Fatalf("Provision: %v", err)
}
id := res.InstanceID
if id == "" {
t.Fatal("expected a non-empty instance id")
}
// The fake authenticates nothing, so it mints no credential; its address is
// reported the level-triggered way, through the observed instance.
if res.ConnectURL != "" || res.ConnectToken != "" {
t.Fatalf("expected no credential from the fake, got url=%q token set=%t",
res.ConnectURL, res.ConnectToken != "")
}
// The fake has no queueing to model — an instance is Running the moment it is
// created — so it always reserves. Reporting false would make the fake exercise
// the Modal-shaped path and leave Pods at Provisioning forever.
if !reserved {
if !res.Reserved {
t.Fatal("reserved = false; the fake allocates synchronously and is Running immediately")
}

Expand Down Expand Up @@ -82,14 +89,15 @@ func TestProvisionIdempotentOnClaim(t *testing.T) {
ctx := context.Background()
req := provider.ProvisionRequest{ClaimName: "claim-a"}

id1, _, err := p.Provision(ctx, testPod(), req)
res1, err := p.Provision(ctx, testPod(), req)
if err != nil {
t.Fatalf("Provision #1: %v", err)
}
id2, _, err := p.Provision(ctx, testPod(), req)
res2, err := p.Provision(ctx, testPod(), req)
if err != nil {
t.Fatalf("Provision #2: %v", err)
}
id1, id2 := res1.InstanceID, res2.InstanceID
if id1 != id2 {
t.Fatalf("ids differ (%q vs %q); Provision must be idempotent on ClaimName", id1, id2)
}
Expand All @@ -102,10 +110,11 @@ func TestTerminateIsIdempotent(t *testing.T) {
p := New()
ctx := context.Background()

id, _, err := p.Provision(ctx, testPod(), provider.ProvisionRequest{ClaimName: "claim-a"})
res, err := p.Provision(ctx, testPod(), provider.ProvisionRequest{ClaimName: "claim-a"})
if err != nil {
t.Fatalf("Provision: %v", err)
}
id := res.InstanceID
if err := p.Terminate(ctx, id); err != nil {
t.Fatalf("Terminate: %v", err)
}
Expand Down
Loading
Loading