document_controller.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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 controller
  14. import (
  15. "context"
  16. "fmt"
  17. "strings"
  18. batchv1 "k8s.io/api/batch/v1"
  19. corev1 "k8s.io/api/core/v1"
  20. apierrors "k8s.io/apimachinery/pkg/api/errors"
  21. apimeta "k8s.io/apimachinery/pkg/api/meta"
  22. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  23. "k8s.io/apimachinery/pkg/runtime"
  24. ctrl "sigs.k8s.io/controller-runtime"
  25. "sigs.k8s.io/controller-runtime/pkg/client"
  26. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  27. logf "sigs.k8s.io/controller-runtime/pkg/log"
  28. "github.com/LocoStack/loco-operator/api/v1alpha1"
  29. "github.com/LocoStack/loco-operator/internal/reconciler"
  30. "github.com/LocoStack/loco-operator/pkg/templates"
  31. )
  32. const (
  33. finalizerDocumentCleanup = "locostack.com/document-cleanup"
  34. )
  35. // DocumentReconciler reconciles a Document object
  36. type DocumentReconciler struct {
  37. client.Client
  38. Scheme *runtime.Scheme
  39. }
  40. // +kubebuilder:rbac:groups=locostack.com,resources=documents,verbs=get;list;watch;create;update;patch;delete
  41. // +kubebuilder:rbac:groups=locostack.com,resources=documents/status,verbs=get;update;patch
  42. // +kubebuilder:rbac:groups=locostack.com,resources=documents/finalizers,verbs=update
  43. // +kubebuilder:rbac:groups=locostack.com,resources=knowledgebases,verbs=get;list;watch
  44. // +kubebuilder:rbac:groups=locostack.com,resources=components,verbs=get;list;watch
  45. // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
  46. func (r *DocumentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  47. log := logf.FromContext(ctx)
  48. doc := &v1alpha1.Document{}
  49. if err := r.Get(ctx, req.NamespacedName, doc); err != nil {
  50. if apierrors.IsNotFound(err) {
  51. return ctrl.Result{}, nil
  52. }
  53. log.Error(err, "Failed to get Document", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  54. return ctrl.Result{}, err
  55. }
  56. patch := client.MergeFrom(doc.DeepCopy())
  57. ingestionJobTemplate, egestionJobTemplate, stack, endpoint, err := r.reconcileKB(ctx, doc)
  58. if err != nil {
  59. return ctrl.Result{}, err
  60. }
  61. if !controllerutil.ContainsFinalizer(doc, finalizerDocumentCleanup) {
  62. patch := client.MergeFrom(doc.DeepCopy())
  63. controllerutil.AddFinalizer(doc, finalizerDocumentCleanup)
  64. return ctrl.Result{}, r.Patch(ctx, doc, patch)
  65. }
  66. if !doc.DeletionTimestamp.IsZero() {
  67. egested, egestionFailed, err := r.reconcileEgestion(ctx, doc, egestionJobTemplate, stack, endpoint)
  68. if err != nil {
  69. return ctrl.Result{}, err
  70. }
  71. if !egested {
  72. return ctrl.Result{}, nil
  73. }
  74. if egestionFailed {
  75. // Handle egestion failure if necessary
  76. return ctrl.Result{}, fmt.Errorf("Egestion failed for document %s/%s", req.NamespacedName.Namespace, req.NamespacedName.Name)
  77. }
  78. controllerutil.RemoveFinalizer(doc, finalizerDocumentCleanup)
  79. return ctrl.Result{}, r.Patch(ctx, doc, patch)
  80. }
  81. ingested, ingestionFailed, err := r.reconcileIngestion(ctx, doc, ingestionJobTemplate, stack, endpoint)
  82. if err != nil {
  83. return ctrl.Result{}, err
  84. }
  85. if ingested {
  86. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  87. Type: "Ready",
  88. Status: metav1.ConditionTrue,
  89. Reason: "Ingested",
  90. Message: "Document has been ingested into the KnowledgeBase",
  91. ObservedGeneration: doc.Generation,
  92. })
  93. } else {
  94. if ingestionFailed {
  95. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  96. Type: "Ready",
  97. Status: metav1.ConditionFalse,
  98. Reason: "Failed",
  99. Message: "Document failed to be ingested into the KnowledgeBase",
  100. ObservedGeneration: doc.Generation,
  101. })
  102. } else {
  103. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  104. Type: "Ready",
  105. Status: metav1.ConditionFalse,
  106. Reason: "Ingesting",
  107. Message: "Document is being ingested into the KnowledgeBase",
  108. ObservedGeneration: doc.Generation,
  109. })
  110. }
  111. }
  112. doc.Status.ObservedGeneration = doc.Generation
  113. if err := r.Status().Patch(ctx, doc, patch); err != nil {
  114. return ctrl.Result{}, client.IgnoreNotFound(err)
  115. }
  116. log.Info("Reconciled Document", "namespace", req.NamespacedName.Namespace, "name", req.NamespacedName.Name)
  117. return ctrl.Result{}, nil
  118. }
  119. func (r *DocumentReconciler) reconcileKB(ctx context.Context, doc *v1alpha1.Document) (*v1alpha1.Template, *v1alpha1.Template, *v1alpha1.Stack, string, error) {
  120. log := logf.FromContext(ctx)
  121. kbRef := doc.Spec.KnowledgeBaseRef
  122. if kbRef == nil {
  123. // TODO
  124. return nil, nil, nil, "", fmt.Errorf("KnowledgeBaseRef is required in Document spec")
  125. }
  126. var stackRef *corev1.LocalObjectReference
  127. var ingestionJobTemplate *v1alpha1.Template
  128. var egestionJobTemplate *v1alpha1.Template
  129. var endpoint string
  130. if kbRef.Kind == "ExternalKnowledgeBase" {
  131. // TODO
  132. } else if kbRef.Kind == "ManagedKnowledgeBase" {
  133. kb := &v1alpha1.ManagedKnowledgeBase{}
  134. if err := r.Get(ctx, client.ObjectKey{Namespace: doc.Namespace, Name: doc.Spec.KnowledgeBaseRef.Name}, kb); err != nil {
  135. if apierrors.IsNotFound(err) {
  136. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  137. Type: "KnowledgeBaseAvailable",
  138. Status: metav1.ConditionFalse,
  139. Reason: "KnowledgeBaseNotFound",
  140. Message: "Referenced KnowledgeBase not found",
  141. ObservedGeneration: doc.Generation,
  142. })
  143. return nil, nil, nil, "", nil
  144. }
  145. log.Error(err, "Failed to get referenced ManagedKnowledgeBase", "namespace", doc.Namespace, "name", doc.Spec.KnowledgeBaseRef.Name)
  146. return nil, nil, nil, "", err
  147. }
  148. stackRef = kb.Spec.StackRef
  149. ingestionJobTemplate = kb.Spec.IngestionJob
  150. egestionJobTemplate = kb.Spec.EgestionJob
  151. endpoint = kb.Status.Endpoint
  152. }
  153. stack := &v1alpha1.Stack{}
  154. if err := r.Get(ctx, client.ObjectKey{Namespace: doc.Namespace, Name: stackRef.Name}, stack); err != nil {
  155. if apierrors.IsNotFound(err) {
  156. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  157. Type: "KnowledgeBaseAvailable",
  158. Status: metav1.ConditionFalse,
  159. Reason: "StackNotFound",
  160. Message: "Stack for referenced KnowledgeBase not found",
  161. ObservedGeneration: doc.Generation,
  162. })
  163. return nil, nil, nil, "", nil
  164. }
  165. log.Error(err, "Failed to get Stack for referenced ManagedKnowledgeBase", "namespace", doc.Namespace, "name", stackRef.Name)
  166. return nil, nil, nil, "", err
  167. }
  168. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  169. Type: "KnowledgeBaseAvailable",
  170. Status: metav1.ConditionTrue,
  171. Reason: "KnowledgeBaseAvailable",
  172. Message: "Referenced KnowledgeBase is available",
  173. ObservedGeneration: doc.Generation,
  174. })
  175. return ingestionJobTemplate, egestionJobTemplate, stack, endpoint, nil
  176. }
  177. func (r *DocumentReconciler) reconcileArtifacts(ctx context.Context, stack *v1alpha1.Stack, doc *v1alpha1.Document) (*corev1.PersistentVolumeClaim, bool, bool, error) {
  178. artifactsReconciler := reconciler.NewArtifactReconciler(r.Client, r.Scheme, stack, "document", doc, &doc.Spec.Source)
  179. return artifactsReconciler.ReconcileArtifact(ctx)
  180. }
  181. func (r *DocumentReconciler) reconcileIngestion(ctx context.Context, doc *v1alpha1.Document, jobTmpl *v1alpha1.Template, stack *v1alpha1.Stack, kbEndpoint string) (bool, bool, error) {
  182. if doc.Status.Phase == "Ready" || doc.Status.Phase == "Failed" {
  183. return true, doc.Status.Phase == "Failed", nil
  184. }
  185. tmpl, err := templates.Manager.ResolveTemplate(jobTmpl, "")
  186. if err != nil {
  187. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  188. Type: "IngestionJobResolved",
  189. Status: metav1.ConditionFalse,
  190. Reason: "TemplateResolutionFailed",
  191. Message: "Failed to resolve ingestion job template: " + err.Error(),
  192. ObservedGeneration: doc.Generation,
  193. })
  194. return false, true, err
  195. }
  196. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  197. Type: "IngestionJobResolved",
  198. Status: metav1.ConditionTrue,
  199. Reason: "TemplateResolved",
  200. Message: "Ingestion job template resolved successfully",
  201. ObservedGeneration: doc.Generation,
  202. })
  203. sourcePvc, sourceReady, sourceFailed, err := r.reconcileArtifacts(ctx, stack, doc)
  204. if err != nil {
  205. return false, true, err
  206. }
  207. if sourceFailed {
  208. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  209. Type: "SourceReady",
  210. Status: metav1.ConditionFalse,
  211. Reason: "SourceFailed",
  212. Message: "Document source is not available",
  213. ObservedGeneration: doc.Generation,
  214. })
  215. return false, true, nil
  216. } else if !sourceReady {
  217. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  218. Type: "SourceReady",
  219. Status: metav1.ConditionFalse,
  220. Reason: "SourceNotReady",
  221. Message: "Document source is not ready",
  222. ObservedGeneration: doc.Generation,
  223. })
  224. return false, false, nil
  225. }
  226. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  227. Type: "SourceReady",
  228. Status: metav1.ConditionTrue,
  229. Reason: "SourceReady",
  230. Message: "Document source is ready",
  231. ObservedGeneration: doc.Generation,
  232. })
  233. doc.Status.Phase = "Ingesting"
  234. jobReconciler := reconciler.NewJobReconciler(r.Client, r.Scheme, stack, "DocumentIngestion", doc.Name, doc)
  235. variables := map[string]string{
  236. "spec.runtime.pvc": sourcePvc.Name,
  237. "spec.docId": doc.Name,
  238. "spec.chunkingStrategy.strategy": doc.Spec.ChunkingStrategy.Strategy,
  239. "spec.chunkingStrategy.chunkSize": fmt.Sprint(doc.Spec.ChunkingStrategy.ChunkSize),
  240. "spec.chunkingStrategy.chunkOverlap": fmt.Sprint(doc.Spec.ChunkingStrategy.ChunkOverlap),
  241. "spec.chunkingStrategy.separators": strings.Join(doc.Spec.ChunkingStrategy.Separators, ","),
  242. "spec.kbEndpoint": kbEndpoint,
  243. }
  244. if doc.Spec.Source.PVC != nil {
  245. variables["spec.filePath"] = doc.Spec.Source.PVC.FilePath
  246. }
  247. done, failed, err := jobReconciler.ReconcileJob(ctx, &tmpl, variables)
  248. if done {
  249. doc.Status.Phase = "Ready"
  250. } else if failed {
  251. doc.Status.Phase = "Failed"
  252. }
  253. return done, failed, err
  254. }
  255. func (r *DocumentReconciler) reconcileEgestion(ctx context.Context, doc *v1alpha1.Document, jobTmpl *v1alpha1.Template, stack *v1alpha1.Stack, kbEndpoint string) (bool, bool, error) {
  256. if doc.Status.Phase != "Ready" {
  257. // If the document is not in the "Ready" phase, we do not need egestion.
  258. return true, false, nil
  259. }
  260. doc.Status.Phase = "Egesting"
  261. jobReconciler := reconciler.NewJobReconciler(r.Client, r.Scheme, stack, "DocumentEgestion", doc.Name, doc)
  262. tmpl, err := templates.Manager.ResolveTemplate(jobTmpl, "")
  263. if err != nil {
  264. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  265. Type: "EgestionJobResolved",
  266. Status: metav1.ConditionFalse,
  267. Reason: "TemplateResolutionFailed",
  268. Message: "Failed to resolve egestion job template: " + err.Error(),
  269. ObservedGeneration: doc.Generation,
  270. })
  271. return false, true, err
  272. }
  273. apimeta.SetStatusCondition(&doc.Status.Conditions, metav1.Condition{
  274. Type: "EgestionJobResolved",
  275. Status: metav1.ConditionTrue,
  276. Reason: "TemplateResolved",
  277. Message: "Egestion job template resolved successfully",
  278. ObservedGeneration: doc.Generation,
  279. })
  280. variables := map[string]string{
  281. "spec.docId": doc.Name,
  282. "spec.kbEndpoint": kbEndpoint,
  283. }
  284. return jobReconciler.ReconcileJob(ctx, &tmpl, variables)
  285. }
  286. // SetupWithManager sets up the controller with the Manager.
  287. func (r *DocumentReconciler) SetupWithManager(mgr ctrl.Manager) error {
  288. return ctrl.NewControllerManagedBy(mgr).
  289. For(&v1alpha1.Document{}).
  290. Owns(&batchv1.Job{}).
  291. Owns(&corev1.PersistentVolumeClaim{}).
  292. Named("document").
  293. Complete(r)
  294. }