Skip to content

Latest commit

 

History

History
528 lines (448 loc) · 20.4 KB

File metadata and controls

528 lines (448 loc) · 20.4 KB

Runner-Operator Contract: Status Updates

Problem Statement

Currently, the runner (wrapper.py) directly updates the AgenticSession CR status, which creates:

  • Race conditions with operator monitoring
  • Security issues (runner needs elevated CR write permissions)
  • Poor handling of timeouts, pod failures, stale tokens
  • Violation of "operator as source of truth" principle

Solution: Operator-Only Status Updates

Architecture Pattern

┌─────────────────────────────────────────────────────────────┐
│                     OPERATOR (Source of Truth)              │
│  - Watches Job/Pod status                                   │
│  - Updates CR status via Conditions                         │
│  - Detects timeouts, failures, ImagePullBackOff            │
│  - Refreshes SA tokens when needed                         │
│  - Retries transient errors                                │
└─────────────────────────────────────────────────────────────┘
                             ▲
                             │ Observes
                             │
┌─────────────────────────────────────────────────────────────┐
│                    KUBERNETES JOB & POD                     │
│  - Job activeDeadlineSeconds handles timeout                │
│  - Pod containerStatuses show runner state                  │
│  - Pod conditions show scheduling issues                    │
└─────────────────────────────────────────────────────────────┘
                             ▲
                             │ Runs in
                             │
┌─────────────────────────────────────────────────────────────┐
│                    RUNNER (Execution Only)                  │
│  - Executes Claude Code SDK                                 │
│  - Writes progress to annotation (not status)               │
│  - Sends messages via WebSocket                             │
│  - Exits with proper exit code                              │
│  - NO CR status updates                                     │
└─────────────────────────────────────────────────────────────┘

Runner Changes: Remove Status Updates

What Runner Should DO

  1. Write progress to annotation (not status):
async def _report_progress(self, message: str):
    """Report progress via annotation (read by operator for observability)."""
    try:
        timestamp = self._utc_iso()
        annotation_key = "ambient-code.io/runner-progress"
        annotation_value = json.dumps({
            "message": message,
            "timestamp": timestamp
        })
        await self._update_cr_annotation(annotation_key, annotation_value)
    except Exception as e:
        logging.debug(f"Progress annotation update failed (non-critical): {e}")
  1. Exit with proper exit codes:
# Success
sys.exit(0)

# User-requested stop
sys.exit(143)  # SIGTERM

# SDK error
sys.exit(1)

# Prerequisite validation failed
sys.exit(2)
  1. Send real-time updates via WebSocket (UI only, not CR):
await self._send_log("Starting Claude Code session...")

What Runner Should NOT DO

Remove these functions from wrapper.py:

# DELETE THIS - operator handles status
async def _update_cr_status(self, fields: dict, blocking: bool = False):
    # REMOVE ENTIRE FUNCTION

Remove all calls to _update_cr_status():

# DELETE THESE
await self._update_cr_status({"phase": "Running", ...})
await self._update_cr_status({"phase": "Completed", ...})
await self._update_cr_status({"phase": "Failed", ...})

Keep annotation updates (for observability):

# KEEP THIS - annotations are metadata, not status
await self._update_cr_annotation("ambient-code.io/sdk-session-id", sdk_session_id)
await self._update_cr_annotation("ambient-code.io/runner-progress", progress_json)

Operator Changes: Complete Reconciliation

1. Job Timeout Handling

Use Kubernetes Job's built-in timeout:

// In operator when creating Job
job := &batchv1.Job{
    Spec: batchv1.JobSpec{
        // Kubernetes handles timeout - no manual monitoring needed
        ActiveDeadlineSeconds: int64Ptr(sessionTimeout),
        BackoffLimit:          int32Ptr(3),
        // ...
    },
}

Detect timeout in reconciliation:

func (r *SessionReconciler) checkJobTimeout(ctx context.Context, session *unstructured.Unstructured, job *batchv1.Job) error {
    // Job exceeded ActiveDeadlineSeconds
    if job.Status.Failed > 0 {
        for _, cond := range job.Status.Conditions {
            if cond.Type == batchv1.JobFailed && cond.Reason == "DeadlineExceeded" {
                r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
                    "Timeout",
                    fmt.Sprintf("Job exceeded timeout of %d seconds", *job.Spec.ActiveDeadlineSeconds))
                r.updateStatus(ctx, session, map[string]interface{}{
                    "completionTime": metav1.Now(),
                })
                // Cleanup
                r.deleteJob(ctx, session, job)
                return nil
            }
        }
    }
    return nil
}

