/* Copyright 2026 LocoStack. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package controller import ( "context" "github.com/LocoStack/loco-operator/api/v1alpha1" "github.com/LocoStack/loco-operator/internal/reconciler" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) func ComponentEventHandler(kind string) handler.EventHandler { mapFunc := func(ctx context.Context, obj client.Object) []reconcile.Request { comp, ok := obj.(*v1alpha1.Component) if !ok { return nil } var reqs []reconcile.Request for _, dep := range comp.Spec.Dependencies { if !dependencyRefMatches(dep, kind) { continue } namespace := dep.Namespace if namespace == "" { namespace = comp.GetNamespace() } reqs = append(reqs, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: dep.Name, Namespace: namespace, }, }) } return reqs } return handler.EnqueueRequestsFromMapFunc(mapFunc) } func dependencyRefMatches(ref corev1.ObjectReference, kind string) bool { if ref.Kind != kind { return false } if ref.APIVersion == "" { return true } gv, err := schema.ParseGroupVersion(ref.APIVersion) return err == nil && gv.Group == v1alpha1.GroupVersion.Group } // stackComponentCategories are the Component categories that the rest of a Stack resolves // implicitly, through the Stack rather than through spec.dependencies: the gateway URL and // master key, the vector and graph store URLs and the tracing collector endpoint are all // rendered into the configs of the other Components, so a change to one of these has to // re-render the rest of the Stack. var stackComponentCategories = map[string]bool{ "Gateway": true, "VectorStore": true, "GraphStore": true, "Observability": true, } // SkillConfigMapEventHandler maps a Skill's ConfigMap to the Components that mount it. The // ConfigMap is owned by the Skill rather than by the Component, so watching the Skill alone // would race its ConfigMap: a Component reconciled off the Skill event can still read the // previous file set, and no further event would arrive to correct it. func SkillConfigMapEventHandler(c client.Client) handler.EventHandler { return handler.EnqueueRequestsFromMapFunc(skillConfigMapMapFunc(c)) } func skillConfigMapMapFunc(c client.Client) handler.MapFunc { return func(ctx context.Context, obj client.Object) []reconcile.Request { if _, ok := obj.GetAnnotations()[v1alpha1.SkillFilePathsAnnotation]; !ok { return nil } return dependentComponentRequests(ctx, c, obj.GetNamespace(), func(comp *v1alpha1.Component) bool { return componentDependsOn(comp, "Skill", obj.GetName(), obj.GetNamespace()) }) } } // AuthSecretEventHandler maps a Secret to the Components whose config embeds a credential // read from it. Model and tool credentials reach a Pod as env vars sourced from these // Secrets, and a Pod's environment is fixed for its lifetime, so a rotated key only takes // effect once the Pod is replaced - which needs the Components depending on that model or // tool to re-render and roll out. func AuthSecretEventHandler(c client.Client) handler.EventHandler { return handler.EnqueueRequestsFromMapFunc(authSecretMapFunc(c)) } func authSecretMapFunc(c client.Client) handler.MapFunc { return func(ctx context.Context, obj client.Object) []reconcile.Request { log := logf.FromContext(ctx) namespace := obj.GetNamespace() // kind -> names of the dependencies reading a credential from this Secret refs := make(map[string]map[string]bool) addRef := func(kind, name string, auth *v1alpha1.AuthSpec) { if !authReadsSecret(auth, obj.GetName()) { return } if refs[kind] == nil { refs[kind] = make(map[string]bool) } refs[kind][name] = true } emList := &v1alpha1.ExternalModelList{} if err := c.List(ctx, emList, client.InNamespace(namespace)); err != nil { log.Error(err, "Failed to list ExternalModels for Secret event", "namespace", namespace, "secret", obj.GetName()) return nil } for i := range emList.Items { addRef("ExternalModel", emList.Items[i].Name, emList.Items[i].Spec.Auth) } etList := &v1alpha1.ExternalToolList{} if err := c.List(ctx, etList, client.InNamespace(namespace)); err != nil { log.Error(err, "Failed to list ExternalTools for Secret event", "namespace", namespace, "secret", obj.GetName()) return nil } for i := range etList.Items { addRef("ExternalTool", etList.Items[i].Name, etList.Items[i].Spec.Auth) } mtList := &v1alpha1.ManagedToolList{} if err := c.List(ctx, mtList, client.InNamespace(namespace)); err != nil { log.Error(err, "Failed to list ManagedTools for Secret event", "namespace", namespace, "secret", obj.GetName()) return nil } for i := range mtList.Items { addRef("ManagedTool", mtList.Items[i].Name, mtList.Items[i].Spec.Auth) } if len(refs) == 0 { return nil } return dependentComponentRequests(ctx, c, namespace, func(comp *v1alpha1.Component) bool { for kind, names := range refs { for name := range names { if componentDependsOn(comp, kind, name, namespace) { return true } } } return false }) } } // dependentComponentRequests enqueues every Component in the namespace that match reports // as depending on the object that triggered the event. func dependentComponentRequests(ctx context.Context, c client.Client, namespace string, match func(*v1alpha1.Component) bool) []reconcile.Request { log := logf.FromContext(ctx) compList := &v1alpha1.ComponentList{} if err := c.List(ctx, compList, client.InNamespace(namespace)); err != nil { log.Error(err, "Failed to list Components for dependency event", "namespace", namespace) return nil } var reqs []reconcile.Request for i := range compList.Items { comp := &compList.Items[i] if !match(comp) { continue } reqs = append(reqs, reconcile.Request{ NamespacedName: types.NamespacedName{Name: comp.GetName(), Namespace: comp.GetNamespace()}, }) } return reqs } // componentDependsOn reports whether comp declares the named object as a dependency. A // dependency reference with no namespace means the Component's own namespace, which is the // only namespace this is ever asked about. func componentDependsOn(comp *v1alpha1.Component, kind, name, namespace string) bool { for _, dep := range comp.Spec.Dependencies { if dep.Name != name || !dependencyRefMatches(dep, kind) { continue } if dep.Namespace == "" || dep.Namespace == namespace { return true } } return false } // authReadsSecret reports whether auth sources any of its credentials from the named Secret. func authReadsSecret(auth *v1alpha1.AuthSpec, name string) bool { for _, ref := range reconciler.AuthSecretRefs(auth) { if ref.Name == name { return true } } return false }