/* Copyright 2025 Scott Scoble. Licensed under the MIT License. */ package controller import ( "context" "fmt" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" kubeclawv1alpha1 "github.com/sscoble/kubeclaw/api/v1alpha1" ) // AgentReconciler reconciles Agent objects. type AgentReconciler struct { client.Client Scheme *runtime.Scheme } func (r *AgentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) logger.Info("reconciling Agent", "name", req.Name, "namespace", req.Namespace) agent := &kubeclawv1alpha1.Agent{} if err := r.Get(ctx, req.NamespacedName, agent); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // Handle deletion if !agent.DeletionTimestamp.IsZero() { if controllerutil.ContainsFinalizer(agent, "kubeclaw.scoble.me/finalizer") { podName := fmt.Sprintf("kubeclaw-agent-%s", agent.Spec.AgentID) pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: podName, Namespace: req.Namespace}} if err := r.Delete(ctx, pod); err != nil && !errors.IsNotFound(err) { return ctrl.Result{}, err } controllerutil.RemoveFinalizer(agent, "kubeclaw.scoble.me/finalizer") if err := r.Update(ctx, agent); err != nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil } // Ensure finalizer if !controllerutil.ContainsFinalizer(agent, "kubeclaw.scoble.me/finalizer") { controllerutil.AddFinalizer(agent, "kubeclaw.scoble.me/finalizer") if err := r.Update(ctx, agent); err != nil { return ctrl.Result{}, err } return ctrl.Result{Requeue: true}, nil } // Resolve AgentConfig if agent.Spec.AgentConfigRef != "" { acfg := &kubeclawv1alpha1.AgentConfig{} if err := r.Get(ctx, types.NamespacedName{Name: agent.Spec.AgentConfigRef, Namespace: req.Namespace}, acfg); err != nil { logger.Error(err, "cannot fetch AgentConfig", "ref", agent.Spec.AgentConfigRef) agent.Status.Phase = "ConfigError" _ = r.Status().Update(ctx, agent) return ctrl.Result{}, err } if agent.Spec.Model == "" && acfg.Spec.Model != "" { agent.Spec.Model = acfg.Spec.Model } if agent.Spec.Thinking == "" && acfg.Spec.Thinking != "" { agent.Spec.Thinking = acfg.Spec.Thinking } } image := agent.Spec.Image if image == "" { image = "ghcr.io/sscoble/kubeclaw-agent:latest" } podName := fmt.Sprintf("kubeclaw-agent-%s", agent.Spec.AgentID) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: podName, Namespace: req.Namespace, Labels: map[string]string{ "app.kubernetes.io/name": "kubeclaw-agent", "app.kubernetes.io/instance": podName, "app.kubernetes.io/part-of": "scobleclaw", "kubeclaw.scoble.me/agent-id": agent.Spec.AgentID, }, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{{ Name: "agent", Image: image, Env: r.buildEnv(agent), Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), corev1.ResourceMemory: resource.MustParse("128Mi"), }, Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("500m"), corev1.ResourceMemory: resource.MustParse("256Mi"), }, }, }}, }, } // Apply resource overrides if agent.Spec.Resources != nil { if cpu, ok := agent.Spec.Resources.Requests["cpu"]; ok { if q, err := resource.ParseQuantity(cpu); err == nil { pod.Spec.Containers[0].Resources.Requests[corev1.ResourceCPU] = q } } if mem, ok := agent.Spec.Resources.Requests["memory"]; ok { if q, err := resource.ParseQuantity(mem); err == nil { pod.Spec.Containers[0].Resources.Requests[corev1.ResourceMemory] = q } } if cpu, ok := agent.Spec.Resources.Limits["cpu"]; ok { if q, err := resource.ParseQuantity(cpu); err == nil { pod.Spec.Containers[0].Resources.Limits[corev1.ResourceCPU] = q } } if mem, ok := agent.Spec.Resources.Limits["memory"]; ok { if q, err := resource.ParseQuantity(mem); err == nil { pod.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory] = q } } } // Create or update foundPod := &corev1.Pod{} err := r.Get(ctx, types.NamespacedName{Name: podName, Namespace: req.Namespace}, foundPod) if err != nil && errors.IsNotFound(err) { logger.Info("creating Pod", "name", podName) if err := r.Create(ctx, pod); err != nil { return ctrl.Result{}, err } } else if err == nil { foundPod.Spec = pod.Spec foundPod.Labels = pod.Labels if err := r.Update(ctx, foundPod); err != nil { return ctrl.Result{}, err } } agent.Status.Phase = "Running" agent.Status.ReadyReplicas = 1 if err := r.Status().Update(ctx, agent); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } func (r *AgentReconciler) buildEnv(agent *kubeclawv1alpha1.Agent) []corev1.EnvVar { env := []corev1.EnvVar{{Name: "KUBECLAW_AGENT_ID", Value: agent.Spec.AgentID}} if agent.Spec.Model != "" { env = append(env, corev1.EnvVar{Name: "KUBECLAW_MODEL", Value: agent.Spec.Model}) } if agent.Spec.Thinking != "" { env = append(env, corev1.EnvVar{Name: "KUBECLAW_THINKING", Value: agent.Spec.Thinking}) } if agent.Spec.DefaultTemplate != "" { env = append(env, corev1.EnvVar{Name: "KUBECLAW_DEFAULT_TEMPLATE", Value: agent.Spec.DefaultTemplate}) } if len(agent.Spec.AllowedTemplates) > 0 { env = append(env, corev1.EnvVar{Name: "KUBECLAW_ALLOWED_TEMPLATES", Value: joinSlice(agent.Spec.AllowedTemplates, ",")}) } return env } func (r *AgentReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&kubeclawv1alpha1.Agent{}). Owns(&corev1.Pod{}). Complete(r) }