2. Runner Container Exit Code Detection

Map exit codes to conditions:

func (r *SessionReconciler) handleRunnerTermination(ctx context.Context, session *unstructured.Unstructured, cs *corev1.ContainerStatus) error {
    term := cs.State.Terminated

    switch term.ExitCode {
    case 0:
        // Success
        r.updateCondition(ctx, session, ConditionTypeCompleted, metav1.ConditionTrue,
            "Success", "Runner completed successfully")
        r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
            "SessionCompleted", "Session finished successfully")

    case 1:
        // SDK error
        r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
            "SDKError", fmt.Sprintf("Runner exited with error: %s", term.Message))
        r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
            "SessionFailed", term.Message)

    case 2:
        // Prerequisite validation failed
        r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
            "PrerequisiteFailed", "Required prerequisite files missing")

    case 143:
        // SIGTERM - user requested stop (already handled by StopSession)
        log.Printf("Runner received SIGTERM (user stop)")

    default:
        // Unknown error
        r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
            "UnknownError", fmt.Sprintf("Runner exited with code %d: %s", term.ExitCode, term.Message))
    }

    // Always set completion time
    r.updateStatus(ctx, session, map[string]interface{}{
        "completionTime": metav1.Now(),
    })

    // Set interactive for restart
    r.setSpecField(ctx, session, "interactive", true)

    // Cleanup Job
    r.deleteJob(ctx, session, job)

    return nil
}

3. SA Token Refresh

Monitor token age and recreate before expiration:

func (r *SessionReconciler) ensureFreshToken(ctx context.Context, session *unstructured.Unstructured) error {
    name := session.GetName()
    namespace := session.GetNamespace()

    // Get token secret
    secretName := fmt.Sprintf("ambient-runner-token-%s", name)
    secret, err := r.K8sClient.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
    if errors.IsNotFound(err) {
        // Secret missing - recreate it
        log.Printf("Token secret missing for session %s, recreating", name)
        return r.provisionRunnerToken(ctx, session)
    }
    if err != nil {
        return fmt.Errorf("failed to check token secret: %w", err)
    }

    // Check token age (ServiceAccount tokens expire after 1 hour by default)
    creationTime := secret.CreationTimestamp.Time
    age := time.Since(creationTime)

    // Refresh token if older than 45 minutes (15 min buffer)
    if age > 45*time.Minute {
        log.Printf("Token for session %s is %v old, refreshing", name, age)

        // Delete old secret
        err := r.K8sClient.CoreV1().Secrets(namespace).Delete(ctx, secretName, metav1.DeleteOptions{})
        if err != nil && !errors.IsNotFound(err) {
            return fmt.Errorf("failed to delete old token: %w", err)
        }

        // Create fresh token
        return r.provisionRunnerToken(ctx, session)
    }

    return nil
}

func (r *SessionReconciler) provisionRunnerToken(ctx context.Context, session *unstructured.Unstructured) error {
    name := session.GetName()
    namespace := session.GetNamespace()
    saName := fmt.Sprintf("ambient-session-%s", name)

    // Mint fresh token
    tr := &authnv1.TokenRequest{
        Spec: authnv1.TokenRequestSpec{
            // Request token with 1 hour expiration
            ExpirationSeconds: int64Ptr(3600),
        },
    }
    tok, err := r.K8sClient.CoreV1().ServiceAccounts(namespace).CreateToken(ctx, saName, tr, metav1.CreateOptions{})
    if err != nil {
        return fmt.Errorf("failed to mint token: %w", err)
    }

    // Store in secret
    secretName := fmt.Sprintf("ambient-runner-token-%s", name)
    secret := &corev1.Secret{
        ObjectMeta: metav1.ObjectMeta{
            Name:      secretName,
            Namespace: namespace,
            Labels:    map[string]string{"app": "ambient-runner-token"},
            OwnerReferences: []metav1.OwnerReference{{
                APIVersion: session.GetAPIVersion(),
                Kind:       session.GetKind(),
                Name:       session.GetName(),
                UID:        session.GetUID(),
                Controller: boolPtr(true),
            }},
        },
        Type: corev1.SecretTypeOpaque,
        StringData: map[string]string{
            "k8s-token": tok.Status.Token,
        },
    }

    _, err = r.K8sClient.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
    if errors.IsAlreadyExists(err) {
        // Update existing secret
        _, err = r.K8sClient.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{})
    }

    log.Printf("Provisioned fresh token for session %s (expires in 1h)", name)
    return err
}

