Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
cd ${GITHUB_WORKSPACE}/src/github.com/mattermost/mattermost-operator
./test/e2e.sh
env:
K8S_VERSION: v1.22.9
K8S_VERSION: v1.32.5
KIND_VERSION: v0.29.0
SDK_VERSION: v1.0.1
IMAGE_NAME: mattermost/mattermost-operator
Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,17 @@ run: generate fmt vet manifests ## Run against the configured Kubernetes cluster
go run ./main.go

install: manifests kustomize ## Install CRDs into a cluster
$(KUSTOMIZE) build config/crd | kubectl apply -f -
$(KUSTOMIZE) build config/crd | kubectl apply --server-side --force-conflicts -f -

uninstall: manifests kustomize ## Uninstall CRDs from a cluster
$(KUSTOMIZE) build config/crd | kubectl delete -f -

deploy: manifests kustomize ## Deploy controller in the configured Kubernetes cluster in ~/.kube/config
kubectl create ns mattermost-operator --dry-run -oyaml | kubectl apply -f -
cd config/manager && $(KUSTOMIZE) edit set image mattermost-operator="mattermost/mattermost-operator:test"
$(KUSTOMIZE) build config/default | kubectl apply -n mattermost-operator -f -
# Server-side apply: the mattermosts CRD exceeds the 256KiB limit of the
# client-side last-applied-configuration annotation.
$(KUSTOMIZE) build config/default | kubectl apply --server-side --force-conflicts -n mattermost-operator -f -

mysql-minio-operators: ## Deploys MinIO and MySQL Operators to the active cluster
./scripts/install-mysql-minio.sh
Expand Down
187 changes: 187 additions & 0 deletions apis/mattermost/v1beta1/agent_types.go
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"
Comment thread
cursor[bot] marked this conversation as resolved.
// +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{})
}
137 changes: 137 additions & 0 deletions apis/mattermost/v1beta1/agent_utils.go
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")
}
Loading
Loading