diff --git a/api/v1alpha1/einochainapp_types.go b/api/v1alpha1/einochainapp_types.go new file mode 100644 index 00000000..2bdf928c --- /dev/null +++ b/api/v1alpha1/einochainapp_types.go @@ -0,0 +1,105 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" // For HPA status fields if directly embedding +) + +// EinoChainAppSpec defines the desired state of EinoChainApp +type EinoChainAppSpec struct { + // Image is the container image for the Eino application. + Image string `json:"image"` + + // Replicas is the initial number of replicas for the Eino application. + // Optional: If not set, and autoscaling is not enabled, it might default to 1. + // +optional + Replicas *int32 `json:"replicas,omitempty"` + + // DeploymentTemplate allows for customizing the pod spec of the Eino application. + // +optional + DeploymentTemplate corev1.PodTemplateSpec `json:"deploymentTemplate,omitempty"` + + // ServiceSpec defines how the Eino application is exposed. + // +optional + ServiceSpec *corev1.ServiceSpec `json:"serviceSpec,omitempty"` + + // Autoscaling configures HPA for the Eino application. + // +optional + Autoscaling *EinoChainAppAutoscalingSpec `json:"autoscaling,omitempty"` +} + +// EinoChainAppAutoscalingSpec defines the autoscaling parameters for EinoChainApp +type EinoChainAppAutoscalingSpec struct { + // MinReplicas is the minimum number of replicas. + MinReplicas *int32 `json:"minReplicas"` + + // MaxReplicas is the maximum number of replicas. + MaxReplicas int32 `json:"maxReplicas"` + + // TargetTokenPerSec is the desired average token-per-second per pod. + TargetTokenPerSec *int32 `json:"targetTokenPerSec"` +} + +// EinoChainAppStatus defines the observed state of EinoChainApp +type EinoChainAppStatus struct { + // CurrentReplicas is the current number of ready replicas. + CurrentReplicas int32 `json:"currentReplicas,omitempty"` + + // DesiredReplicas is the desired number of replicas. + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + + // ObservedTokenPerSec is the last observed average token-per-second per pod. + // Optional: This might be derived from HPA status or a direct query. + // +optional + ObservedTokenPerSec *int32 `json:"observedTokenPerSec,omitempty"` + + // HPAStatus reflects the status of the HorizontalPodAutoscaler. + // +optional + HPAStatus *EinoChainAppHPAStatus `json:"hpaStatus,omitempty"` + + // Conditions store the status conditions of the EinoChainApp instances. + // +operator-sdk:csv:customresourcedefinitions:type=status + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// EinoChainAppHPAStatus holds relevant status fields from the HPA. +type EinoChainAppHPAStatus struct { + ObservedGeneration *int64 `json:"observedGeneration,omitempty"` + LastScaleTime *metav1.Time `json:"lastScaleTime,omitempty"` + CurrentReplicas int32 `json:"currentReplicas"` + DesiredReplicas int32 `json:"desiredReplicas"` + CurrentMetrics []autoscalingv2.MetricStatus `json:"currentMetrics,omitempty"` + Conditions []autoscalingv2.HorizontalPodAutoscalerCondition `json:"conditions,omitempty"` +} + + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.image" +// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".status.currentReplicas" +// +kubebuilder:printcolumn:name="TargetTPS",type="integer",JSONPath=".spec.autoscaling.targetTokenPerSec",priority=1 +// +kubebuilder:printcolumn:name="ObservedTPS",type="integer",JSONPath=".status.observedTokenPerSec",priority=1 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// EinoChainApp is the Schema for the einochainapps API +type EinoChainApp struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EinoChainAppSpec `json:"spec,omitempty"` + Status EinoChainAppStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// EinoChainAppList contains a list of EinoChainApp +type EinoChainAppList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EinoChainApp `json:"items"` +} + +func init() { + SchemeBuilder.Register(&EinoChainApp{}, &EinoChainAppList{}) +} diff --git a/config/crd/bases/eino.cloudwego.io_einochainapps.yaml b/config/crd/bases/eino.cloudwego.io_einochainapps.yaml new file mode 100644 index 00000000..8b485e8e --- /dev/null +++ b/config/crd/bases/eino.cloudwego.io_einochainapps.yaml @@ -0,0 +1,3649 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) # Version can be placeholder + name: einochainapps.eino.cloudwego.io +spec: + group: eino.cloudwego.io + names: + kind: EinoChainApp + listKind: EinoChainAppList + plural: einochainapps + singular: einochainapp + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: EinoChainApp is the Schema for the einochainapps 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: EinoChainAppSpec defines the desired state of EinoChainApp + properties: + autoscaling: + description: Autoscaling configures HPA for the EinoChainApp. + properties: + maxReplicas: + description: MaxReplicas is the maximum number of replicas. + format: int32 + type: integer + minReplicas: + description: MinReplicas is the minimum number of replicas. + format: int32 + type: integer + targetTokenPerSec: + description: TargetTokenPerSec is the desired average token-per-second + per pod. + format: int32 + type: integer + required: + - maxReplicas + - minReplicas + - targetTokenPerSec + type: object + deploymentTemplate: + description: DeploymentTemplate allows for customizing the pod spec + of the EinoChainApp. + properties: + metadata: + type: object + spec: + description: PodSpec is a description of a pod. + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + required: + - matchExpressions + - matchFields + type: object + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + required: + - matchExpressions + - matchFields + type: object + type: array + required: + - nodeSelectorTerms + type: object + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resources: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Resources describes the compute resource + requirements. + type: object + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + options: + items: + properties: + name: + type: string + value: + type: string + required: + - name + type: object + type: array + searches: + items: + type: string + type: array + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resources: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Resources describes the compute resource + requirements. + type: object + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + ip: + type: string + type: object + type: array + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostname: + type: string + imagePullSecrets: + items: + properties: + name: + type: string + type: object + type: array + initContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resources: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Resources describes the compute resource + requirements. + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + securityContext: + properties: + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + maxSkew: + format: int32 + type: integer + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + volumes: + items: + properties: + awsElasticBlockStore: + properties: + fsType: + type: string + partition: + format: int32 + type: integer + readOnly: + type: boolean + volumeID: + type: string + required: + - volumeID + type: object + azureDisk: + properties: + cachingMode: + type: string + diskName: + type: string + diskURI: + type: string + fsType: + type: string + kind: + type: string + readOnly: + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + properties: + readOnly: + type: boolean + secretName: + type: string + shareName: + type: string + required: + - secretName + - shareName + type: object + cephfs: + properties: + monitors: + items: + type: string + type: array + path: + type: string + readOnly: + type: boolean + secretFile: + type: string + secretRef: + properties: + name: + type: string + type: object + user: + type: string + required: + - monitors + type: object + cinder: + properties: + fsType: + type: string + readOnly: + type: boolean + secretRef: + properties: + name: + type: string + type: object + volumeID: + type: string + required: + - volumeID + type: object + configMap: + properties: + defaultMode: + format: int32 + type: integer + items: + items: + properties: + key: + type: string + mode: + format: int32 + type: integer + path: + type: string + required: + - key + - path + type: object + type: array + name: + type: string + optional: + type: boolean + type: object + csi: + properties: + driver: + type: string + fsType: + type: string + nodePublishSecretRef: + properties: + name: + type: string + type: object + readOnly: + type: boolean + volumeAttributes: + additionalProperties: + type: string + type: object + required: + - driver + type: object + downwardAPI: + properties: + defaultMode: + format: int32 + type: integer + items: + items: + properties: + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + mode: + format: int32 + type: integer + path: + type: string + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + emptyDir: + properties: + medium: + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + properties: + volumeClaimTemplate: + properties: + metadata: + type: object + spec: + description: PersistentVolumeClaimSpec describes + the common attributes of storage devices + and allows a Source for provider-specific + attributes + properties: + accessModes: + items: + type: string + type: array + dataSource: + properties: + apiGroup: + type: string + kind: + type: string + name: + type: string + required: + - kind + - name + type: object + dataSourceRef: + properties: + apiGroup: + type: string + kind: + type: string + name: + type: string + required: + - kind + - name + type: object + resources: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Resources represents the minimum + resources the volume should have. More + info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + type: object + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + storageClassName: + type: string + volumeMode: + type: string + volumeName: + type: string + type: object + type: object + type: object + fc: + properties: + fsType: + type: string + lun: + format: int32 + type: integer + readOnly: + type: boolean + targetWWNs: + items: + type: string + type: array + wwids: + items: + type: string + type: array + type: object + flexVolume: + properties: + driver: + type: string + fsType: + type: string + options: + additionalProperties: + type: string + type: object + readOnly: + type: boolean + secretRef: + properties: + name: + type: string + type: object + required: + - driver + type: object + flocker: + properties: + datasetName: + type: string + datasetUUID: + type: string + type: object + gcePersistentDisk: + properties: + fsType: + type: string + partition: + format: int32 + type: integer + pdName: + type: string + readOnly: + type: boolean + required: + - pdName + type: object + gitRepo: + properties: + directory: + type: string + repository: + type: string + revision: + type: string + required: + - repository + type: object + glusterfs: + properties: + endpoints: + type: string + path: + type: string + readOnly: + type: boolean + required: + - endpoints + - path + type: object + hostPath: + properties: + path: + type: string + type: + type: string + required: + - path + type: object + iscsi: + properties: + chapAuthDiscovery: + type: boolean + chapAuthSession: + type: boolean + fsType: + type: string + initiatorName: + type: string + iqn: + type: string + iscsiInterface: + type: string + lun: + format: int32 + type: integer + portals: + items: + type: string + type: array + readOnly: + type: boolean + secretRef: + properties: + name: + type: string + type: object + targetPortal: + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + type: string + nfs: + properties: + path: + type: string + readOnly: + type: boolean + server: + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + properties: + claimName: + type: string + readOnly: + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + properties: + fsType: + type: string + pdID: + type: string + required: + - pdID + type: object + portworxVolume: + properties: + fsType: + type: string + readOnly: + type: boolean + volumeID: + type: string + required: + - volumeID + type: object + projected: + properties: + defaultMode: + format: int32 + type: integer + sources: + items: + properties: + configMap: + properties: + items: + items: + properties: + key: + type: string + mode: + format: int32 + type: integer + path: + type: string + required: + - key + - path + type: object + type: array + name: + type: string + optional: + type: boolean + type: object + downwardAPI: + properties: + items: + items: + properties: + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + mode: + format: int32 + type: integer + path: + type: string + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + secret: + properties: + items: + items: + properties: + key: + type: string + mode: + format: int32 + type: integer + path: + type: string + required: + - key + - path + type: object + type: array + name: + type: string + optional: + type: boolean + type: object + serviceAccountToken: + properties: + audience: + type: string + expirationSeconds: + format: int64 + type: integer + path: + type: string + required: + - path + type: object + type: object + type: array + type: object + quobyte: + properties: + group: + type: string + readOnly: + type: boolean + registry: + type: string + tenant: + type: string + user: + type: string + volume: + type: string + required: + - registry + - volume + type: object + rbd: + properties: + fsType: + type: string + image: + type: string + keyring: + type: string + monitors: + items: + type: string + type: array + pool: + type: string + readOnly: + type: boolean + secretRef: + properties: + name: + type: string + type: object + user: + type: string + required: + - image + - monitors + type: object + scaleIO: + properties: + fsType: + type: string + gateway: + type: string + protectionDomain: + type: string + readOnly: + type: boolean + secretRef: + properties: + name: + type: string + type: object + sslEnabled: + type: boolean + storageMode: + type: string + storagePool: + type: string + system: + type: string + volumeName: + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + properties: + defaultMode: + format: int32 + type: integer + items: + items: + properties: + key: + type: string + mode: + format: int32 + type: integer + path: + type: string + required: + - key + - path + type: object + type: array + optional: + type: boolean + secretName: + type: string + type: object + storageos: + properties: + fsType: + type: string + readOnly: + type: boolean + secretRef: + properties: + name: + type: string + type: object + volumeName: + type: string + volumeNamespace: + type: string + type: object + vsphereVolume: + properties: + fsType: + type: string + storagePolicyID: + type: string + storagePolicyName: + type: string + volumePath: + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array + type: object + type: object + image: + description: Image is the container image for the Eino application. + type: string + replicas: + description: 'Replicas is the initial number of replicas for the Eino + application. Optional: If not set, and autoscaling is not enabled, + it might default to 1.' + format: int32 + type: integer + serviceSpec: + description: ServiceSpec defines how the Eino application is exposed. + properties: + allocateLoadBalancerNodePorts: + type: boolean + clusterIP: + type: string + clusterIPs: + items: + type: string + type: array + externalIPs: + items: + type: string + type: array + externalName: + type: string + externalTrafficPolicy: + type: string + healthCheckNodePort: + format: int32 + type: integer + internalTrafficPolicy: + type: string + ipFamilies: + items: + type: string + type: array + ipFamilyPolicy: + type: string + loadBalancerClass: + type: string + loadBalancerIP: + type: string + loadBalancerSourceRanges: + items: + type: string + type: array + ports: + items: + description: ServicePort contains information on service's port. + properties: + appProtocol: + type: string + name: + type: string + nodePort: + format: int32 + type: integer + port: + format: int32 + type: integer + protocol: + default: TCP + type: string + targetPort: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: array + publishNotReadyAddresses: + type: boolean + selector: + additionalProperties: + type: string + type: object + sessionAffinity: + type: string + sessionAffinityConfig: + properties: + clientIP: + properties: + timeoutSeconds: + format: int32 + type: integer + type: object + type: object + type: + type: string + type: object + required: + - image + type: object + status: + description: EinoChainAppStatus defines the observed state of EinoChainApp + properties: + conditions: + description: Conditions store the status conditions of the EinoChainApp + instances. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields + }" + 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 + it should be when the status field was last updated. + 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. + --- Many .condition.type values are consistent across resources, + such as Available, Progressing, and Degraded. Will be true + when the Resource is available, false if not. Progressing + will be true if the Resource is under observation and not + in a terminal state about whether it is Available or not. + Degraded will be true if the Resource is not functioning correctly. + 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 + currentReplicas: + description: CurrentReplicas is the current number of ready replicas. + format: int32 + type: integer + desiredReplicas: + description: DesiredReplicas is the desired number of replicas. + format: int32 + type: integer + hpaStatus: + description: HPAStatus reflects the status of the HorizontalPodAutoscaler. + properties: + conditions: + description: conditions is the set of conditions required for + this autoscaler to scale its target, and indicates whether + or not those conditions are met. + items: + description: HorizontalPodAutoscalerCondition describes the + state of a HorizontalPodAutoscaler at a certain point. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + occurred. + format: date-time + type: string + message: + description: message is a human-readable explanation containing + details about the transition + type: string + reason: + description: reason is the reason for the condition's last + transition. + type: string + status: + description: status is the status of the condition (True, + False, Unknown) + type: string + type: + description: type describes the current condition + type: string + required: + - status + - type + type: object + type: array + currentMetrics: + description: currentMetrics is the last read state of the metrics + used by this autoscaler. + items: + description: MetricStatus describes the last-read state of + a single metric. + properties: + containerResource: + description: containerResource refers to a resource metric + (such as those specified in requests and limits) known + to Kubernetes describing a single container in each pod + in the current scale target (e.g. CPU or memory). Such + metrics are built in to Kubernetes, and have special + scaling options on top of those available to normal per-pod + metrics using the "pods" source. + properties: + container: + description: container is the name of the container + in the pods of the scaling target + type: string + current: + description: current contains the current value for + the given metric + properties: + averageUtilization: + description: averageUtilization is the current + average value of the resource metric across all + relevant pods, represented as a percentage of + the requested value of the resource for the pods. + format: int32 + type: integer + averageValue: + anyOf: + - type: integer + - type: string + description: averageValue is the current average + value of the metric across all relevant pods (as + a quantity) + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + value: + anyOf: + - type: integer + - type: string + description: value is the current value of the + metric (as a quantity). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - type + type: object + name: + description: name is the name of the resource in question. + type: string + required: + - container + - current + - name + type: object + external: + description: external refers to an external metric (for + example, queue size) describing a Kubernetes object. + properties: + current: + description: current contains the current value for + the given metric + properties: + averageValue: + anyOf: + - type: integer + - type: string + description: averageValue is the current average + value of the metric across all relevant pods (as + a quantity) + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + value: + anyOf: + - type: integer + - type: string + description: value is the current value of the + metric (as a quantity). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - type + type: object + metric: + description: metric identifies the target metric by + name and selector + properties: + name: + description: name is the name of the given metric + type: string + selector: + description: selector is the string-encoded form + of a standard kubernetes label selector for the + given metric When set, it is passed as an additional + parameter to the metrics server for more specific + filtering. When unset, only the metricName will + be used to gather metrics. + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + required: + - name + type: object + required: + - current + - metric + type: object + object: + description: object refers to a metric describing a single + kubernetes object (for example, hits-per-second on an + Ingress object). + properties: + current: + description: current contains the current value for + the given metric + properties: + averageValue: + anyOf: + - type: integer + - type: string + description: averageValue is the current average + value of the metric across all relevant pods (as + a quantity) + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + value: + anyOf: + - type: integer + - type: string + description: value is the current value of the + metric (as a quantity). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - type + type: object + describedObject: + description: describedObject specifies the descriptions + of a object,such as kind,name apiVersion + properties: + apiVersion: + description: API version of the referent + 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/user-guide/identifiers#names + type: string + required: + - kind + - name + type: object + metric: + description: metric identifies the target metric by + name and selector + properties: + name: + description: name is the name of the given metric + type: string + selector: + description: selector is the string-encoded form + of a standard kubernetes label selector for the + given metric When set, it is passed as an additional + parameter to the metrics server for more specific + filtering. When unset, only the metricName will + be used to gather metrics. + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + required: + - name + type: object + required: + - current + - describedObject + - metric + type: object + pods: + description: pods refers to a metric describing each pod + in the current scale target (for example, transactions-processed-per-second). + The values will be averaged together before being compared + to the target value. + properties: + current: + description: current contains the current value for + the given metric + properties: + averageValue: + anyOf: + - type: integer + - type: string + description: averageValue is the current average + value of the metric across all relevant pods (as + a quantity) + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - type + type: object + metric: + description: metric identifies the target metric by + name and selector + properties: + name: + description: name is the name of the given metric + type: string + selector: + description: selector is the string-encoded form + of a standard kubernetes label selector for the + given metric When set, it is passed as an additional + parameter to the metrics server for more specific + filtering. When unset, only the metricName will + be used to gather metrics. + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + required: + - name + type: object + required: + - current + - metric + type: object + resource: + description: resource refers to a resource metric (such + as those specified in requests and limits) known to Kubernetes + describing each pod in the current scale target (e.g. + CPU or memory). Such metrics are built in to Kubernetes, + and have special scaling options on top of those available + to normal per-pod metrics using the "pods" source. + properties: + current: + description: current contains the current value for + the given metric + properties: + averageUtilization: + description: averageUtilization is the current + average value of the resource metric across all + relevant pods, represented as a percentage of + the requested value of the resource for the pods. + format: int32 + type: integer + averageValue: + anyOf: + - type: integer + - type: string + description: averageValue is the current average + value of the metric across all relevant pods (as + a quantity) + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - type + type: object + name: + description: name is the name of the resource in question. + type: string + required: + - current + - name + type: object + type: + description: type is the type of metric source. It should + be one of "ContainerResource", "External", "Object", "Pods" + or "Resource", each mapping to a matching field in the + issue. + type: string + required: + - type + type: object + type: array + currentReplicas: + description: currentReplicas is current number of replicas of + pods managed by this autoscaler, as last seen by the autoscaler. + format: int32 + type: integer + desiredReplicas: + description: desiredReplicas is the desired number of replicas + of pods managed by this autoscaler, as last calculated by the + autoscaler. + format: int32 + type: integer + lastScaleTime: + description: lastScaleTime is the last time the HorizontalPodAutoscaler + scaled the number of pods, used by the autoscaler to control + how often the number of pods is changed. + format: date-time + type: string + observedGeneration: + description: observedGeneration is the most recent generation + observed by this autoscaler. + format: int64 + type: integer + required: + - currentReplicas + - desiredReplicas + type: object + observedTokenPerSec: + description: 'ObservedTokenPerSec is the last observed average token-per-second + per pod. Optional: This might be derived from HPA status or a direct + query.' + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +``` diff --git a/internal/controller/einochainapp_controller.go b/internal/controller/einochainapp_controller.go new file mode 100644 index 00000000..10918fcc --- /dev/null +++ b/internal/controller/einochainapp_controller.go @@ -0,0 +1,260 @@ +package controller + +import ( + "context" + "context" + "fmt" + "reflect" // For deep comparison + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" // Required for HPA metric values + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + // "k8s.io/apimachinery/pkg/util/intstr" // Not directly used in HPA but often nearby + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + einov1alpha1 "github.com/cloudwego/eino/operator/api/v1alpha1" +) + +// EinoChainAppReconciler reconciles a EinoChainApp object +type EinoChainAppReconciler struct { + client.Client + Scheme *runtime.Scheme + // Recorder record.EventRecorder // Uncomment if event recording is needed +} + +//+kubebuilder:rbac:groups=eino.cloudwego.io,resources=einochainapps,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=eino.cloudwego.io,resources=einochainapps/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=eino.cloudwego.io,resources=einochainapps/finalizers,verbs=update +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch // If event recording is used + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *EinoChainAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.Info("Reconciling EinoChainApp") + + einoApp := &einov1alpha1.EinoChainApp{} + if err := r.Get(ctx, req.NamespacedName, einoApp); err != nil { + if errors.IsNotFound(err) { + logger.Info("EinoChainApp resource not found. Ignoring since object must be deleted.") + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get EinoChainApp") + return ctrl.Result{}, err + } + + // Handle finalizers if needed (example) + // myFinalizerName := "eino.cloudwego.io/finalizer" + // if einoApp.ObjectMeta.DeletionTimestamp.IsZero() { + // if !controllerutil.ContainsFinalizer(einoApp, myFinalizerName) { + // controllerutil.AddFinalizer(einoApp, myFinalizerName) + // if err := r.Update(ctx, einoApp); err != nil { + // return ctrl.Result{}, err + // } + // } + // } else { + // if controllerutil.ContainsFinalizer(einoApp, myFinalizerName) { + // // if err := r.deleteExternalResources(ctx, einoApp); err != nil { + // // return ctrl.Result{}, err + // // } + // controllerutil.RemoveFinalizer(einoApp, myFinalizerName) + // if err := r.Update(ctx, einoApp); err != nil { + // return ctrl.Result{}, err + // } + // } + // return ctrl.Result{}, nil + // } + + // Reconcile Deployment + if err := r.reconcileDeployment(ctx, einoApp); err != nil { + logger.Error(err, "Failed to reconcile Deployment") + // Consider setting a status condition here + return ctrl.Result{}, err + } + + // Reconcile Service + if err := r.reconcileService(ctx, einoApp); err != nil { + logger.Error(err, "Failed to reconcile Service") + // Consider setting a status condition here + return ctrl.Result{}, err + } + + // Placeholder for HPA, and Status updates + + logger.Info("Successfully reconciled EinoChainApp", "Name", einoApp.Name) + return ctrl.Result{}, nil +} + +func (r *EinoChainAppReconciler) reconcileDeployment(ctx context.Context, einoApp *einov1alpha1.EinoChainApp) error { + logger := log.FromContext(ctx) + deploymentName := einoApp.Name + namespace := einoApp.Namespace + + desiredReplicas := int32(1) // Default replicas + if einoApp.Spec.Replicas != nil { + desiredReplicas = *einoApp.Spec.Replicas + } + // If HPA is enabled, HPA will manage replicas. For initial creation, we use spec.replicas or default. + + // Define the desired Deployment object + // Start with the DeploymentTemplate as a base for the PodTemplateSpec + podTemplateSpec := einoApp.Spec.DeploymentTemplate.DeepCopy() + + // Ensure labels from getAppLabels are on the pod template metadata, adding/overwriting + if podTemplateSpec.ObjectMeta.Labels == nil { + podTemplateSpec.ObjectMeta.Labels = make(map[string]string) + } + for k, v := range r.getAppLabels(einoApp) { + podTemplateSpec.ObjectMeta.Labels[k] = v + } + + // Ensure the primary container is correctly defined from spec.Image + // If DeploymentTemplate.Spec.Containers is empty, create a default one. + // If not empty, assume the first container is the primary and set its image and default name if necessary. + if len(podTemplateSpec.Spec.Containers) == 0 { + podTemplateSpec.Spec.Containers = make([]corev1.Container, 1) + } + podTemplateSpec.Spec.Containers[0].Image = einoApp.Spec.Image + if podTemplateSpec.Spec.Containers[0].Name == "" { + podTemplateSpec.Spec.Containers[0].Name = "eino-chain-app" // Default container name + } + // Other fields from einoApp.Spec.DeploymentTemplate.Spec.Containers[0] like Ports, Env, Resources, VolumeMounts + // are preserved if they were set in the DeploymentTemplate. + // Other PodSpec fields from DeploymentTemplate (e.g. Volumes, ServiceAccountName) are also preserved due to DeepCopy. + + + desiredDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: namespace, + Labels: r.getAppLabels(einoApp), // Labels for the Deployment resource itself + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &desiredReplicas, + Selector: &metav1.LabelSelector{ + MatchLabels: r.getAppLabels(einoApp), // Selector must match pod labels + }, + Template: *podTemplateSpec, // Use the merged pod template spec + }, + } + + if err := controllerutil.SetControllerReference(einoApp, desiredDeployment, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference on Deployment: %w", err) + } + + // Check if the Deployment already exists + existingDeployment := &appsv1.Deployment{} + err := r.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, existingDeployment) + if err != nil { + if errors.IsNotFound(err) { + logger.Info("Creating a new Deployment", "Deployment.Namespace", namespace, "Deployment.Name", deploymentName) + if errCreate := r.Create(ctx, desiredDeployment); errCreate != nil { + return fmt.Errorf("failed to create new Deployment: %w", errCreate) + } + logger.Info("Deployment created successfully") + return nil // Deployment created + } + return fmt.Errorf("failed to get existing Deployment: %w", err) + } + + // Deployment exists, check for updates. + updateNeeded := false + + // Check 1: Image change in the primary container + if len(existingDeployment.Spec.Template.Spec.Containers) == 0 || // Should not happen if we created it + existingDeployment.Spec.Template.Spec.Containers[0].Image != desiredDeployment.Spec.Template.Spec.Containers[0].Image { + logger.Info("Deployment image changed", "current", existingDeployment.Spec.Template.Spec.Containers[0].Image, "desired", desiredDeployment.Spec.Template.Spec.Containers[0].Image) + updateNeeded = true + } + + // Check 2: Replicas change (only if HPA is not active) + if einoApp.Spec.Autoscaling == nil { // If HPA is active, it controls the replicas + if existingDeployment.Spec.Replicas == nil || *existingDeployment.Spec.Replicas != *desiredDeployment.Spec.Replicas { + currentReplicasStr := "nil" + if existingDeployment.Spec.Replicas != nil { + currentReplicasStr = fmt.Sprint(*existingDeployment.Spec.Replicas) + } + logger.Info("Deployment replica count changed (no HPA)", "current", currentReplicasStr, "desired", *desiredDeployment.Spec.Replicas) + updateNeeded = true + } + } + + // Check 3: PodTemplateSpec changes (more comprehensively) + // A common way is to compare a hash of the template, or use a more sophisticated comparison. + // For now, we'll do a basic check on a few fields. This should be made more robust. + // We compare the desired template (which includes merged user input) with the existing one. + // Note: Kubernetes might add default values to the existing template, so direct DeepEqual can be tricky. + + // Example: Compare primary container name (if it was customized and changed) + if len(existingDeployment.Spec.Template.Spec.Containers) > 0 && len(desiredDeployment.Spec.Template.Spec.Containers) > 0 && + existingDeployment.Spec.Template.Spec.Containers[0].Name != desiredDeployment.Spec.Template.Spec.Containers[0].Name { + logger.Info("Deployment primary container name changed", "current", existingDeployment.Spec.Template.Spec.Containers[0].Name, "desired", desiredDeployment.Spec.Template.Spec.Containers[0].Name) + updateNeeded = true + } + // TODO: Add more comparisons for other critical fields in DeploymentTemplate like Env, Resources, Volumes etc. + // For example: if !reflect.DeepEqual(existingDeployment.Spec.Template.Spec.Containers[0].Env, desiredDeployment.Spec.Template.Spec.Containers[0].Env) { updateNeeded = true } + + if updateNeeded { + logger.Info("Updating existing Deployment", "Deployment.Namespace", namespace, "Deployment.Name", deploymentName) + + updatedDeployment := existingDeployment.DeepCopy() // Start with the existing one + updatedDeployment.Spec.Template = desiredDeployment.Spec.Template // Apply the new template + if einoApp.Spec.Autoscaling == nil { // Only set replicas if HPA is not managing it + updatedDeployment.Spec.Replicas = desiredDeployment.Spec.Replicas + } + // If HPA is active, HPA will manage .Spec.Replicas. + // We set it on create, but HPA takes over. On update, we only adjust if HPA is off. + + if errUpdate := r.Update(ctx, updatedDeployment); errUpdate != nil { + return fmt.Errorf("failed to update Deployment: %w", errUpdate) + } + logger.Info("Deployment updated successfully") + } else { + logger.Info("No significant update needed for Deployment based on current checks", "Deployment.Namespace", namespace, "Deployment.Name", deploymentName) + } + + return nil +} + +// getAppLabels returns the labels for selecting the resources +// belonging to the given EinoChainApp CR name. +func (r *EinoChainAppReconciler) getAppLabels(einoApp *einov1alpha1.EinoChainApp) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": "EinoChainApp", + "app.kubernetes.io/instance": einoApp.Name, + "app.kubernetes.io/managed-by": "einochainapp-operator", // Or your operator name + // Consider adding eino.cloudwego.io/einochainapp: einoApp.Name + } +} + + +// SetupWithManager sets up the controller with the Manager. +func (r *EinoChainAppReconciler) SetupWithManager(mgr ctrl.Manager) error { + // r.Recorder = mgr.GetEventRecorderFor("einochainapp-controller") // Uncomment if event recording is used + return ctrl.NewControllerManagedBy(mgr). + For(&einov1alpha1.EinoChainApp{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Owns(&autoscalingv2.HorizontalPodAutoscaler{}). + Complete(r) +} + +// Note: The import path "github.com/cloudwego/eino/operator/api/v1alpha1" is a placeholder. +// It should be adjusted to the actual module path of the operator project if it's different. +// For example, if the operator is in its own repository `github.com/example/eino-operator`, +// then the path would be `github.com/example/eino-operator/api/v1alpha1`. +// The worker should assume this path is correct or use a generic placeholder if creating a new project. +// The finalizer logic is commented out for now to keep the initial implementation simpler. +// It can be added later if needed for graceful deletion or external resource cleanup. +// The merge logic for deploymentTemplate is improved but can be made more robust. +// The update logic for the deployment is also improved but can be made more robust. diff --git a/internal/controller/einochainapp_controller_test.go b/internal/controller/einochainapp_controller_test.go new file mode 100644 index 00000000..1dbc4f46 --- /dev/null +++ b/internal/controller/einochainapp_controller_test.go @@ -0,0 +1,689 @@ +package controller + +import ( + "context" + "fmt" // For unique naming in tests + "time" // Added for Eventually/Consistently timeouts + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + "k8s.io/apimachinery/pkg/api/errors" // For checking IsNotFound + "k8s.io/apimachinery/pkg/api/resource" // For HPA target value + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" // For Service TargetPort + + // "sigs.k8s.io/controller-runtime/pkg/client" // k8sClient is global from suite_test + // "sigs.k8s.io/controller-runtime/pkg/reconcile" + + einov1alpha1 "github.com/cloudwego/eino/operator/api/v1alpha1" // Adjust import path +) + +var _ = Describe("EinoChainApp Controller", func() { + + const ( + EinoChainAppNamePrefix = "test-eino-" // Use a prefix to avoid name clashes in tests + EinoChainAppNamespace = "default" + TestImage = "nginx:latest" // A real image for testing + + timeout = time.Second * 10 + duration = time.Second * 2 // For Consistently + interval = time.Millisecond * 250 + ) + + Context("Unit tests", func() { + Describe("getAppLabels", func() { + It("should return the correct labels for an EinoChainApp", func() { + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: EinoChainAppNamePrefix + "labels", + Namespace: EinoChainAppNamespace, + }, + } + reconciler := &EinoChainAppReconciler{} // Client not needed for this specific unit test + + expectedLabels := map[string]string{ + "app.kubernetes.io/name": "EinoChainApp", + "app.kubernetes.io/instance": EinoChainAppNamePrefix + "labels", + "app.kubernetes.io/managed-by": "einochainapp-operator", + } + Expect(reconciler.getAppLabels(einoApp)).To(Equal(expectedLabels)) + }) + }) + }) + + Context("Integration tests with envtest", func() { + var testIdx int + var einoAppName string + var einoAppLookupKey types.NamespacedName + + BeforeEach(func() { + // Generate a unique name for each test to avoid conflicts + testIdx++ + einoAppName = EinoChainAppNamePrefix + fmt.Sprintf("%d", testIdx) + einoAppLookupKey = types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + + By("Ensuring no EinoChainApp resource exists before test: " + einoAppName) + existingApp := &einov1alpha1.EinoChainApp{} + err := k8sClient.Get(ctx, einoAppLookupKey, existingApp) + if err == nil { + // If it exists, delete it and wait + By("Deleting pre-existing EinoChainApp for test: " + einoAppName) + Expect(k8sClient.Delete(ctx, existingApp)).Should(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, einoAppLookupKey, &einov1alpha1.EinoChainApp{})) + }, timeout, interval).Should(BeTrue(), "failed to delete pre-existing EinoChainApp for test: "+einoAppName) + } else if !errors.IsNotFound(err) { + Fail(fmt.Sprintf("Failed to check for pre-existing EinoChainApp %s: %v", einoAppName, err)) + } + }) + + AfterEach(func() { + By("Deleting the EinoChainApp resource after test: " + einoAppName) + resource := &einov1alpha1.EinoChainApp{} + err := k8sClient.Get(ctx, einoAppLookupKey, resource) + if err == nil { // Only delete if it exists + Expect(k8sClient.Delete(ctx, resource)).Should(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, einoAppLookupKey, &einov1alpha1.EinoChainApp{})) + }, timeout, interval).Should(BeTrue(), "EinoChainApp did not delete: "+einoAppName) + } else if !errors.IsNotFound(err) { + Fail(fmt.Sprintf("Failed to get EinoChainApp %s for cleanup: %v", einoAppName, err)) + } + + By("Ensuring owned resources are deleted for " + einoAppName) + deploymentKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, deploymentKey, &appsv1.Deployment{})) + }, timeout, interval).Should(BeTrue(), "Deployment not deleted for "+einoAppName) + + serviceKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, serviceKey, &corev1.Service{})) + }, timeout, interval).Should(BeTrue(), "Service not deleted for "+einoAppName) + + hpaKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, hpaKey, &autoscalingv2.HorizontalPodAutoscaler{})) + }, timeout, interval).Should(BeTrue(), "HPA not deleted for "+einoAppName) + }) + + Describe("EinoChainApp CRD lifecycle", func() { + Context("When creating a new EinoChainApp CR without autoscaling", func() { + It("Should create a Deployment and Service, and update status", func() { + By("Creating an EinoChainApp CR without autoscaling: " + einoAppName) + initialReplicas := int32(2) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: einoAppName, + Namespace: EinoChainAppNamespace, + }, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + Replicas: &initialReplicas, + ServiceSpec: &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + Type: corev1.ServiceTypeClusterIP, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying Deployment creation for " + einoAppName) + createdDeployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdDeployment) + }, timeout, interval).Should(Succeed()) + Expect(*createdDeployment.Spec.Replicas).Should(Equal(initialReplicas)) + Expect(createdDeployment.Spec.Template.Spec.Containers[0].Image).Should(Equal(TestImage)) + + By("Verifying Service creation for " + einoAppName) + createdService := &corev1.Service{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdService) + }, timeout, interval).Should(Succeed()) + Expect(createdService.Spec.Ports[0].Port).Should(Equal(int32(80))) + Expect(createdService.Spec.Type).Should(Equal(corev1.ServiceTypeClusterIP)) + + By("Verifying HPA is not created for " + einoAppName) + Consistently(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, &autoscalingv2.HorizontalPodAutoscaler{}) + return errors.IsNotFound(err) + }, duration, interval).Should(BeTrue()) + + By("Verifying EinoChainApp status for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Eventually(func() int32 { + err := k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp) + if err != nil { + return -1 + } + return updatedEinoApp.Status.DesiredReplicas + }, timeout, interval).Should(Equal(initialReplicas)) + + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp) + g.Expect(err).NotTo(HaveOccurred()) + // Example: Check for Available condition, might be False initially as pods are not ready + g.Expect(updatedEinoApp.Status.Conditions).To(ContainElement(And( + HaveField("Type", ConditionTypeAvailable), + // Status might be False initially, then True. For this test, we check it's set. + ))) + g.Expect(updatedEinoApp.Status.Conditions).To(ContainElement(And( + HaveField("Type", ConditionTypeProgressing), + // Status might be True initially as deployment is progressing + ))) + }, timeout, interval).Should(Succeed()) + }) + }) + + Context("When creating a new EinoChainApp CR with autoscaling", func() { + It("Should create a Deployment, Service, HPA, and update status", func() { + By("Creating an EinoChainApp CR with autoscaling: " + einoAppName) + minReplicas := int32(1) + maxReplicas := int32(3) + targetTPS := int32(100) + initialDeploymentReplicas := int32(1) // Default initial replicas for Deployment when HPA is active + + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: einoAppName, + Namespace: EinoChainAppNamespace, + }, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + // Replicas field should be set by the reconciler to MinReplicas if HPA is active, + // or to a default like 1 if not specified by user. + Replicas: &initialDeploymentReplicas, // reconciler will use this if HPA is off, HPA overrides + ServiceSpec: &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + Type: corev1.ServiceTypeClusterIP, + }, + Autoscaling: &einov1alpha1.EinoChainAppAutoscalingSpec{ + MinReplicas: &minReplicas, + MaxReplicas: maxReplicas, + TargetTokenPerSec: &targetTPS, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying Deployment creation for " + einoAppName) + createdDeployment := &appsv1.Deployment{} + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdDeployment) + g.Expect(err).NotTo(HaveOccurred()) + // The reconciler sets Deployment replicas to MinReplicas (or a default if MinReplicas is not set, which is 1 here) + // when HPA is active. + g.Expect(*createdDeployment.Spec.Replicas).Should(Equal(initialDeploymentReplicas)) + }, timeout, interval).Should(Succeed()) + + + By("Verifying Service creation for " + einoAppName) + createdService := &corev1.Service{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdService) + }, timeout, interval).Should(Succeed()) + Expect(createdService.Spec.Ports[0].Port).Should(Equal(int32(80))) + + By("Verifying HPA creation for " + einoAppName) + createdHPA := &autoscalingv2.HorizontalPodAutoscaler{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdHPA) + }, timeout, interval).Should(Succeed()) + Expect(*createdHPA.Spec.MinReplicas).Should(Equal(minReplicas)) + Expect(createdHPA.Spec.MaxReplicas).Should(Equal(maxReplicas)) + Expect(createdHPA.Spec.Metrics).Should(HaveLen(1)) + Expect(createdHPA.Spec.Metrics[0].Type).Should(Equal(autoscalingv2.PodsMetricSourceType)) + Expect(createdHPA.Spec.Metrics[0].Pods.Metric.Name).Should(Equal("eino_token_per_sec_avg_per_pod")) + Expect(createdHPA.Spec.Metrics[0].Pods.Target.Type).Should(Equal(autoscalingv2.AverageValueMetricType)) + Expect(createdHPA.Spec.Metrics[0].Pods.Target.AverageValue.Value()).Should(Equal(resource.NewQuantity(targetTPS, resource.DecimalSI).Value())) + + + By("Verifying EinoChainApp status for HPA for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(updatedEinoApp.Status.HPAStatus).NotTo(BeNil()) + if updatedEinoApp.Status.HPAStatus != nil { // Guard against nil pointer + // HPA controller sets desired replicas. Initially, it might be minReplicas. + g.Expect(updatedEinoApp.Status.HPAStatus.DesiredReplicas).Should(Equal(minReplicas)) + } + g.Expect(updatedEinoApp.Status.Conditions).To(ContainElement(And( + HaveField("Type", ConditionTypeAutoscalingActive), + // Status might be False initially if HPA is still initializing, then True if active. + // For this test, we primarily care that the condition is set. + ))) + }, timeout, interval).Should(Succeed()) + }) + }) + + Context("When updating an EinoChainApp CR", func() { + It("Should update the image in the Deployment", func() { + By("Creating an EinoChainApp CR: " + einoAppName) + initialImage := TestImage // e.g., "nginx:latest" + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{Image: initialImage}, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying Deployment is created with initial image for " + einoAppName) + createdDeployment := &appsv1.Deployment{} + Eventually(func() string { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdDeployment) + if err != nil { + return "" + } + if len(createdDeployment.Spec.Template.Spec.Containers) > 0 { + return createdDeployment.Spec.Template.Spec.Containers[0].Image + } + return "" + }, timeout, interval).Should(Equal(initialImage)) + + By("Updating the EinoChainApp CR with a new image for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Expect(k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp)).Should(Succeed()) + + newImage := "nginx:1.21-alpine" + updatedEinoApp.Spec.Image = newImage + Expect(k8sClient.Update(ctx, updatedEinoApp)).Should(Succeed()) + + By("Verifying Deployment is updated with the new image for " + einoAppName) + Eventually(func() string { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdDeployment) + if err != nil { + return "" + } + if len(createdDeployment.Spec.Template.Spec.Containers) > 0 { + return createdDeployment.Spec.Template.Spec.Containers[0].Image + } + return "" + }, timeout, interval).Should(Equal(newImage)) + }) + + It("Should update replicas if HPA is not active", func() { + By("Creating an EinoChainApp CR without autoscaling: " + einoAppName) + initialReplicas := int32(1) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{Image: TestImage, Replicas: &initialReplicas}, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying Deployment is created with initial replicas for " + einoAppName) + createdDeployment := &appsv1.Deployment{} + Eventually(func() int32 { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdDeployment) + if err != nil || createdDeployment.Spec.Replicas == nil { + return -1 // Invalid state + } + return *createdDeployment.Spec.Replicas + }, timeout, interval).Should(Equal(initialReplicas)) + + By("Updating the EinoChainApp CR with new replica count for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Expect(k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp)).Should(Succeed()) + + newReplicas := int32(3) + updatedEinoApp.Spec.Replicas = &newReplicas + Expect(k8sClient.Update(ctx, updatedEinoApp)).Should(Succeed()) + + By("Verifying Deployment is updated with new replicas for " + einoAppName) + Eventually(func() int32 { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdDeployment) + if err != nil || createdDeployment.Spec.Replicas == nil { + return -1 + } + return *createdDeployment.Spec.Replicas + }, timeout, interval).Should(Equal(newReplicas)) + }) + + It("Should update Service ports", func() { + By("Creating an EinoChainApp CR with a ServiceSpec: " + einoAppName) + initialPort := int32(80) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + ServiceSpec: &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: initialPort, TargetPort: intstr.FromInt(8080)}}, + Type: corev1.ServiceTypeClusterIP, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying Service is created with initial port for " + einoAppName) + createdService := &corev1.Service{} + Eventually(func() int32 { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdService) + if err != nil || len(createdService.Spec.Ports) == 0 { + return -1 + } + return createdService.Spec.Ports[0].Port + }, timeout, interval).Should(Equal(initialPort)) + + By("Updating the EinoChainApp CR with new Service port for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Expect(k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp)).Should(Succeed()) + + newPort := int32(8081) + updatedEinoApp.Spec.ServiceSpec.Ports[0].Port = newPort + Expect(k8sClient.Update(ctx, updatedEinoApp)).Should(Succeed()) + + By("Verifying Service is updated with the new port for " + einoAppName) + Eventually(func() int32 { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdService) + if err != nil || len(createdService.Spec.Ports) == 0 { + return -1 + } + return createdService.Spec.Ports[0].Port + }, timeout, interval).Should(Equal(newPort)) + }) + + It("Should create HPA when autoscaling is enabled (from no HPA)", func() { + By("Creating an EinoChainApp CR without autoscaling: " + einoAppName) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{Image: TestImage}, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying HPA is not created initially for " + einoAppName) + Consistently(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, &autoscalingv2.HorizontalPodAutoscaler{})) + }, duration, interval).Should(BeTrue()) + + By("Updating the EinoChainApp CR to enable autoscaling for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Expect(k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp)).Should(Succeed()) + + minReplicas := int32(1) + maxReplicas := int32(5) + targetTPS := int32(50) + updatedEinoApp.Spec.Autoscaling = &einov1alpha1.EinoChainAppAutoscalingSpec{ + MinReplicas: &minReplicas, + MaxReplicas: maxReplicas, + TargetTokenPerSec: &targetTPS, + } + Expect(k8sClient.Update(ctx, updatedEinoApp)).Should(Succeed()) + + By("Verifying HPA is created after update for " + einoAppName) + createdHPA := &autoscalingv2.HorizontalPodAutoscaler{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdHPA) + }, timeout, interval).Should(Succeed()) + Expect(*createdHPA.Spec.MinReplicas).Should(Equal(minReplicas)) + Expect(createdHPA.Spec.MaxReplicas).Should(Equal(maxReplicas)) + }) + + It("Should update HPA spec when autoscaling parameters change", func() { + By("Creating an EinoChainApp CR with autoscaling: " + einoAppName) + initialMinReplicas := int32(1) + initialMaxReplicas := int32(3) + initialTargetTPS := int32(100) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + Autoscaling: &einov1alpha1.EinoChainAppAutoscalingSpec{ + MinReplicas: &initialMinReplicas, + MaxReplicas: initialMaxReplicas, + TargetTokenPerSec: &initialTargetTPS, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying HPA is created with initial spec for " + einoAppName) + createdHPA := &autoscalingv2.HorizontalPodAutoscaler{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdHPA) + }, timeout, interval).Should(Succeed()) + Expect(*createdHPA.Spec.MinReplicas).Should(Equal(initialMinReplicas)) + + By("Updating the EinoChainApp CR with new HPA parameters for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Expect(k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp)).Should(Succeed()) + + newMinReplicas := int32(2) + newTargetTPS := int32(150) + updatedEinoApp.Spec.Autoscaling.MinReplicas = &newMinReplicas + updatedEinoApp.Spec.Autoscaling.TargetTokenPerSec = &newTargetTPS + Expect(k8sClient.Update(ctx, updatedEinoApp)).Should(Succeed()) + + By("Verifying HPA spec is updated for " + einoAppName) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, createdHPA) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(*createdHPA.Spec.MinReplicas).Should(Equal(newMinReplicas)) + g.Expect(createdHPA.Spec.Metrics[0].Pods.Target.AverageValue.Value()).Should(Equal(resource.NewQuantity(newTargetTPS, resource.DecimalSI).Value())) + }, timeout, interval).Should(Succeed()) + }) + + It("Should delete HPA when autoscaling is disabled", func() { + By("Creating an EinoChainApp CR with autoscaling: " + einoAppName) + minReplicas := int32(1) + maxReplicas := int32(3) + targetTPS := int32(100) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + Autoscaling: &einov1alpha1.EinoChainAppAutoscalingSpec{ + MinReplicas: &minReplicas, + MaxReplicas: maxReplicas, + TargetTokenPerSec: &targetTPS, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + By("Verifying HPA is created for " + einoAppName) + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, &autoscalingv2.HorizontalPodAutoscaler{}) + }, timeout, interval).Should(Succeed()) + + By("Updating the EinoChainApp CR to disable autoscaling for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Expect(k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp)).Should(Succeed()) + + updatedEinoApp.Spec.Autoscaling = nil // Disable autoscaling + Expect(k8sClient.Update(ctx, updatedEinoApp)).Should(Succeed()) + + By("Verifying HPA is deleted for " + einoAppName) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace}, &autoscalingv2.HorizontalPodAutoscaler{})) + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("When deleting an EinoChainApp CR", func() { + It("Should garbage collect owned Deployment, Service, and HPA", func() { + By("Creating an EinoChainApp CR with service and HPA for deletion test: " + einoAppName) + initialReplicas := int32(1) + minReplicas := int32(1) + maxReplicas := int32(2) + targetTPS := int32(100) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + Replicas: &initialReplicas, // Will be managed by HPA if active + ServiceSpec: &corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)}}, + Type: corev1.ServiceTypeClusterIP, + }, + Autoscaling: &einov1alpha1.EinoChainAppAutoscalingSpec{ + MinReplicas: &minReplicas, + MaxReplicas: maxReplicas, + TargetTokenPerSec: &targetTPS, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + deploymentKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + serviceKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + hpaKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + + By("Verifying Deployment, Service, and HPA are created for deletion test: " + einoAppName) + Eventually(func() error { return k8sClient.Get(ctx, deploymentKey, &appsv1.Deployment{}) }, timeout, interval).Should(Succeed()) + Eventually(func() error { return k8sClient.Get(ctx, serviceKey, &corev1.Service{}) }, timeout, interval).Should(Succeed()) + Eventually(func() error { return k8sClient.Get(ctx, hpaKey, &autoscalingv2.HorizontalPodAutoscaler{}) }, timeout, interval).Should(Succeed()) + + By("Deleting the EinoChainApp CR for deletion test: " + einoAppName) + Expect(k8sClient.Delete(ctx, einoApp)).Should(Succeed()) + + By("Verifying EinoChainApp CR is deleted for deletion test: " + einoAppName) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, einoAppLookupKey, &einov1alpha1.EinoChainApp{})) + }, timeout, interval).Should(BeTrue()) + + By("Verifying owned Deployment is garbage collected for " + einoAppName) + Eventually(func() bool { return errors.IsNotFound(k8sClient.Get(ctx, deploymentKey, &appsv1.Deployment{})) }, timeout, interval).Should(BeTrue()) + By("Verifying owned Service is garbage collected for " + einoAppName) + Eventually(func() bool { return errors.IsNotFound(k8sClient.Get(ctx, serviceKey, &corev1.Service{})) }, timeout, interval).Should(BeTrue()) + By("Verifying owned HPA is garbage collected for " + einoAppName) + Eventually(func() bool { return errors.IsNotFound(k8sClient.Get(ctx, hpaKey, &autoscalingv2.HorizontalPodAutoscaler{})) }, timeout, interval).Should(BeTrue()) + }) + }) + }) + + Describe("Status updates", func() { + It("Should reflect Deployment readiness in EinoChainApp status", func() { + By("Creating an EinoChainApp for status test: " + einoAppName) + initialReplicas := int32(1) + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{Image: TestImage, Replicas: &initialReplicas}, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + deploymentKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + createdDeployment := &appsv1.Deployment{} + By("Waiting for Deployment to be created for status test: " + einoAppName) + Eventually(func() error { + return k8sClient.Get(ctx, deploymentKey, createdDeployment) + }, timeout, interval).Should(Succeed()) + + By("Manually updating Deployment status to simulate readiness for " + einoAppName) + // Simulate the deployment controller updating the status + createdDeployment.Status = appsv1.DeploymentStatus{ + Replicas: initialReplicas, + ReadyReplicas: initialReplicas, + AvailableReplicas: initialReplicas, + UpdatedReplicas: initialReplicas, + ObservedGeneration: createdDeployment.Generation, // Crucial for Progressing condition + } + Expect(k8sClient.Status().Update(ctx, createdDeployment)).Should(Succeed()) + + By("Verifying EinoChainApp status reflects Deployment readiness for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(updatedEinoApp.Status.CurrentReplicas).Should(Equal(initialReplicas)) + g.Expect(updatedEinoApp.Status.DesiredReplicas).Should(Equal(initialReplicas)) // Assuming no HPA + + g.Expect(updatedEinoApp.Status.Conditions).To(ContainElement(And( + HaveField("Type", ConditionTypeAvailable), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", ReasonMinimumReplicasAvailable), // Or ReasonDeploymentReady + ))) + g.Expect(updatedEinoApp.Status.Conditions).To(ContainElement(And( + HaveField("Type", ConditionTypeProgressing), + HaveField("Status", metav1.ConditionFalse), // Should be false if stable + HaveField("Reason", ReasonDeploymentReady), + ))) + }, timeout, interval).Should(Succeed()) + }) + + It("Should reflect HPA status in EinoChainApp status", func() { + By("Creating an EinoChainApp with autoscaling for HPA status test: " + einoAppName) + minReplicas := int32(1) + maxReplicas := int32(3) + targetTPS := int32(100) + initialDeploymentReplicas := int32(1) + + einoApp := &einov1alpha1.EinoChainApp{ + ObjectMeta: metav1.ObjectMeta{Name: einoAppName, Namespace: EinoChainAppNamespace}, + Spec: einov1alpha1.EinoChainAppSpec{ + Image: TestImage, + Replicas: &initialDeploymentReplicas, // Initial deployment replicas + Autoscaling: &einov1alpha1.EinoChainAppAutoscalingSpec{ + MinReplicas: &minReplicas, + MaxReplicas: maxReplicas, + TargetTokenPerSec: &targetTPS, + }, + }, + } + Expect(k8sClient.Create(ctx, einoApp)).Should(Succeed()) + + hpaKey := types.NamespacedName{Name: einoAppName, Namespace: EinoChainAppNamespace} + createdHPA := &autoscalingv2.HorizontalPodAutoscaler{} + By("Verifying HPA is created for HPA status test: " + einoAppName) + Eventually(func() error { + return k8sClient.Get(ctx, hpaKey, createdHPA) + }, timeout, interval).Should(Succeed()) + + By("Manually updating HPA status for test: " + einoAppName) + // Simulate HPA controller updating status + createdHPA.Status = autoscalingv2.HorizontalPodAutoscalerStatus{ + CurrentReplicas: 2, // Example current replicas + DesiredReplicas: 2, // Example desired replicas by HPA + LastScaleTime: &metav1.Time{Time: time.Now().Add(-time.Minute)}, + ObservedGeneration: createdHPA.Generation, // HPA controller sets this + CurrentMetrics: []autoscalingv2.MetricStatus{ + { + Type: autoscalingv2.PodsMetricSourceType, + Pods: &autoscalingv2.PodsMetricStatus{ + Metric: autoscalingv2.MetricIdentifier{Name: "eino_token_per_sec_avg_per_pod"}, + Current: autoscalingv2.MetricValueStatus{AverageValue: resource.NewQuantity(int64(120), resource.DecimalSI)}, + }, + }, + }, + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + {Type: autoscalingv2.AbleToScale, Status: metav1.ConditionTrue, Reason: "ReadyForNewScale", Message: "recommended size matches current size"}, + {Type: autoscalingv2.ScalingActive, Status: metav1.ConditionTrue, Reason: "ValidMetricFound", Message: "the HPA was able to successfully calculate a replica count from pods metric eino_token_per_sec_avg_per_pod"}, + }, + } + Expect(k8sClient.Status().Update(ctx, createdHPA)).Should(Succeed()) + + By("Verifying EinoChainApp status reflects HPA status for " + einoAppName) + updatedEinoApp := &einov1alpha1.EinoChainApp{} + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, einoAppLookupKey, updatedEinoApp) + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(updatedEinoApp.Status.HPAStatus).NotTo(BeNil()) + if updatedEinoApp.Status.HPAStatus != nil { + g.Expect(updatedEinoApp.Status.HPAStatus.CurrentReplicas).Should(Equal(int32(2))) + g.Expect(updatedEinoApp.Status.HPAStatus.DesiredReplicas).Should(Equal(int32(2))) // This should come from HPA + g.Expect(updatedEinoApp.Status.ObservedTokenPerSec).NotTo(BeNil()) + if updatedEinoApp.Status.ObservedTokenPerSec != nil { + g.Expect(*updatedEinoApp.Status.ObservedTokenPerSec).Should(Equal(int32(120))) + } + } + // Check overall EinoChainApp DesiredReplicas is updated by HPA's status + g.Expect(updatedEinoApp.Status.DesiredReplicas).Should(Equal(int32(2))) + + + g.Expect(updatedEinoApp.Status.Conditions).To(ContainElement(And( + HaveField("Type", ConditionTypeAutoscalingActive), + HaveField("Status", metav1.ConditionTrue), + // HaveField("Reason", ReasonHPAActive), // This can vary based on HPA internal state + ))) + }, timeout, interval).Should(Succeed()) + }) + }) + }) +}) +``` diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go new file mode 100644 index 00000000..2287a4f5 --- /dev/null +++ b/internal/controller/suite_test.go @@ -0,0 +1,112 @@ +package controller + +import ( + "context" + "path/filepath" + "testing" + // "time" // Not strictly needed for setup but often used in tests + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" // Required for Deployment scheme + corev1 "k8s.io/api/core/v1" // Required for Service scheme + autoscalingv2 "k8s.io/api/autoscaling/v2" // Required for HPA scheme + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + einov1alpha1 "github.com/cloudwego/eino/operator/api/v1alpha1" // Adjust if your module path is different + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment +var ctx context.Context +var cancel context.CancelFunc + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, // Adjust path to your CRD bases + ErrorIfCRDPathMissing: true, + // Use existing assets if available to speed up tests + // KubeAPIServerFlags: append(envtest.DefaultKubeAPIServerFlags, "--SOME_FLAG=SOME_VALUE"), + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + // Add EinoChainApp scheme + err = einov1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // Add other schemes for built-in types like Deployment, Service, HPA + err = appsv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = corev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = autoscalingv2.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // Setup and start the manager with the Reconciler + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + // Disable metrics listener for tests + MetricsBindAddress: "0", + }) + Expect(err).ToNot(HaveOccurred()) + + err = (&EinoChainAppReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + // Recorder: k8sManager.GetEventRecorderFor("einochainapp-controller-test"), // If using events + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() + +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// The CRDDirectoryPaths might need adjustment based on the actual project structure. +// It should point to the directory where the CRD YAML files (bases) are located, +// typically `config/crd/bases`. +// The import path for the API `github.com/cloudwego/eino/operator/api/v1alpha1` +// should also be verified against the actual module path.