4. Complete Reconciliation Loop with All Error Handling

func (r *SessionReconciler) reconcileSession(ctx context.Context, session *unstructured.Unstructured) (ctrl.Result, error) {
    name := session.GetName()
    namespace := session.GetNamespace()

    // Step 1: Ensure token is fresh (refresh if > 45min old)
    if err := r.ensureFreshToken(ctx, session); err != nil {
        r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
            "TokenRefreshFailed", fmt.Sprintf("Failed to refresh SA token: %v", err))
        return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // Retry
    }

    // Step 2: Ensure PVC exists and is bound
    pvcReady, err := r.ensurePVC(ctx, session)
    if err != nil {
        r.updateCondition(ctx, session, ConditionTypePVCReady, metav1.ConditionFalse,
            "ProvisionFailed", err.Error())
        return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
    }
    if !pvcReady {
        r.updateCondition(ctx, session, ConditionTypePVCReady, metav1.ConditionFalse,
            "Provisioning", "PVC is being provisioned")
        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
    }
    r.updateCondition(ctx, session, ConditionTypePVCReady, metav1.ConditionTrue,
        "Bound", "PVC is bound and ready")

    // Step 3: Verify secrets exist
    secretsReady, missingSecret, err := r.verifySecrets(ctx, session)
    if err != nil {
        r.updateCondition(ctx, session, ConditionTypeSecretsReady, metav1.ConditionFalse,
            "VerificationFailed", err.Error())
        return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
    }
    if !secretsReady {
        r.updateCondition(ctx, session, ConditionTypeSecretsReady, metav1.ConditionFalse,
            "SecretNotFound", fmt.Sprintf("Secret '%s' not found", missingSecret))
        return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
    }
    r.updateCondition(ctx, session, ConditionTypeSecretsReady, metav1.ConditionTrue,
        "AllSecretsFound", "All required secrets are present")

    // Step 4: Ensure Job exists
    jobName := fmt.Sprintf("%s-job", name)
    job, err := r.K8sClient.BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{})
    if errors.IsNotFound(err) {
        // Create Job
        job, err = r.createJob(ctx, session)
        if err != nil {
            r.updateCondition(ctx, session, ConditionTypeJobCreated, metav1.ConditionFalse,
                "CreationFailed", err.Error())
            return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
        }
        r.updateCondition(ctx, session, ConditionTypeJobCreated, metav1.ConditionTrue,
            "Created", "Job created successfully")
        return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // Let pod schedule
    }
    if err != nil {
        return ctrl.Result{RequeueAfter: 5 * time.Second}, fmt.Errorf("failed to get job: %w", err)
    }

    // Step 5: Check for Job timeout
    if err := r.checkJobTimeout(ctx, session, job); err != nil {
        return ctrl.Result{}, err
    }

    // Step 6: Check Job failure (backoff limit exceeded)
    if job.Spec.BackoffLimit != nil && job.Status.Failed >= *job.Spec.BackoffLimit {
        r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
            "BackoffLimitExceeded",
            fmt.Sprintf("Job failed after %d attempts", job.Status.Failed))
        r.updateStatus(ctx, session, map[string]interface{}{
            "completionTime": metav1.Now(),
        })
        r.deleteJob(ctx, session, job)
        return ctrl.Result{}, nil // Terminal
    }

    // Step 7: Monitor pod status
    pods, err := r.K8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
        LabelSelector: fmt.Sprintf("job-name=%s", jobName),
    })
    if err != nil {
        return ctrl.Result{RequeueAfter: 5 * time.Second}, err
    }

    if len(pods.Items) == 0 {
        // No pods yet - waiting for scheduler
        r.updateCondition(ctx, session, ConditionTypePodScheduled, metav1.ConditionFalse,
            "PodPending", "Waiting for pod to be scheduled")
        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
    }

    pod := pods.Items[0]

    // Check pod phase-level failures
    if pod.Status.Phase == corev1.PodFailed {
        failureMsg := fmt.Sprintf("Pod failed: %s - %s", pod.Status.Reason, pod.Status.Message)
        r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
            "PodFailed", failureMsg)
        r.updateStatus(ctx, session, map[string]interface{}{
            "completionTime": metav1.Now(),
        })
        r.deleteJob(ctx, session, job)
        return ctrl.Result{}, nil // Terminal
    }

    // Check pod scheduling
    if pod.Spec.NodeName != "" {
        r.updateCondition(ctx, session, ConditionTypePodScheduled, metav1.ConditionTrue,
            "Scheduled", fmt.Sprintf("Pod scheduled on node %s", pod.Spec.NodeName))
    } else {
        // Check for scheduling issues
        for _, cond := range pod.Status.Conditions {
            if cond.Type == corev1.PodScheduled && cond.Status == corev1.ConditionFalse {
                r.updateCondition(ctx, session, ConditionTypePodScheduled, metav1.ConditionFalse,
                    cond.Reason, cond.Message)
                return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
            }
        }
    }

    // Step 8: Check runner container status
    runnerCS := getContainerStatus(&pod, "ambient-code-runner")
    if runnerCS == nil {
        return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // Container not ready
    }

    // Container running
    if runnerCS.State.Running != nil {
        r.updateCondition(ctx, session, ConditionTypeRunnerStarted, metav1.ConditionTrue,
            "ContainerRunning", "Runner container is active")
        r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionTrue,
            "SessionRunning", "Session is running normally")

        // Set start time if not set
        if getStartTime(session) == nil {
            r.updateStatus(ctx, session, map[string]interface{}{
                "startTime": metav1.Now(),
            })
        }

        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Keep monitoring
    }

    // Container waiting (check for errors)
    if runnerCS.State.Waiting != nil {
        waiting := runnerCS.State.Waiting
        isPermanentError := false

        switch waiting.Reason {
        case "ImagePullBackOff", "ErrImagePull", "InvalidImageName":
            isPermanentError = true
        case "CrashLoopBackOff":
            isPermanentError = runnerCS.RestartCount > 3 // Permanent after 3 retries
        case "CreateContainerConfigError":
            isPermanentError = true
        }

        if isPermanentError {
            r.updateCondition(ctx, session, ConditionTypeRunnerStarted, metav1.ConditionFalse,
                waiting.Reason, waiting.Message)
            r.updateCondition(ctx, session, ConditionTypeFailed, metav1.ConditionTrue,
                waiting.Reason, fmt.Sprintf("Runner container failed: %s", waiting.Message))
            r.updateCondition(ctx, session, ConditionTypeReady, metav1.ConditionFalse,
                "SessionFailed", waiting.Message)
            r.updateStatus(ctx, session, map[string]interface{}{
                "completionTime": metav1.Now(),
            })
            r.deleteJob(ctx, session, job)
            return ctrl.Result{}, nil // Terminal
        } else {
            // Transient error - keep retrying
            r.updateCondition(ctx, session, ConditionTypeRunnerStarted, metav1.ConditionFalse,
                waiting.Reason, waiting.Message)
            return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
        }
    }

    // Container terminated
    if runnerCS.State.Terminated != nil {
        return r.handleRunnerTermination(ctx, session, runnerCS)
    }

    return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}

