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