Browse Source

feat(kb): add Document api

Thomas Zhang 2 tháng trước cách đây
mục cha
commit
b0f8d04aa0

+ 8 - 0
PROJECT

@@ -73,4 +73,12 @@ resources:
   kind: ManagedKnowledgeBase
   path: github.com/LocoStack/loco-operator/api/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1
+    namespaced: true
+  controller: true
+  domain: locostack.com
+  kind: Document
+  path: github.com/LocoStack/loco-operator/api/v1alpha1
+  version: v1alpha1
 version: "3"

+ 139 - 0
api/v1alpha1/document_types.go

@@ -0,0 +1,139 @@
+/*
+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 v1alpha1
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// ChunkingSpec configures how document content is split into chunks before embedding.
+type ChunkingSpec struct {
+	// strategy selects the chunking algorithm.
+	// fixed: split at exact token boundaries.
+	// recursive: split on separators, respecting document structure (default).
+	// semantic: split at semantic boundaries using the embedding model.
+	// sentence, markdown, html: structure-aware strategies for specific formats.
+	// +kubebuilder:validation:Enum=fixed;recursive;markdown
+	// +kubebuilder:default=recursive
+	// +optional
+	Strategy string `json:"strategy,omitempty"`
+
+	// chunkSize is the target chunk size in tokens.
+	// +kubebuilder:validation:Minimum=1
+	// +kubebuilder:default=512
+	// +optional
+	ChunkSize int32 `json:"chunkSize,omitempty"`
+
+	// chunkOverlap is the number of tokens carried over from the previous chunk
+	// to preserve context at boundaries.
+	// +kubebuilder:validation:Minimum=0
+	// +kubebuilder:default=64
+	// +optional
+	ChunkOverlap int32 `json:"chunkOverlap,omitempty"`
+
+	// separators is an ordered list of separator strings for the recursive strategy.
+	// +optional
+	Separators []string `json:"separators,omitempty"`
+}
+
+// DocumentSpec defines the desired state of Document
+type DocumentSpec struct {
+	// knowledgeBaseRef references the target knowledge base.
+	// +kubebuilder:validation:Required
+	KnowledgeBaseRef *corev1.ObjectReference `json:"knowledgeBaseRef"`
+
+	// source describes the origin of the document content and metadata.
+	// +kubebuilder:validation:Required
+	Source ArtifactSpec `json:"source"`
+
+	// chunkingStrategy configures how the document content is split into chunks before embedding.
+	// +kubebuilder:validation:Required
+	ChunkingStrategy ChunkingSpec `json:"chunkingStrategy"`
+}
+
+// DocumentStatus defines the observed state of Document.
+type DocumentStatus struct {
+	// phase is the current ingestion phase: Pending, Ingesting, Ready, Failed, or Egesting.
+	// +kubebuilder:validation:Enum=Pending;Ingesting;Ready;Failed;Egesting
+	// +optional
+	Phase string `json:"phase,omitempty"`
+
+	// ingestedAt is the timestamp of the last successful ingestion.
+	// +optional
+	IngestedAt *metav1.Time `json:"ingestedAt,omitempty"`
+
+	// jobName is the name of the active or most-recently-completed ingestor Job.
+	// +optional
+	JobName string `json:"jobName,omitempty"`
+
+	// observedGeneration is the .metadata.generation last reconciled.
+	// +optional
+	ObservedGeneration int64 `json:"observedGeneration,omitempty"`
+
+	// For Kubernetes API conventions, see:
+	// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
+
+	// conditions represent the current state of the Document resource.
+	// Each condition has a unique type and reflects the status of a specific aspect of the resource.
+	//
+	// Standard condition types include:
+	// - "Available": the resource is fully functional
+	// - "Progressing": the resource is being created or updated
+	// - "Degraded": the resource failed to reach or maintain its desired state
+	//
+	// The status of each condition is one of True, False, or Unknown.
+	// +listType=map
+	// +listMapKey=type
+	// +optional
+	Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:shortName=doc
+// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=".status.phase"
+
+// Document is the Schema for the documents API
+type Document struct {
+	metav1.TypeMeta `json:",inline"`
+
+	// metadata is a standard object metadata
+	// +optional
+	metav1.ObjectMeta `json:"metadata,omitzero"`
+
+	// spec defines the desired state of Document
+	// +required
+	Spec DocumentSpec `json:"spec"`
+
+	// status defines the observed state of Document
+	// +optional
+	Status DocumentStatus `json:"status,omitzero"`
+}
+
+// +kubebuilder:object:root=true
+
+// DocumentList contains a list of Document
+type DocumentList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitzero"`
+	Items           []Document `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&Document{}, &DocumentList{})
+}

+ 127 - 0
api/v1alpha1/zz_generated.deepcopy.go

@@ -104,6 +104,26 @@ func (in *AuthSpec) DeepCopy() *AuthSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ChunkingSpec) DeepCopyInto(out *ChunkingSpec) {
+	*out = *in
+	if in.Separators != nil {
+		in, out := &in.Separators, &out.Separators
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChunkingSpec.
+func (in *ChunkingSpec) DeepCopy() *ChunkingSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ChunkingSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Component) DeepCopyInto(out *Component) {
 	*out = *in
@@ -260,6 +280,113 @@ func (in *ConditionalArgs) DeepCopy() *ConditionalArgs {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Document) DeepCopyInto(out *Document) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Document.
+func (in *Document) DeepCopy() *Document {
+	if in == nil {
+		return nil
+	}
+	out := new(Document)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Document) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DocumentList) DeepCopyInto(out *DocumentList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Document, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentList.
+func (in *DocumentList) DeepCopy() *DocumentList {
+	if in == nil {
+		return nil
+	}
+	out := new(DocumentList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DocumentList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DocumentSpec) DeepCopyInto(out *DocumentSpec) {
+	*out = *in
+	if in.KnowledgeBaseRef != nil {
+		in, out := &in.KnowledgeBaseRef, &out.KnowledgeBaseRef
+		*out = new(v1.ObjectReference)
+		**out = **in
+	}
+	in.Source.DeepCopyInto(&out.Source)
+	in.ChunkingStrategy.DeepCopyInto(&out.ChunkingStrategy)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentSpec.
+func (in *DocumentSpec) DeepCopy() *DocumentSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(DocumentSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DocumentStatus) DeepCopyInto(out *DocumentStatus) {
+	*out = *in
+	if in.IngestedAt != nil {
+		in, out := &in.IngestedAt, &out.IngestedAt
+		*out = (*in).DeepCopy()
+	}
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]metav1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentStatus.
+func (in *DocumentStatus) DeepCopy() *DocumentStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(DocumentStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ExternalKnowledgeBase) DeepCopyInto(out *ExternalKnowledgeBase) {
 	*out = *in

+ 7 - 0
cmd/main.go

@@ -234,6 +234,13 @@ func main() {
 		setupLog.Error(err, "Failed to create controller", "controller", "ManagedKnowledgeBase")
 		os.Exit(1)
 	}
+	if err := (&controller.DocumentReconciler{
+		Client: mgr.GetClient(),
+		Scheme: mgr.GetScheme(),
+	}).SetupWithManager(mgr); err != nil {
+		setupLog.Error(err, "Failed to create controller", "controller", "Document")
+		os.Exit(1)
+	}
 	// +kubebuilder:scaffold:builder
 
 	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {

+ 345 - 0
config/crd/bases/locostack.com_documents.yaml

@@ -0,0 +1,345 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.20.1
+  name: documents.locostack.com
+spec:
+  group: locostack.com
+  names:
+    kind: Document
+    listKind: DocumentList
+    plural: documents
+    shortNames:
+    - doc
+    singular: document
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.phase
+      name: Phase
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: Document is the Schema for the documents API
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: spec defines the desired state of Document
+            properties:
+              chunkingStrategy:
+                description: chunkingStrategy configures how the document content
+                  is split into chunks before embedding.
+                properties:
+                  chunkOverlap:
+                    default: 64
+                    description: |-
+                      chunkOverlap is the number of tokens carried over from the previous chunk
+                      to preserve context at boundaries.
+                    format: int32
+                    minimum: 0
+                    type: integer
+                  chunkSize:
+                    default: 512
+                    description: chunkSize is the target chunk size in tokens.
+                    format: int32
+                    minimum: 1
+                    type: integer
+                  separators:
+                    description: separators is an ordered list of separator strings
+                      for the recursive strategy.
+                    items:
+                      type: string
+                    type: array
+                  strategy:
+                    default: recursive
+                    description: |-
+                      strategy selects the chunking algorithm.
+                      fixed: split at exact token boundaries.
+                      recursive: split on separators, respecting document structure (default).
+                      semantic: split at semantic boundaries using the embedding model.
+                      sentence, markdown, html: structure-aware strategies for specific formats.
+                    enum:
+                    - fixed
+                    - recursive
+                    - markdown
+                    type: string
+                type: object
+              knowledgeBaseRef:
+                description: knowledgeBaseRef references the target knowledge base.
+                properties:
+                  apiVersion:
+                    description: API version of the referent.
+                    type: string
+                  fieldPath:
+                    description: |-
+                      If referring to a piece of an object instead of an entire object, this string
+                      should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2].
+                      For example, if the object reference is to a container within a pod, this would take on a value like:
+                      "spec.containers{name}" (where "name" refers to the name of the container that triggered
+                      the event) or if no container name is specified "spec.containers[2]" (container with
+                      index 2 in this pod). This syntax is chosen only to have some well-defined way of
+                      referencing a part of an object.
+                    type: string
+                  kind:
+                    description: |-
+                      Kind of the referent.
+                      More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+                    type: string
+                  name:
+                    description: |-
+                      Name of the referent.
+                      More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                    type: string
+                  namespace:
+                    description: |-
+                      Namespace of the referent.
+                      More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
+                    type: string
+                  resourceVersion:
+                    description: |-
+                      Specific resourceVersion to which this reference is made, if any.
+                      More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
+                    type: string
+                  uid:
+                    description: |-
+                      UID of the referent.
+                      More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids
+                    type: string
+                type: object
+                x-kubernetes-map-type: atomic
+              source:
+                description: source describes the origin of the document content and
+                  metadata.
+                properties:
+                  huggingFace:
+                    description: |-
+                      huggingFace pulls artifacts from HuggingFace Hub.
+                      The operator creates a managed PVC and a one-off download Job.
+                    properties:
+                      endpoint:
+                        description: |-
+                          endpoint is the base URL for HuggingFace API requests.
+                          Defaults to "https://huggingface.co" if not specified.
+                        type: string
+                      fileName:
+                        description: fileName is the specific file to pull from the
+                          repo (e.g. a single GGUF for llama.cpp).
+                        type: string
+                      repo:
+                        description: |-
+                          repo is the HuggingFace repository id (e.g. meta-llama/Llama-3.1-8B-Instruct).
+                          Defaults from modelClassRef.baseModelId when omitted.
+                        type: string
+                      revision:
+                        description: revision is the branch, tag, or commit to pull.
+                          Defaults to "main".
+                        type: string
+                      tokenSecretRef:
+                        description: |-
+                          tokenSecretRef references a Secret key holding an HF token for gated or private repos.
+                          Injected into the download Job only; inference pods do not receive the token.
+                        properties:
+                          key:
+                            description: The key of the secret to select from.  Must
+                              be a valid secret key.
+                            type: string
+                          name:
+                            default: ""
+                            description: |-
+                              Name of the referent.
+                              This field is effectively required, but due to backwards compatibility is
+                              allowed to be empty. Instances of this type with an empty value here are
+                              almost certainly wrong.
+                              More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                            type: string
+                          optional:
+                            description: Specify whether the Secret or its key must
+                              be defined
+                            type: boolean
+                        required:
+                        - key
+                        type: object
+                        x-kubernetes-map-type: atomic
+                    required:
+                    - repo
+                    type: object
+                  pvc:
+                    description: |-
+                      pvc mounts artifacts from a PersistentVolumeClaim.
+                      The user is responsible for populating the volume before referencing it.
+                    properties:
+                      claimName:
+                        description: claimName is the name of an existing PVC in the
+                          same namespace.
+                        type: string
+                      filePath:
+                        description: filePath is the path inside the PVC where the
+                          artifact is located.
+                        type: string
+                    required:
+                    - claimName
+                    type: object
+                  url:
+                    description: |-
+                      url downloads artifacts from an arbitrary HTTP/HTTPS URL.
+                      The operator creates a managed PVC and a one-off download Job.
+                    properties:
+                      authSecretRef:
+                        description: |-
+                          authSecretRef references a Secret whose key-value pairs are injected as HTTP
+                          request headers into the download Job. Not mounted in inference pods.
+                        properties:
+                          name:
+                            default: ""
+                            description: |-
+                              Name of the referent.
+                              This field is effectively required, but due to backwards compatibility is
+                              allowed to be empty. Instances of this type with an empty value here are
+                              almost certainly wrong.
+                              More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                            type: string
+                        type: object
+                        x-kubernetes-map-type: atomic
+                      checksum:
+                        description: 'checksum is the expected checksum of the downloaded
+                          file (format: sha256:<hex>).'
+                        type: string
+                      fileName:
+                        description: |-
+                          fileName is the filename to write inside the PVC.
+                          Defaults to the last path segment of the URL.
+                        type: string
+                      url:
+                        description: url is the HTTP or HTTPS URL to download the
+                          artifacts from.
+                        type: string
+                    required:
+                    - url
+                    type: object
+                type: object
+            required:
+            - chunkingStrategy
+            - knowledgeBaseRef
+            - source
+            type: object
+          status:
+            description: status defines the observed state of Document
+            properties:
+              conditions:
+                description: |-
+                  conditions represent the current state of the Document resource.
+                  Each condition has a unique type and reflects the status of a specific aspect of the resource.
+
+                  Standard condition types include:
+                  - "Available": the resource is fully functional
+                  - "Progressing": the resource is being created or updated
+                  - "Degraded": the resource failed to reach or maintain its desired state
+
+                  The status of each condition is one of True, False, or Unknown.
+                items:
+                  description: Condition contains details for one aspect of the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition transitioned from one status to another.
+                        This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                        Producers of specific condition types may define expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              ingestedAt:
+                description: ingestedAt is the timestamp of the last successful ingestion.
+                format: date-time
+                type: string
+              jobName:
+                description: jobName is the name of the active or most-recently-completed
+                  ingestor Job.
+                type: string
+              observedGeneration:
+                description: observedGeneration is the .metadata.generation last reconciled.
+                format: int64
+                type: integer
+              phase:
+                description: 'phase is the current ingestion phase: Pending, Ingesting,
+                  Ready, Failed, or Egesting.'
+                enum:
+                - Pending
+                - Ingesting
+                - Ready
+                - Failed
+                - Egesting
+                type: string
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}

+ 1 - 0
config/crd/kustomization.yaml

@@ -10,6 +10,7 @@ resources:
 - bases/locostack.com_managedtools.yaml
 - bases/locostack.com_externalknowledgebases.yaml
 - bases/locostack.com_managedknowledgebases.yaml
+- bases/locostack.com_documents.yaml
 # +kubebuilder:scaffold:crdkustomizeresource
 
 patches:

+ 27 - 0
config/rbac/document_admin_role.yaml

@@ -0,0 +1,27 @@
+# This rule is not used by the project loco-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants full permissions ('*') over locostack.com.
+# This role is intended for users authorized to modify roles and bindings within the cluster,
+# enabling them to delegate specific permissions to other users or groups as needed.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: document-admin-role
+rules:
+- apiGroups:
+  - locostack.com
+  resources:
+  - documents
+  verbs:
+  - '*'
+- apiGroups:
+  - locostack.com
+  resources:
+  - documents/status
+  verbs:
+  - get

+ 33 - 0
config/rbac/document_editor_role.yaml

@@ -0,0 +1,33 @@
+# This rule is not used by the project loco-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants permissions to create, update, and delete resources within the locostack.com.
+# This role is intended for users who need to manage these resources
+# but should not control RBAC or manage permissions for others.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: document-editor-role
+rules:
+- apiGroups:
+  - locostack.com
+  resources:
+  - documents
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - locostack.com
+  resources:
+  - documents/status
+  verbs:
+  - get

+ 29 - 0
config/rbac/document_viewer_role.yaml

@@ -0,0 +1,29 @@
+# This rule is not used by the project loco-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants read-only access to locostack.com resources.
+# This role is intended for users who need visibility into these resources
+# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: document-viewer-role
+rules:
+- apiGroups:
+  - locostack.com
+  resources:
+  - documents
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - locostack.com
+  resources:
+  - documents/status
+  verbs:
+  - get

+ 3 - 0
config/rbac/kustomization.yaml

@@ -22,6 +22,9 @@ resources:
 # default, aiding admins in cluster management. Those roles are
 # not used by the loco-operator itself. You can comment the following lines
 # if you do not want those helpers be installed with your Project.
+- document_admin_role.yaml
+- document_editor_role.yaml
+- document_viewer_role.yaml
 - managedknowledgebase_admin_role.yaml
 - managedknowledgebase_editor_role.yaml
 - managedknowledgebase_viewer_role.yaml

+ 3 - 0
config/rbac/role.yaml

@@ -46,6 +46,7 @@ rules:
   - locostack.com
   resources:
   - components
+  - documents
   - externalknowledgebases
   - externalmodels
   - externaltools
@@ -65,6 +66,7 @@ rules:
   - locostack.com
   resources:
   - components/finalizers
+  - documents/finalizers
   - externalknowledgebases/finalizers
   - externalmodels/finalizers
   - externaltools/finalizers
@@ -78,6 +80,7 @@ rules:
   - locostack.com
   resources:
   - components/status
+  - documents/status
   - externalknowledgebases/status
   - externalmodels/status
   - externaltools/status

+ 1 - 0
config/samples/kustomization.yaml

@@ -8,4 +8,5 @@ resources:
 - v1alpha1_managedtool.yaml
 - v1alpha1_externalknowledgebase.yaml
 - v1alpha1_managedknowledgebase.yaml
+- v1alpha1_document.yaml
 # +kubebuilder:scaffold:manifestskustomizesamples

+ 9 - 0
config/samples/v1alpha1_document.yaml

@@ -0,0 +1,9 @@
+apiVersion: locostack.com/v1alpha1
+kind: Document
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: document-sample
+spec:
+  # TODO(user): Add fields here

+ 63 - 0
internal/controller/document_controller.go

@@ -0,0 +1,63 @@
+/*
+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"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+)
+
+// DocumentReconciler reconciles a Document object
+type DocumentReconciler struct {
+	client.Client
+	Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=locostack.com,resources=documents,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=locostack.com,resources=documents/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=locostack.com,resources=documents/finalizers,verbs=update
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+// TODO(user): Modify the Reconcile function to compare the state specified by
+// the Document object against the actual cluster state, and then
+// perform operations to make the cluster state reflect the state specified by
+// the user.
+//
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.23.3/pkg/reconcile
+func (r *DocumentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	_ = logf.FromContext(ctx)
+
+	// TODO(user): your logic here
+
+	return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *DocumentReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.Document{}).
+		Named("document").
+		Complete(r)
+}

+ 84 - 0
internal/controller/document_controller_test.go

@@ -0,0 +1,84 @@
+/*
+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/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+)
+
+var _ = Describe("Document Controller", func() {
+	Context("When reconciling a resource", func() {
+		const resourceName = "test-resource"
+
+		ctx := context.Background()
+
+		typeNamespacedName := types.NamespacedName{
+			Name:      resourceName,
+			Namespace: "default", // TODO(user):Modify as needed
+		}
+		document := &v1alpha1.Document{}
+
+		BeforeEach(func() {
+			By("creating the custom resource for the Kind Document")
+			err := k8sClient.Get(ctx, typeNamespacedName, document)
+			if err != nil && errors.IsNotFound(err) {
+				resource := &v1alpha1.Document{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      resourceName,
+						Namespace: "default",
+					},
+					// TODO(user): Specify other spec details if needed.
+				}
+				Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+			}
+		})
+
+		AfterEach(func() {
+			// TODO(user): Cleanup logic after each test, like removing the resource instance.
+			resource := &v1alpha1.Document{}
+			err := k8sClient.Get(ctx, typeNamespacedName, resource)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("Cleanup the specific resource instance Document")
+			Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+		})
+		It("should successfully reconcile the resource", func() {
+			By("Reconciling the created resource")
+			controllerReconciler := &DocumentReconciler{
+				Client: k8sClient,
+				Scheme: k8sClient.Scheme(),
+			}
+
+			_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+				NamespacedName: typeNamespacedName,
+			})
+			Expect(err).NotTo(HaveOccurred())
+			// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
+			// Example: If you expect a certain status condition after reconciliation, verify it here.
+		})
+	})
+})