Skip to content
29 changes: 27 additions & 2 deletions api/v1alpha1/sandbox_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,36 @@ type ConditionType string
func (c ConditionType) String() string { return string(c) }

const (
// SandboxConditionInitialized represents the first-time setup of the sandbox dependencies
SandboxConditionInitialized ConditionType = "Initialized"
// SandboxReasonInitializing indicates the sandbox dependencies are being provisioned
SandboxReasonInitializing = "SandboxInitializing"
// SandboxReasonInitialized indicates the first-time setup of the sandbox is complete.
SandboxReasonInitialized = "SandboxInitialized"

// SandboxConditionSuspended indicates the sandbox is administratively suspended
SandboxConditionSuspended ConditionType = "Suspended"
// SandboxReasonPendingEvaluation indicates the reason hasn't been evaluated yet by the controller.
SandboxReasonPendingEvaluation = "PendingEvaluation"
// SandboxReasonUserSuspended indicates the sandbox has been suspended by the user
SandboxReasonUserSuspended = "UserSuspended"
// SandboxReasonNotSuspended indicates the sandbox is operational and not suspended
SandboxReasonNotSuspended = "NotSuspended"

// SandboxConditionReady indicates readiness for Sandbox
SandboxConditionReady ConditionType = "Ready"

// SandboxReasonExpired indicates expired state for Sandbox
// SandboxReasonReady indicates the sandbox is fully operational
SandboxReasonReady = "SandboxReady"
// SandboxReasonPodProvisioning indicates the sandbox pod is being created or starting
SandboxReasonPodProvisioning = "PodProvisioning"
// SandboxReasonSuspended indicates the sandbox is suspended and not ready
SandboxReasonSuspended = "SandboxSuspended"
// SandboxReasonUnresponsive indicates the sandbox pod is in an unknown state
SandboxReasonUnresponsive = "SandboxUnresponsive"
// SandboxReasonExpired indicates the sandbox is being terminated due to expiration.
SandboxReasonExpired = "SandboxExpired"
// SandboxReasonDeleting indicates the sandbox is being terminated due to deletion.
SandboxReasonDeleting = "SandboxDeleting"

// SandboxPodNameAnnotation is the annotation used to track the pod name adopted from a warm pool.
SandboxPodNameAnnotation = "agents.x-k8s.io/pod-name"
Expand Down
23 changes: 23 additions & 0 deletions clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@
from typing import Literal, Union
from pydantic import BaseModel

class SandboxCondition(BaseModel):
"""Represents a status condition of the Sandbox."""
type: str
status: str
reason: str | None = None
message: str | None = None

class SandboxStatus(BaseModel):
"""Represents the status of the Sandbox with parsed conditions."""
conditions: list[SandboxCondition] = []

@property
def initialized(self) -> str:
return next((c.status for c in self.conditions if c.type == "Initialized"), "Unknown")

@property
def suspended(self) -> str:
return next((c.status for c in self.conditions if c.type == "Suspended"), "Unknown")

@property
def ready(self) -> str:
return next((c.status for c in self.conditions if c.type == "Ready"), "Unknown")

class ExecutionResult(BaseModel):
"""A structured object for holding the result of a command execution."""
stdout: str = "" # Standard output from the command.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
from .models import (
SandboxConnectionConfig,
SandboxLocalTunnelConnectionConfig,
SandboxTracerConfig
SandboxTracerConfig,
SandboxStatus,
)
from .k8s_helper import K8sHelper
from .connector import SandboxConnector
Expand Down Expand Up @@ -125,6 +126,12 @@ def _close_connection(self):
self._is_closed = True
logging.info(f"Connection to sandbox claim '{self.claim_name}' has been closed.")

def status(self) -> SandboxStatus:
"""Fetches the current status of the Sandbox custom resource, reflecting initialized, suspended, and ready conditions."""
sandbox_object = self.k8s_helper.get_sandbox(self.sandbox_id, self.namespace) or {}
status_dict = sandbox_object.get('status', {})
return SandboxStatus(**status_dict)

def terminate(self):
"""Permanent deletion of all server side infrastructure and client side connection."""
# Close the client side connection and trace manager lifecycle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from unittest.mock import MagicMock, patch

from k8s_agent_sandbox.sandbox import Sandbox
from k8s_agent_sandbox.models import SandboxLocalTunnelConnectionConfig, SandboxTracerConfig
from k8s_agent_sandbox.models import SandboxLocalTunnelConnectionConfig, SandboxTracerConfig, SandboxStatus


class TestSandbox(unittest.TestCase):
Expand Down Expand Up @@ -167,5 +167,25 @@ def test_terminate(self):

self.mock_k8s_helper.delete_sandbox_claim.assert_called_once_with(self.claim_name, self.namespace)

def test_status(self):
"""Tests that the status method correctly parses sandbox conditions."""
self.mock_k8s_helper.get_sandbox.return_value = {
"status": {
"conditions": [
{"type": "Initialized", "status": "True", "reason": "SandboxInitialized"},
{"type": "Suspended", "status": "False", "reason": "NotSuspended"},
{"type": "Ready", "status": "True", "reason": "SandboxReady"}
]
}
}

status = self.sandbox.status()

self.assertIsInstance(status, SandboxStatus)
self.assertEqual(status.initialized, "True")
self.assertEqual(status.suspended, "False")
self.assertEqual(status.ready, "True")
self.mock_k8s_helper.get_sandbox.assert_called_with(self.sandbox_id, self.namespace)

if __name__ == '__main__':
unittest.main()
8 changes: 8 additions & 0 deletions clients/python/agentic-sandbox-client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@

def run_sandbox_tests(sandbox: Sandbox):
"""Tests methods on the Sandbox object (execution, files, etc)."""
print("\n--- Testing Sandbox Status ---")
status = sandbox.status()
print(f"Sandbox status conditions: {status.conditions}")
assert status.initialized == "True", f"Expected initialized='True', got {status.initialized}"
assert status.suspended == "False", f"Expected suspended='False', got {status.suspended}"
assert status.ready == "True", f"Expected ready='True', got {status.ready}"
print("--- Sandbox Status Test Passed! ---")

print("\n--- Testing Command Execution ---")
command_to_run = "echo 'Hello from the sandbox shruti!'"
print(f"Executing command: '{command_to_run}'")
Expand Down
167 changes: 115 additions & 52 deletions controllers/sandbox_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ func (r *SandboxReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
// If the sandbox is being deleted, do nothing
if !sandbox.DeletionTimestamp.IsZero() {
log.Info("Sandbox is being deleted")

oldStatus := sandbox.Status.DeepCopy()
meta.SetStatusCondition(&sandbox.Status.Conditions, metav1.Condition{
Type: string(sandboxv1alpha1.SandboxConditionReady),
ObservedGeneration: sandbox.Generation,
Status: metav1.ConditionFalse,
Reason: sandboxv1alpha1.SandboxReasonDeleting,
Message: "Sandbox is terminating",
})
if err := r.updateStatus(ctx, oldStatus, sandbox); err != nil {
log.Error(err, "Failed to update status for terminating sandbox")
}

return ctrl.Result{}, nil
}

Expand Down Expand Up @@ -167,8 +180,9 @@ func (r *SandboxReconciler) reconcileChildResources(ctx context.Context, sandbox
var allErrors error

// Reconcile PVCs
err := r.reconcilePVCs(ctx, sandbox)
allErrors = errors.Join(allErrors, err)
pvcErr := r.reconcilePVCs(ctx, sandbox)
allErrors = errors.Join(allErrors, pvcErr)
pvcsProvisioned := pvcErr == nil

// Reconcile Pod
pod, err := r.reconcilePod(ctx, sandbox, nameHash)
Expand All @@ -182,73 +196,122 @@ func (r *SandboxReconciler) reconcileChildResources(ctx context.Context, sandbox
}

// Reconcile Service
svc, err := r.reconcileService(ctx, sandbox, nameHash)
_, err = r.reconcileService(ctx, sandbox, nameHash)
allErrors = errors.Join(allErrors, err)
svcsProvisioned := err == nil

// compute and set overall Ready condition
readyCondition := r.computeReadyCondition(sandbox, allErrors, svc, pod)
meta.SetStatusCondition(&sandbox.Status.Conditions, readyCondition)
// compute and set overall conditions
conditions := r.computeConditions(sandbox, svcsProvisioned, pod, pvcsProvisioned)
for _, condition := range conditions {
meta.SetStatusCondition(&sandbox.Status.Conditions, condition)
}

return allErrors
}

func (r *SandboxReconciler) computeReadyCondition(sandbox *sandboxv1alpha1.Sandbox, err error, svc *corev1.Service, pod *corev1.Pod) metav1.Condition {
readyCondition := metav1.Condition{
func (r *SandboxReconciler) computeConditions(sandbox *sandboxv1alpha1.Sandbox, svcsProvisioned bool, pod *corev1.Pod, pvcsProvisioned bool) []metav1.Condition {
var conditions []metav1.Condition
gen := sandbox.Generation

// 1. Initialized Condition
initialized := metav1.Condition{
Type: string(sandboxv1alpha1.SandboxConditionInitialized),
ObservedGeneration: gen,
Status: metav1.ConditionFalse,
Reason: sandboxv1alpha1.SandboxReasonInitializing,
Message: "Provisioning dependencies",
}
if svcsProvisioned && pvcsProvisioned {
initialized.Status = metav1.ConditionTrue
initialized.Reason = sandboxv1alpha1.SandboxReasonInitialized
initialized.Message = "Service and PVCs are provisioned"
}
conditions = append(conditions, initialized)

// 2. Suspended Condition
suspended := metav1.Condition{
Type: string(sandboxv1alpha1.SandboxConditionSuspended),
ObservedGeneration: gen,
Status: metav1.ConditionUnknown,
Reason: sandboxv1alpha1.SandboxReasonPendingEvaluation,
Message: "The suspension status has not yet been determined.",
}
isSuspended := sandbox.Spec.Replicas != nil && *sandbox.Spec.Replicas == 0
if isSuspended {
suspended.Status = metav1.ConditionTrue
suspended.Reason = sandboxv1alpha1.SandboxReasonUserSuspended
suspended.Message = "Sandbox has been suspended by the user"
} else if pod != nil {
suspended.Status = metav1.ConditionFalse
suspended.Reason = sandboxv1alpha1.SandboxReasonNotSuspended
suspended.Message = "Sandbox is operational and not suspended"
}
conditions = append(conditions, suspended)

// 3. Ready Condition
ready := metav1.Condition{
Type: string(sandboxv1alpha1.SandboxConditionReady),
ObservedGeneration: sandbox.Generation,
Message: "",
ObservedGeneration: gen,
Status: metav1.ConditionFalse,
Reason: "DependenciesNotReady",
Reason: sandboxv1alpha1.SandboxReasonInitializing,
Message: "Sandbox is initializing",
}

if err != nil {
readyCondition.Reason = "ReconcilerError"
readyCondition.Message = "Error seen: " + err.Error()
return readyCondition
}
expired, _ := checkSandboxExpiry(sandbox)

message := ""
podReady := false
if pod != nil {
message = "Pod exists with phase: " + string(pod.Status.Phase)
// Check if pod Ready condition is true
if pod.Status.Phase == corev1.PodRunning {
message = "Pod is Running but not Ready"
if !sandbox.DeletionTimestamp.IsZero() {
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonDeleting
ready.Message = "Sandbox is terminating"
} else if expired {
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonExpired
ready.Message = "Sandbox has expired"
} else if initialized.Status == metav1.ConditionFalse {
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonInitializing
ready.Message = "Waiting for Sandbox to be provisioned"
} else if isSuspended {
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonSuspended
ready.Message = "Sandbox is suspended"
} else if pod == nil {
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonPodProvisioning
ready.Message = "Pod is initializing"
} else {
// Pod exists
switch pod.Status.Phase {
case corev1.PodRunning:
podIsReady := false
for _, condition := range pod.Status.Conditions {
if condition.Type == corev1.PodReady {
if condition.Status == corev1.ConditionTrue {
message = "Pod is Ready"
podReady = true
}
if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue {
podIsReady = true
break
}
}
if podIsReady {
ready.Status = metav1.ConditionTrue
ready.Reason = sandboxv1alpha1.SandboxReasonReady
ready.Message = "Sandbox is operational"
} else {
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonPodProvisioning
ready.Message = "Pod is Running but not Ready"
}
case corev1.PodUnknown:
ready.Status = metav1.ConditionUnknown
ready.Reason = sandboxv1alpha1.SandboxReasonUnresponsive
ready.Message = "Pod status is unknown"
default:
ready.Status = metav1.ConditionFalse
ready.Reason = sandboxv1alpha1.SandboxReasonPodProvisioning
ready.Message = "Pod is in phase: " + string(pod.Status.Phase)
}
} else {
if sandbox.Spec.Replicas != nil && *sandbox.Spec.Replicas == 0 {
message = "Pod does not exist, replicas is 0"
// This is intended behaviour. So marking it ready.
podReady = true
} else {
message = "Pod does not exist"
}
}

svcReady := false
if svc != nil {
message += "; Service Exists"
svcReady = true
} else {
message += "; Service does not exist"
}

readyCondition.Message = message
if podReady && svcReady {
readyCondition.Status = metav1.ConditionTrue
readyCondition.Reason = "DependenciesReady"
}
conditions = append(conditions, ready)

return readyCondition
return conditions
}

func (r *SandboxReconciler) updateStatus(ctx context.Context, oldStatus *sandboxv1alpha1.SandboxStatus, sandbox *sandboxv1alpha1.Sandbox) error {
Expand Down Expand Up @@ -624,7 +687,7 @@ func checkSandboxExpiry(sandbox *sandboxv1alpha1.Sandbox) (bool, time.Duration)
// sandboxMarkedExpired checks if the sandbox is already marked as expired
func sandboxMarkedExpired(sandbox *sandboxv1alpha1.Sandbox) bool {
cond := meta.FindStatusCondition(sandbox.Status.Conditions, string(sandboxv1alpha1.SandboxConditionReady))
return cond != nil && cond.Reason == sandboxv1alpha1.SandboxReasonExpired
return cond != nil && (cond.Reason == sandboxv1alpha1.SandboxReasonExpired)
}

// SetupWithManager sets up the controller with the Manager.
Expand Down
Loading