| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- package reconciler
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "maps"
- "sort"
- "strings"
- "github.com/LocoStack/loco-operator/api/v1alpha1"
- "github.com/LocoStack/loco-operator/pkg/templates/litellm"
- corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
- "sigs.k8s.io/controller-runtime/pkg/client"
- "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
- )
- type AgentReconciler struct {
- client client.Client
- scheme *runtime.Scheme
- stack *v1alpha1.Stack
- component *v1alpha1.Component
- }
- func NewAgentReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *AgentReconciler {
- return &AgentReconciler{
- client: client,
- scheme: scheme,
- stack: stack,
- component: component,
- }
- }
- type agentComponentEndpoint struct {
- Name string
- URL string
- }
- type AgentComponents struct {
- Gateway agentComponentEndpoint
- GatewayKey string
- Model agentComponentEndpoint
- Skills []string
- Tools []agentComponentEndpoint
- KnowledgeBases []agentComponentEndpoint
- Agents []agentComponentEndpoint
- }
- func (r *AgentReconciler) getDependencies(ctx context.Context) ([]client.Object, *AgentComponents, error) {
- comps := &AgentComponents{
- Tools: []agentComponentEndpoint{},
- KnowledgeBases: []agentComponentEndpoint{},
- }
- endpointResolver := NewEndpointResolver(r.client, r.scheme, r.stack.Namespace)
- deps := make([]client.Object, 0)
- for _, dep := range r.component.Spec.Dependencies {
- obj, endpoint, err := endpointResolver.ResolveEndpoint(ctx, r.stack, dep)
- if err != nil {
- return nil, nil, fmt.Errorf("Failed to resolve endpoint for %s %s: %w", dep.Kind, dep.Name, err)
- }
- switch dep.Kind {
- case "ExternalModel":
- fallthrough
- case "ManagedModel":
- var category string
- if extModel, ok := obj.(*v1alpha1.ExternalModel); ok {
- category = extModel.Spec.Category
- comps.Model.Name = extModel.Spec.ModelName
- } else if managedModel, ok := obj.(*v1alpha1.ManagedModel); ok {
- category = managedModel.Spec.Category
- comps.Model.Name = managedModel.Spec.ModelName
- } else {
- return nil, nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
- }
- if category != "language" {
- return nil, nil, fmt.Errorf("Unsupported model category for agent: %s", category)
- }
- comps.Model.URL = endpoint
- case "Skill":
- comps.Skills = append(comps.Skills, dep.Name)
- case "ExternalTool":
- fallthrough
- case "ManagedTool":
- var toolName string
- if extTool, ok := obj.(*v1alpha1.ExternalTool); ok {
- toolName = extTool.Spec.ToolName
- } else if managedTool, ok := obj.(*v1alpha1.ManagedTool); ok {
- toolName = managedTool.Spec.ToolName
- }
- comps.Tools = append(comps.Tools, agentComponentEndpoint{Name: toolName, URL: endpoint})
- case "ExternalKnowledgeBase":
- fallthrough
- case "ManagedKnowledgeBase":
- comps.KnowledgeBases = append(comps.KnowledgeBases, agentComponentEndpoint{Name: dep.Name, URL: endpoint})
- case "Agent":
- comps.Agents = append(comps.Agents, agentComponentEndpoint{Name: dep.Name, URL: endpoint})
- default:
- return nil, nil, fmt.Errorf("Unsupported dependency kind for agent: %s", dep.Kind)
- }
- deps = append(deps, obj)
- }
- sort.Strings(comps.Skills)
- sort.Slice(comps.Tools, func(i, j int) bool {
- return comps.Tools[i].Name < comps.Tools[j].Name
- })
- sort.Slice(comps.KnowledgeBases, func(i, j int) bool {
- return comps.KnowledgeBases[i].Name < comps.KnowledgeBases[j].Name
- })
- sort.Slice(comps.Agents, func(i, j int) bool {
- return comps.Agents[i].Name < comps.Agents[j].Name
- })
- gatewayComp, err := endpointResolver.resolveGatewayEndpoint(ctx, r.stack)
- if err != nil {
- return nil, nil, fmt.Errorf("Failed to resolve gateway for %s: %w", r.component.Name, err)
- }
- comps.Gateway = agentComponentEndpoint{Name: gatewayComp.Name, URL: gatewayComp.Status.Endpoint}
- masterKey, err := r.gatewayMasterKey(ctx, gatewayComp)
- if err != nil {
- return nil, nil, fmt.Errorf("resolving gateway master key: %w", err)
- }
- comps.GatewayKey = masterKey
- return deps, comps, nil
- }
- func (r *AgentReconciler) gatewayMasterKey(ctx context.Context, gatewayComp *v1alpha1.Component) (string, error) {
- if gatewayComp == nil {
- return "", nil
- }
- secret := &corev1.Secret{}
- secretName := litellm.MasterKeySecretName(gatewayComp.Name)
- if err := r.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: r.component.GetNamespace()}, secret); err != nil {
- return "", fmt.Errorf("Failed to get gateway master key secret %s: %w", secretName, err)
- }
- key, ok := secret.Data[litellm.LITELLM_AUTH_SECRET_KEY]
- if !ok {
- return "", fmt.Errorf("Secret %s does not contain key %q", secretName, litellm.LITELLM_AUTH_SECRET_KEY)
- }
- return string(key), nil
- }
- // AddSkillsToTmpl adds skill mounts to the template,
- // mounting each dependency Skill's ConfigMap under <skillsRoot>/skills/<skillName>,
- // and returns a hash of the mounted files. Agent runtimes read their skills once at
- // startup, and a ConfigMap edited in place never restarts the Pod, so callers fold that
- // hash into the Pod template to roll the agent out whenever a Skill's content changes.
- // A Skill's ConfigMap stores one entry per file (SKILL.md plus its scripts/references/
- // assets), keyed by v1alpha1.SkillConfigMapKey(path) rather than the raw relative path,
- // since ConfigMap keys cannot contain "/". The ConfigMap's v1alpha1.SkillFilePathsAnnotation
- // records the key -> original relative path mapping, so it can be read directly rather than
- // re-fetched and recomputed from the Skill spec; a ConfigMapVolumeSource with per-key Items
- // then lays the files back out as a real directory tree using that mapping.
- func (r *AgentReconciler) AddSkillsToTmpl(ctx context.Context, comps *AgentComponents, tmpl *v1alpha1.Template, skillsRoot string) (string, error) {
- volumes := make([]corev1.Volume, 0, len(comps.Skills))
- volumeMounts := make([]corev1.VolumeMount, 0, len(comps.Skills))
- skillsHash := sha256.New()
- for _, skillName := range comps.Skills {
- cm := &corev1.ConfigMap{}
- if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.stack.Namespace, Name: skillName}, cm); err != nil {
- return "", fmt.Errorf("Failed to get ConfigMap for Skill %s: %w", skillName, err)
- }
- filePathsJSON, ok := cm.Annotations[v1alpha1.SkillFilePathsAnnotation]
- if !ok {
- return "", fmt.Errorf("ConfigMap for Skill %s is missing the %q annotation", skillName, v1alpha1.SkillFilePathsAnnotation)
- }
- var filePaths map[string]string
- if err := json.Unmarshal([]byte(filePathsJSON), &filePaths); err != nil {
- return "", fmt.Errorf("unmarshaling file paths for Skill %s: %w", skillName, err)
- }
- keys := sortedKeys(filePaths)
- items := make([]corev1.KeyToPath, 0, len(keys))
- for _, key := range keys {
- items = append(items, corev1.KeyToPath{
- Key: key,
- Path: filePaths[key],
- })
- _, _ = fmt.Fprintf(skillsHash, "%s/%s=%s\n", skillName, filePaths[key], cm.Data[key])
- }
- volumes = append(volumes, corev1.Volume{
- Name: skillName,
- VolumeSource: corev1.VolumeSource{
- ConfigMap: &corev1.ConfigMapVolumeSource{
- LocalObjectReference: corev1.LocalObjectReference{Name: skillName},
- Items: items,
- },
- },
- })
- volumeMounts = append(volumeMounts, corev1.VolumeMount{
- Name: skillName,
- MountPath: fmt.Sprintf("%s/skills/%s", skillsRoot, skillName),
- ReadOnly: true,
- })
- }
- tmpl.Spec.Volumes = append(tmpl.Spec.Volumes, volumes...)
- tmpl.Spec.Runtime.VolumeMounts = append(tmpl.Spec.Runtime.VolumeMounts, volumeMounts...)
- return hex.EncodeToString(skillsHash.Sum(nil)), nil
- }
- type mcpConfig struct {
- MCPServers map[string]mcpServer `json:"mcpServers"`
- }
- type mcpServer struct {
- URL string `json:"url"`
- Headers map[string]string `json:"headers,omitempty"`
- }
- // reconcileMcpConfig writes the agent's mcp.json as a Secret (not a ConfigMap): when a
- // gateway is present, the generated config embeds the resolved litellm master key value
- // in the auth header, so the credential must not land in a ConfigMap.
- func (r *AgentReconciler) reconcileMcpConfig(ctx context.Context, comps *AgentComponents) (string, error) {
- cfg := mcpConfig{MCPServers: make(map[string]mcpServer)}
- if comps.Gateway.URL != "" && len(comps.Tools) > 0 {
- toolNames := make([]string, 0, len(comps.Tools))
- for _, tool := range comps.Tools {
- toolNames = append(toolNames, tool.Name)
- }
- sort.Strings(toolNames)
- cfg.MCPServers["litellm"] = mcpServer{
- URL: fmt.Sprintf("%s/%s/mcp", comps.Gateway.URL, strings.Join(toolNames, ",")),
- Headers: map[string]string{
- litellm.MCPAuthHeaderName: "Bearer " + comps.GatewayKey,
- },
- }
- } else {
- for _, tool := range comps.Tools {
- cfg.MCPServers[tool.Name] = mcpServer{URL: tool.URL}
- }
- }
- jsonBytes, err := json.Marshal(cfg)
- if err != nil {
- return "", fmt.Errorf("marshaling agent mcp config: %w", err)
- }
- h := sha256.Sum256(jsonBytes)
- configHash := hex.EncodeToString(h[:])
- secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-mcp-config", r.component.GetName()), Namespace: r.component.GetNamespace()}}
- _, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
- if secret.Labels == nil {
- secret.Labels = map[string]string{}
- }
- maps.Copy(secret.Labels, ResourceLabels(r.stack.Name, r.component.Spec.Category, r.component.GetName(), ""))
- if secret.Annotations == nil {
- secret.Annotations = make(map[string]string)
- }
- secret.Annotations["hash"] = configHash
- secret.Data = map[string][]byte{
- "mcp.json": jsonBytes,
- }
- return controllerutil.SetControllerReference(r.component, secret, r.scheme)
- })
- return configHash, err
- }
|