-
Notifications
You must be signed in to change notification settings - Fork 89
Add Agent CRD and reconciler for remote AI agents #464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nickmisasi
wants to merge
17
commits into
master
Choose a base branch
from
the-trail-ship
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
4b63bda
Add Agent CRD and core reconciler
nickmisasi cec8125
Add persistent storage support for Agents
nickmisasi 6c42bcf
Add egress policy modes and imagePullPolicy auto-detection
nickmisasi 8bb3ec5
Add operator-managed LiteLLM gateway infrastructure
nickmisasi 90f03d3
Move LiteLLM gateway ownership to the Mattermost CR
cursoragent 90997d3
Fix agent label taxonomy, NetworkPolicy precision, and egress dispatch
cursoragent 68381b6
Restructure Agent reconciler around canonical aggregator pattern
cursoragent 75a67c8
Overhaul agent and LiteLLM test suites
cursoragent bd9bac4
Generate typed clientset artifacts for the Agent CRD
cursoragent 969cd20
Harden LiteLLM gateway lifecycle and agent dependency handling
cursoragent 3b0e454
Guard gateway updates against foreign ownership and tighten URL CEL
cursoragent b95ab11
Harden agent pod with restricted security context
cursoragent eb541eb
Constrain agent names to RFC 1035 DNS labels
cursoragent 8ed502a
Default gateway requests and limits only as a pair
cursoragent 7ae9309
Reset ReadyReplicas when the agent deployment is gone
cursoragent c0f8221
Bump e2e cluster to Kubernetes 1.32 for CRD CEL validation support
cursoragent 396fe7c
Use server-side apply when deploying operator manifests
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. | ||
| // See License.txt for license information. | ||
|
|
||
| package v1beta1 | ||
|
|
||
| import ( | ||
| corev1 "k8s.io/api/core/v1" | ||
| "k8s.io/apimachinery/pkg/api/resource" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| ) | ||
|
|
||
| //////////////////////////////////////////////////////////////////////////////// | ||
| // IMPORTANT! // | ||
| //////////////////////////////////////////////////////////////////////////////// | ||
| // Run "make generate manifests" in the root of this repository to regenerate // | ||
| // code after modifying this file. // | ||
| // Add custom validation using kubebuilder tags: // | ||
| // https://book.kubebuilder.io/reference/generating-crd.html // | ||
| //////////////////////////////////////////////////////////////////////////////// | ||
|
|
||
| // AgentEgressPolicy controls outbound network access from an agent pod. | ||
| type AgentEgressPolicy string | ||
|
|
||
| // AgentMattermostRef references a Mattermost CR by name. | ||
| type AgentMattermostRef struct { | ||
| // Name of the Mattermost CR in the same namespace. | ||
| // +kubebuilder:validation:MinLength=1 | ||
| Name string `json:"name"` | ||
| } | ||
|
|
||
| // AgentStorageConfig defines optional persistent storage for the agent pod. | ||
| type AgentStorageConfig struct { | ||
| // Size is the requested PVC storage size (e.g., "1Gi", "500Mi"). | ||
| Size resource.Quantity `json:"size"` | ||
|
|
||
| // StorageClassName is the name of the StorageClass to use for the PVC. | ||
| // If omitted, the cluster default StorageClass is used. | ||
| // +optional | ||
| StorageClassName *string `json:"storageClassName,omitempty"` | ||
|
|
||
| // MountPath is the path inside the container where the volume is mounted. | ||
| // Defaults to "/data". | ||
| // +optional | ||
| MountPath string `json:"mountPath,omitempty"` | ||
| } | ||
|
|
||
| // AgentSpec defines the desired state of Agent | ||
| // +k8s:openapi-gen=true | ||
| type AgentSpec struct { | ||
| // MattermostRef is a reference to the Mattermost CR in the same namespace | ||
| // that this agent is associated with. | ||
| MattermostRef AgentMattermostRef `json:"mattermostRef"` | ||
|
|
||
| // Image defines the agent container image. | ||
| // +kubebuilder:validation:MinLength=1 | ||
| Image string `json:"image"` | ||
|
|
||
| // Hooks lists the Mattermost plugin hook names this agent subscribes to. | ||
| // Example: ["MessageHasBeenPosted", "UserHasJoinedChannel"] | ||
| // +optional | ||
| Hooks []string `json:"hooks,omitempty"` | ||
|
|
||
| // Resources defines the CPU/memory requests and limits for the agent pod. | ||
| // +optional | ||
| Resources corev1.ResourceRequirements `json:"resources,omitempty"` | ||
|
|
||
| // EgressPolicy controls outbound network access from the agent pod. | ||
| // - "deny" (default): only Mattermost server, DNS, and LiteLLM gateway | ||
| // - "allowWeb": additionally permits outbound TCP 80/443 to any destination (port-based; domain-level filtering is future work) | ||
| // - "allow": permits all outbound traffic | ||
| // +kubebuilder:validation:Enum=deny;allowWeb;allow | ||
| // +kubebuilder:default=deny | ||
| // +optional | ||
| EgressPolicy AgentEgressPolicy `json:"egressPolicy,omitempty"` | ||
|
|
||
| // Env defines optional environment variables to inject into the agent pod. | ||
| // +optional | ||
| Env []corev1.EnvVar `json:"env,omitempty"` | ||
|
|
||
| // LLMGateway configures the LLM gateway for this agent. | ||
| // When OperatorManaged is set, the agent uses the LiteLLM gateway managed | ||
| // by the referenced Mattermost installation. When External is set, the | ||
| // agent uses an existing LiteLLM instance. | ||
| // +optional | ||
| LLMGateway *LLMGatewayConfig `json:"llmGateway,omitempty"` | ||
|
|
||
| // Storage configures optional persistent storage for the agent pod. | ||
| // When set, the operator creates a PVC and mounts it into the agent container. | ||
| // +optional | ||
| Storage *AgentStorageConfig `json:"storage,omitempty"` | ||
| } | ||
|
|
||
| // AgentStatus defines the observed state of Agent | ||
| type AgentStatus struct { | ||
| // State is the current running state of the agent. | ||
| // +optional | ||
| State RunningState `json:"state,omitempty"` | ||
|
|
||
| // Endpoint is the in-cluster HTTP service endpoint for this agent. | ||
| // Format: "http://<name>.<namespace>.svc.cluster.local:8080" | ||
| // +optional | ||
| Endpoint string `json:"endpoint,omitempty"` | ||
|
|
||
| // ObservedGeneration is the last observed Generation of the Agent resource | ||
| // that was acted on. | ||
| // +optional | ||
| ObservedGeneration int64 `json:"observedGeneration,omitempty"` | ||
|
|
||
| // Error records the last observed error in the reconciliation of this Agent. | ||
| // +optional | ||
| Error string `json:"error,omitempty"` | ||
|
|
||
| // ReadyReplicas is the number of ready replicas for the agent deployment. | ||
| // +optional | ||
| ReadyReplicas int32 `json:"readyReplicas,omitempty"` | ||
| } | ||
|
|
||
| // LLMGatewayConfig defines how the agent connects to an LLM gateway. | ||
| // +kubebuilder:validation:XValidation:rule="has(self.external) != has(self.operatorManaged)",message="exactly one of external or operatorManaged must be set" | ||
| type LLMGatewayConfig struct { | ||
| // External configures the agent to use an existing LiteLLM instance. | ||
| // +optional | ||
| External *ExternalLLMGateway `json:"external,omitempty"` | ||
|
|
||
| // OperatorManaged opts this agent into the LiteLLM gateway of the | ||
| // referenced Mattermost installation (spec.agents.llmGateway on the | ||
| // Mattermost CR). The Mattermost agents plugin must create the Secret | ||
| // named "agent-<name>-litellm-key" (key "apiKey") with this agent's | ||
| // virtual key before the agent pod can start. | ||
| // +optional | ||
| OperatorManaged *OperatorManagedGateway `json:"operatorManaged,omitempty"` | ||
| } | ||
|
|
||
| // ExternalLLMGateway configures the agent to use an externally managed LiteLLM instance. | ||
| type ExternalLLMGateway struct { | ||
| // URL is the base URL of the external LiteLLM instance. | ||
| // It must be an absolute http:// or https:// URL: the agent NetworkPolicy | ||
| // permits TCP egress to this URL's explicit port, or to port 443 for | ||
| // HTTPS and port 80 for HTTP. | ||
| // Example: "http://litellm.my-namespace.svc.cluster.local:4000" | ||
| // +kubebuilder:validation:MinLength=1 | ||
| // +kubebuilder:validation:XValidation:rule="isURL(self) && url(self).getHostname() != '' && url(self).getScheme() in ['http', 'https']",message="must be a valid absolute URL with a host and an http or https scheme" | ||
| URL string `json:"url"` | ||
|
|
||
| // VirtualKeySecret is the name of the K8s Secret containing the virtual key | ||
| // for this agent. The Secret must have a key "apiKey". | ||
| // +kubebuilder:validation:MinLength=1 | ||
| VirtualKeySecret string `json:"virtualKeySecret"` | ||
| } | ||
|
|
||
| // OperatorManagedGateway is an empty marker (the `emptyDir: {}` idiom) that | ||
| // opts an agent into the installation-level LiteLLM gateway. The gateway | ||
| // itself is configured and deployed via spec.agents.llmGateway on the | ||
| // referenced Mattermost CR. | ||
| type OperatorManagedGateway struct{} | ||
|
|
||
| // +genclient | ||
|
|
||
| // Agent is the Schema for the agents API | ||
| // +k8s:openapi-gen=true | ||
| // +kubebuilder:object:root=true | ||
| // +kubebuilder:subresource:status | ||
| // +kubebuilder:printcolumn:priority=0,name="State",type=string,JSONPath=".status.state",description="State of Agent" | ||
| // +kubebuilder:printcolumn:priority=0,name="Image",type=string,JSONPath=".spec.image",description="Image of Agent" | ||
| // +kubebuilder:printcolumn:priority=0,name="Endpoint",type=string,JSONPath=".status.endpoint",description="HTTP Endpoint" | ||
| // +kubebuilder:validation:XValidation:rule="self.metadata.name != self.spec.mattermostRef.name",message="agent name must differ from the referenced Mattermost installation name" | ||
| // +kubebuilder:validation:XValidation:rule="self.metadata.name.matches('^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$')",message="agent name must be a valid RFC 1035 DNS label: at most 63 lowercase alphanumeric or '-' characters, starting with a letter and ending alphanumeric" | ||
| type Agent struct { | ||
| metav1.TypeMeta `json:",inline"` | ||
| metav1.ObjectMeta `json:"metadata,omitempty"` | ||
|
|
||
| Spec AgentSpec `json:"spec,omitempty"` | ||
| Status AgentStatus `json:"status,omitempty"` | ||
| } | ||
|
|
||
| // +kubebuilder:object:root=true | ||
|
|
||
| // AgentList contains a list of Agent | ||
| type AgentList struct { | ||
| metav1.TypeMeta `json:",inline"` | ||
| metav1.ListMeta `json:"metadata,omitempty"` | ||
| Items []Agent `json:"items"` | ||
| } | ||
|
|
||
| func init() { | ||
| SchemeBuilder.Register(&Agent{}, &AgentList{}) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. | ||
| // See License.txt for license information. | ||
|
|
||
| package v1beta1 | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| corev1 "k8s.io/api/core/v1" | ||
| "k8s.io/apimachinery/pkg/api/resource" | ||
| ) | ||
|
|
||
| const ( | ||
| AgentEgressPolicyDeny AgentEgressPolicy = "deny" | ||
| AgentEgressPolicyAllowWeb AgentEgressPolicy = "allowWeb" | ||
| AgentEgressPolicyAllow AgentEgressPolicy = "allow" | ||
|
|
||
| AgentAppName = "mattermost-agent" | ||
| AgentContainerName = "agent" | ||
| AgentHTTPPort = int32(8080) | ||
| AgentLiteLLMDefaultImage = "ghcr.io/berriai/litellm-database:main-v1.82.0-stable" | ||
| AgentLiteLLMPort = int32(4000) | ||
| AgentLiteLLMDeploymentName = "mm-agent-litellm" | ||
| AgentLiteLLMServiceName = "mm-agent-litellm" | ||
| AgentLiteLLMMasterKeySecretName = "mm-agent-litellm-master-key" | ||
| AgentLiteLLMDBCredentialsSecret = "mm-agent-litellm-db-credentials" | ||
| AgentStorageDefaultMountPath = "/data" | ||
|
|
||
| SecretKeyBotToken = "token" | ||
| SecretKeyHookSecret = "hookSecret" | ||
| SecretKeyAPIKey = "apiKey" | ||
| SecretKeyMasterKey = "masterKey" | ||
| SecretKeyConnectionString = "connectionString" | ||
| ) | ||
|
|
||
| // SetDefaults sets missing values in the Agent manifest to their defaults. | ||
| func (a *Agent) SetDefaults() { | ||
| if a.Spec.EgressPolicy == "" { | ||
| a.Spec.EgressPolicy = AgentEgressPolicyDeny | ||
| } | ||
|
|
||
| if a.Spec.Resources.Requests == nil && a.Spec.Resources.Limits == nil { | ||
| a.Spec.Resources.Requests = corev1.ResourceList{ | ||
| corev1.ResourceCPU: resource.MustParse("100m"), | ||
| corev1.ResourceMemory: resource.MustParse("128Mi"), | ||
| } | ||
| a.Spec.Resources.Limits = corev1.ResourceList{ | ||
| corev1.ResourceCPU: resource.MustParse("500m"), | ||
| corev1.ResourceMemory: resource.MustParse("512Mi"), | ||
| } | ||
| } | ||
|
|
||
| if a.Spec.Storage != nil && a.Spec.Storage.MountPath == "" { | ||
| a.Spec.Storage.MountPath = AgentStorageDefaultMountPath | ||
| } | ||
| } | ||
|
|
||
| // HasOperatorManagedGateway reports whether the agent opts into the | ||
| // operator-managed LiteLLM gateway of its Mattermost installation. | ||
| func (a *Agent) HasOperatorManagedGateway() bool { | ||
| return a.Spec.LLMGateway != nil && a.Spec.LLMGateway.OperatorManaged != nil | ||
| } | ||
|
|
||
| // HasExternalGateway reports whether the agent uses an externally managed gateway. | ||
| func (a *Agent) HasExternalGateway() bool { | ||
| return a.Spec.LLMGateway != nil && a.Spec.LLMGateway.External != nil | ||
| } | ||
|
|
||
| // GatewayEndpoint resolves the configured gateway URL and virtual-key Secret. | ||
| func (a *Agent) GatewayEndpoint() (baseURL, keySecretName string, ok bool) { | ||
| switch { | ||
| case a.HasOperatorManagedGateway(): | ||
| return LiteLLMServiceURL(a.Namespace), a.LiteLLMKeySecretName(), true | ||
| case a.HasExternalGateway(): | ||
| return a.Spec.LLMGateway.External.URL, a.Spec.LLMGateway.External.VirtualKeySecret, true | ||
| default: | ||
| return "", "", false | ||
| } | ||
| } | ||
|
|
||
| // ClusterServiceURL returns the HTTP URL for an in-cluster Service. | ||
| func ClusterServiceURL(name, namespace string, port int32) string { | ||
| return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", name, namespace, port) | ||
| } | ||
|
|
||
| // LiteLLMServiceURL returns the in-cluster base URL for the LiteLLM service. | ||
| func LiteLLMServiceURL(namespace string) string { | ||
| return ClusterServiceURL(AgentLiteLLMServiceName, namespace, AgentLiteLLMPort) | ||
| } | ||
|
|
||
| // AgentLabels returns the full set of labels for all resources belonging to the agent. | ||
| func AgentLabels(agent *Agent) map[string]string { | ||
| l := AgentResourceLabels(agent.Name) | ||
| l[AgentNameLabel] = agent.Name | ||
| l[ClusterLabel] = agent.Spec.MattermostRef.Name | ||
| l["app"] = AgentAppName | ||
| return l | ||
| } | ||
|
|
||
| // AgentSelectorLabels returns the minimal label set used as the pod selector. | ||
| // Selector labels are immutable on a Deployment after creation; keeping this set | ||
| // minimal means future additions to AgentLabels do not break the selector. | ||
| func AgentSelectorLabels(agent *Agent) map[string]string { | ||
| return map[string]string{ | ||
| AgentNameLabel: agent.Name, | ||
| "app": AgentAppName, | ||
| } | ||
| } | ||
|
|
||
| // AgentResourceLabels returns the resource-scoped label for the agent. | ||
| func AgentResourceLabels(name string) map[string]string { | ||
| return map[string]string{ClusterResourceLabel: name} | ||
| } | ||
|
|
||
| func (a *Agent) scopedName(suffix string) string { | ||
| return "agent-" + a.Name + "-" + suffix | ||
| } | ||
|
|
||
| // BotTokenSecretName returns the name of the K8s Secret storing the agent's bot token. | ||
| func (a *Agent) BotTokenSecretName() string { | ||
| return a.scopedName("token") | ||
| } | ||
|
|
||
| // LiteLLMKeySecretName returns the name of the K8s Secret storing this agent's LiteLLM virtual key. | ||
| func (a *Agent) LiteLLMKeySecretName() string { | ||
| return a.scopedName("litellm-key") | ||
| } | ||
|
|
||
| // HookSecretName returns the name of the K8s Secret storing this agent's hook secret. | ||
| func (a *Agent) HookSecretName() string { | ||
| return a.scopedName("hook-secret") | ||
| } | ||
|
|
||
| // StoragePVCName returns the name of the PVC for the agent's persistent storage. | ||
| func (a *Agent) StoragePVCName() string { | ||
| return a.scopedName("storage") | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.