- 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
190 lines
5.9 KiB
Go
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)
|
|
}
|