kubeclaw/internal/controller/agent_controller.go
Marcus (agent-cos) ece81f651c feat: full kubeclaw operator — CRDs, controllers, deploy manifests, Helm chart
- 7 CRDs (Agent, AgentConfig, AgentGroup, Channel, Provider, WorkflowTemplate, CompliancePolicy)
- 3 controllers (Agent, WorkflowTemplate, CompliancePolicy)
- Generated deepcopy methods (controller-gen v0.16.5)
- Dockerfile (multi-stage Go build)
- Nix build-image.nix (dockerTools.streamLayeredImage)
- Helm chart with RBAC, deployment, service account
- Deploy manifests (RBAC + controller deployment)
- Sample manifests for all 7 CRDs
- Quickstart script
- Successfully deployed to k3s on n97-mini:
  - Controller running and reconciling
  - All 7 CRDs registered
  - Agent CR creates Pod (ErrImagePull expected — no agent image yet)
  - Controller logs show reconciliation working
2026-05-12 17:08:31 -07:00

190 lines
5.9 KiB
Go

/*
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)
}