common.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. /*
  2. Copyright 2026 LocoStack.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package reconciler
  14. import (
  15. "context"
  16. "fmt"
  17. "sort"
  18. "strings"
  19. "github.com/LocoStack/loco-operator/api/v1alpha1"
  20. corev1 "k8s.io/api/core/v1"
  21. "k8s.io/apimachinery/pkg/runtime"
  22. "sigs.k8s.io/controller-runtime/pkg/client"
  23. )
  24. type ComponentReconciler interface {
  25. // create all resources for the component, and return the list of dependencies
  26. ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error)
  27. ComponentAvailable(ctx context.Context) bool
  28. ResourceName(name string) string
  29. }
  30. func ResourceLabels(stack, component, name, namespace string) map[string]string {
  31. labels := map[string]string{
  32. "app.kubernetes.io/managed-by": "loco-operator",
  33. "locostack.com/stack": stack,
  34. "locostack.com/component": component,
  35. "locostack.com/name": name,
  36. }
  37. if namespace != "" {
  38. labels["locostack.com/namespace"] = namespace
  39. }
  40. return labels
  41. }
  42. // substituteVariables replaces placeholders in the input string with actual values from the variables map.
  43. // Placeholders are in the format $(placeholder).
  44. func substituteVariables(variables map[string]string, input string) (bool, string) {
  45. for placeholder, value := range variables {
  46. if !strings.Contains(input, "$(") {
  47. return true, input
  48. }
  49. if strings.Contains(input, fmt.Sprintf("$(%s)", placeholder)) {
  50. input = strings.ReplaceAll(input, fmt.Sprintf("$(%s)", placeholder), value)
  51. }
  52. }
  53. return false, input
  54. }
  55. func mustSubstituteVariables(variables map[string]string, input string) string {
  56. _, substituted := substituteVariables(variables, input)
  57. return substituted
  58. }
  59. func buildVolumes(variables map[string]string, tmpl *v1alpha1.Template) []corev1.Volume {
  60. volumes := make([]corev1.Volume, len(tmpl.Spec.Volumes))
  61. if len(volumes) == 0 {
  62. volumes = nil
  63. } else {
  64. for i, vol := range tmpl.Spec.Volumes {
  65. volumes[i] = vol
  66. if vol.VolumeSource.ConfigMap != nil {
  67. volumes[i].VolumeSource.ConfigMap.LocalObjectReference.Name = mustSubstituteVariables(variables, vol.VolumeSource.ConfigMap.LocalObjectReference.Name)
  68. }
  69. if vol.VolumeSource.Secret != nil {
  70. volumes[i].VolumeSource.Secret.SecretName = mustSubstituteVariables(variables, vol.VolumeSource.Secret.SecretName)
  71. }
  72. if vol.VolumeSource.PersistentVolumeClaim != nil {
  73. volumes[i].VolumeSource.PersistentVolumeClaim.ClaimName = mustSubstituteVariables(variables, vol.VolumeSource.PersistentVolumeClaim.ClaimName)
  74. }
  75. }
  76. }
  77. return volumes
  78. }
  79. func buildContainerSpec(variables map[string]string, tmpl *v1alpha1.Template) corev1.Container {
  80. command := make([]string, len(tmpl.Spec.Runtime.Command))
  81. if len(command) == 0 {
  82. command = nil
  83. } else {
  84. for i, cmd := range tmpl.Spec.Runtime.Command {
  85. command[i] = mustSubstituteVariables(variables, cmd)
  86. }
  87. }
  88. args := make([]string, len(tmpl.Spec.Runtime.Args))
  89. if len(args) == 0 {
  90. args = nil
  91. } else {
  92. for i, arg := range tmpl.Spec.Runtime.Args {
  93. args[i] = mustSubstituteVariables(variables, arg)
  94. }
  95. for _, arg := range tmpl.Spec.Runtime.ExtraArgs {
  96. args = append(args, mustSubstituteVariables(variables, arg))
  97. }
  98. for _, arg := range tmpl.Spec.Runtime.ConditionalArgs {
  99. complete, substituted := substituteVariables(variables, arg.When)
  100. if complete && substituted != "" {
  101. for _, a := range arg.Args {
  102. args = append(args, mustSubstituteVariables(variables, a))
  103. }
  104. }
  105. }
  106. }
  107. env := make([]corev1.EnvVar, len(tmpl.Spec.Runtime.Env))
  108. if len(env) == 0 {
  109. env = nil
  110. } else {
  111. for i, envVar := range tmpl.Spec.Runtime.Env {
  112. env[i] = envVar
  113. env[i].Value = mustSubstituteVariables(variables, envVar.Value)
  114. if envVar.ValueFrom != nil && envVar.ValueFrom.SecretKeyRef != nil {
  115. env[i].ValueFrom.SecretKeyRef.LocalObjectReference.Name = mustSubstituteVariables(variables, envVar.ValueFrom.SecretKeyRef.LocalObjectReference.Name)
  116. env[i].ValueFrom.SecretKeyRef.Key = mustSubstituteVariables(variables, envVar.ValueFrom.SecretKeyRef.Key)
  117. }
  118. }
  119. }
  120. envFrom := make([]corev1.EnvFromSource, len(tmpl.Spec.Runtime.EnvFrom))
  121. if len(envFrom) == 0 {
  122. envFrom = nil
  123. } else {
  124. for i, envFromSource := range tmpl.Spec.Runtime.EnvFrom {
  125. envFrom[i] = envFromSource
  126. if envFromSource.ConfigMapRef != nil {
  127. envFrom[i].ConfigMapRef.LocalObjectReference.Name = mustSubstituteVariables(variables, envFromSource.ConfigMapRef.LocalObjectReference.Name)
  128. }
  129. if envFromSource.SecretRef != nil {
  130. envFrom[i].SecretRef.LocalObjectReference.Name = mustSubstituteVariables(variables, envFromSource.SecretRef.LocalObjectReference.Name)
  131. }
  132. }
  133. }
  134. ports := make([]corev1.ContainerPort, 0)
  135. if tmpl.Spec.Runtime.Port != 0 {
  136. ports = append(ports, corev1.ContainerPort{
  137. ContainerPort: tmpl.Spec.Runtime.Port,
  138. Protocol: corev1.ProtocolTCP,
  139. })
  140. }
  141. return corev1.Container{
  142. Name: "main",
  143. Image: tmpl.Spec.Runtime.Image,
  144. ImagePullPolicy: corev1.PullIfNotPresent,
  145. Command: command,
  146. Args: args,
  147. Env: env,
  148. EnvFrom: envFrom,
  149. VolumeMounts: tmpl.Spec.Runtime.VolumeMounts,
  150. Ports: ports,
  151. Resources: tmpl.Spec.Resources,
  152. }
  153. }
  154. func buildPodSpec(variables map[string]string, tmpl *v1alpha1.Template) (corev1.PodSpec, error) {
  155. return corev1.PodSpec{
  156. NodeSelector: tmpl.Spec.NodeSelector,
  157. Affinity: tmpl.Spec.Affinity,
  158. Tolerations: tmpl.Spec.Tolerations,
  159. TopologySpreadConstraints: tmpl.Spec.TopologySpreadConstraints,
  160. Containers: []corev1.Container{buildContainerSpec(variables, tmpl)},
  161. SecurityContext: tmpl.Spec.SecurityContext,
  162. Volumes: buildVolumes(variables, tmpl),
  163. }, nil
  164. }
  165. type EndpointResolver struct {
  166. client client.Client
  167. scheme *runtime.Scheme
  168. namespace string
  169. }
  170. func NewEndpointResolver(client client.Client, scheme *runtime.Scheme, namespace string) *EndpointResolver {
  171. return &EndpointResolver{
  172. client: client,
  173. scheme: scheme,
  174. namespace: namespace,
  175. }
  176. }
  177. func (r *EndpointResolver) ResolveEndpoint(ctx context.Context, stack *v1alpha1.Stack, ref corev1.ObjectReference) (client.Object, string, error) {
  178. switch ref.Kind {
  179. case "ExternalModel":
  180. return r.resolveExternalModelEndpoint(ctx, stack, ref.Name)
  181. case "ManagedModel":
  182. return r.resolveManagedModelEndpoint(ctx, stack, ref.Name)
  183. case "Skill":
  184. return nil, "", nil
  185. case "ExternalTool":
  186. return r.resolveExternalToolEndpoint(ctx, stack, ref.Name)
  187. case "ManagedTool":
  188. return r.resolveManagedToolEndpoint(ctx, stack, ref.Name)
  189. case "ExternalKnowledgeBase":
  190. return r.resolveExternalKnowledgeBaseEndpoint(ctx, ref.Name)
  191. case "ManagedKnowledgeBase":
  192. return r.resolveManagedKnowledgeBaseEndpoint(ctx, ref.Name)
  193. case "Component":
  194. return r.resolveComponentEndpoint(ctx, ref.Name)
  195. default:
  196. return nil, "", fmt.Errorf("unsupported reference kind: %s", ref.Kind)
  197. }
  198. }
  199. func (r *EndpointResolver) resolveGatewayEndpoint(ctx context.Context, stack *v1alpha1.Stack) (*v1alpha1.Component, error) {
  200. gatewayEnabled := true
  201. if stack.Spec.Gateway != nil {
  202. gatewayEnabled = stack.Spec.Gateway.Enabled
  203. }
  204. if !gatewayEnabled {
  205. return nil, nil
  206. }
  207. gwList := &v1alpha1.ComponentList{}
  208. err := r.client.List(ctx, gwList, client.InNamespace(stack.Namespace), client.MatchingLabels{"locostack.com/component": "Gateway", "stack.locostack.com/default": "true"})
  209. if err != nil {
  210. return nil, fmt.Errorf("Failed to list gateway components: %v", err)
  211. }
  212. if len(gwList.Items) == 0 {
  213. return nil, fmt.Errorf("No gateway component found for stack %s", stack.Name)
  214. } else if len(gwList.Items) > 1 {
  215. return nil, fmt.Errorf("Multiple gateway components found for stack %s", stack.Name)
  216. }
  217. gatewayComp := &gwList.Items[0]
  218. if gatewayComp.Status.Endpoint == "" {
  219. return nil, fmt.Errorf("Gateway component for stack %s not ready", stack.Name)
  220. }
  221. return gatewayComp, nil
  222. }
  223. func (r *EndpointResolver) resolveExternalModelEndpoint(ctx context.Context, stack *v1alpha1.Stack, name string) (client.Object, string, error) {
  224. var model v1alpha1.ExternalModel
  225. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, &model); err != nil {
  226. return nil, "", fmt.Errorf("Failed to get ExternalModel %s: %w", name, err)
  227. }
  228. gatewayComp, err := r.resolveGatewayEndpoint(ctx, stack)
  229. if err != nil {
  230. return nil, "", fmt.Errorf("Failed to resolve gateway endpoint for ExternalModel %s: %w", name, err)
  231. }
  232. if gatewayComp != nil {
  233. return &model, gatewayComp.Status.Endpoint, nil
  234. }
  235. return &model, model.Spec.APIBase, nil
  236. }
  237. func (r *EndpointResolver) resolveManagedModelEndpoint(ctx context.Context, stack *v1alpha1.Stack, name string) (client.Object, string, error) {
  238. var model v1alpha1.ManagedModel
  239. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, &model); err != nil {
  240. return nil, "", fmt.Errorf("Failed to get ManagedModel %s: %w", name, err)
  241. }
  242. gatewayComp, err := r.resolveGatewayEndpoint(ctx, stack)
  243. if err != nil {
  244. return nil, "", fmt.Errorf("Failed to resolve gateway endpoint for ManagedModel %s: %w", name, err)
  245. }
  246. if gatewayComp != nil {
  247. return &model, gatewayComp.Status.Endpoint + "/v1", nil
  248. }
  249. return &model, model.Status.Endpoint, nil
  250. }
  251. func (r *EndpointResolver) resolveExternalToolEndpoint(ctx context.Context, stack *v1alpha1.Stack, name string) (client.Object, string, error) {
  252. var tool v1alpha1.ExternalTool
  253. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, &tool); err != nil {
  254. return nil, "", fmt.Errorf("Failed to get ExternalTool %s: %w", name, err)
  255. }
  256. gatewayComp, err := r.resolveGatewayEndpoint(ctx, stack)
  257. if err != nil {
  258. return nil, "", fmt.Errorf("Failed to resolve gateway endpoint for ExternalTool %s: %w", name, err)
  259. }
  260. if gatewayComp != nil {
  261. return &tool, gatewayComp.Status.Endpoint + "/mcp", nil
  262. }
  263. return &tool, tool.Spec.Endpoint, nil
  264. }
  265. func (r *EndpointResolver) resolveManagedToolEndpoint(ctx context.Context, stack *v1alpha1.Stack, name string) (client.Object, string, error) {
  266. var tool v1alpha1.ManagedTool
  267. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, &tool); err != nil {
  268. return nil, "", fmt.Errorf("Failed to get ManagedTool %s: %w", name, err)
  269. }
  270. gatewayComp, err := r.resolveGatewayEndpoint(ctx, stack)
  271. if err != nil {
  272. return nil, "", fmt.Errorf("Failed to resolve gateway endpoint for ManagedTool %s: %w", name, err)
  273. }
  274. if gatewayComp != nil {
  275. return &tool, gatewayComp.Status.Endpoint, nil
  276. }
  277. return &tool, tool.Status.Endpoint, nil
  278. }
  279. func (r *EndpointResolver) resolveExternalKnowledgeBaseEndpoint(ctx context.Context, name string) (client.Object, string, error) {
  280. var kb v1alpha1.ExternalKnowledgeBase
  281. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, &kb); err != nil {
  282. return nil, "", fmt.Errorf("Failed to get ExternalKnowledgeBase %s: %w", name, err)
  283. }
  284. // TODO: use gateway endpoint
  285. return &kb, "", nil
  286. }
  287. func (r *EndpointResolver) resolveManagedKnowledgeBaseEndpoint(ctx context.Context, name string) (client.Object, string, error) {
  288. var kb v1alpha1.ManagedKnowledgeBase
  289. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, &kb); err != nil {
  290. return nil, "", fmt.Errorf("Failed to get ManagedKnowledgeBase %s: %w", name, err)
  291. }
  292. return &kb, kb.Status.Endpoint, nil
  293. }
  294. func (r *EndpointResolver) resolveComponentEndpoint(ctx context.Context, name string) (client.Object, string, error) {
  295. comp := &v1alpha1.Component{}
  296. if err := r.client.Get(ctx, client.ObjectKey{Namespace: r.namespace, Name: name}, comp); err != nil {
  297. return nil, "", fmt.Errorf("Failed to get Component %s: %w", name, err)
  298. }
  299. return comp, comp.Status.Endpoint, nil
  300. }
  301. func sortedKeys[V any](m map[string]V) []string {
  302. keys := make([]string, 0, len(m))
  303. for key := range m {
  304. keys = append(keys, key)
  305. }
  306. sort.Strings(keys)
  307. return keys
  308. }