Migration Strategy

Phase 1: Add Operator Capabilities (No Breaking Changes)

  1. Update operator to handle all failure scenarios
  2. Add token refresh logic
  3. Add condition-based reconciliation
  4. Keep runner status updates for now (backward compatible)

Phase 2: Update Runner (Gradual Rollout)

  1. Add exit code signaling
  2. Add progress annotations
  3. Keep status updates temporarily (log deprecation warnings)

Phase 3: Remove Runner Status Updates

  1. Remove _update_cr_status() from wrapper.py
  2. Update runner RBAC to remove CR write permissions
  3. Operator is now sole source of truth

Phase 4: Validation

  1. Test all failure scenarios (timeout, ImagePullBackOff, etc.)
  2. Verify token refresh works
  3. Monitor for race conditions
  4. Verify UI shows correct status from conditions

Benefits

No stuck sessions - Operator detects and handles all failure modes ✅ Better security - Runner doesn't need CR write access ✅ Token refresh - Operator handles expiration automatically ✅ Timeout handling - Kubernetes Job handles it natively ✅ Clearer debugging - Conditions show exactly what failed ✅ Separation of concerns - Runner executes, operator manages lifecycle

Testing Scenarios

  1. Happy path: Session completes successfully → exit code 0
  2. Timeout: Job exceeds ActiveDeadlineSeconds → Condition: Timeout
  3. Image pull error: Bad image → Condition: ImagePullBackOff
  4. Secret missing: Runner secrets not found → Condition: SecretsNotReady
  5. Stale token: Token > 45min old → Auto-refreshed before job creation
  6. Pod eviction: Node pressure → Condition: PodEvicted, Job retries
  7. Runner crash: SDK error → exit code 1 → Condition: SDKError
  8. User stop: DELETE job → exit code 143 → Condition: Stopped