Documentation
¶
Overview ¶
Package v1alpha1 contains the Nebula API types.
Nebula is an operator that orchestrates GPU workloads across NeoClouds (RunPod, Modal, Kubernetes, ...). It follows a Karpenter-style split:
NodePool - policy: which providers are allowed, how to choose between
them (cost/availability), failover behaviour, and the GPU shape.
NodeClaim - one provisioned external instance and its lifecycle. Owns the
terminate finalizer so a paid instance is never leaked.
On top of that provisioning core sit the workload types, each synthesizing Pods onto the same placement path rather than bypassing it:
Sandbox - one interactive remote box (agent workspace, shell, scratch GPU),
reachable with the same kubectl exec/logs as a local Pod.
SandboxSet - maintains N Sandboxes, and owns /scale so `kubectl scale` and HPA
drive the count. Keeping boxes ready ahead of demand is a USE of
this, not its definition — there are no lease semantics here.
+kubebuilder:object:generate=true +groupName=nebula.inftyai.com
Index ¶
- Constants
- Variables
- type CapacityType
- type EgressMode
- type EgressPolicy
- type FailoverPolicy
- type NodeClaim
- type NodeClaimList
- type NodeClaimPhase
- type NodeClaimSpec
- type NodeClaimStatus
- type NodePool
- type NodePoolList
- type NodePoolSpec
- type NodePoolStatus
- type PlacementStrategy
- type PodReference
- type ProviderSpec
- type Sandbox
- type SandboxList
- type SandboxPhase
- type SandboxSet
- type SandboxSetList
- type SandboxSetSpec
- type SandboxSetStatus
- type SandboxSpec
- type SandboxStatus
- type SandboxTemplateMetadata
- type SandboxTemplateSpec
Constants ¶
const ( // EnabledLabel opts a Pod into Nebula. It doubles as the webhook's // objectSelector so only opted-in Pods ever hit the mutating webhook. EnabledLabel = "nebula.inftyai.com/enabled" // EnabledValue is the only value of EnabledLabel that opts a Pod in. The // comparison is exact, so a Pod labelled "True" or "1" is NOT opted in — the // label is the webhook's objectSelector, and the API server matches it // literally, so anything else would make the controllers and the selector // disagree about which Pods are Nebula's. EnabledValue = "true" // ProviderSelectionGate is the scheduling gate the webhook injects at Pod // CREATE. The placement controller removes it once it has chosen a // provider (by adding a provider nodeSelector), releasing the Pod to the // scheduler. ProviderSelectionGate = "nebula.inftyai.com/provider-selection" // ProviderLabel is set on each provider's virtual node and added to a Pod's // nodeSelector by the placement controller to route it to that provider. ProviderLabel = "nebula.inftyai.com/provider" // ManagedByLabel marks every object Nebula creates and owns (starting with // the virtual nodes). It uses the well-known app.kubernetes.io/managed-by key // so standard tooling recognizes it; its value is always ManagedByValue. This // is the stable, management-scoped selector for "everything Nebula manages", // independent of provider routing — for NetworkPolicies, monitoring scrape // configs, and operator queries. ManagedByLabel = "app.kubernetes.io/managed-by" // ManagedByValue is the sole value of ManagedByLabel. ManagedByValue = "nebula" // PoolLabel records which NodePool a Pod (and its NodeClaim) belongs to. Its // value is the NodePool name, so the key mirrors the CRD kind. PoolLabel = "nebula.inftyai.com/nodepool" // SandboxLabel records which Sandbox a Pod belongs to. Its value is the Sandbox // name, so the key mirrors the CRD kind. The Sandbox controller selects its own // Pod by it, and it is what makes `kubectl get pods -l // nebula.inftyai.com/sandbox=alice` work. SandboxLabel = "nebula.inftyai.com/sandbox" // SandboxSetLabel records which SandboxSet created a Sandbox. Its value is the // set name. It is the selector the set's /scale subresource publishes in status // (so HPA can find the set's members) and how the set controller enumerates the // boxes it owns — ownerReferences alone would not support a label-selector query. SandboxSetLabel = "nebula.inftyai.com/sandboxset" // AcceleratorTypeLabel carries the accelerator TYPE only (e.g. "a100-40gb", // "h100"). The COUNT is a standard container resource request/limit // (nvidia.com/gpu today), so scheduling fit and provisioning read the same number // and there is no bespoke count grammar. A label rather than an annotation so Pods // can be selected by accelerator type — and label values forbid ":", which is why // the count could never live here. The name says "accelerator", not GPU, so TPUs // and friends fit when such a provider lands. Matched case-insensitively against // the catalog ("a100" and "A100" both resolve); the provider's canonical casing is // what gets provisioned (see catalog.Base.MapAccelerator). Read type+count together // via util.AcceleratorRequest. AcceleratorTypeLabel = "nebula.inftyai.com/accelerator-type" // 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 // literal IP and rejects a DNS name, the common AWS case — so it rides an // annotation. The virtual kubelet writes it as soon as it knows the address, which // is NOT tied to the phase: a provider that mints a connect URL at create time // (Modal) publishes from CreatePod, before Running; one whose address only exists // after boot (AWS) publishes from the poll loop. Absent until then, never cleared. // This one flows outward — VK writes, operators read. EndpointAnnotation = "nebula.inftyai.com/endpoint" // InstanceIDAnnotation carries the provider's id for the external instance backing // this Pod. Written by the virtual kubelet as soon as Provision returns an id — which // is the only place it is ever learned, since VK otherwise holds it in memory — and // never cleared. // // It exists so the NodeClaim controller can record status.InstanceID from the Pod it // has already fetched. Before this it asked the PROVIDER, listing every instance and // matching on claim name, on every reconcile until the id resolved: correct, but a // provider API call per reconcile per claim, which against a real backend means // hundreds of DescribeInstances/list calls for one large batch, into APIs that rate // limit. The id is a fact VK already knows, so it flows outward on the Pod like the // endpoint does rather than being searched for. // // The claim's own copy is still the durable one: teardown runs after the Pod is gone, // so it reads status.InstanceID, falling back to List-by-claim-name when the id never // made it across. InstanceIDAnnotation = "nebula.inftyai.com/instance-id" // TerminateInstanceFinalizer is held by every NodeClaim to guarantee teardown. VK // owns the happy path (DeletePod → provider.Terminate), but its teardown is // edge-triggered and its tracking in-memory, so a Pod force-deleted during a VK // outage would leak a paid instance. This finalizer makes teardown // level-triggered: the cluster-scoped claim outlives the namespaced Pod, so on // delete the NodeClaim controller resolves the provider, finds the instance by // claim name via List, and Terminates before releasing — independent of VK // liveness (see docs/architecture.md §3). TerminateInstanceFinalizer = "nebula.inftyai.com/terminate-instance" )
Well-known keys used across the project. Kept here so the webhook, the placement controller and the NodeClaim controller share one source of truth.
const ( // PodReasonProvisioning: capacity has not been allocated yet. Stamped by CreatePod // before it calls Provision, and HELD if Provision returns an id without reserving // capacity — a Modal sandbox the control plane accepted but that is still queued // for a GPU. So the instance may exist (and then must be reclaimed) even under this // reason; what has not happened is the allocation. Replaced by Initializing as soon // as capacity is committed: at once for a provider that allocates synchronously // (AWS), otherwise when the first poll observes the instance. PodReasonProvisioning = "Provisioning" // PodReasonInitializing: the instance EXISTS but is not yet reachable — booting // (EC2 "pending"), running with reachability checks outstanding (<2/2, EC2's own // "Initializing" term, which this mirrors), or a Modal sandbox whose probe has not // passed. Distinct from Provisioning so a Pod stuck here points at a slow boot // rather than a stuck allocation, and so the NodeClaim controller can tell an // instance exists. VK stamps it only on EVIDENCE of existence: the provider // observed the instance in List, or Provision reported it reserved (capacity // committed, not merely requested). That is what makes it safe to key Bound off. PodReasonInitializing = "Initializing" // PodReasonRunning: the provider reports the instance running. PodReasonRunning = "Running" // PodReasonProvisionFailed: the provider rejected or failed the Provision call. PodReasonProvisionFailed = "ProvisionFailed" // PodReasonConfigError: the Pod references something unreadable — a missing Secret or // ConfigMap behind an env var, or a downward-API field this node cannot answer — so // nothing was requested from the provider. The kubelet's CreateContainerConfigError, // and non-terminal for the same reason: the reference usually appears moments later, and // waiting is free while nothing exists to bill. PodReasonConfigError = "ConfigError" // PodReasonFailed: the provider reports the instance in a failed state. PodReasonFailed = "Failed" // PodReasonTerminated: the instance is gone from the provider (torn down, // reclaimed, or exited). Disappearance alone does not say WHY, so this is the // neutral term rather than "Preempted". PodReasonTerminated = "Terminated" )
Pod status reasons the virtual kubelet stamps on the Pods it reports, projecting the external instance's lifecycle onto standard Pod status (pkg/vnode/status.go is the only writer).
They are public rather than private to pkg/vnode because they are a CONTRACT between packages. The Pod phase is lossy — provisioning and booting both surface as PodPending — so the reason is the only thing separating "no instance yet" from "an instance exists and is coming up", and the NodeClaim controller keys its teardown guard off exactly that (see desiredPhase). A rename on the writing side that the reader missed would still compile, still pass tests, and silently leak paid instances. They are also user-facing (operators match status.reason in jsonpath and alerts), so the whole set lives here — not just the values with in-tree readers.
const ( // ReasonPoolValid: the pool passed validation. ReasonPoolValid = "Valid" // ReasonUnknownProvider: a spec.providers[] entry names a provider with no // registered adapter, so the pool cannot place onto it. This is an // environmental check (it depends on the provider registry, populated at // controller startup), so it lives here as a status condition rather than as // an admission rule. Static spec validation (e.g. Weighted requires weights) // is enforced at admission by a CEL rule on NodePoolSpec instead. ReasonUnknownProvider = "UnknownProvider" )
NodePool condition reasons.
const ( // ReasonSandboxReady: the instance is running and reachable. ReasonSandboxReady = "Ready" // ReasonSandboxProvisioning: still bringing the instance up (covers both // placement and boot; the phase distinguishes them). ReasonSandboxProvisioning = "Provisioning" // ReasonSandboxFailed: the instance failed, was rejected, or vanished. ReasonSandboxFailed = "Failed" // ReasonSandboxExpired: spec.TTL elapsed and the instance was released. ReasonSandboxExpired = "Expired" // ReasonPodConflict: a Pod of the required name already exists and is NOT owned // by this Sandbox. The controller refuses to adopt it — it could be an unrelated // workload, and adopting would hand someone else's Pod a terminate finalizer — // so the sandbox surfaces the collision instead of acting on a guess. ReasonPodConflict = "PodConflict" )
Sandbox condition reasons.
const ( // ReasonSandboxSetReady: every desired box is Ready. ReasonSandboxSetReady = "Ready" // ReasonSandboxSetProgressing: at least one box is still coming up. Not an error // — a cold set takes minutes by nature, since each box is a real instance. ReasonSandboxSetProgressing = "Progressing" // ReasonSandboxSetScaledToZero: spec.Replicas is 0, so there is nothing to be // ready. Distinguished from Progressing so a parked set does not read as a stuck // one. ReasonSandboxSetScaledToZero = "ScaledToZero" )
SandboxSet condition reasons.
const ( // NodePoolConditionReady is True when the pool's policy is valid and usable: // every referenced provider is registered and the strategy is well-formed. // It flips to False (with a reason) on a configuration error, so an operator // sees the problem on the pool rather than as silent placement failures. NodePoolConditionReady = "Ready" )
NodePool condition types (standard Kubernetes condition convention).
const ( // SandboxConditionReady is True exactly when the sandbox is usable — the // instance is running and reachable. It is the condition to wait on // (`kubectl wait --for=condition=Ready sandbox/x`) and mirrors the Pod's own // Ready condition. SandboxConditionReady = "Ready" )
Sandbox condition types (standard Kubernetes condition convention).
const ( // SandboxSetConditionReady is True when every desired box is Ready. Callers that // can start work with a partially ready set should read status.ReadyReplicas // instead of waiting on this. SandboxSetConditionReady = "Ready" )
SandboxSet condition types (standard Kubernetes condition convention).
Variables ¶
var ( // GroupVersion is the group/version used to register these objects. GroupVersion = schema.GroupVersion{Group: "nebula.inftyai.com", Version: "v1alpha1"} // SchemeBuilder registers the types with a Scheme. SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme )
Functions ¶
This section is empty.
Types ¶
type CapacityType ¶
type CapacityType string
CapacityType is the purchase model (the outer axis). Each provider maps it to its own concept — e.g. RunPod Spot -> interruptible/podRentInterruptable. +kubebuilder:validation:Enum=Spot;OnDemand
const ( // CapacitySpot is interruptible/preemptible capacity (cheapest, reclaimable). CapacitySpot CapacityType = "Spot" // CapacityOnDemand is standard pay-as-you-go capacity. CapacityOnDemand CapacityType = "OnDemand" )
type EgressMode ¶
type EgressMode string
EgressMode is how a pool treats outbound traffic. No mode restricts inbound. +kubebuilder:validation:Enum=Open;Blocked;Allowlist
const ( // EgressOpen places no restriction, and is what an omitted spec.egress means. EgressOpen EgressMode = "Open" // EgressBlocked permits no outbound connection at all. EgressBlocked EgressMode = "Blocked" // EgressAllowlist permits EgressPolicy.Targets and nothing else. EgressAllowlist EgressMode = "Allowlist" )
type EgressPolicy ¶
type EgressPolicy struct {
// Mode is required once spec.egress is set, so a half-written policy is rejected
// rather than defaulted into a weaker one.
Mode EgressMode `json:"mode"`
// Targets is what mode Allowlist permits: CIDRs, bare IPs and domain names with an
// optional wildcard, mixed in one list, e.g. ["10.0.0.0/8", "*.huggingface.co"].
//
// +optional
// +kubebuilder:validation:MaxItems=64
// +kubebuilder:validation:items:MaxLength=253
Targets []string `json:"targets,omitempty"`
}
EgressPolicy is a pool's outbound network policy. The rules below keep Blocked and Allowlist disjoint, so "no egress" has one spelling instead of three. +kubebuilder:validation:XValidation:rule="self.mode == 'Allowlist' || !has(self.targets)",message="targets is only valid with mode Allowlist" +kubebuilder:validation:XValidation:rule="self.mode != 'Allowlist' || (has(self.targets) && self.targets.size() > 0)",message="mode Allowlist requires at least one target; use mode Blocked to permit nothing" +kubebuilder:validation:XValidation:rule="!has(self.targets) || self.targets.all(t, !t.contains(','))",message="a target must not contain a comma; list each target as its own entry"
func (*EgressPolicy) DeepCopy ¶
func (in *EgressPolicy) DeepCopy() *EgressPolicy
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressPolicy.
func (*EgressPolicy) DeepCopyInto ¶
func (in *EgressPolicy) DeepCopyInto(out *EgressPolicy)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*EgressPolicy) GetTargets ¶
func (p *EgressPolicy) GetTargets() []string
GetTargets reads Targets off a possibly-nil policy, for the same reason as ModeOrOpen.
func (*EgressPolicy) ModeOrOpen ¶
func (p *EgressPolicy) ModeOrOpen() EgressMode
ModeOrOpen reads a nil policy as Open, since an omitted spec.egress and an explicit Open are the same thing and no caller should nil-check for it.
func (*EgressPolicy) RestrictsEgress ¶
func (p *EgressPolicy) RestrictsEgress() bool
RestrictsEgress reports whether the policy needs a provider to enforce anything.
type FailoverPolicy ¶
type FailoverPolicy struct {
// BlocklistTTL is the BASE duration a failed placement is excluded before the
// provider becomes a candidate for it again. The controller adds a random jitter
// (up to 30s) on top so Pods that failed for the same reason do not all retry the
// just-freed candidate in lockstep, so the effective exclusion is this value plus
// that jitter.
//
// +kubebuilder:default="30s"
BlocklistTTL metav1.Duration `json:"blocklistTTL,omitempty"`
}
FailoverPolicy tunes capacity-error failover. Failover is always on — backing off a placement that just failed is correct behaviour, not a toggle (to avoid a whole provider, use a single-element Providers list instead). The blocklist itself is derived, high-churn runtime state held in controller memory — NOT in the API: when a provision fails, the controller excludes the failing placement (keyed at the granularity of the error, e.g. a specific provider+GPU+capacity type, so a failed H100 request does not block A100 requests on the same provider) for BlocklistTTL, then reconsiders it. This spec only tunes that.
func (*FailoverPolicy) DeepCopy ¶
func (in *FailoverPolicy) DeepCopy() *FailoverPolicy
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailoverPolicy.
func (*FailoverPolicy) DeepCopyInto ¶
func (in *FailoverPolicy) DeepCopyInto(out *FailoverPolicy)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type NodeClaim ¶
type NodeClaim struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec NodeClaimSpec `json:"spec,omitempty"`
Status NodeClaimStatus `json:"status,omitempty"`
}
NodeClaim represents one external GPU instance and its lifecycle.
func (*NodeClaim) DeepCopy ¶
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeClaim.
func (*NodeClaim) DeepCopyInto ¶
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*NodeClaim) DeepCopyObject ¶
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type NodeClaimList ¶
type NodeClaimList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []NodeClaim `json:"items"`
}
NodeClaimList contains a list of NodeClaim.
func (*NodeClaimList) DeepCopy ¶
func (in *NodeClaimList) DeepCopy() *NodeClaimList
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeClaimList.
func (*NodeClaimList) DeepCopyInto ¶
func (in *NodeClaimList) DeepCopyInto(out *NodeClaimList)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*NodeClaimList) DeepCopyObject ¶
func (in *NodeClaimList) DeepCopyObject() runtime.Object
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type NodeClaimPhase ¶
type NodeClaimPhase string
NodeClaimPhase is the coarse, user-facing lifecycle state.
The claim is a passive teardown ledger, not a status mirror: workload runtime status (CPU/logs/restarts/readiness) belongs to the Pod (see pkg/vnode/status.go). It tracks only what its own job needs, keyed off the served Pod: Provisioning (no instance yet), Bound (an instance EXISTS — the guard the teardown backstop trusts), Terminated (gone). Finer states like Preempted are absent because nothing can detect them: InstanceState has no Preempted value, and an absent instance only says it is gone, not why. Add a phase only when something actually sets it.
The ledger's question is EXISTENCE, not readiness — a booting instance is just as billable as a serving one, so both are Bound and readiness is left to the Pod. (Hence no Initializing phase: a readiness distinction on an object that does not track readiness.)
const ( // NodeClaimProvisioning: the served Pod has been observed but the external // instance does not yet exist — provisioning is still allocating it. The claim // does NOT earn the Bound teardown guard here: a Pod that vanishes while still // provisioning is treated as possible cache lag (grace window), not a real // teardown, because we never confirmed an instance was actually created. NodeClaimProvisioning NodeClaimPhase = "Provisioning" // NOTE: there is deliberately no "Initializing" phase. It meant "exists but not // reachable yet" and did NOT earn the teardown guard, which stranded a real, // billable instance behind the grace window whenever its Pod vanished mid-boot. // That state is now Bound; readiness lives on the Pod alone. // // NodeClaimBound: an external instance EXISTS at the provider. This is the durable // guard the backstop trusts — a Bound claim whose Pod later disappears is a real // teardown, not cache lag, so it is reclaimed immediately instead of after the // grace window. // // Existence, NOT readiness: a booting instance (EC2 "pending", or running with its // 2/2 checks outstanding; a Modal sandbox whose probe has not passed) is Bound, // because it bills the same as one that is serving. Usability is the Pod's Ready // condition. NodeClaimBound NodeClaimPhase = "Bound" // NodeClaimTerminating: the served Pod is being deleted but the instance may not be // reclaimed yet — teardown is in flight. Distinct from Terminated (already GONE): // here the Pod object still exists (grace period draining, VK's DeletePod running, // a finalizer pending), so the claim reads "going away" rather than being stranded // on a stale Provisioning/Bound. A forward transition from ANY prior phase, since a // deleting Pod is on its way out regardless of provisioning progress. The claim // self-deletes (firing the backstop) once the Pod object is fully gone. NodeClaimTerminating NodeClaimPhase = "Terminating" // NodeClaimTerminated: the instance is gone. Set when the served Pod reaches a // terminal phase (Failed/Succeeded), which VK reports when the provider's instance // disappears. The claim stays as the ledger of the vanished instance until its Pod // is deleted, and does NOT self-delete here — there is nothing left to reclaim. NodeClaimTerminated NodeClaimPhase = "Terminated" )
type NodeClaimSpec ¶
type NodeClaimSpec struct {
// PodRef links this claim to the Pod it serves. UID pins the exact Pod so a
// recreated Pod of the same name gets a fresh claim rather than adopting the
// old instance.
PodRef PodReference `json:"podRef"`
// Provider is the chosen NeoCloud. Immutable: a NodeClaim never migrates.
// Recovery from preemption is delete-and-recreate. Held durably so teardown
// knows which provider API to call even after status is lost.
Provider string `json:"provider"`
// CapacityType is the purchase tier placement selected (Spot/OnDemand). Stored
// durably because it is a provisioning input that cannot be read off the Pod, and
// Provision needs it to re-issue the request after a controller restart. Immutable,
// like Provider. Empty means "use the provider's default" (Modal is OnDemand-only
// and ignores it).
// +optional
CapacityType CapacityType `json:"capacityType,omitempty"`
// Region is the region candidate placement selected, in the provider's own
// vocabulary (e.g. AWS "us-east-1"). Durable and immutable for the same reason as
// CapacityType: Provision must re-issue in the same region after a restart. Empty
// means the provider's default — a pool that declared no region constraint.
//
// Not always a single region NAME: a provider whose create cannot fail over
// collapses every declared region into ONE candidate, joined by a provider-private
// separator (Modal uses "|", so us-east + us-west records "us-east|us-west"). Only
// that provider can split it back. Treat the value as an opaque token.
// +optional
Region string `json:"region,omitempty"`
// Accelerator is the accelerator pool this claim serves, as "type:count" (e.g.
// "H100:8"), resolved at placement time from the Pod. It names the POOL, not the
// SKU: a launch may span several interchangeable instance types (AWS's fleet tries
// alternates), so this stays truthful regardless of which alternate lands. Unlike
// Provider/CapacityType/Region it is NOT a provisioning input (the provider
// re-derives it from the Pod) — it is recorded so `kubectl get nc` shows what each
// instance serves. Empty for a CPU-only claim.
// +optional
Accelerator string `json:"accelerator,omitempty"`
// PoolRef is the NodePool whose policy produced this claim, for reporting.
// +optional
PoolRef string `json:"poolRef,omitempty"`
}
NodeClaimSpec is the durable identity of one external instance: who it serves, which provider it lives on, and which policy produced it. Controller-created (not user-facing), one per placed Pod. The workload shape (image, resources, GPU type/count, spot) is NOT duplicated here — it lives on the Pod, which the provider controller reads directly. This is a ledger, not a spec: it exists to survive the Node so teardown can reclaim the instance and never leak a paid GPU.
func (*NodeClaimSpec) DeepCopy ¶
func (in *NodeClaimSpec) DeepCopy() *NodeClaimSpec
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeClaimSpec.
func (*NodeClaimSpec) DeepCopyInto ¶
func (in *NodeClaimSpec) DeepCopyInto(out *NodeClaimSpec)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type NodeClaimStatus ¶
type NodeClaimStatus struct {
// Phase is the coarse lifecycle state.
// +optional
Phase NodeClaimPhase `json:"phase,omitempty"`
// InstanceID is the provider's identifier for the external instance (e.g. a
// RunPod pod id). This is the field that must not be lost: the terminate
// finalizer uses it to reclaim the instance.
// +optional
InstanceID string `json:"instanceID,omitempty"`
// NodeName is the virtual Node created for this instance, once it exists.
// +optional
NodeName string `json:"nodeName,omitempty"`
// Endpoint is the reachable address (e.g. SSH host:port) once ready.
// +optional
Endpoint string `json:"endpoint,omitempty"`
// PriceUSDPerHour is what this instance costs per hour in USD, as a decimal string
// ("7.9000"), resolved from the provider's catalog against the served Pod's shape.
// Status, not spec: it is a derived result nobody can declare up front. A string
// because it is written to be READ — a print column can only echo a field, never
// scale a fixed-point integer back into currency.
//
// Empty means UNPRICED, not free: the provider implements no provider.Pricer, or its
// catalog has no row for this candidate. Consumers must skip such a claim rather than
// count it as $0.
//
// Written once and never refreshed, so a catalog edit cannot retroactively reprice a
// running instance and rewrite the cost history it has already reported.
// +optional
PriceUSDPerHour string `json:"priceUSDPerHour,omitempty"`
// EstimatedCostUSD is what this instance has cost SO FAR, as a decimal string ("412.94000000")
// — PriceUSDPerHour integrated over the time it has held an instance. A string for the same
// reason the rate is one: it exists to be read off a print column.
//
// More decimals than the rate, and they are not decoration: each checkpoint re-reads this field
// to measure the next window, so digits dropped here are money dropped, and a cheap enough claim
// would round back to where it started every minute and never accrue at all. Round it for
// display; never round it back into this field.
//
// ESTIMATED, and the name says so on purpose: it is our own arithmetic over a list price
// (see PriceUSDPerHour), not a figure any provider has confirmed. Nothing here has been
// invoiced. Reconcile against the provider's billing export before anyone is charged.
//
// Within those limits it is the AUTHORITATIVE total, not the Prometheus counter: it is
// written exactly once per window and survives a restart. It is also only LIVE cost — it
// dies with the claim, so it is not a history. The metric is what outlives an instance.
// +optional
EstimatedCostUSD string `json:"estimatedCostUSD,omitempty"`
// LastAccruedAt is how far cost accrual has counted: an ANCHOR for the next measurement,
// not a note about the last one. "Accrued" in the accounting sense — cost incurred but not
// yet invoiced, which is all EstimatedCostUSD ever holds.
//
// It is the reason a restart loses nothing: the next window is rate x (now - LastAccruedAt),
// so time that passed while Nebula was down is still counted on recovery instead of vanishing
// with the in-memory total.
//
// The invariant that makes it safe: this NEVER moves past cost that has been durably
// recorded, because it advances only in the same patch that writes EstimatedCostUSD. A
// failed write therefore loses nothing — the same window is counted next time.
//
// Unset means "not counting yet". Never treat it as the epoch, which would charge decades
// on the first tick.
// +optional
LastAccruedAt *metav1.Time `json:"lastAccruedAt,omitempty"`
// CostLabels attributes this claim's spend to whoever asked for it: the values the served
// Pod carried for the label keys the operator configured (--cost-labels). Keyed by the POD
// label key, verbatim — so this map reads like the Pod it came from. The metric emits under a
// derived name Prometheus accepts ("example.com/org-id" here is example_com_org_id there), so
// do not expect the two to match by string.
//
// Status rather than spec because it is observed from the Pod, and it sits with the rest of
// the billing record for a reason: it is stamped in the SAME patch that opens the accrual
// anchor, so no window can ever be charged before its attribution is known.
//
// Written once, on first observation, and never refreshed — relabeling a Pod must not
// retroactively re-attribute spend already reported under the old values. A name the
// current --cost-labels no longer lists is ignored rather than cleaned up; one it lists but
// this map lacks reports as "none".
//
// Nil and EMPTY differ, which is why this field has no omitempty: nil means nothing has been
// observed yet (or --cost-labels is unset), while an empty map is the settled fact that the
// Pod carried none of the configured keys. With omitempty an empty map would not survive the
// write, so every reconcile would re-read the Pod and a label added later could still move
// the claim's attribution.
// +optional
// +kubebuilder:validation:Nullable
CostLabels map[string]string `json:"costLabels"`
}
NodeClaimStatus is the durable record reconciled against the provider by the poll loop.
func (*NodeClaimStatus) DeepCopy ¶
func (in *NodeClaimStatus) DeepCopy() *NodeClaimStatus
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeClaimStatus.
func (*NodeClaimStatus) DeepCopyInto ¶
func (in *NodeClaimStatus) DeepCopyInto(out *NodeClaimStatus)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type NodePool ¶
type NodePool struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec NodePoolSpec `json:"spec,omitempty"`
Status NodePoolStatus `json:"status,omitempty"`
}
NodePool is the placement policy for GPU workloads across NeoClouds.
func (*NodePool) DeepCopy ¶
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodePool.
func (*NodePool) DeepCopyInto ¶
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*NodePool) DeepCopyObject ¶
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type NodePoolList ¶
type NodePoolList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []NodePool `json:"items"`
}
NodePoolList contains a list of NodePool.
func (*NodePoolList) DeepCopy ¶
func (in *NodePoolList) DeepCopy() *NodePoolList
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodePoolList.
func (*NodePoolList) DeepCopyInto ¶
func (in *NodePoolList) DeepCopyInto(out *NodePoolList)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*NodePoolList) DeepCopyObject ¶
func (in *NodePoolList) DeepCopyObject() runtime.Object
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type NodePoolSpec ¶
type NodePoolSpec struct {
// Providers is the ordered set of NeoClouds this pool is allowed to use.
// A Pod bound to this pool can only ever be placed on a provider in this
// list. Order is significant only for the Ordered strategy (it is the
// inner, provider-ranking axis). A pool can list at most 8 providers so the
// candidate set remains bounded while larger configurations are unproven.
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=8
Providers []ProviderSpec `json:"providers"`
// CapacityTypes is the OUTER axis: the purchase models to try, in fallback
// order. e.g. [Spot, OnDemand] means "use spot on any provider first; only
// when spot is exhausted everywhere, drop to on-demand". A single-element
// list pins the pool to that type. This replaces a spot on/off flag.
// +kubebuilder:validation:MinItems=1
// +kubebuilder:default={OnDemand,Spot}
CapacityTypes []CapacityType `json:"capacityTypes,omitempty"`
// Strategy is the INNER axis: how to rank providers within the active
// capacity tier. It never overrides the capacity tier ordering.
//
// Only Ordered is accepted today. LowestPrice and Weighted are defined as
// constants (and the Weighted weight rule is already enforced above) but are
// deliberately kept OUT of the enum until the ranking is implemented: admitting
// a value the placement walk silently ignores would let a pool claim a policy it
// does not get, which is worse than rejecting it at admission. Widening the enum
// is the one change needed to enable them once selectPlacement ranks.
// +kubebuilder:validation:Enum=Ordered
// +kubebuilder:default=Ordered
Strategy PlacementStrategy `json:"strategy,omitempty"`
// Failover controls how a provider that fails at provision time (e.g.
// RunPod reports no capacity) is temporarily excluded and re-tried.
// +optional
Failover *FailoverPolicy `json:"failover,omitempty"`
// Egress restricts OUTBOUND connections from this pool's workloads; omitted means
// Open. Inbound is never affected — a Blocked sandbox still serves its consumer's
// tunnel and connect token, it just cannot call out.
//
// +optional
Egress *EgressPolicy `json:"egress,omitempty"`
}
NodePoolSpec is the placement policy for a set of workloads. Editing it changes behaviour for every Pod that selects the pool, without touching any workload.
Placement walks two axes in a fixed order: capacity type first, provider second.
FOR each capacityType in CapacityTypes (in listed order): // outer: hard tier
candidates = Providers x (each provider's Regions) x {this capacityType},
available now, minus blocklist // region nests per provider
IF candidates non-empty:
pick one via Strategy (Ordered today; see Strategy) // inner: rank candidates
DONE
// else fall through to the next capacity tier
Region nests under each provider (see ProviderSpec.Regions) because a region name only means something to one provider. It widens the candidate key to {provider, region, accelerator, capacityType} but does not change the order above.
So CapacityTypes is a HARD preference: every provider's Spot is tried before ANY provider's OnDemand, even if some provider's on-demand were momentarily cheaper. Strategy only ranks providers within the active tier; it never crosses tiers.
The CEL rule below enforces that Weighted has a weight on every provider — a static property of the spec, so admission is the right place for it. The rule is currently UNREACHABLE (the Strategy enum admits only Ordered), kept so widening the enum cannot ship without its weight validation. +kubebuilder:validation:XValidation:rule="self.strategy != 'Weighted' || self.providers.all(p, has(p.weight))",message="strategy Weighted requires a weight on every provider" (AWS once required at least one region here, because an omitted list meant "the client's default region" and its client has none. Omitted now means "every region the provider serves", which is a valid — if broad — AWS policy, so the rule is gone. See ProviderSpec.Regions.)
func (*NodePoolSpec) DeepCopy ¶
func (in *NodePoolSpec) DeepCopy() *NodePoolSpec
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodePoolSpec.
func (*NodePoolSpec) DeepCopyInto ¶
func (in *NodePoolSpec) DeepCopyInto(out *NodePoolSpec)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type NodePoolStatus ¶
type NodePoolStatus struct {
// Placed counts existing instances per provider (booting included), for
// at-a-glance balance.
// +optional
Placed map[string]int32 `json:"placed,omitempty"`
// Providers is a comma-separated list of provider names from the pool
// spec. kubectl printcolumns cannot join array fields via JSONPath, so
// the controller materializes this summary for `kubectl get nodepool`.
// +optional
Providers string `json:"providers,omitempty"`
// Conditions follows the standard Kubernetes condition convention.
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
NodePoolStatus surfaces the current placement picture for observability.
func (*NodePoolStatus) DeepCopy ¶
func (in *NodePoolStatus) DeepCopy() *NodePoolStatus
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodePoolStatus.
func (*NodePoolStatus) DeepCopyInto ¶
func (in *NodePoolStatus) DeepCopyInto(out *NodePoolStatus)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type PlacementStrategy ¶
type PlacementStrategy string
PlacementStrategy ranks providers WITHIN a capacity tier (the inner axis).
Only StrategyOrdered is admitted by NodePoolSpec.Strategy's enum today. The other two are declared here so the vocabulary is stable and testable ahead of the ranking implementation, NOT because they can be requested — see Strategy.
const ( // StrategyLowestPrice picks the lowest $/hr provider in the active tier. // NOT YET ACCEPTED by the Strategy enum. StrategyLowestPrice PlacementStrategy = "LowestPrice" // StrategyOrdered uses the Providers list order as strict priority. The only // strategy accepted today, and the default. StrategyOrdered PlacementStrategy = "Ordered" // StrategyWeighted spreads placements to match per-provider weights. // NOT YET ACCEPTED by the Strategy enum. StrategyWeighted PlacementStrategy = "Weighted" )
type PodReference ¶
type PodReference struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
// UID pins the exact Pod object; a recreated Pod of the same name gets a new
// NodeClaim rather than silently adopting the old instance.
UID string `json:"uid"`
}
PodReference identifies the namespaced Pod a cluster-scoped NodeClaim serves.
func (*PodReference) DeepCopy ¶
func (in *PodReference) DeepCopy() *PodReference
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodReference.
func (*PodReference) DeepCopyInto ¶
func (in *PodReference) DeepCopyInto(out *PodReference)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type ProviderSpec ¶
type ProviderSpec struct {
// Name is the provider identifier, matching the ProviderLabel value on that
// provider's virtual node (e.g. "runpod", "modal", "kubernetes").
Name string `json:"name"`
// Weight is the relative share of new placements for the Weighted strategy.
// Ignored by other strategies, which today means ignored entirely: Strategy
// accepts only Ordered, so setting this has no effect until Weighted is enabled.
// +kubebuilder:validation:Minimum=1
// +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:
// - 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 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.
// +optional
// +kubebuilder:validation:items:MaxLength=32
Regions []string `json:"regions,omitempty"`
}
ProviderSpec is one provider's entry in a pool: which provider, and the per-provider placement policy (Weighted share, allowed regions). It is not a mere reference — it carries config — so it is a Spec, not a Ref.
func (*ProviderSpec) DeepCopy ¶
func (in *ProviderSpec) DeepCopy() *ProviderSpec
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderSpec.
func (*ProviderSpec) DeepCopyInto ¶
func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type Sandbox ¶
type Sandbox struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec SandboxSpec `json:"spec,omitempty"`
Status SandboxStatus `json:"status,omitempty"`
}
Sandbox is one interactive remote instance — an agent workspace, a shell, a scratch GPU box — reachable with the same `kubectl exec` / `kubectl logs` a local Pod would be.
func (*Sandbox) DeepCopy ¶
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Sandbox.
func (*Sandbox) DeepCopyInto ¶
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*Sandbox) DeepCopyObject ¶
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type SandboxList ¶
type SandboxList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Sandbox `json:"items"`
}
SandboxList contains a list of Sandbox.
func (*SandboxList) DeepCopy ¶
func (in *SandboxList) DeepCopy() *SandboxList
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxList.
func (*SandboxList) DeepCopyInto ¶
func (in *SandboxList) DeepCopyInto(out *SandboxList)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*SandboxList) DeepCopyObject ¶
func (in *SandboxList) DeepCopyObject() runtime.Object
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type SandboxPhase ¶
type SandboxPhase string
SandboxPhase is the coarse, user-facing lifecycle state, derived from the synthesized Pod rather than tracked independently. The Pod (via the virtual kubelet) is the source of truth for what the external instance is doing — see pkg/vnode/status.go — so this is a projection, and the vocabulary intentionally mirrors the Pod status reasons the vnode stamps.
const ( // SandboxPending: the sandbox exists but its Pod has not been placed yet — // typically waiting on the provider-selection gate, e.g. because no provider in // the pool can currently serve the requested accelerator. A sandbox that sits // here points at placement, not at the provider. SandboxPending SandboxPhase = "Pending" // SandboxProvisioning: a provider Provision call is in flight; the external // instance does not exist yet. SandboxProvisioning SandboxPhase = "Provisioning" // SandboxInitializing: the instance exists at the provider but is not yet // reachable — booting, or up but not yet passing reachability checks. Kept // distinct from Provisioning so a stuck sandbox distinguishes "cannot get // capacity" from "capacity granted, slow boot". SandboxInitializing SandboxPhase = "Initializing" // SandboxReady: the instance is running and reachable. This is the only phase // in which exec/logs can succeed. SandboxReady SandboxPhase = "Ready" // SandboxFailed: the instance failed or vanished (terminated out-of-band, // reclaimed, or the provision was rejected). Terminal: a sandbox holds // filesystem state that a fresh instance would not have, so it is never // silently recreated underneath its user. Delete and recreate it explicitly. SandboxFailed SandboxPhase = "Failed" // SandboxExpired: spec.TTL elapsed and the instance was released. Terminal, and // deliberately not garbage: the object stays as the record of why the box went // away, so a user who returns to a dead sandbox gets an answer instead of a // NotFound. SandboxExpired SandboxPhase = "Expired" )
type SandboxSet ¶
type SandboxSet struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec SandboxSetSpec `json:"spec,omitempty"`
Status SandboxSetStatus `json:"status,omitempty"`
}
SandboxSet maintains N Sandboxes, so boxes can be kept ready ahead of demand instead of making a consumer wait minutes for an instance to provision.
func (*SandboxSet) DeepCopy ¶
func (in *SandboxSet) DeepCopy() *SandboxSet
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxSet.
func (*SandboxSet) DeepCopyInto ¶
func (in *SandboxSet) DeepCopyInto(out *SandboxSet)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*SandboxSet) DeepCopyObject ¶
func (in *SandboxSet) DeepCopyObject() runtime.Object
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type SandboxSetList ¶
type SandboxSetList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []SandboxSet `json:"items"`
}
SandboxSetList contains a list of SandboxSet.
func (*SandboxSetList) DeepCopy ¶
func (in *SandboxSetList) DeepCopy() *SandboxSetList
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxSetList.
func (*SandboxSetList) DeepCopyInto ¶
func (in *SandboxSetList) DeepCopyInto(out *SandboxSetList)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (*SandboxSetList) DeepCopyObject ¶
func (in *SandboxSetList) DeepCopyObject() runtime.Object
DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
type SandboxSetSpec ¶
type SandboxSetSpec struct {
// Replicas is how many Sandboxes to maintain. Zero is legal and useful: it
// releases every box while keeping the set's definition, which is how a set is
// parked overnight without being forgotten.
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=1
Replicas int32 `json:"replicas,omitempty"`
// Template is the shape of every Sandbox this set creates. All boxes in one set
// are the same shape by construction — the set exists to make boxes
// interchangeable at the point of HANDOUT, so a caller can take any ready box
// without inspecting it. Two shapes means two sets.
//
// A template is right here for the same reason it is wrong on Sandbox itself:
// this object does not describe a box, it describes how to make them.
Template SandboxTemplateSpec `json:"template"`
}
SandboxSetSpec maintains N Sandboxes. That is the whole contract — a SET, not a pool: there are no lease semantics here (claim a box, hold it, return it). Keeping N boxes alive is what ENABLES warm pooling and fan-out, but those are uses of a set, not its job. "Pool" is also already taken in this API group by NodePool.
It creates Sandbox OBJECTS, not replicas inside itself: a set answers "how many", a Sandbox answers "which one, running what, for whom". Because each box stays its own object, per-box RBAC, per-box status, and a visible failure keep working — none of which survives being flattened into a replica index.
Boxes get GENERATED names (myset-a4f2x), not ordinals. An ordinal implies a slot that gets refilled, so a dead box would be replaced by an empty one wearing the same name — same address, different filesystem. A generated name makes a replacement visibly a NEW box.
func (*SandboxSetSpec) DeepCopy ¶
func (in *SandboxSetSpec) DeepCopy() *SandboxSetSpec
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxSetSpec.
func (*SandboxSetSpec) DeepCopyInto ¶
func (in *SandboxSetSpec) DeepCopyInto(out *SandboxSetSpec)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type SandboxSetStatus ¶
type SandboxSetStatus struct {
// Replicas is how many Sandboxes the set currently owns, ready or not. It is the
// /scale subresource's status counterpart.
// +optional
Replicas int32 `json:"replicas,omitempty"`
// ReadyReplicas is how many owned Sandboxes are Ready — the number of boxes that
// can actually serve an exec right now.
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// Selector is the label selector matching this set's Sandboxes, serialized in the
// string form the /scale subresource requires. HPA and KEDA read the target's
// selector from there, so autoscaling a set does not work without it.
// +optional
Selector string `json:"selector,omitempty"`
// Sandboxes names the boxes this set owns, so the set is a usable handout list: a
// caller reads it to find a box to use without listing and filtering Sandboxes
// itself. Ordered by name for a stable diff.
// +optional
Sandboxes []string `json:"sandboxes,omitempty"`
// Conditions follows the standard Kubernetes condition convention.
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
SandboxSetStatus is the observed state of the set.
func (*SandboxSetStatus) DeepCopy ¶
func (in *SandboxSetStatus) DeepCopy() *SandboxSetStatus
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxSetStatus.
func (*SandboxSetStatus) DeepCopyInto ¶
func (in *SandboxSetStatus) DeepCopyInto(out *SandboxSetStatus)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type SandboxSpec ¶
type SandboxSpec struct {
// NodePoolRef names the NodePool whose policy places this sandbox: which
// providers are allowed, which capacity tiers, how to rank them. Required —
// there is no implicit default pool, because placing a paid GPU instance
// against a guessed policy is not a safe default.
// +kubebuilder:validation:MinLength=1
NodePoolRef string `json:"nodePoolRef"`
// Image is the container image the sandbox runs, defaulting to a plain Ubuntu:
// a bare distro to exec into IS the "give me a remote shell" case, and anything
// else can be installed from inside it. (Defaulting a paid GPU shape would be
// guessing at spend; defaulting a shell is not.)
//
// It does NOT switch to a CUDA image when an accelerator is requested: a default
// that depends on another field is not expressible in a structural schema. Ask for
// a CUDA image explicitly.
//
// There is no command field and one cannot be set — the structural schema rejects
// `command:` as unknown, no webhook needed. A sandbox has nothing to run at boot,
// so the controller supplies a placeholder that only has to not exit (today a
// long-running `sleep`, which the image must have). A user command would displace
// it and take the instance down.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:default="ubuntu:24.04"
// +optional
Image string `json:"image,omitempty"`
// AcceleratorType is the requested accelerator TYPE (e.g. "a100-40gb",
// "h100"), matched case-insensitively against the provider catalog. The COUNT
// is NOT here: it is a standard nvidia.com/gpu entry in Resources, so exactly
// one number drives scheduling fit and provisioning. The controller stamps
// this onto the synthesized Pod's AcceleratorTypeLabel, so a Sandbox and a
// hand-written Nebula Pod go through identical placement.
//
// Empty means a CPU-only sandbox, which is a legitimate (and cheap) thing to
// want for a shell or an agent that only needs a filesystem.
// +optional
AcceleratorType string `json:"acceleratorType,omitempty"`
// Resources is the standard Kubernetes resource requirements for the sandbox
// container, verbatim. The accelerator count rides here as an nvidia.com/gpu
// limit (`limits: {nvidia.com/gpu: "1"}`), which is where both the placement
// controller and the scheduler already read it from.
// +optional
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
// Env is passed to the sandbox container verbatim, including valueFrom
// references — a sandbox usually needs at least a registry or Hugging Face
// token, and re-inventing secret indirection here would be strictly worse than
// reusing the field everyone already knows.
// +optional
Env []corev1.EnvVar `json:"env,omitempty"`
// TTL bounds the sandbox's total lifetime, measured from the moment it first
// became Ready (NOT from creation, so a slow provision does not eat into the
// user's time). On expiry the controller releases the instance and the sandbox
// reports phase Expired.
//
// This exists because the failure mode of a remote GPU box is financial: an
// abandoned sandbox bills until someone notices. Omit it for an unbounded
// sandbox, which is a deliberate choice rather than the default.
// +optional
TTL *metav1.Duration `json:"ttl,omitempty"`
}
SandboxSpec is one long-lived, interactive remote box: an agent's workspace, a shell, a scratch GPU machine.
A Sandbox is SINGULAR — one object, one instance, no replicas field. A sandbox is not fungible: someone is attached to it, it holds state in its filesystem, and its name is its stable identity. A rolling update would evict a live session, and "scale in by one" would have to guess whose box to kill. The count lives one level up in SandboxSet, which creates N Sandbox OBJECTS — that is what keeps per-box RBAC, image, TTL, and a visible failure working underneath a pool.
It reuses corev1 types (ResourceRequirements, EnvVar) because the controller synthesizes a Pod, so the spec must be PodSpec-shaped anyway. Re-declaring them would fork the source of truth for the accelerator COUNT, which placement and the scheduler both read from the container's nvidia.com/gpu limit.
The CEL rule below rejects a GPU count with no accelerator type: that pair is contradictory (util.AcceleratorRequest errors on it), so without the rule the object is admitted and then fails at PLACEMENT minutes later. The inverse is allowed — a type with no count means one accelerator. +kubebuilder:validation:XValidation:rule="has(self.acceleratorType) || !has(self.resources) || ((!has(self.resources.limits) || !('nvidia.com/gpu' in self.resources.limits)) && (!has(self.resources.requests) || !('nvidia.com/gpu' in self.resources.requests)))",message="nvidia.com/gpu requires acceleratorType to be set"
func (*SandboxSpec) DeepCopy ¶
func (in *SandboxSpec) DeepCopy() *SandboxSpec
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxSpec.
func (*SandboxSpec) DeepCopyInto ¶
func (in *SandboxSpec) DeepCopyInto(out *SandboxSpec)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type SandboxStatus ¶
type SandboxStatus struct {
// Phase is the coarse lifecycle state.
// +optional
Phase SandboxPhase `json:"phase,omitempty"`
// PodName is the synthesized Pod backing this sandbox. It is recorded even
// though it currently equals the Sandbox name, so tooling (and `kubectl exec`
// wrappers) read the pod identity from status rather than reconstructing it
// from a naming convention this controller would then be unable to change.
// +optional
PodName string `json:"podName,omitempty"`
// Endpoint is the reachable address of the external instance once it is
// running, in the provider's own form (a public DNS name or an IP). Mirrored
// from the Pod's EndpointAnnotation, which is where the virtual kubelet
// publishes it.
// +optional
Endpoint string `json:"endpoint,omitempty"`
// ReadyTime is when the sandbox first became Ready. It is the anchor TTL is
// measured from, so it is durable status rather than a derived value: if it
// were recomputed from the Pod, a Pod status blip could silently restart the
// user's clock.
// +optional
ReadyTime *metav1.Time `json:"readyTime,omitempty"`
// ExpiryTime is when TTL will elapse (ReadyTime + TTL), surfaced so a user can
// see the deadline without doing the arithmetic. Absent when no TTL is set or
// the sandbox has not become Ready yet.
// +optional
ExpiryTime *metav1.Time `json:"expiryTime,omitempty"`
// Conditions follows the standard Kubernetes condition convention.
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
SandboxStatus is the observed state, projected from the synthesized Pod.
func (*SandboxStatus) DeepCopy ¶
func (in *SandboxStatus) DeepCopy() *SandboxStatus
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxStatus.
func (*SandboxStatus) DeepCopyInto ¶
func (in *SandboxStatus) DeepCopyInto(out *SandboxStatus)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type SandboxTemplateMetadata ¶
type SandboxTemplateMetadata struct {
// Labels are applied to each created Sandbox, on top of the set-ownership
// labels the controller adds.
// +optional
Labels map[string]string `json:"labels,omitempty"`
// Annotations are applied to each created Sandbox.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
}
SandboxTemplateMetadata is the subset of ObjectMeta a template may set. It is spelled out rather than embedding metav1.ObjectMeta because embedding would advertise fields a template cannot honour (name, ownerReferences, resourceVersion) and bloat the CRD schema with them.
func (*SandboxTemplateMetadata) DeepCopy ¶
func (in *SandboxTemplateMetadata) DeepCopy() *SandboxTemplateMetadata
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxTemplateMetadata.
func (*SandboxTemplateMetadata) DeepCopyInto ¶
func (in *SandboxTemplateMetadata) DeepCopyInto(out *SandboxTemplateMetadata)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
type SandboxTemplateSpec ¶
type SandboxTemplateSpec struct {
// Metadata is the labels and annotations applied to each created Sandbox. Only
// labels and annotations are honoured; a name here is ignored, since names are
// generated per box.
// +optional
Metadata SandboxTemplateMetadata `json:"metadata,omitempty"`
// Spec is the SandboxSpec of every box in the set.
Spec SandboxSpec `json:"spec"`
}
SandboxTemplateSpec is the Sandbox a set stamps out: the standard Kubernetes template shape (metadata + spec), so created boxes can carry the labels a caller selects them by.
func (*SandboxTemplateSpec) DeepCopy ¶
func (in *SandboxTemplateSpec) DeepCopy() *SandboxTemplateSpec
DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxTemplateSpec.
func (*SandboxTemplateSpec) DeepCopyInto ¶
func (in *SandboxTemplateSpec) DeepCopyInto(out *SandboxTemplateSpec)
DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.