From 1892695df588bf7710d674331de60357967426d8 Mon Sep 17 00:00:00 2001 From: blublinsky Date: Tue, 12 May 2026 08:58:31 +0100 Subject: [PATCH] Removes the Lightspeed Core (lcore) code Co-authored-by: Cursor --- .cursor/skills/find-duplication/SKILL.md | 2 +- AGENTS.md | 11 +- ARCHITECTURE.md | 25 +- CONTRIBUTING.md | 10 +- api/v1alpha1/olsconfig_types.go | 28 +- api/v1alpha1/zz_generated.deepcopy.go | 5 - ...tspeed-operator.clusterserviceversion.yaml | 24 +- .../ols.openshift.io_olsconfigs.yaml | 99 +- cmd/main.go | 37 +- .../bases/ols.openshift.io_olsconfigs.yaml | 99 +- config/manager/manager.yaml | 3 - ...tspeed-operator.clusterserviceversion.yaml | 21 +- internal/controller/appserver/assets_test.go | 46 +- internal/controller/lcore/assets.go | 462 ----- internal/controller/lcore/assets_test.go | 1071 ----------- internal/controller/lcore/config.go | 989 ---------- internal/controller/lcore/config_test.go | 691 ------- internal/controller/lcore/deployment.go | 1421 --------------- internal/controller/lcore/deployment_test.go | 1615 ----------------- .../lcore/lcore_generic_provider_test.go | 391 ---- internal/controller/lcore/reconciler.go | 632 ------- internal/controller/lcore/reconciler_test.go | 552 ------ internal/controller/lcore/suite_test.go | 217 --- internal/controller/olsconfig_controller.go | 57 +- internal/controller/olsconfig_helpers.go | 14 +- .../controller/olsconfig_reconciler_test.go | 261 +++ internal/controller/postgres/assets_test.go | 27 +- .../controller/postgres/reconciler_test.go | 14 +- internal/controller/reconciler/interface.go | 13 +- internal/controller/utils/constants.go | 60 +- internal/controller/utils/credentials.go | 55 - .../utils/resource_defaults_test.go | 72 + internal/controller/utils/test_fixtures.go | 148 -- internal/controller/utils/testing.go | 24 - internal/controller/utils/types.go | 3 - internal/controller/utils/utils.go | 194 +- .../controller/utils/utils_deployment_test.go | 91 - .../utils/utils_generic_provider_test.go | 254 --- internal/controller/utils/utils_misc_test.go | 218 +-- .../controller/utils/utils_secrets_test.go | 41 - internal/controller/watchers/watchers.go | 24 +- internal/controller/watchers/watchers_test.go | 355 +++- test/e2e/client.go | 2 +- test/e2e/constants.go | 4 +- test/e2e/utils.go | 18 +- 45 files changed, 798 insertions(+), 9602 deletions(-) delete mode 100644 internal/controller/lcore/assets.go delete mode 100644 internal/controller/lcore/assets_test.go delete mode 100644 internal/controller/lcore/config.go delete mode 100644 internal/controller/lcore/config_test.go delete mode 100644 internal/controller/lcore/deployment.go delete mode 100644 internal/controller/lcore/deployment_test.go delete mode 100644 internal/controller/lcore/lcore_generic_provider_test.go delete mode 100644 internal/controller/lcore/reconciler.go delete mode 100644 internal/controller/lcore/reconciler_test.go delete mode 100644 internal/controller/lcore/suite_test.go create mode 100644 internal/controller/olsconfig_reconciler_test.go delete mode 100644 internal/controller/utils/credentials.go create mode 100644 internal/controller/utils/resource_defaults_test.go delete mode 100644 internal/controller/utils/utils_generic_provider_test.go diff --git a/.cursor/skills/find-duplication/SKILL.md b/.cursor/skills/find-duplication/SKILL.md index 79f8101ce..1757e41e6 100644 --- a/.cursor/skills/find-duplication/SKILL.md +++ b/.cursor/skills/find-duplication/SKILL.md @@ -93,7 +93,7 @@ For each duplicate found, classify: |----------|--------| | **Extract** — identical logic in 3+ places | Recommend a shared helper in utils | | **Parameterize** — same structure, different values | Recommend a common function with parameters | -| **Acceptable** — similar but serving different domains (app server vs lcore) | Note it, no action needed | +| **Acceptable** — similar but serving different domains (e.g. appserver vs postgres) | Note it, no action needed | | **Boilerplate** — kubebuilder/controller-runtime patterns | Skip, this is framework convention | | **Test-only** — repeated test setup/fixtures | Recommend shared test fixture (only if user asked) | diff --git a/AGENTS.md b/AGENTS.md index a5bebb4e8..494318a88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ OLSConfigReconciler.Reconcile() → ├── reconcileLLMSecrets() ├── reconcileConsoleUI() ├── reconcilePostgresServer() -└── reconcileAppServer() OR reconcileLCore() [MUTUALLY EXCLUSIVE via --enable-lcore flag] +└── reconcileAppServer() (application server via `appserver` package) └── [12+ sub-tasks via ReconcileTask pattern] ``` @@ -73,8 +73,7 @@ make test-e2e # E2E tests (requires cluster) ### Controllers - `internal/controller/olsconfig_controller.go` - Main reconciler with finalizer logic -- `internal/controller/appserver/` - App server (LEGACY) -- `internal/controller/lcore/` - Lightspeed Core (NEW) +- `internal/controller/appserver/` - App server - `internal/controller/postgres/` - PostgreSQL - `internal/controller/console/` - Console UI - `internal/controller/watchers/` - External resource watching @@ -83,8 +82,10 @@ make test-e2e # E2E tests (requires cluster) ### Tests - `*_test.go` - Unit tests (co-located) -- `test/e2e/` - E2E tests -- `internal/controller/utils/testing.go` - Test utilities +- `test/e2e/` - E2E tests (shared helpers in `assets.go`, `utils.go`, `client.go`, and related files) +- `internal/controller/utils/test_fixtures.go` - Shared controller **unit-test** fixtures (default `OLSConfig` CR, random secret/configmap/TLS helpers, telemetry pull-secret create/delete, shared `With*` provider mutators). Add here when the same shape is reused across tests or packages. +- For **one-off** CR or spec fragments used only in one file, **inline** next to the test (or use a file-local unexported helper in that `*_test.go`) instead of `test_fixtures.go`, so refactors do not leave unused exported helpers. +- `internal/controller/utils/testing.go` - `TestReconciler`, `NewTestReconciler`, and `StatusHasCondition` for envtest-based suites - `internal/controller/suite_test.go` - Test suite setup, shared helpers - `cleanupOLSConfig()` - Reusable CR cleanup helper (removes finalizers, waits for deletion) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c775e31b2..1d8564578 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ This document describes the internal architecture of the OpenShift Lightspeed Op ## Overview -The operator follows a modular, component-based architecture where each major component (application server, Lightspeed Core/Llama Stack, PostgreSQL, Console UI) is managed by its own dedicated package with independent reconciliation logic. +The operator follows a modular, component-based architecture where each major component (application server, PostgreSQL, Console UI) is managed by its own dedicated package with independent reconciliation logic. ## Key Design Decisions @@ -37,7 +37,7 @@ The operator follows a modular, component-based architecture where each major co **Core Orchestration:** - Main `Reconcile()` method coordinates all reconciliation phases - `SetupWithManager()` configures controller watches and event handlers -- Selects backend: calls either `appserver.ReconcileAppServer()` OR `lcore.ReconcileLCore()` based on `--enable-lcore` flag +- Reconciles the application server via `appserver` (see `ReconcileAppServerResources` / `ReconcileAppServerDeployment` in `olsconfig_controller.go`) **Support Functions:** - Implements `reconciler.Reconciler` interface (provides config/images to components) @@ -58,7 +58,7 @@ The operator follows a modular, component-based architecture where each major co - Detect OpenShift version and select appropriate images - Start controller and handle graceful shutdown -**Key Flags:** `--enable-lcore` (backend selection), `--controller-namespace`. See `cmd/main.go` for complete list. +**Key Flags:** Image URLs, `--controller-namespace`, reconcile interval, and related runtime options. See `cmd/main.go` for the complete list. ### Reconciler Interface (`internal/controller/reconciler`) @@ -70,22 +70,9 @@ Provides clean contract between main controller and component packages: ### Application Server Package (`internal/controller/appserver`) -**Purpose:** Manages OpenShift Lightspeed application server (LEGACY backend - LLM API proxy) +**Purpose:** Manages the OpenShift Lightspeed application server (LLM API, RAG, optional MCP, metrics). -**Entry Point:** `ReconcileAppServer(reconciler.Reconciler, context, *OLSConfig)` - -### Lightspeed Core Package (`internal/controller/lcore`) - -**Purpose:** Manages Lightspeed Core + Llama Stack server (NEW backend - agent-based with MCP support) - -**Entry Point:** `ReconcileLCore(reconciler.Reconciler, context, *OLSConfig)` - -**Key Features:** -- Dynamic LLM configuration (supports OpenAI, Azure OpenAI, others) -- CA certificate support for custom TLS -- RAG support with vector database -- MCP (Model Context Protocol) integration -- Metrics with K8s authentication +**Entry Points:** `ReconcileAppServerResources` and `ReconcileAppServerDeployment` (invoked from `olsconfig_controller.go`). ### PostgreSQL Package (`internal/controller/postgres`) @@ -138,7 +125,7 @@ High-level reconciliation sequence: 6. Reconcile Components: - Console UI (if enabled) - PostgreSQL (if conversation cache enabled) - - Backend (AppServer OR LCore - mutually exclusive, controlled by --enable-lcore flag) + - Application server (`appserver` package) 7. Update Status Conditions based on deployment readiness ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6219214e9..babaf1ee5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,12 +10,12 @@ This guide provides instructions for contributing to the OpenShift Lightspeed Op ## Quick Context The operator uses a **modular, component-based architecture**: -- Each component (appserver, lcore, postgres, console) is a self-contained package +- Each component (`appserver`, `postgres`, `console`) is a self-contained package - Components use the `reconciler.Reconciler` interface (no circular dependencies) - Task-based reconciliation pattern (list of tasks executed sequentially) - Two resource approaches: **Owned** (operator-created, ResourceVersion tracking) vs **External** (user-provided, data comparison) -**Best way to learn**: Read existing component code (`appserver/`, `postgres/`, `console/`, `lcore/`) +**Best way to learn**: Read existing component code (`appserver/`, `postgres/`, `console/`) --- @@ -58,7 +58,7 @@ func ReconcileMyComponent(r reconciler.Reconciler, ctx context.Context, cr *olsv } ``` -**Reference**: See `appserver/reconciler.go`, `postgres/reconciler.go`, or `lcore/reconciler.go` +**Reference**: See `appserver/reconciler.go` or `postgres/reconciler.go` ### Step 3: Implement Asset Generation @@ -70,7 +70,7 @@ func ReconcileMyComponent(r reconciler.Reconciler, ctx context.Context, cr *olsv - Always set owner references with `controllerutil.SetControllerReference()` - Use `utils.DefaultLabels()` for consistent labeling -**Reference**: See `appserver/assets.go`, `postgres/assets.go`, or `lcore/assets.go` +**Reference**: See `appserver/assets.go` or `postgres/assets.go` ### Step 4: Add Constants @@ -139,7 +139,7 @@ The OLM bundle needs regeneration when changes affect how the operator is deploy **Bundle Update Required:** - **RBAC Changes**: Modified `//+kubebuilder:rbac` markers in Go code OR changed files in `config/rbac/` - **CRD Changes**: Modified API types in `api/v1alpha1/olsconfig_types.go` -- **Image Changes**: New operator image, new operand images (appserver, lcore, postgres, console), or image version changes +- **Image Changes**: New operator image, new operand images (appserver, postgres, console), or image version changes - **CSV Metadata**: Changed operator description, keywords, maintainers, links, or other metadata **Bundle Update NOT Required:** diff --git a/api/v1alpha1/olsconfig_types.go b/api/v1alpha1/olsconfig_types.go index 55963b5a9..9fcdddab3 100644 --- a/api/v1alpha1/olsconfig_types.go +++ b/api/v1alpha1/olsconfig_types.go @@ -21,7 +21,6 @@ import ( corev1 "k8s.io/api/core/v1" resource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" ) // OLSConfigSpec defines the desired state of OLSConfig @@ -343,9 +342,6 @@ type DeploymentConfig struct { // MCP server container settings. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="MCP Server Container" MCPServerContainer ContainerConfig `json:"mcpServer,omitempty"` - // Llama Stack container settings. - // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Llama Stack Container" - LlamaStackContainer ContainerConfig `json:"llamaStack,omitempty"` // Console container settings. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Console Deployment" ConsoleContainer Config `json:"console,omitempty"` @@ -482,10 +478,6 @@ type ModelSpec struct { // ProviderSpec defines the desired state of LLM provider. // +kubebuilder:validation:XValidation:message="'deploymentName' must be specified for 'azure_openai' provider",rule="self.type != \"azure_openai\" || self.deploymentName != \"\"" // +kubebuilder:validation:XValidation:message="'projectID' must be specified for 'watsonx' provider",rule="self.type != \"watsonx\" || self.projectID != \"\"" -// +kubebuilder:validation:XValidation:message="'providerType' and 'config' must be used together in llamaStackGeneric mode",rule="!has(self.providerType) || has(self.config)" -// +kubebuilder:validation:XValidation:message="'config' requires 'providerType' to be set",rule="!has(self.config) || has(self.providerType)" -// +kubebuilder:validation:XValidation:message="Llama Stack Generic mode (providerType set) requires type='llamaStackGeneric'",rule="!has(self.providerType) || self.type == \"llamaStackGeneric\"" -// +kubebuilder:validation:XValidation:message="Llama Stack Generic mode cannot use legacy provider-specific fields",rule="self.type != \"llamaStackGeneric\" || (!has(self.deploymentName) && !has(self.projectID) && !has(self.url) && !has(self.apiVersion))" // +kubebuilder:validation:XValidation:message="credentialKey must not be empty or whitespace",rule="!has(self.credentialKey) || !self.credentialKey.matches('^[ \\t\\n\\r\\v\\f]*$')" // +kubebuilder:validation:XValidation:message="googleVertexConfig is required for google_vertex provider",rule="self.type != \"google_vertex\" || has(self.googleVertexConfig)" // +kubebuilder:validation:XValidation:message="googleVertexAnthropicConfig is required for google_vertex_anthropic provider",rule="self.type != \"google_vertex_anthropic\" || has(self.googleVertexAnthropicConfig)" @@ -515,7 +507,7 @@ type ProviderSpec struct { // Provider type // +kubebuilder:validation:Required // +required - // +kubebuilder:validation:Enum=azure_openai;bam;openai;watsonx;rhoai_vllm;rhelai_vllm;fake_provider;llamaStackGeneric;google_vertex;google_vertex_anthropic + // +kubebuilder:validation:Enum=azure_openai;bam;openai;watsonx;rhoai_vllm;rhelai_vllm;fake_provider;google_vertex;google_vertex_anthropic // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Provider Type" Type string `json:"type"` // Deployment name for Azure OpenAI provider @@ -540,24 +532,10 @@ type ProviderSpec struct { // +kubebuilder:validation:Optional // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="TLS Security Profile",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} TLSSecurityProfile *configv1.TLSSecurityProfile `json:"tlsSecurityProfile,omitempty"` - // Llama Stack Generic provider type for provider configuration (e.g., "remote::openai", "inline::sentence-transformers") - // When set, this provider uses Llama Stack Generic mode instead of legacy mode. - // Must follow pattern: (inline|remote):: - // +kubebuilder:validation:Pattern=`^(inline|remote)::[a-z0-9][a-z0-9_-]*$` - // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Llama Stack Provider Type" - ProviderType string `json:"providerType,omitempty"` - // Arbitrary configuration for the provider (Llama Stack Generic mode only) - // This map is passed directly to Llama Stack provider configuration. - // Credentials are automatically injected as environment variable substitutions. - // Example: {"url": "https://...", "custom_field": "value"} - // +kubebuilder:pruning:PreserveUnknownFields - // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Llama Stack Provider Config" - Config *runtime.RawExtension `json:"config,omitempty"` // Secret key name for provider credentials (defaults to "apitoken" if not set). // Specifies which key inside credentialsSecretRef to read the credential value from. - // The credential value is always exposed to the container as env var {PROVIDER_NAME}_API_KEY - // (derived from the provider name, not this field), and referenced in the Llama Stack config - // YAML as ${env.PROVIDER_NAME_API_KEY}. This field only controls which secret data key is read. + // The credential value is exposed to the app server container as env var {PROVIDER_NAME}_API_KEY + // (derived from the provider name, not this field). This field only controls which secret data key is read. // +kubebuilder:validation:Optional // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Credential Key Name" CredentialKey string `json:"credentialKey,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b10c1bad2..cceaa7b9b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -116,7 +116,6 @@ func (in *DeploymentConfig) DeepCopyInto(out *DeploymentConfig) { in.APIContainer.DeepCopyInto(&out.APIContainer) in.DataCollectorContainer.DeepCopyInto(&out.DataCollectorContainer) in.MCPServerContainer.DeepCopyInto(&out.MCPServerContainer) - in.LlamaStackContainer.DeepCopyInto(&out.LlamaStackContainer) in.ConsoleContainer.DeepCopyInto(&out.ConsoleContainer) in.DatabaseContainer.DeepCopyInto(&out.DatabaseContainer) } @@ -531,10 +530,6 @@ func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile *out = (*in).DeepCopy() } - if in.Config != nil { - in, out := &in.Config, &out.Config - *out = (*in).DeepCopy() - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderSpec. diff --git a/bundle/manifests/lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/lightspeed-operator.clusterserviceversion.yaml index 9767af3a6..8b7ef5632 100644 --- a/bundle/manifests/lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/lightspeed-operator.clusterserviceversion.yaml @@ -93,19 +93,11 @@ spec: - description: API Version for Azure OpenAI provider displayName: Azure OpenAI API Version path: llm.providers[0].apiVersion - - description: |- - Arbitrary configuration for the provider (Llama Stack Generic mode only) - This map is passed directly to Llama Stack provider configuration. - Credentials are automatically injected as environment variable substitutions. - Example: {"url": "https://...", "custom_field": "value"} - displayName: Llama Stack Provider Config - path: llm.providers[0].config - description: |- Secret key name for provider credentials (defaults to "apitoken" if not set). Specifies which key inside credentialsSecretRef to read the credential value from. - The credential value is always exposed to the container as env var {PROVIDER_NAME}_API_KEY - (derived from the provider name, not this field), and referenced in the Llama Stack config - YAML as ${env.PROVIDER_NAME_API_KEY}. This field only controls which secret data key is read. + The credential value is exposed to the app server container as env var {PROVIDER_NAME}_API_KEY + (derived from the provider name, not this field). This field only controls which secret data key is read. displayName: Credential Key Name path: llm.providers[0].credentialKey - description: Deployment name for Azure OpenAI provider @@ -156,12 +148,6 @@ spec: - description: Watsonx Project ID displayName: Watsonx Project ID path: llm.providers[0].projectID - - description: |- - Llama Stack Generic provider type for provider configuration (e.g., "remote::openai", "inline::sentence-transformers") - When set, this provider uses Llama Stack Generic mode instead of legacy mode. - Must follow pattern: (inline|remote):: - displayName: Llama Stack Provider Type - path: llm.providers[0].providerType - description: TLS Security Profile used by connection to provider displayName: TLS Security Profile path: llm.providers[0].tlsSecurityProfile @@ -271,9 +257,6 @@ spec: path: ols.deployment.database.replicas x-descriptors: - urn:alm:descriptor:com.tectonic.ui:podCount - - description: Llama Stack container settings. - displayName: Llama Stack Container - path: ols.deployment.llamaStack - description: MCP server container settings. displayName: MCP Server Container path: ols.deployment.mcpServer @@ -891,9 +874,6 @@ spec: - --metrics-bind-address=:8443 - --secure-metrics-server - --cert-dir=/etc/tls/private - - --lcore-image=quay.io/lightspeed-core/lightspeed-stack:dev-latest - - --use-lcore=false - - --lcore-server=true - --service-image=registry.redhat.io/openshift-lightspeed/lightspeed-service-api-rhel9@sha256:5287134ee84c4837e74db54a36f9abe71dcbd46d067b04d99bfaa972a4f149cd - --console-image=registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9@sha256:c613e471cd3b77fb85dacb337b5b9c6b9fddd06563485b0189809690b142dd4f - --console-image-pf5=registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9@sha256:86ac43e54a7d121762265cfbb9b32d3b75226e75ef25abaff2adb22c1171f00a diff --git a/bundle/manifests/ols.openshift.io_olsconfigs.yaml b/bundle/manifests/ols.openshift.io_olsconfigs.yaml index 5450f3db3..8ac3fe75b 100644 --- a/bundle/manifests/ols.openshift.io_olsconfigs.yaml +++ b/bundle/manifests/ols.openshift.io_olsconfigs.yaml @@ -61,21 +61,12 @@ spec: apiVersion: description: API Version for Azure OpenAI provider type: string - config: - description: |- - Arbitrary configuration for the provider (Llama Stack Generic mode only) - This map is passed directly to Llama Stack provider configuration. - Credentials are automatically injected as environment variable substitutions. - Example: {"url": "https://...", "custom_field": "value"} - type: object - x-kubernetes-preserve-unknown-fields: true credentialKey: description: |- Secret key name for provider credentials (defaults to "apitoken" if not set). Specifies which key inside credentialsSecretRef to read the credential value from. - The credential value is always exposed to the container as env var {PROVIDER_NAME}_API_KEY - (derived from the provider name, not this field), and referenced in the Llama Stack config - YAML as ${env.PROVIDER_NAME_API_KEY}. This field only controls which secret data key is read. + The credential value is exposed to the app server container as env var {PROVIDER_NAME}_API_KEY + (derived from the provider name, not this field). This field only controls which secret data key is read. type: string credentialsSecretRef: description: The name of the secret object that stores API @@ -163,13 +154,6 @@ spec: projectID: description: Watsonx Project ID type: string - providerType: - description: |- - Llama Stack Generic provider type for provider configuration (e.g., "remote::openai", "inline::sentence-transformers") - When set, this provider uses Llama Stack Generic mode instead of legacy mode. - Must follow pattern: (inline|remote):: - pattern: ^(inline|remote)::[a-z0-9][a-z0-9_-]*$ - type: string tlsSecurityProfile: description: TLS Security Profile used by connection to provider @@ -311,7 +295,6 @@ spec: - rhoai_vllm - rhelai_vllm - fake_provider - - llamaStackGeneric - google_vertex - google_vertex_anthropic type: string @@ -333,18 +316,6 @@ spec: - message: '''projectID'' must be specified for ''watsonx'' provider' rule: self.type != "watsonx" || self.projectID != "" - - message: '''providerType'' and ''config'' must be used together - in llamaStackGeneric mode' - rule: '!has(self.providerType) || has(self.config)' - - message: '''config'' requires ''providerType'' to be set' - rule: '!has(self.config) || has(self.providerType)' - - message: Llama Stack Generic mode (providerType set) requires - type='llamaStackGeneric' - rule: '!has(self.providerType) || self.type == "llamaStackGeneric"' - - message: Llama Stack Generic mode cannot use legacy provider-specific - fields - rule: self.type != "llamaStackGeneric" || (!has(self.deploymentName) - && !has(self.projectID) && !has(self.url) && !has(self.apiVersion)) - message: credentialKey must not be empty or whitespace rule: '!has(self.credentialKey) || !self.credentialKey.matches(''^[ \t\n\r\v\f]*$'')' @@ -4258,72 +4229,6 @@ spec: type: object type: array type: object - llamaStack: - description: Llama Stack container settings. - properties: - resources: - description: |- - Resource requirements (CPU, memory) - Uses standard corev1.ResourceRequirements - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - 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: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - 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: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object mcpServer: description: MCP server container settings. properties: diff --git a/cmd/main.go b/cmd/main.go index 0aa887044..4242daee9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -100,7 +100,6 @@ var ( "console-plugin-pf5": utils.ConsoleUIImagePF5Default, "console-plugin-4-19": utils.ConsoleUIImage419Default, "openshift-mcp-server-image": utils.OpenShiftMCPServerImageDefault, - "lightspeed-core": utils.LlamaStackImageDefault, "dataverse-exporter-image": utils.DataverseExporterImageDefault, "ocp-rag-image": utils.OcpRagImageDefault, } @@ -120,7 +119,7 @@ func init() { // overrideImages overrides the default images with the images provided by the user. // If an image is not provided, the default is used. -func overrideImages(serviceImage string, consoleImage string, consoleImage_pf5 string, consoleImage_419 string, postgresImage string, openshiftMCPServerImage string, lcoreImage string, dataverseExporterImage string, ocpRagImage string) map[string]string { +func overrideImages(serviceImage string, consoleImage string, consoleImage_pf5 string, consoleImage_419 string, postgresImage string, openshiftMCPServerImage string, dataverseExporterImage string, ocpRagImage string) map[string]string { res := defaultImages if serviceImage != "" { res["lightspeed-service"] = serviceImage @@ -140,9 +139,6 @@ func overrideImages(serviceImage string, consoleImage string, consoleImage_pf5 s if openshiftMCPServerImage != "" { res["openshift-mcp-server-image"] = openshiftMCPServerImage } - if lcoreImage != "" { - res["lightspeed-core"] = lcoreImage - } if dataverseExporterImage != "" { res["dataverse-exporter-image"] = dataverseExporterImage } @@ -181,11 +177,8 @@ func main() { var namespace string var postgresImage string var openshiftMCPServerImage string - var lcoreImage string var dataverseExporterImage string var ocpRagImage string - var useLCore bool - var lcoreServerMode bool flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, @@ -203,11 +196,8 @@ func main() { flag.StringVar(&namespace, "namespace", "", "The namespace where the operator is deployed.") flag.StringVar(&postgresImage, "postgres-image", utils.PostgresServerImageDefault, "The image of the PostgreSQL server.") flag.StringVar(&openshiftMCPServerImage, "openshift-mcp-server-image", utils.OpenShiftMCPServerImageDefault, "The image of the OpenShift MCP server container.") - flag.StringVar(&lcoreImage, "lcore-image", utils.LlamaStackImageDefault, "The image of the LCore container.") flag.StringVar(&dataverseExporterImage, "dataverse-exporter-image", utils.DataverseExporterImageDefault, "The image of the dataverse exporter container.") flag.StringVar(&ocpRagImage, "ocp-rag-image", utils.OcpRagImageDefault, "The image with the OCP RAG databases.") - flag.BoolVar(&useLCore, "use-lcore", false, "Use LCore instead of AppServer for the application server deployment.") - flag.BoolVar(&lcoreServerMode, "lcore-server", true, "Use LCore in a server mode.") opts := zap.Options{ Development: true, } @@ -220,25 +210,9 @@ func main() { namespace = getWatchNamespace() } - imagesMap := overrideImages(serviceImage, consoleImage, consoleImage_pf5, consoleImage_419, postgresImage, openshiftMCPServerImage, lcoreImage, dataverseExporterImage, ocpRagImage) + imagesMap := overrideImages(serviceImage, consoleImage, consoleImage_pf5, consoleImage_419, postgresImage, openshiftMCPServerImage, dataverseExporterImage, ocpRagImage) setupLog.Info("Images setting loaded", "images", listImages()) - // Log which backend is being used - backendType := "AppServer" - if useLCore { - backendType = "LCore" - } - setupLog.Info("========================================") - setupLog.Info(">>> BACKEND CONFIGURATION <<<", "backendType", backendType) - if useLCore { - deploymentMode := "server" - if !lcoreServerMode { - deploymentMode = "library" - } - setupLog.Info(">>> LCORE DEPLOYMENT MODE <<<", "mode", deploymentMode) - } - setupLog.Info("========================================") - setupLog.Info("Starting the operator", "metricsAddr", metricsAddr, "probeAddr", probeAddr, "certDir", certDir, "certName", certName, "keyName", keyName, "namespace", namespace) // Get K8 client and context cfg, err := config.GetConfig() @@ -428,13 +402,13 @@ func main() { // AnnotatedSecretMapping maps secret names to their affected deployments. // These are secrets that the operator manages and annotates with watchers.openshift.io/watch. // When these secrets change, the watcher will restart the listed deployments. - // Key: secret name, Value: list of deployment names (use "ACTIVE_BACKEND" for appserver/lcore). + // Key: secret name, Value: list of deployment names (use "ACTIVE_BACKEND" for appserver). // Only list secrets here that need to restart specific deployments beyond the active backend. AnnotatedSecretMapping: map[string][]string{}, // AnnotatedConfigMapMapping maps configmap names to their affected deployments. // These are configmaps that the operator manages and annotates with watchers.openshift.io/watch. // When these configmaps change, the watcher will restart the listed deployments. - // Key: configmap name, Value: list of deployment names (use "ACTIVE_BACKEND" for appserver/lcore) + // Key: configmap name, Value: list of deployment names (use "ACTIVE_BACKEND" for appserver) // Only list configmaps here that need to restart specific deployments beyond the active backend. AnnotatedConfigMapMapping: map[string][]string{}, } @@ -450,9 +424,6 @@ func main() { LightspeedServicePostgresImage: imagesMap["postgres-image"], OpenShiftMCPServerImage: imagesMap["openshift-mcp-server-image"], DataverseExporterImage: imagesMap["dataverse-exporter-image"], - LightspeedCoreImage: imagesMap["lightspeed-core"], - UseLCore: useLCore, - LCoreServerMode: lcoreServerMode, Namespace: namespace, PrometheusAvailable: prometheusAvailable, }, diff --git a/config/crd/bases/ols.openshift.io_olsconfigs.yaml b/config/crd/bases/ols.openshift.io_olsconfigs.yaml index 55bb40f04..869e1da55 100644 --- a/config/crd/bases/ols.openshift.io_olsconfigs.yaml +++ b/config/crd/bases/ols.openshift.io_olsconfigs.yaml @@ -61,21 +61,12 @@ spec: apiVersion: description: API Version for Azure OpenAI provider type: string - config: - description: |- - Arbitrary configuration for the provider (Llama Stack Generic mode only) - This map is passed directly to Llama Stack provider configuration. - Credentials are automatically injected as environment variable substitutions. - Example: {"url": "https://...", "custom_field": "value"} - type: object - x-kubernetes-preserve-unknown-fields: true credentialKey: description: |- Secret key name for provider credentials (defaults to "apitoken" if not set). Specifies which key inside credentialsSecretRef to read the credential value from. - The credential value is always exposed to the container as env var {PROVIDER_NAME}_API_KEY - (derived from the provider name, not this field), and referenced in the Llama Stack config - YAML as ${env.PROVIDER_NAME_API_KEY}. This field only controls which secret data key is read. + The credential value is exposed to the app server container as env var {PROVIDER_NAME}_API_KEY + (derived from the provider name, not this field). This field only controls which secret data key is read. type: string credentialsSecretRef: description: The name of the secret object that stores API @@ -163,13 +154,6 @@ spec: projectID: description: Watsonx Project ID type: string - providerType: - description: |- - Llama Stack Generic provider type for provider configuration (e.g., "remote::openai", "inline::sentence-transformers") - When set, this provider uses Llama Stack Generic mode instead of legacy mode. - Must follow pattern: (inline|remote):: - pattern: ^(inline|remote)::[a-z0-9][a-z0-9_-]*$ - type: string tlsSecurityProfile: description: TLS Security Profile used by connection to provider @@ -311,7 +295,6 @@ spec: - rhoai_vllm - rhelai_vllm - fake_provider - - llamaStackGeneric - google_vertex - google_vertex_anthropic type: string @@ -333,18 +316,6 @@ spec: - message: '''projectID'' must be specified for ''watsonx'' provider' rule: self.type != "watsonx" || self.projectID != "" - - message: '''providerType'' and ''config'' must be used together - in llamaStackGeneric mode' - rule: '!has(self.providerType) || has(self.config)' - - message: '''config'' requires ''providerType'' to be set' - rule: '!has(self.config) || has(self.providerType)' - - message: Llama Stack Generic mode (providerType set) requires - type='llamaStackGeneric' - rule: '!has(self.providerType) || self.type == "llamaStackGeneric"' - - message: Llama Stack Generic mode cannot use legacy provider-specific - fields - rule: self.type != "llamaStackGeneric" || (!has(self.deploymentName) - && !has(self.projectID) && !has(self.url) && !has(self.apiVersion)) - message: credentialKey must not be empty or whitespace rule: '!has(self.credentialKey) || !self.credentialKey.matches(''^[ \t\n\r\v\f]*$'')' @@ -4258,72 +4229,6 @@ spec: type: object type: array type: object - llamaStack: - description: Llama Stack container settings. - properties: - resources: - description: |- - Resource requirements (CPU, memory) - Uses standard corev1.ResourceRequirements - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - 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: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - 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: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object mcpServer: description: MCP server container settings. properties: diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 313c073c5..5881c44a8 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -71,9 +71,6 @@ spec: - "--metrics-bind-address=:8443" - --secure-metrics-server - "--cert-dir=/etc/tls/private" - - "--lcore-image=quay.io/lightspeed-core/lightspeed-stack:dev-latest" - - "--use-lcore=false" - - "--lcore-server=true" image: quay.io/openshift-lightspeed/lightspeed-operator:latest imagePullPolicy: Always name: manager diff --git a/config/manifests/bases/lightspeed-operator.clusterserviceversion.yaml b/config/manifests/bases/lightspeed-operator.clusterserviceversion.yaml index 290e1592e..0a569dcd0 100644 --- a/config/manifests/bases/lightspeed-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/lightspeed-operator.clusterserviceversion.yaml @@ -59,19 +59,11 @@ spec: - description: API Version for Azure OpenAI provider displayName: Azure OpenAI API Version path: llm.providers[0].apiVersion - - description: |- - Arbitrary configuration for the provider (Llama Stack Generic mode only) - This map is passed directly to Llama Stack provider configuration. - Credentials are automatically injected as environment variable substitutions. - Example: {"url": "https://...", "custom_field": "value"} - displayName: Llama Stack Provider Config - path: llm.providers[0].config - description: |- Secret key name for provider credentials (defaults to "apitoken" if not set). Specifies which key inside credentialsSecretRef to read the credential value from. - The credential value is always exposed to the container as env var {PROVIDER_NAME}_API_KEY - (derived from the provider name, not this field), and referenced in the Llama Stack config - YAML as ${env.PROVIDER_NAME_API_KEY}. This field only controls which secret data key is read. + The credential value is exposed to the app server container as env var {PROVIDER_NAME}_API_KEY + (derived from the provider name, not this field). This field only controls which secret data key is read. displayName: Credential Key Name path: llm.providers[0].credentialKey - description: Deployment name for Azure OpenAI provider @@ -124,12 +116,6 @@ spec: - description: Watsonx Project ID displayName: Watsonx Project ID path: llm.providers[0].projectID - - description: |- - Llama Stack Generic provider type for provider configuration (e.g., "remote::openai", "inline::sentence-transformers") - When set, this provider uses Llama Stack Generic mode instead of legacy mode. - Must follow pattern: (inline|remote):: - displayName: Llama Stack Provider Type - path: llm.providers[0].providerType - description: TLS Security Profile used by connection to provider displayName: TLS Security Profile path: llm.providers[0].tlsSecurityProfile @@ -241,9 +227,6 @@ spec: path: ols.deployment.database.replicas x-descriptors: - urn:alm:descriptor:com.tectonic.ui:podCount - - description: Llama Stack container settings. - displayName: Llama Stack Container - path: ols.deployment.llamaStack - description: MCP server container settings. displayName: MCP Server Container path: ols.deployment.mcpServer diff --git a/internal/controller/appserver/assets_test.go b/internal/controller/appserver/assets_test.go index 4df1b859f..b201dbe18 100644 --- a/internal/controller/appserver/assets_test.go +++ b/internal/controller/appserver/assets_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -98,8 +99,15 @@ var _ = Describe("App server assets", func() { UvicornLogLevel: string(olsv1alpha1.LogLevelInfo), }, ConversationCache: utils.ConversationCacheConfig{ - Type: utils.OLSDefaultCacheType, - Postgres: utils.GetTestPostgresCacheConfig(), + Type: utils.OLSDefaultCacheType, + Postgres: utils.PostgresCacheConfig{ + Host: strings.Join([]string{utils.PostgresServiceName, utils.OLSNamespaceDefault, "svc"}, "."), + Port: utils.PostgresServicePort, User: utils.PostgresDefaultUser, + DbName: utils.PostgresDefaultDbName, + PasswordPath: path.Join(utils.CredentialsMountRoot, utils.PostgresSecretName, utils.OLSComponentPasswordFileName), + SSLMode: utils.PostgresDefaultSSLMode, + CACertPath: path.Join(utils.OLSAppCertsMountRoot, "postgres-ca", "service-ca.crt"), + }, }, TLSConfig: utils.TLSConfig{ TLSCertificatePath: path.Join(utils.OLSAppCertsMountRoot, utils.OLSCertsSecretName, "tls.crt"), @@ -253,8 +261,10 @@ var _ = Describe("App server assets", func() { }) It("should generate configmap with queryFilters", func() { - crWithFilters := utils.WithQueryFilters(cr) - cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), crWithFilters) + cr.Spec.OLSConfig.QueryFilters = []olsv1alpha1.QueryFiltersSpec{ + {Name: "testFilter", Pattern: "testPattern", ReplaceWith: "testReplace"}, + } + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) Expect(err).NotTo(HaveOccurred()) Expect(cm.Name).To(Equal(utils.OLSConfigCmName)) Expect(cm.Namespace).To(Equal(utils.OLSNamespaceDefault)) @@ -322,8 +332,13 @@ var _ = Describe("App server assets", func() { }) It("should generate configmap with token quota limiters", func() { - crWithFilters := utils.WithQuotaLimiters(cr) - cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), crWithFilters) + cr.Spec.OLSConfig.QuotaHandlersConfig = &olsv1alpha1.QuotaHandlersConfig{ + LimitersConfig: []olsv1alpha1.LimiterConfig{ + {Name: "my_user_limiter", Type: "user_limiter", InitialQuota: 10000, QuotaIncrease: 100, Period: "1d"}, + {Name: "my_cluster_limiter", Type: "cluster_limiter", InitialQuota: 20000, QuotaIncrease: 200, Period: "30d"}, + }, + } + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) Expect(err).NotTo(HaveOccurred()) Expect(cm.Name).To(Equal(utils.OLSConfigCmName)) Expect(cm.Namespace).To(Equal(utils.OLSNamespaceDefault)) @@ -369,8 +384,9 @@ var _ = Describe("App server assets", func() { }) It("should generate configmap with IBM watsonx provider", func() { - watsonx := utils.WithWatsonxProvider(cr) - cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), watsonx) + setFirstLLMProviderNameAndType(cr, "watsonx", "watsonx") + cr.Spec.LLMConfig.Providers[0].WatsonProjectID = "testProjectID" + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) Expect(err).NotTo(HaveOccurred()) var olsConfigMap map[string]interface{} @@ -384,8 +400,8 @@ var _ = Describe("App server assets", func() { }) It("should generate configmap with rhoai_vllm provider", func() { - provider := utils.WithRHOAIProvider(cr) - cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), provider) + setFirstLLMProviderNameAndType(cr, "rhoai_vllm", "rhoai_vllm") + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) Expect(err).NotTo(HaveOccurred()) var olsConfigMap map[string]interface{} @@ -398,8 +414,8 @@ var _ = Describe("App server assets", func() { }) It("should generate configmap with rhelia_vllm provider", func() { - provider := utils.WithRHELAIProvider(cr) - cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), provider) + setFirstLLMProviderNameAndType(cr, "rhelai_vllm", "rhelai_vllm") + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) Expect(err).NotTo(HaveOccurred()) var olsConfigMap map[string]interface{} @@ -2403,3 +2419,9 @@ var _ = Describe("Helper function unit tests", func() { }) }) }) + +// setFirstLLMProviderNameAndType sets Providers[0] name and type; use utils.With* helpers for richer shapes. +func setFirstLLMProviderNameAndType(cr *olsv1alpha1.OLSConfig, name, providerType string) { + cr.Spec.LLMConfig.Providers[0].Name = name + cr.Spec.LLMConfig.Providers[0].Type = providerType +} diff --git a/internal/controller/lcore/assets.go b/internal/controller/lcore/assets.go deleted file mode 100644 index a7e7553b5..000000000 --- a/internal/controller/lcore/assets.go +++ /dev/null @@ -1,462 +0,0 @@ -package lcore - -import ( - "context" - "fmt" - "strings" - - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - - monv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/utils" -) - -// Service account for running LCore server -func GenerateServiceAccount(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.ServiceAccount, error) { - return utils.GenerateServiceAccount(r, cr, utils.OLSAppServerServiceAccountName) -} - -// SAR = SubjectAccessReview -// SARClusterRole provides permissions for the OLS Application Server to perform -// authentication and authorization checks for users accessing the OLS API. -func GenerateSARClusterRole(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*rbacv1.ClusterRole, error) { - role := rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.OLSAppServerSARRoleName, - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{"authorization.k8s.io"}, - Resources: []string{"subjectaccessreviews"}, - Verbs: []string{"create"}, - }, - { - APIGroups: []string{"authentication.k8s.io"}, - Resources: []string{"tokenreviews"}, - Verbs: []string{"create"}, - }, - { - APIGroups: []string{"config.openshift.io"}, - Resources: []string{"clusterversions"}, - Verbs: []string{"get", "list"}, - }, - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - ResourceNames: []string{"pull-secret"}, - Verbs: []string{"get"}, - }, - { - NonResourceURLs: []string{"/ls-access"}, - Verbs: []string{"get"}, - }, - }, - } - - if err := controllerutil.SetControllerReference(cr, &role, r.GetScheme()); err != nil { - return nil, err - } - - return &role, nil -} - -// Binding SARClusterRole to server account -func generateSARClusterRoleBinding(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*rbacv1.ClusterRoleBinding, error) { - rb := rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.OLSAppServerSARRoleBindingName, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: utils.OLSAppServerServiceAccountName, - Namespace: r.GetNamespace(), - }, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: utils.OLSAppServerSARRoleName, - }, - } - - if err := controllerutil.SetControllerReference(cr, &rb, r.GetScheme()); err != nil { - return nil, err - } - - return &rb, nil -} - -func GenerateService(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.Service, error) { - annotations := map[string]string{} - - // Let service-ca operator generate a TLS certificate if the user does not provide their own - if cr.Spec.OLSConfig.TLSConfig == nil || cr.Spec.OLSConfig.TLSConfig.KeyCertSecretRef.Name == "" { - annotations[utils.ServingCertSecretAnnotationKey] = utils.OLSCertsSecretName - } - - service := corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.OLSAppServerServiceName, - Namespace: r.GetNamespace(), - Labels: utils.GenerateAppServerSelectorLabels(), - Annotations: annotations, - }, - Spec: corev1.ServiceSpec{ - Ports: []corev1.ServicePort{ - { - Name: "https", - Protocol: corev1.ProtocolTCP, - Port: utils.OLSAppServerServicePort, - TargetPort: intstr.Parse("https"), - }, - }, - Selector: utils.GenerateAppServerSelectorLabels(), - }, - } - if err := controllerutil.SetControllerReference(cr, &service, r.GetScheme()); err != nil { - return nil, err - } - - return &service, nil -} - -func GenerateServiceMonitor(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*monv1.ServiceMonitor, error) { - metaLabels := utils.GenerateAppServerSelectorLabels() - metaLabels["monitoring.openshift.io/collection-profile"] = "full" - metaLabels["app.kubernetes.io/component"] = "metrics" - metaLabels["openshift.io/user-monitoring"] = "true" - - valFalse := false - serverName := strings.Join([]string{utils.OLSAppServerServiceName, r.GetNamespace(), "svc"}, ".") - var schemeHTTPS monv1.Scheme = "https" - - serviceMonitor := monv1.ServiceMonitor{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.AppServerServiceMonitorName, - Namespace: r.GetNamespace(), - Labels: metaLabels, - }, - Spec: monv1.ServiceMonitorSpec{ - Endpoints: []monv1.Endpoint{ - { - Port: "https", - Path: utils.AppServerMetricsPath, - Interval: "30s", - Scheme: &schemeHTTPS, - HTTPConfigWithProxyAndTLSFiles: monv1.HTTPConfigWithProxyAndTLSFiles{ - HTTPConfigWithTLSFiles: monv1.HTTPConfigWithTLSFiles{ - TLSConfig: &monv1.TLSConfig{ - TLSFilesConfig: monv1.TLSFilesConfig{ - CAFile: "/etc/prometheus/configmaps/serving-certs-ca-bundle/service-ca.crt", - CertFile: "/etc/prometheus/secrets/metrics-client-certs/tls.crt", - KeyFile: "/etc/prometheus/secrets/metrics-client-certs/tls.key", - }, - SafeTLSConfig: monv1.SafeTLSConfig{ - InsecureSkipVerify: &valFalse, - ServerName: &serverName, - }, - }, - HTTPConfigWithoutTLS: monv1.HTTPConfigWithoutTLS{ - Authorization: &monv1.SafeAuthorization{ - Type: "Bearer", - Credentials: &corev1.SecretKeySelector{ - Key: "token", - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.MetricsReaderServiceAccountTokenSecretName, - }, - }, - }, - }, - }, - }, - }, - }, - JobLabel: "app.kubernetes.io/name", - Selector: metav1.LabelSelector{ - MatchLabels: utils.GenerateAppServerSelectorLabels(), - }, - }, - } - - if err := controllerutil.SetControllerReference(cr, &serviceMonitor, r.GetScheme()); err != nil { - return nil, err - } - - return &serviceMonitor, nil -} - -func GeneratePrometheusRule(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*monv1.PrometheusRule, error) { - metaLabels := utils.GenerateAppServerSelectorLabels() - metaLabels["app.kubernetes.io/component"] = "metrics" - - rule := monv1.PrometheusRule{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.AppServerPrometheusRuleName, - Namespace: r.GetNamespace(), - Labels: metaLabels, - }, - Spec: monv1.PrometheusRuleSpec{ - Groups: []monv1.RuleGroup{ - { - Name: "ols.operations.rules", - Rules: []monv1.Rule{ - { - Record: "ols:rest_api_query_calls_total:2xx", - Expr: intstr.FromString("sum by(status_code) (ols_rest_api_calls_total{path=\"/v1/streaming_query\",status_code=~\"2..\"})"), - Labels: map[string]string{"status_code": "2xx"}, - }, - { - Record: "ols:rest_api_query_calls_total:4xx", - Expr: intstr.FromString("sum by(status_code) (ols_rest_api_calls_total{path=\"/v1/streaming_query\",status_code=~\"4..\"})"), - Labels: map[string]string{"status_code": "4xx"}, - }, - { - Record: "ols:rest_api_query_calls_total:5xx", - Expr: intstr.FromString("sum by(status_code) (ols_rest_api_calls_total{path=\"/v1/streaming_query\",status_code=~\"5..\"})"), - Labels: map[string]string{"status_code": "5xx"}, - }, - { - Record: "ols:provider_model_configuration", - Expr: intstr.FromString("max by (provider,model) (ols_provider_model_configuration)"), - }, - }, - }, - }, - }, - } - - if err := controllerutil.SetControllerReference(cr, &rule, r.GetScheme()); err != nil { - return nil, err - } - - return &rule, nil -} - -func GenerateAppServerNetworkPolicy(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*networkingv1.NetworkPolicy, error) { - np := networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.OLSAppServerNetworkPolicyName, - Namespace: r.GetNamespace(), - Labels: utils.GenerateAppServerSelectorLabels(), - }, - Spec: networkingv1.NetworkPolicySpec{ - PodSelector: metav1.LabelSelector{ - MatchLabels: utils.GenerateAppServerSelectorLabels(), - }, - Ingress: []networkingv1.NetworkPolicyIngressRule{ - { - // allow prometheus to scrape metrics (both cluster and user-workload monitoring) - From: []networkingv1.NetworkPolicyPeer{ - { - PodSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - { - Key: "app.kubernetes.io/name", - Operator: metav1.LabelSelectorOpIn, - Values: []string{"prometheus"}, - }, - { - Key: "prometheus", - Operator: metav1.LabelSelectorOpIn, - Values: []string{"k8s", "user-workload"}, - }, - }, - }, - NamespaceSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - { - Key: "kubernetes.io/metadata.name", - Operator: metav1.LabelSelectorOpIn, - Values: []string{"openshift-monitoring", "openshift-user-workload-monitoring"}, - }, - }, - }, - }, - }, - - Ports: []networkingv1.NetworkPolicyPort{ - { - Protocol: &[]corev1.Protocol{corev1.ProtocolTCP}[0], - Port: &[]intstr.IntOrString{intstr.FromInt(utils.OLSAppServerContainerPort)}[0], - }, - }, - }, - { - // allow the console to access the API - From: []networkingv1.NetworkPolicyPeer{ - { - PodSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "console", - }, - }, - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "kubernetes.io/metadata.name": "openshift-console", - }, - }, - }, - }, - Ports: []networkingv1.NetworkPolicyPort{ - { - Protocol: &[]corev1.Protocol{corev1.ProtocolTCP}[0], - Port: &[]intstr.IntOrString{intstr.FromInt(utils.OLSAppServerContainerPort)}[0], - }, - }, - }, - { - // allow ingress controller to access the API - From: []networkingv1.NetworkPolicyPeer{ - { - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "network.openshift.io/policy-group": "ingress", - }, - }, - }, - }, - Ports: []networkingv1.NetworkPolicyPort{ - { - Protocol: &[]corev1.Protocol{corev1.ProtocolTCP}[0], - Port: &[]intstr.IntOrString{intstr.FromInt(utils.OLSAppServerContainerPort)}[0], - }, - }, - }, - }, - Egress: []networkingv1.NetworkPolicyEgressRule{}, - PolicyTypes: []networkingv1.PolicyType{ - networkingv1.PolicyTypeIngress, - }, - }, - } - - if err := controllerutil.SetControllerReference(cr, &np, r.GetScheme()); err != nil { - return nil, err - } - - return &np, nil -} - -func GenerateMetricsReaderSecret(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.Secret, error) { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.MetricsReaderServiceAccountTokenSecretName, - Namespace: r.GetNamespace(), - Annotations: map[string]string{ - "kubernetes.io/service-account.name": utils.MetricsReaderServiceAccountName, - }, - Labels: map[string]string{ - "app.kubernetes.io/name": "service-account-token", - "app.kubernetes.io/component": "metrics", - "app.kubernetes.io/part-of": "lightspeed-operator", - }, - }, - Type: corev1.SecretTypeServiceAccountToken, - } - - if err := controllerutil.SetControllerReference(cr, secret, r.GetScheme()); err != nil { - return nil, err - } - - return secret, nil -} - -// GenerateLlamaStackConfigMap generates the Llama Stack configuration ConfigMap -func GenerateLlamaStackConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*corev1.ConfigMap, error) { - llamaStackYAML, err := buildLlamaStackYAML(r, ctx, cr) - if err != nil { - return nil, fmt.Errorf("failed to build Llama Stack YAML: %w", err) - } - - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.LlamaStackConfigCmName, - Namespace: r.GetNamespace(), - Labels: utils.GenerateAppServerSelectorLabels(), - }, - Data: map[string]string{ - "run.yaml": llamaStackYAML, - }, - } - - if err := controllerutil.SetControllerReference(cr, cm, r.GetScheme()); err != nil { - return nil, err - } - - return cm, nil -} - -// GenerateLcoreConfigMap generates the LCore configuration ConfigMap -func GenerateLcoreConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*corev1.ConfigMap, error) { - // Build OLS config YAML from components - lcoreConfigYAML, err := buildLCoreConfigYAML(r, cr) - if err != nil { - return nil, fmt.Errorf("failed to build OLS config YAML: %w", err) - } - - // Create ConfigMap - cm := corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.LCoreConfigCmName, - Namespace: r.GetNamespace(), - Labels: utils.GenerateAppServerSelectorLabels(), - }, - Data: map[string]string{ - "lightspeed-stack.yaml": lcoreConfigYAML, - }, - } - - if err := controllerutil.SetControllerReference(cr, &cm, r.GetScheme()); err != nil { - return nil, err - } - - return &cm, nil -} - -// generateExporterConfigMap generates the ConfigMap for the data exporter -func generateExporterConfigMap(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.ConfigMap, error) { - serviceID := utils.ServiceIDOLS - if cr.Labels != nil { - if _, hasRHOSLightspeedLabel := cr.Labels[utils.RHOSOLightspeedOwnerIDLabel]; hasRHOSLightspeedLabel { - serviceID = utils.ServiceIDRHOSO - } - } - - // Collection interval is set to 300 seconds in production (5 minutes) - exporterConfigContent := fmt.Sprintf(`service_id: "%s" -ingress_server_url: "https://console.redhat.com/api/ingress/v1/upload" -allowed_subdirs: - - feedback - - transcripts - - config_status -# Collection settings -collection_interval: 300 -cleanup_after_send: true -ingress_connection_timeout: 30`, serviceID) - - cm := corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.ExporterConfigCmName, - Namespace: r.GetNamespace(), - Labels: utils.GenerateAppServerSelectorLabels(), - }, - Data: map[string]string{ - utils.ExporterConfigFilename: exporterConfigContent, - }, - } - - if err := controllerutil.SetControllerReference(cr, &cm, r.GetScheme()); err != nil { - return nil, err - } - - return &cm, nil -} diff --git a/internal/controller/lcore/assets_test.go b/internal/controller/lcore/assets_test.go deleted file mode 100644 index 8a1878b86..000000000 --- a/internal/controller/lcore/assets_test.go +++ /dev/null @@ -1,1071 +0,0 @@ -package lcore - -import ( - "context" - "fmt" - "strings" - "testing" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/yaml" -) - -func TestBuildLlamaStackYAML_SupportedProvider(t *testing.T) { - // Create a test CR with supported provider (OpenAI) - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "openai", - Type: "openai", - Models: []olsv1alpha1.ModelSpec{ - {Name: "gpt-4o"}, - }, - }, - }, - }, - }, - } - - // Build the YAML - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackYAML returned error for supported provider: %v", err) - } - - // Verify it's not empty - if len(yamlOutput) == 0 { - t.Fatal("buildLlamaStackYAML returned empty string") - } - - // Verify it's valid YAML by unmarshaling - var result map[string]interface{} - err = yaml.Unmarshal([]byte(yamlOutput), &result) - if err != nil { - t.Fatalf("buildLlamaStackYAML produced invalid YAML: %v\nYAML output:\n%s", err, yamlOutput) - } - - // Verify key sections exist - expectedKeys := []string{"version", "apis", "providers", "server", "models"} - for _, key := range expectedKeys { - if _, exists := result[key]; !exists { - t.Errorf("Expected key '%s' not found in YAML output", key) - } - } - - t.Logf("Successfully validated Llama Stack YAML (%d bytes)", len(yamlOutput)) -} - -func TestBuildLlamaStackYAML_UnsupportedProvider(t *testing.T) { - // Test unsupported providers (watsonx, bam are not supported) - // Note: rhoai_vllm and rhelai_vllm are now supported via OpenAI compatibility - unsupportedProviders := []string{"watsonx", "bam"} - - for _, providerType := range unsupportedProviders { - t.Run(providerType, func(t *testing.T) { - // Create a test CR with unsupported provider - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - Type: providerType, - Models: []olsv1alpha1.ModelSpec{ - {Name: "test-model"}, - }, - }, - }, - }, - }, - } - - // Build the YAML - should return error - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - - // Verify error is returned - if err == nil { - t.Fatalf("Expected error for unsupported provider '%s', but got none. Output: %s", providerType, yamlOutput) - } - - // Verify error message mentions the provider - expectedErrMsg := "not currently supported by Llama Stack" - if err.Error() == "" { - t.Errorf("Error message is empty for unsupported provider '%s'", providerType) - } - if !strings.Contains(err.Error(), expectedErrMsg) { - t.Errorf("Error message '%s' doesn't contain expected text '%s'", err.Error(), expectedErrMsg) - } - if !strings.Contains(err.Error(), providerType) { - t.Errorf("Error message '%s' doesn't mention provider type '%s'", err.Error(), providerType) - } - - t.Logf("Correctly rejected unsupported provider '%s' with error: %v", providerType, err) - }) - } -} - -func TestBuildLlamaStackYAML_OpenAICompatibleProviders(t *testing.T) { - // Test that vLLM providers (rhoai_vllm, rhelai_vllm) use remote::vllm provider type - vllmProviders := []string{"rhoai_vllm", "rhelai_vllm"} - - for _, providerType := range vllmProviders { - t.Run(providerType, func(t *testing.T) { - // Create a test CR with vLLM provider - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - Type: providerType, - URL: "https://test-vllm-endpoint.com/v1", - Models: []olsv1alpha1.ModelSpec{ - {Name: "test-model"}, - }, - }, - }, - }, - }, - } - - // Build the YAML - should succeed - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - - // Verify no error is returned - if err != nil { - t.Fatalf("Unexpected error for supported provider '%s': %v", providerType, err) - } - - // Verify it's valid YAML - var result map[string]interface{} - err = yaml.Unmarshal([]byte(yamlOutput), &result) - if err != nil { - t.Fatalf("buildLlamaStackYAML produced invalid YAML for '%s': %v", providerType, err) - } - - // Verify provider is configured as remote::vllm - providers, ok := result["providers"].(map[string]interface{}) - if !ok { - t.Fatalf("providers section not found or invalid type") - } - - inference, ok := providers["inference"].([]interface{}) - if !ok || len(inference) == 0 { - t.Fatalf("inference providers not found or empty") - } - - // Find the test provider (not the sentence-transformers one) - var testProvider map[string]interface{} - for _, provider := range inference { - p, ok := provider.(map[string]interface{}) - if !ok { - continue - } - if p["provider_id"] == "test-provider" { - testProvider = p - break - } - } - - if testProvider == nil { - t.Fatalf("Test provider not found in inference providers") - } - - // Verify it's configured as vLLM (not OpenAI) - if testProvider["provider_type"] != "remote::vllm" { - t.Errorf("Expected provider_type 'remote::vllm' for %s, got '%v'", providerType, testProvider["provider_type"]) - } - - // Verify URL is present in config - config, ok := testProvider["config"].(map[string]interface{}) - if !ok { - t.Fatalf("provider config not found or invalid type") - } - - if url, ok := config["url"].(string); !ok || url == "" { - t.Errorf("Expected URL to be configured for %s provider", providerType) - } - - t.Logf("Successfully validated '%s' provider uses remote::vllm", providerType) - }) - } -} - -func TestBuildLlamaStackYAML_AzureProvider(t *testing.T) { - // Create a fake secret with API token for Azure provider - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "azure-secret", - Namespace: "test-namespace", - }, - Data: map[string][]byte{ - "apitoken": []byte("test-api-key"), - }, - } - - // Create a fake client with the secret - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = olsv1alpha1.AddToScheme(scheme) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(secret). - Build() - - // Create a test reconciler - logger := zap.New(zap.UseDevMode(true)) - testReconciler := utils.NewTestReconciler( - fakeClient, - logger, - scheme, - "test-namespace", - ) - - // Create a test CR with Azure OpenAI provider - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "azure-openai", - Type: "azure_openai", - URL: "https://my-azure.openai.azure.com", - AzureDeploymentName: "gpt-4-deployment", - APIVersion: "2024-02-15-preview", - Models: []olsv1alpha1.ModelSpec{ - { - Name: "gpt-4", - ContextWindowSize: 128000, - }, - }, - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "azure-secret", - }, - }, - }, - }, - }, - } - - // Build the YAML - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(testReconciler, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackYAML returned error for Azure provider: %v", err) - } - - // Verify it's valid YAML - var result map[string]interface{} - err = yaml.Unmarshal([]byte(yamlOutput), &result) - if err != nil { - t.Fatalf("buildLlamaStackYAML produced invalid YAML: %v", err) - } - - // Verify Azure provider configuration - providers, ok := result["providers"].(map[string]interface{}) - if !ok { - t.Fatalf("providers section not found or invalid type") - } - - inference, ok := providers["inference"].([]interface{}) - if !ok || len(inference) == 0 { - t.Fatalf("inference providers not found or empty") - } - - // Find the Azure provider (not the sentence-transformers one) - var azureProvider map[string]interface{} - for _, provider := range inference { - p, ok := provider.(map[string]interface{}) - if !ok { - continue - } - if p["provider_type"] == "remote::azure" { - azureProvider = p - break - } - } - - if azureProvider == nil { - t.Fatalf("Azure provider not found in inference providers") - } - - // Check provider_type - if azureProvider["provider_type"] != "remote::azure" { - t.Errorf("Expected provider_type 'remote::azure', got '%v'", azureProvider["provider_type"]) - } - - // Check config fields - config, ok := azureProvider["config"].(map[string]interface{}) - if !ok { - t.Fatalf("provider config not found or invalid type") - } - - // Verify Azure-specific fields are present - // Note: Config always includes api_key (required by LiteLLM) plus client credentials fields - // The client credentials fields will have empty defaults if not used - requiredFields := []string{ - "api_key", // Always present (required by LiteLLM's Pydantic validation) - "client_id", // Always present (with empty default if not using client credentials) - "tenant_id", // Always present (with empty default if not using client credentials) - "client_secret", // Always present (with empty default if not using client credentials) - "api_base", // Azure endpoint - "api_version", // Azure API version - "deployment_name", // Azure deployment - } - for _, field := range requiredFields { - if _, exists := config[field]; !exists { - t.Errorf("Expected field '%s' not found in Azure provider config", field) - } - } - - // Verify api_key has the correct env var format - if apiKey, ok := config["api_key"].(string); ok && apiKey != "" { - if !strings.HasPrefix(apiKey, "${env.") || !strings.HasSuffix(apiKey, "_API_KEY}") { - t.Errorf("api_key doesn't have correct env var format, got: %s", apiKey) - } - } else { - t.Errorf("api_key field is missing or empty") - } - - t.Logf("Successfully validated Llama Stack YAML with Azure provider (%d bytes)", len(yamlOutput)) -} - -// TestBuildLlamaStackYAML_GenericProvider tests the generic provider path using -// remote::openai as the providerType. This validates that a known provider can be -// configured through the generic llamaStackGeneric mechanism instead of the legacy path. -func TestBuildLlamaStackYAML_GenericProvider(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "my-openai", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::openai", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "my-openai-secret", - }, - Config: &runtime.RawExtension{ - Raw: []byte(`{"url": "https://api.openai.com/v1", "custom_field": "test_value"}`), - }, - Models: []olsv1alpha1.ModelSpec{ - {Name: "gpt-4o"}, - }, - }, - }, - }, - }, - } - - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackYAML failed for generic provider: %v", err) - } - - if len(yamlOutput) == 0 { - t.Fatal("buildLlamaStackYAML returned empty string for generic provider") - } - - var result map[string]interface{} - err = yaml.Unmarshal([]byte(yamlOutput), &result) - if err != nil { - t.Fatalf("buildLlamaStackYAML produced invalid YAML: %v\nOutput:\n%s", err, yamlOutput) - } - - // Verify providers section - providers, ok := result["providers"].(map[string]interface{}) - if !ok { - t.Fatal("providers section missing or invalid type") - } - inference, ok := providers["inference"].([]interface{}) - if !ok || len(inference) == 0 { - t.Fatal("inference providers missing or empty") - } - - // Find the generic openai provider - var genericProvider map[string]interface{} - for _, provider := range inference { - p, ok := provider.(map[string]interface{}) - if !ok { - continue - } - if p["provider_id"] == "my-openai" { - genericProvider = p - break - } - } - if genericProvider == nil { - t.Fatal("Generic openai provider not found in inference providers") - } - - // Verify provider_type comes from the ProviderType field (generic path) - if genericProvider["provider_type"] != "remote::openai" { - t.Errorf("Expected provider_type='remote::openai', got '%v'", genericProvider["provider_type"]) - } - - // Verify config fields are passed through - config, ok := genericProvider["config"].(map[string]interface{}) - if !ok { - t.Fatal("provider config missing or invalid type") - } - if config["url"] != "https://api.openai.com/v1" { - t.Errorf("Config URL not passed through correctly, got: %v", config["url"]) - } - if config["custom_field"] != "test_value" { - t.Errorf("Custom config field not preserved, got: %v", config["custom_field"]) - } - - // Verify credential auto-injection - apiKey, ok := config["api_key"] - if !ok { - t.Error("api_key not auto-injected into config") - } else { - expectedKey := "${env.MY_OPENAI_API_KEY}" - if apiKey != expectedKey { - t.Errorf("Expected api_key='%s', got '%v'", expectedKey, apiKey) - } - } - - // Verify model mapping - models, ok := result["models"].([]interface{}) - if !ok || len(models) == 0 { - t.Fatal("models section missing or empty") - } - foundModel := false - for _, model := range models { - m, ok := model.(map[string]interface{}) - if !ok { - continue - } - if m["model_id"] == "gpt-4o" { - foundModel = true - if m["provider_id"] != "my-openai" { - t.Errorf("Model not mapped to correct provider, got: %v", m["provider_id"]) - } - break - } - } - if !foundModel { - t.Error("Model not found in models section") - } - - t.Logf("Generic provider with remote::openai generated correctly (%d bytes)", len(yamlOutput)) -} - -// TODO: Add tests for additional generic providers as they become supported -// when they are officially supported. - -func TestBuildLlamaStackYAML_GenericProvider_InvalidValues(t *testing.T) { - // Test multiple invalid configurations - testCases := []struct { - name string - provider olsv1alpha1.ProviderSpec - expectedError string - }{ - { - name: "invalid JSON in config", - provider: olsv1alpha1.ProviderSpec{ - Name: "broken-json", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - Config: &runtime.RawExtension{ - Raw: []byte(`{invalid json syntax`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - expectedError: "failed to unmarshal config", - }, - { - name: "type llamaStackGeneric without providerType", - provider: olsv1alpha1.ProviderSpec{ - Name: "missing-provider-type", - Type: utils.LlamaStackGenericType, - // ProviderType not set - this should fail - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - expectedError: "requires providerType and config fields to be set", - }, - { - name: "empty config Raw bytes", - provider: olsv1alpha1.ProviderSpec{ - Name: "empty-config", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::empty", - Config: &runtime.RawExtension{ - Raw: []byte(""), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - expectedError: "failed to unmarshal config", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{tc.provider}, - }, - }, - } - - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - - // Should return error - if err == nil { - t.Fatalf("Expected error for %s, got none. Output: %s", tc.name, yamlOutput) - } - - // Verify error message contains expected substring - if !strings.Contains(err.Error(), tc.expectedError) { - t.Errorf("Expected error containing '%s', got: %v", tc.expectedError, err) - } - - // Verify error message mentions provider name (for better debugging) - if !strings.Contains(err.Error(), tc.provider.Name) { - t.Errorf("Error message should mention provider name '%s', got: %v", tc.provider.Name, err) - } - - t.Logf("✓ Correctly rejected %s with error: %v", tc.name, err) - }) - } -} - -func TestBuildLlamaStackYAML_GenericProvider_ConfigWithExistingCredential(t *testing.T) { - // Test generic provider where config already contains an explicit api_key field. - // hasAPIKeyField() detects it and skips auto-injection to avoid overriding the - // caller's value (e.g. a custom env var substitution pattern). - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "custom", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - Config: &runtime.RawExtension{ - // Config explicitly sets api_key to a custom env var substitution. - // The operator must not overwrite it with the default injection. - Raw: []byte(`{ - "url": "https://api.custom.com/v1", - "api_key": "${env.CUSTOM_SECRET_TOKEN}" - }`), - }, - Models: []olsv1alpha1.ModelSpec{ - {Name: "custom-model"}, - }, - }, - }, - }, - }, - } - - // Build the YAML - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackYAML failed: %v", err) - } - - // Parse YAML output - var result map[string]interface{} - err = yaml.Unmarshal([]byte(yamlOutput), &result) - if err != nil { - t.Fatalf("Invalid YAML: %v", err) - } - - // Find the custom provider - providers := result["providers"].(map[string]interface{}) - inference := providers["inference"].([]interface{}) - var customProvider map[string]interface{} - for _, provider := range inference { - p := provider.(map[string]interface{}) - if p["provider_id"] == "custom" { - customProvider = p - break - } - } - - if customProvider == nil { - t.Fatal("Custom provider not found") - } - - // Get config - config := customProvider["config"].(map[string]interface{}) - - // CRITICAL: Verify the caller's api_key value is preserved unchanged. - apiKey, ok := config["api_key"] - if !ok { - t.Error("api_key field was lost from config") - } else if apiKey != "${env.CUSTOM_SECRET_TOKEN}" { - t.Errorf("api_key was overwritten; expected '${env.CUSTOM_SECRET_TOKEN}', got '%v'", apiKey) - } - - // Verify URL is still present - if config["url"] != "https://api.custom.com/v1" { - t.Errorf("URL not preserved, got: %v", config["url"]) - } - - t.Logf("✓ Explicit api_key in config preserved correctly (auto-injection skipped)") -} - -func TestBuildLCoreConfigYAML(t *testing.T) { - // Use a proper CR from test fixtures instead of nil - cr := utils.GetDefaultOLSConfigCR() - - // Create a fake client and reconciler for the test - scheme := runtime.NewScheme() - _ = olsv1alpha1.AddToScheme(scheme) - _ = corev1.AddToScheme(scheme) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - Build() - logger := zap.New(zap.UseDevMode(true)) - testReconciler := utils.NewTestReconciler( - fakeClient, - logger, - scheme, - "test-namespace", - ) - - yamlOutput, err := buildLCoreConfigYAML(testReconciler, cr) - if err != nil { - t.Fatalf("buildLCoreConfigYAML returned error: %v", err) - } - - // Verify it's not empty - if len(yamlOutput) == 0 { - t.Fatal("buildLCoreConfigYAML returned empty string") - } - - // Verify it's valid YAML by unmarshaling - var result map[string]interface{} - err = yaml.Unmarshal([]byte(yamlOutput), &result) - if err != nil { - t.Fatalf("buildLCoreConfigYAML produced invalid YAML: %v\nYAML output:\n%s", err, yamlOutput) - } - - // Verify key sections exist for LCore config - expectedKeys := []string{"name", "service", "llama_stack", "user_data_collection", "authentication"} - for _, key := range expectedKeys { - if _, ok := result[key]; !ok { - t.Errorf("buildLCoreConfigYAML missing expected key: %s", key) - } - } - - t.Logf("Successfully validated LCore Config YAML (%d bytes)", len(yamlOutput)) -} - -func TestLCoreConfigYAMLComponentFunctions(t *testing.T) { - // Use a proper CR from test fixtures instead of nil - cr := utils.GetDefaultOLSConfigCR() - - // Test that all component functions return non-empty maps - components := map[string]func(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{}{ - "Service": buildLCoreServiceConfig, - "LlamaStack": buildLCoreLlamaStackConfig, - "UserDataCollection": buildLCoreUserDataCollectionConfig, - "Authentication": buildLCoreAuthenticationConfig, - // "MCPServers": buildLCoreMCPServersConfig, // Commented out - function is unused - } - - for name, fn := range components { - t.Run(name, func(t *testing.T) { - result := fn(nil, cr) - if len(result) == 0 { - t.Errorf("build%sConfig returned empty map", name) - } - // Verify the map can be marshaled to YAML - yamlBytes, err := yaml.Marshal(result) - if err != nil { - t.Errorf("build%sConfig produced invalid map that can't be marshaled: %v", name, err) - } - if len(yamlBytes) == 0 { - t.Errorf("build%sConfig marshaled to empty YAML", name) - } - }) - } -} - -// TestLlamaStackYAMLComponentFunctions is commented out - functions now return maps instead of strings -// The individual component functions are tested implicitly through TestBuildLlamaStackYAML -//func TestLlamaStackYAMLComponentFunctions(t *testing.T) { -// // Component functions now return maps/slices, not strings -// // They are tested implicitly through the full YAML generation test -//} - -func TestGenerateExporterConfigMap_DefaultServiceID(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - } - - // Use a minimal mock reconciler with OLSConfig registered in scheme - scheme := runtime.NewScheme() - _ = olsv1alpha1.AddToScheme(scheme) - _ = corev1.AddToScheme(scheme) - r := &mockReconcilerForAssets{ - namespace: utils.OLSNamespaceDefault, - scheme: scheme, - } - - cm, err := generateExporterConfigMap(r, cr) - if err != nil { - t.Fatalf("generateExporterConfigMap returned error: %v", err) - } - - if cm.Name != utils.ExporterConfigCmName { - t.Errorf("Expected ConfigMap name %s, got %s", utils.ExporterConfigCmName, cm.Name) - } - - if cm.Namespace != utils.OLSNamespaceDefault { - t.Errorf("Expected namespace %s, got %s", utils.OLSNamespaceDefault, cm.Namespace) - } - - configContent := cm.Data[utils.ExporterConfigFilename] - if !strings.Contains(configContent, `service_id: "`+utils.ServiceIDOLS+`"`) { - t.Errorf("Expected service_id '%s' in config, got: %s", utils.ServiceIDOLS, configContent) - } - - // Verify other required fields - requiredFields := []string{"ingress_server_url", "allowed_subdirs", "collection_interval"} - for _, field := range requiredFields { - if !strings.Contains(configContent, field) { - t.Errorf("Expected field '%s' in exporter config", field) - } - } -} - -func TestGenerateExporterConfigMap_RHOSLightspeedServiceID(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Labels: map[string]string{ - utils.RHOSOLightspeedOwnerIDLabel: "test-owner-id", - }, - }, - } - - scheme := runtime.NewScheme() - _ = olsv1alpha1.AddToScheme(scheme) - _ = corev1.AddToScheme(scheme) - r := &mockReconcilerForAssets{ - namespace: utils.OLSNamespaceDefault, - scheme: scheme, - } - - cm, err := generateExporterConfigMap(r, cr) - if err != nil { - t.Fatalf("generateExporterConfigMap returned error: %v", err) - } - - configContent := cm.Data[utils.ExporterConfigFilename] - if !strings.Contains(configContent, `service_id: "`+utils.ServiceIDRHOSO+`"`) { - t.Errorf("Expected service_id '%s' when RHOSO label present, got: %s", utils.ServiceIDRHOSO, configContent) - } -} - -// mockReconcilerForAssets is a minimal mock for testing asset generation -type mockReconcilerForAssets struct { - reconciler.Reconciler - namespace string - scheme *runtime.Scheme -} - -func (m *mockReconcilerForAssets) GetNamespace() string { - return m.namespace -} - -func (m *mockReconcilerForAssets) GetScheme() *runtime.Scheme { - return m.scheme -} - -// TestBuildLlamaStackYAML_EdgeCases tests edge cases and error conditions -func TestBuildLlamaStackYAML_EdgeCases(t *testing.T) { - tests := []struct { - name string - provider olsv1alpha1.ProviderSpec - expectError bool - errorContains string - }{ - { - name: "MalformedJSON_Config", - provider: olsv1alpha1.ProviderSpec{ - Name: "bad-json", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - Config: &runtime.RawExtension{ - Raw: []byte(`{invalid json}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - expectError: true, - errorContains: "invalid character", - }, - { - name: "EmptyProviderName", - provider: olsv1alpha1.ProviderSpec{ - Name: "", - Type: "openai", - ProviderType: "", - Models: []olsv1alpha1.ModelSpec{{Name: "gpt-4"}}, - }, - expectError: false, // Name can be empty in provider spec - }, - { - name: "LongProviderName", - provider: olsv1alpha1.ProviderSpec{ - Name: "provider-" + strings.Repeat("a", 200), - Type: "openai", - ProviderType: "", - Models: []olsv1alpha1.ModelSpec{{Name: "gpt-4"}}, - }, - expectError: false, // Should handle long names - }, - { - name: "SpecialCharactersInProviderName", - provider: olsv1alpha1.ProviderSpec{ - Name: "my-provider@#$%", - Type: "openai", - ProviderType: "", - Models: []olsv1alpha1.ModelSpec{{Name: "gpt-4"}}, - }, - expectError: false, // Should not fail on special chars in name - }, - { - name: "VeryLargeConfig", - provider: olsv1alpha1.ProviderSpec{ - Name: "large-config", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - Config: &runtime.RawExtension{ - Raw: []byte(`{"data": "` + strings.Repeat("x", 10000) + `"}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test"}}, - }, - expectError: false, - }, - { - name: "NestedConfig", - provider: olsv1alpha1.ProviderSpec{ - Name: "nested", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - Config: &runtime.RawExtension{ - Raw: []byte(`{"outer": {"inner": {"deep": {"value": 123}}}}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test"}}, - }, - expectError: false, - }, - { - name: "ConfigWithArrays", - provider: olsv1alpha1.ProviderSpec{ - Name: "arrays", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - Config: &runtime.RawExtension{ - Raw: []byte(`{"models": ["model1", "model2"], "endpoints": [{"url": "https://test"}]}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test"}}, - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{tt.provider}, - }, - }, - } - - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error, but got none. Output: %s", yamlOutput) - } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { - t.Errorf("Expected error containing '%s', got: %v", tt.errorContains, err) - } - } else { - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - // Verify output is valid YAML - var result map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlOutput), &result); err != nil { - t.Errorf("buildLlamaStackYAML produced invalid YAML: %v", err) - } - } - }) - } -} - -// TestProviderNameToEnvVarConversion tests environment variable name conversion edge cases -func TestProviderNameToEnvVarConversion(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"simple", "SIMPLE"}, - {"with-hyphens", "WITH_HYPHENS"}, - {"multiple-hyphens-here", "MULTIPLE_HYPHENS_HERE"}, - {"MixedCase", "MIXEDCASE"}, - {"trailing-", "TRAILING_"}, - {"-leading", "_LEADING"}, - {"multiple--hyphens", "MULTIPLE__HYPHENS"}, - {"already_underscore", "ALREADY_UNDERSCORE"}, - {"123numeric", "_123NUMERIC"}, // Leading digit prefixed with underscore - {"", "_"}, // Empty string gets underscore prefix (POSIX compliance, prevents collisions) - // Special characters are stripped to produce POSIX-valid env var names - {"my-provider@#$%", "MY_PROVIDER"}, - {"dots.in.name", "DOTSINNAME"}, - {"provider!with*chars", "PROVIDERWITHCHARS"}, - {"!@#$%", "_"}, // All special chars sanitize to empty, then prefix - {" ", "_"}, // Whitespace sanitizes to empty, then prefix - } - - for _, tt := range tests { - t.Run(fmt.Sprintf("convert_%s", tt.input), func(t *testing.T) { - result := utils.ProviderNameToEnvVarName(tt.input) - if result != tt.expected { - t.Errorf("ProviderNameToEnvVarName(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - -// TestGenericProviderCredentialInjection verifies that the env var substitution -// pattern (${env.PROVIDER_API_KEY}) is emitted in the Llama Stack YAML config -// for a generic provider. This covers the config-generation side only; the -// SecretKeyRef wiring is tested in deployment_test.go. -func TestGenericProviderCredentialInjection(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::test-backend", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-provider-secret", - }, - CredentialKey: "secret_key", - Config: &runtime.RawExtension{ - Raw: []byte(`{"api_endpoint": "https://api.example.com"}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - }, - }, - }, - } - - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackYAML failed: %v", err) - } - - // Verify that the custom credential key is referenced in environment variables - var result map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlOutput), &result); err != nil { - t.Fatalf("Invalid YAML produced: %v", err) - } - - // Check that environment variable substitution is present - if !strings.Contains(yamlOutput, "TEST_PROVIDER_API_KEY") { - t.Errorf("Expected environment variable reference 'TEST_PROVIDER_API_KEY' in output") - } -} - -func TestBuildLlamaStackYAML_GenericProvider_NoCredentials(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "public-llm", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::public-provider", - Config: &runtime.RawExtension{ - Raw: []byte(`{"url": "https://public.example.com"}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "public-model"}}, - // NO CredentialsSecretRef - }, - }, - }, - }, - } - - ctx := context.Background() - yamlOutput, err := buildLlamaStackYAML(nil, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackYAML returned error for credential-less generic provider: %v", err) - } - if len(yamlOutput) == 0 { - t.Fatal("buildLlamaStackYAML returned empty string") - } - - var result map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlOutput), &result); err != nil { - t.Fatalf("Invalid YAML: %v", err) - } - - // Find the public-llm provider - providers := result["providers"].(map[string]interface{}) - inference := providers["inference"].([]interface{}) - var publicProvider map[string]interface{} - for _, p := range inference { - pm := p.(map[string]interface{}) - if pm["provider_id"] == "public-llm" { - publicProvider = pm - break - } - } - if publicProvider == nil { - t.Fatal("public-llm provider not found in inference providers") - } - if publicProvider["provider_type"] != "remote::public-provider" { - t.Errorf("Expected provider_type 'remote::public-provider', got '%v'", publicProvider["provider_type"]) - } - - config := publicProvider["config"].(map[string]interface{}) - if config["url"] != "https://public.example.com" { - t.Errorf("Expected url 'https://public.example.com', got '%v'", config["url"]) - } - // api_key should NOT be injected when no credentials are configured - if _, exists := config["api_key"]; exists { - t.Errorf("api_key should not be present for credential-less generic provider, but found: %v", config["api_key"]) - } -} - -func TestDeepCopyMap_NilInput(t *testing.T) { - result := deepCopyMap(nil) - if result != nil { - t.Errorf("deepCopyMap(nil) should return nil, got %v", result) - } -} diff --git a/internal/controller/lcore/config.go b/internal/controller/lcore/config.go deleted file mode 100644 index a9cb410bc..000000000 --- a/internal/controller/lcore/config.go +++ /dev/null @@ -1,989 +0,0 @@ -package lcore - -import ( - "context" - "encoding/json" - "fmt" - "path" - "slices" - "strings" - - "sigs.k8s.io/yaml" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" -) - -// DefaultQuerySystemPrompt is the same system prompt as lightspeed-service -// (ols/customize/ols/prompts.py QUERY_SYSTEM_INSTRUCTION) -const DefaultQuerySystemPrompt = `# ROLE -You are "OpenShift Lightspeed," an expert AI virtual assistant specializing in -OpenShift and related Red Hat products and services. Your persona is that of a -friendly, but personal, technical authority. You are the ultimate technical -resource and will provide direct, accurate, and comprehensive answers. - -# INSTRUCTIONS & CONSTRAINTS -- **Expertise Focus:** Your core expertise is centered on the OpenShift platform - and the following specific products: - - OpenShift Container Platform (including Plus, Kubernetes Engine, Virtualization Engine) - - Advanced Cluster Manager (ACM) - - Advanced Cluster Security (ACS) - - Quay - - Serverless (Knative) - - Service Mesh (Istio) - - Pipelines (Shipwright, TektonCD) - - GitOps (ArgoCD) - - OpenStack -- **Broader Knowledge:** You may also answer questions about other Red Hat - products and services, but you must prioritize the provided context - and chat history for these topics. -- **Strict Adherence:** - 1. **ALWAYS** use the provided context and chat history as your primary - source of truth. If a user's question can be answered from this information, - do so. - 2. If the context does not contain a clear answer, and the question is - about your core expertise (OpenShift and the listed products), draw upon your - extensive internal knowledge. - 3. If the context does not contain a clear answer, and the question is about - a general Red Hat product or service, state politely that you are unable to - provide a definitive answer without more information and ask the user for - additional details or context. - 4. Do not hallucinate or invent information. If you cannot confidently - answer, admit it. -- **Behavioral Directives:** - - Maintain your persona as a friendly, but authoritative, technical expert. - - Never assume another identity or role. - - Refuse to answer questions or execute commands not about your specified - topics. - - Do not include URLs in your replies unless they are explicitly provided in - the context. - - Never mention your last update date or knowledge cutoff. You always have - the most recent information on OpenShift and related products, especially with - the provided context. - -# TASK EXECUTION -You will receive a user query, along with context and chat history. Your task is -to respond to the user's query by following the instructions and constraints -above. Your responses should be clear, concise, and helpful, whether you are -providing troubleshooting steps, explaining concepts, or suggesting best -practices.` - -// ============================================================================ -// Llama Stack component builder functions (return maps for maintainability) -// ============================================================================ - -func buildLlamaStackCoreConfig(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - return map[string]interface{}{ - "version": "2", - // image_name is a semantic identifier for the llama-stack configuration - // Note: Does NOT affect PostgreSQL database name (llama-stack uses hardcoded "llamastack") - "image_name": "openshift-lightspeed-configuration", - // Enabled APIs for RAG + MCP: agents (for MCP), files, inference, safety (required by agents), tool_runtime, vector_io - "apis": []string{"agents", "files", "inference", "safety", "tool_runtime", "vector_io"}, - "benchmarks": []interface{}{}, - "container_image": nil, - "datasets": []interface{}{}, - "external_providers_dir": nil, - "inference_store": map[string]interface{}{ - "db_path": ".llama/distributions/ollama/inference_store.db", - "type": "sqlite", - }, - "logging": nil, - "metadata_store": map[string]interface{}{ - "db_path": "/tmp/llama-stack/registry.db", - "namespace": nil, - "type": "sqlite", - }, - } -} - -func buildLlamaStackFileProviders(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) []interface{} { - return []interface{}{ - map[string]interface{}{ - "provider_id": "localfs", - "provider_type": "inline::localfs", - "config": map[string]interface{}{ - "storage_dir": "/tmp/llama-stack-files", - "metadata_store": map[string]interface{}{ - "backend": "sql_default", - "namespace": "files_metadata", - "table_name": "files_metadata", - }, - }, - }, - } -} - -func buildLlamaStackAgentProviders(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) []interface{} { - return []interface{}{ - map[string]interface{}{ - "provider_id": "meta-reference", - "provider_type": "inline::meta-reference", - "config": map[string]interface{}{ - "persistence": map[string]interface{}{ - "agent_state": map[string]interface{}{ - "backend": "kv_default", - "table_name": "agent_state", - "namespace": "agent_state", - }, - "responses": map[string]interface{}{ - "backend": "sql_default", - "table_name": "agent_responses", - "namespace": "agent_responses", - }, - }, - }, - }, - } -} - -func buildLlamaStackInferenceProviders(_ reconciler.Reconciler, _ context.Context, cr *olsv1alpha1.OLSConfig) ([]interface{}, error) { - // Always include sentence-transformers (required for embeddings) - providers := []interface{}{ - map[string]interface{}{ - "provider_id": "sentence-transformers", - "provider_type": "inline::sentence-transformers", - "config": map[string]interface{}{}, - }, - } - - // Guard against nil CR or empty Providers - if cr == nil || cr.Spec.LLMConfig.Providers == nil { - return providers, nil - } - - // Add LLM providers from OLSConfig - for _, provider := range cr.Spec.LLMConfig.Providers { - providerConfig := map[string]interface{}{ - "provider_id": provider.Name, - } - - // Convert provider name to valid environment variable name - envVarName := utils.ProviderNameToEnvVarName(provider.Name) - - // Check if this is Llama Stack Generic provider configuration (providerType is set) - if provider.ProviderType != "" { - // Llama Stack Generic provider configuration: use providerType and config directly - providerConfig["provider_type"] = provider.ProviderType - - // Unmarshal the config from RawExtension - config := map[string]interface{}{} - if provider.Config != nil && provider.Config.Raw != nil { - if err := json.Unmarshal(provider.Config.Raw, &config); err != nil { - return nil, fmt.Errorf("failed to unmarshal config for provider '%s': %w", provider.Name, err) - } - } - - // Deep copy to prevent mutations - configCopy := deepCopyMap(config) - - // Auto-inject api_key if not already present in config and credentials are configured. - // Skip injection when: - // - the user has explicitly set api_key in config (custom env var name) - // - no CredentialsSecretRef is configured (public/unauthenticated provider) - if !hasAPIKeyField(configCopy) && provider.CredentialsSecretRef.Name != "" { - configCopy["api_key"] = fmt.Sprintf("${env.%s%s}", envVarName, utils.EnvVarSuffixAPIKey) - } - - providerConfig["config"] = configCopy - - } else { - // Predefined provider types: map to Llama Stack provider types using getProviderType helper - llamaType, err := getProviderType(&provider) - if err != nil { - return nil, err - } - providerConfig["provider_type"] = llamaType - - // Build provider-specific configuration - switch provider.Type { - // fake_provider follows the vLLM credential path (api_token); it is included - // in the CRD enum and mapping solely for operator integration testing. - case "openai", "rhoai_vllm", "rhelai_vllm", "fake_provider": - config := map[string]interface{}{} - // Determine the appropriate config field for credentials - // - OpenAI uses remote::openai (validates against OpenAI model whitelist) - // - vLLM/fake uses remote::vllm / remote::fake (accepts any custom model names) - if provider.Type == "openai" { - // Set API key from environment variable - // Llama Stack will substitute ${env.VAR_NAME} with the actual env var value - config["api_key"] = fmt.Sprintf("${env.%s%s}", envVarName, utils.EnvVarSuffixAPIKey) - } else { - // Set API token from environment variable for vLLM - // Llama Stack will substitute ${env.VAR_NAME} with the actual env var value - config["api_token"] = fmt.Sprintf("${env.%s%s}", envVarName, utils.EnvVarSuffixAPIKey) - } - - // Add custom URL if specified - if provider.URL != "" { - config["url"] = provider.URL - } - providerConfig["config"] = config - - case "azure_openai": - config := map[string]interface{}{} - - // Azure supports both API key and client credentials authentication - // Always include api_key (required by LiteLLM's Pydantic validation) - config["api_key"] = fmt.Sprintf("${env.%s%s}", envVarName, utils.EnvVarSuffixAPIKey) - - // Also include client credentials fields (will be empty if not using client credentials) - config["client_id"] = fmt.Sprintf("${env.%s%s:=}", envVarName, utils.EnvVarSuffixClientID) - config["tenant_id"] = fmt.Sprintf("${env.%s%s:=}", envVarName, utils.EnvVarSuffixTenantID) - config["client_secret"] = fmt.Sprintf("${env.%s%s:=}", envVarName, utils.EnvVarSuffixClientSecret) - - // Azure-specific fields - if provider.AzureDeploymentName != "" { - config["deployment_name"] = provider.AzureDeploymentName - } - if provider.APIVersion != "" { - config["api_version"] = provider.APIVersion - } - if provider.URL != "" { - config["api_base"] = provider.URL - } - providerConfig["config"] = config - - case utils.GoogleVertexType, utils.GoogleVertexAnthropicType: - providerConfig["config"] = buildVertexAIInferenceConfig(&provider) - - default: - return nil, fmt.Errorf("internal error: no config builder for legacy provider type '%s' (provider '%s'); update the switch in buildLlamaStackInferenceProviders", provider.Type, provider.Name) - } - } - - providers = append(providers, providerConfig) - } - - return providers, nil -} - -// providerTypeMapping defines how legacy OLSConfig provider types map to Llama Stack provider_type strings. -// New providers that are fully supported without operator changes should use the llamaStackGeneric -// type with the providerType field instead of adding entries here. -// -// To add a new legacy provider: -// 1. Add an entry to this map with OLSConfig type as key -// 2. Set the Llama Stack provider_type value (e.g., "remote::new-provider") -// 3. Add credential/config handling in the switch inside buildLlamaStackInferenceProviders -var providerTypeMapping = map[string]string{ - "openai": "remote::openai", - "rhoai_vllm": "remote::vllm", - "rhelai_vllm": "remote::vllm", - "azure_openai": "remote::azure", - utils.GoogleVertexType: "remote::vertexai", - utils.GoogleVertexAnthropicType: "remote::vertexai", - // fake_provider is included in the CRD enum for testing purposes - "fake_provider": "remote::fake", -} - -// getProviderType returns the Llama Stack provider_type string for a legacy OLSConfig -// provider type (openai, azure_openai, rhoai_vllm, rhelai_vllm, google_vertex, google_vertex_anthropic). -// It is only called for providers where ProviderType == "" (the legacy path); -// generic providers (ProviderType != "") set provider_type directly without this function. -// Returns an error for unsupported types (watsonx, bam) or invalid generic usage. -func getProviderType(provider *olsv1alpha1.ProviderSpec) (string, error) { - // Legacy providers use predefined mapping - if llamaType, exists := providerTypeMapping[provider.Type]; exists { - return llamaType, nil - } - - // Unsupported provider type - switch provider.Type { - case "watsonx", "bam": - return "", fmt.Errorf("provider type '%s' (provider '%s') is not currently supported by Llama Stack. Supported types: openai, azure_openai, rhoai_vllm, rhelai_vllm, google_vertex, google_vertex_anthropic, %s", provider.Type, provider.Name, utils.LlamaStackGenericType) - case utils.LlamaStackGenericType: - return "", fmt.Errorf("provider type '%s' (provider '%s') requires providerType and config fields to be set", utils.LlamaStackGenericType, provider.Name) - default: - return "", fmt.Errorf("unknown provider type '%s' (provider '%s'). Supported types: openai, azure_openai, rhoai_vllm, rhelai_vllm, google_vertex, google_vertex_anthropic, %s", provider.Type, provider.Name, utils.LlamaStackGenericType) - } -} - -// buildVertexAIInferenceConfig builds the Llama Stack remote::vertexai provider config -// (project, location). Application Default Credentials use GOOGLE_APPLICATION_CREDENTIALS -// on the deployment. See https://llamastack.github.io/docs/providers/inference/remote_vertexai -func buildVertexAIInferenceConfig(provider *olsv1alpha1.ProviderSpec) map[string]interface{} { - var vc *olsv1alpha1.VertexConfig - switch provider.Type { - case utils.GoogleVertexType: - vc = provider.GoogleVertexConfig - case utils.GoogleVertexAnthropicType: - vc = provider.GoogleVertexAnthropicConfig - default: - return map[string]interface{}{} - } - - config := map[string]interface{}{} - if vc != nil && vc.ProjectID != "" { - config["project"] = vc.ProjectID - } - if vc != nil && vc.Location != "" { - config["location"] = vc.Location - } - return config -} - -// deepCopyMap creates a deep copy of a map[string]interface{}, including nested maps -// and slices. This prevents mutations of the copy from affecting the original. -func deepCopyMap(src map[string]interface{}) map[string]interface{} { - if src == nil { - return nil - } - dst := make(map[string]interface{}, len(src)) - for k, v := range src { - dst[k] = deepCopyValue(v) - } - return dst -} - -// deepCopyValue recursively deep-copies a value that may be a primitive, map, or slice. -func deepCopyValue(v interface{}) interface{} { - switch val := v.(type) { - case map[string]interface{}: - return deepCopyMap(val) - case []interface{}: - dstSlice := make([]interface{}, len(val)) - for i, elem := range val { - dstSlice[i] = deepCopyValue(elem) - } - return dstSlice - default: - return v - } -} - -// hasAPIKeyField reports whether the config already contains an explicit "api_key" field. -// We only check for "api_key" because that is the field we auto-inject; suppressing -// injection only when the caller has already set it avoids silently skipping injection -// for providers that require api_key but happen to have an unrelated field (e.g. api_token). -func hasAPIKeyField(config map[string]interface{}) bool { - _, ok := config["api_key"] - return ok -} - -// Safety API - Required by agents provider (for MCP) -// Note: You can configure excluded_categories if needed -func buildLlamaStackSafety(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) []interface{} { - return []interface{}{ - map[string]interface{}{ - "provider_id": "llama-guard", - "provider_type": "inline::llama-guard", - "config": map[string]interface{}{ - "excluded_categories": []interface{}{}, - }, - }, - } -} - -func buildLlamaStackToolRuntime(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) []interface{} { - return []interface{}{ - map[string]interface{}{ - "provider_id": "model-context-protocol", - "provider_type": "remote::model-context-protocol", - "config": map[string]interface{}{}, - }, - map[string]interface{}{ - "provider_id": "rag-runtime", - "provider_type": "inline::rag-runtime", - "config": map[string]interface{}{}, - }, - } -} - -func buildLlamaStackVectorDB(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) []interface{} { - return []interface{}{ - map[string]interface{}{ - "provider_id": "faiss", - "provider_type": "inline::faiss", - "config": map[string]interface{}{ - "kvstore": map[string]interface{}{ - "backend": "sql_default", - "table_name": "vector_store", - }, - "persistence": map[string]interface{}{ - "backend": "kv_default", - "namespace": "vector_persistence", - }, - }, - }, - } -} - -func buildLlamaStackServerConfig(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - return map[string]interface{}{ - "auth": nil, - "host": "0.0.0.0", // Listen on all interfaces so lightspeed-stack container can connect - "port": 8321, - "quota": nil, - "tls_cafile": nil, - "tls_certfile": nil, - "tls_keyfile": nil, - } -} - -func buildLlamaStackVectorDBs(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) []interface{} { - vectorDBs := []interface{}{} - - // Use RAG configuration from OLSConfig if available - if len(cr.Spec.OLSConfig.RAG) > 0 { - for _, rag := range cr.Spec.OLSConfig.RAG { - vectorDB := map[string]interface{}{ - "embedding_model": "sentence-transformers/all-mpnet-base-v2", - "embedding_dimension": 768, - "provider_id": "faiss", - } - - // Use IndexID if specified, otherwise generate a default - if rag.IndexID != "" { - vectorDB["vector_db_id"] = rag.IndexID - } else { - // Generate a simple ID from the image name - vectorDB["vector_db_id"] = "rag_" + sanitizeID(rag.Image) - } - - vectorDBs = append(vectorDBs, vectorDB) - } - } else { - // Default fallback if no RAG configured - vectorDBs = append(vectorDBs, map[string]interface{}{ - "vector_db_id": "my_knowledge_base", - "embedding_model": "sentence-transformers/all-mpnet-base-v2", - "embedding_dimension": 768, - "provider_id": "faiss", - }) - } - - return vectorDBs -} - -// sanitizeID creates a valid ID from an image name -func sanitizeID(image string) string { - // Extract just the image name without registry/tag - // e.g., "quay.io/my-org/my-rag:latest" -> "my-rag" - parts := strings.Split(image, "/") - name := parts[len(parts)-1] - name = strings.Split(name, ":")[0] - // Replace invalid characters with underscores - name = strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - return r - } - return '_' - }, name) - return name -} - -func buildLlamaStackModels(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) []interface{} { - models := []interface{}{ - // Always include sentence-transformers embedding model for RAG - map[string]interface{}{ - "model_id": "sentence-transformers/all-mpnet-base-v2", - "model_type": "embedding", - "provider_id": "sentence-transformers", - "provider_model_id": "sentence-transformers/all-mpnet-base-v2", - "metadata": map[string]interface{}{ - "embedding_dimension": 768, - }, - }, - } - - // Add LLM models from OLSConfig providers - for _, provider := range cr.Spec.LLMConfig.Providers { - for _, model := range provider.Models { - modelConfig := map[string]interface{}{ - "model_id": model.Name, - "model_type": "llm", - "provider_id": provider.Name, - "provider_model_id": model.Name, - } - - // Add model-specific metadata if available - metadata := map[string]interface{}{} - if model.ContextWindowSize > 0 { - metadata["context_window_size"] = model.ContextWindowSize - } - if model.Parameters.MaxTokensForResponse > 0 { - metadata["max_tokens"] = model.Parameters.MaxTokensForResponse - } - if model.URL != "" { - metadata["url"] = model.URL - } - if len(metadata) > 0 { - modelConfig["metadata"] = metadata - } - - models = append(models, modelConfig) - } - } - - return models -} - -func buildLlamaStackToolGroups(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) []interface{} { - return []interface{}{ - map[string]interface{}{ - "toolgroup_id": "builtin::rag", - "provider_id": "rag-runtime", - }, - } -} - -// buildLlamaStackStorage configures persistent storage for Llama Stack -// This defines storage backends and how different data types use them -func buildLlamaStackStorage(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - // Define storage backends - SQL only - backends := map[string]interface{}{ - "sql_default": map[string]interface{}{ - "type": "sql_sqlite", - "db_path": "/tmp/llama-stack/sql_store.db", - }, - "kv_default": map[string]interface{}{ - "type": "kv_sqlite", - "db_path": "/tmp/llama-stack/kv_store.db", - }, - "postgres_backend": map[string]interface{}{ - "type": "sql_postgres", - "host": "lightspeed-postgres-server.openshift-lightspeed.svc", - "port": 5432, - "user": "postgres", - "password": "${env.POSTGRES_PASSWORD}", - // Note: Database name is HARDCODED to "llamastack" in llama-stack's postgres adapter - // Not configurable - llama-stack ignores image_name for database selection - "ssl_mode": "require", - "ca_cert_path": "/etc/certs/postgres-ca/service-ca.crt", - "gss_encmode": "disable", - }, - } - - // Map data stores to backends - all use SQL with table_name - stores := map[string]interface{}{ - "metadata": map[string]interface{}{ - "namespace": "registry", - "backend": "kv_default", - }, - "inference": map[string]interface{}{ - "table_name": "inference_store", - "backend": "sql_default", - }, - "conversations": map[string]interface{}{ - "table_name": "openai_conversations", // Required by config schema but ignored - llama-stack uses hardcoded names - "backend": "postgres_backend", - }, - } - - return map[string]interface{}{ - "backends": backends, - "stores": stores, - } -} - -// buildLlamaStackYAML assembles the complete Llama Stack configuration and converts to YAML -func buildLlamaStackYAML(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (string, error) { - // Build the complete config as a map - config := buildLlamaStackCoreConfig(r, cr) - - // Build inference providers with error handling - inferenceProviders, err := buildLlamaStackInferenceProviders(r, ctx, cr) - if err != nil { - return "", fmt.Errorf("failed to build inference providers: %w", err) - } - - // Build providers map - only include providers for enabled APIs - config["providers"] = map[string]interface{}{ - "files": buildLlamaStackFileProviders(r, cr), - "agents": buildLlamaStackAgentProviders(r, cr), - "inference": inferenceProviders, - "safety": buildLlamaStackSafety(r, cr), - "tool_runtime": buildLlamaStackToolRuntime(r, cr), - "vector_io": buildLlamaStackVectorDB(r, cr), - } - - // Add top-level fields - config["scoring_fns"] = []interface{}{} - config["server"] = buildLlamaStackServerConfig(r, cr) - config["storage"] = buildLlamaStackStorage(r, cr) - config["vector_dbs"] = buildLlamaStackVectorDBs(r, cr) - config["models"] = buildLlamaStackModels(r, cr) - config["tool_groups"] = buildLlamaStackToolGroups(r, cr) - config["telemetry"] = map[string]interface{}{ - "enabled": false, - } - - // Convert to YAML - yamlBytes, err := yaml.Marshal(config) - if err != nil { - return "", fmt.Errorf("failed to marshal Llama Stack config to YAML: %w", err) - } - - return string(yamlBytes), nil -} - -// ============================================================================ -// LCore Config component builder functions (return maps for maintainability) -// ============================================================================ - -func buildLCoreServiceConfig(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{} { - // Map LogLevel from OLSConfig - // Valid values: DEBUG, INFO, WARNING, ERROR, CRITICAL - // Default to info if not specified - logLevel := olsv1alpha1.LogLevelInfo - if cr.Spec.OLSConfig.LogLevel != "" { - logLevel = cr.Spec.OLSConfig.LogLevel - } - - // color_log: enable colored logs for DEBUG, disable for production (INFO+) - colorLog := logLevel == olsv1alpha1.LogLevelDebug - - serviceConfig := map[string]interface{}{ - "host": "0.0.0.0", - "port": utils.OLSAppServerContainerPort, - "auth_enabled": false, - "workers": 1, - "color_log": colorLog, - "access_log": true, - // Note: log_level is not a valid field in lightspeed-stack service config - // The service uses standard Python logging which respects the LOG_LEVEL env var - "tls_config": map[string]interface{}{ - "tls_certificate_path": "/etc/certs/lightspeed-tls/tls.crt", - "tls_key_path": "/etc/certs/lightspeed-tls/tls.key", - }, - } - - // Add proxy configuration if specified - if cr.Spec.OLSConfig.ProxyConfig != nil { - proxyConfigMap := map[string]interface{}{} - - if cr.Spec.OLSConfig.ProxyConfig.ProxyURL != "" { - proxyConfigMap["proxy_url"] = cr.Spec.OLSConfig.ProxyConfig.ProxyURL - } - - proxyCACertRef := cr.Spec.OLSConfig.ProxyConfig.ProxyCACertificateRef - cmName := utils.GetProxyCACertConfigMapName(proxyCACertRef) - if cmName != "" { - certKey := utils.GetProxyCACertKey(proxyCACertRef) - proxyConfigMap["proxy_ca_cert_path"] = path.Join(utils.OLSAppCertsMountRoot, utils.ProxyCACertVolumeName, certKey) - } - - if len(proxyConfigMap) > 0 { - serviceConfig["proxy_config"] = proxyConfigMap - } - } - - return serviceConfig -} - -func buildLCoreLlamaStackConfig(r reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - // Server mode: llama-stack runs as a separate service (container) - // Library mode: llama-stack runs as an embedded library - isLibraryMode := r != nil && !r.GetLCoreServerMode() - - llamaStackConfig := map[string]interface{}{ - "use_as_library_client": isLibraryMode, - "url": "http://localhost:8321", - "api_key": "xyzzy", - } - - // In library mode, add path to llama-stack config file - if isLibraryMode { - llamaStackConfig["library_client_config_path"] = utils.LlamaStackConfigMountPath - } - - return llamaStackConfig -} - -func buildLCoreUserDataCollectionConfig(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{} { - // Map UserDataCollection from OLSConfig - // Feedback and transcripts are enabled by default, disabled if specified in CR - feedbackEnabled := !cr.Spec.OLSConfig.UserDataCollection.FeedbackDisabled - transcriptsEnabled := !cr.Spec.OLSConfig.UserDataCollection.TranscriptsDisabled - - return map[string]interface{}{ - "feedback_enabled": feedbackEnabled, - "feedback_storage": "/tmp/data/feedback", - "transcripts_enabled": transcriptsEnabled, - "transcripts_storage": "/tmp/data/transcripts", - } -} - -func buildLCoreAuthenticationConfig(_ reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - return map[string]interface{}{ - "module": "k8s", - } -} - -func buildLCoreInferenceConfig(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{} { - return map[string]interface{}{ - "default_provider": cr.Spec.OLSConfig.DefaultProvider, - "default_model": cr.Spec.OLSConfig.DefaultModel, - } -} - -// buildLCoreDatabaseConfig configures persistent database storage -// Supports SQLite (file-based) or PostgreSQL (server-based) -// Default: PostgreSQL (shared with App Server) -func buildLCoreDatabaseConfig(r reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - // Example: SQLite configuration - // return map[string]interface{}{ - // "sqlite": map[string]interface{}{ - // "db_path": "/app-root/data/lightspeed-stack.db", // Mount a PVC here for persistence - // }, - // } - - // PostgreSQL configuration (shared with App Server) - return map[string]interface{}{ - "postgres": map[string]interface{}{ - "host": utils.PostgresServiceName + "." + r.GetNamespace() + ".svc", - "port": utils.PostgresServicePort, - "db": utils.PostgresDefaultDbName, - "user": utils.PostgresDefaultUser, - "password": "${env.POSTGRES_PASSWORD}", // Environment variable substitution via llama_stack.core.stack.replace_env_vars - "ssl_mode": utils.PostgresDefaultSSLMode, - "gss_encmode": "disable", // Default from lightspeed-stack constants - "ca_cert_path": "/etc/certs/postgres-ca/service-ca.crt", - "namespace": "lcore", // Separate schema for LCore to avoid conflicts with App Server - }, - } -} - -// buildLCoreMCPServersConfig configures Model Context Protocol servers -// Allows integration with external context providers for agent workflows -// NOTE: Secret validation is performed separately during deployment generation -func buildLCoreMCPServersConfig(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) []map[string]interface{} { - mcpServers := []map[string]interface{}{} - - // Add OpenShift MCP server if introspection is enabled - if utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { - mcpServers = append(mcpServers, map[string]interface{}{ - "name": "openshift", - "url": fmt.Sprintf(utils.OpenShiftMCPServerURL, utils.OpenShiftMCPServerPort), - // Authorization headers for K8s authentication - "authorization_headers": map[string]string{ - utils.K8S_AUTH_HEADER: utils.KUBERNETES_PLACEHOLDER, - }, - }) - } - - // Add user-defined MCP servers - if cr.Spec.FeatureGates != nil && slices.Contains(cr.Spec.FeatureGates, utils.FeatureGateMCPServer) { - for _, server := range cr.Spec.MCPServers { - // Build MCP server config - mcpServer := map[string]interface{}{ - "name": server.Name, - "url": server.URL, - } - - // Add timeout if specified (default is handled by lightspeed-stack) - if server.Timeout > 0 { - mcpServer["timeout"] = server.Timeout - } - - // Add authorization headers if configured - if len(server.Headers) > 0 { - headers := make(map[string]string) - invalidServer := false - for _, header := range server.Headers { - if invalidServer { - break - } - headerName := header.Name - var headerValue string - - // Determine header value based on discriminator type - switch header.ValueFrom.Type { - case olsv1alpha1.MCPHeaderSourceTypeKubernetes: - headerValue = utils.KUBERNETES_PLACEHOLDER - case olsv1alpha1.MCPHeaderSourceTypeClient: - headerValue = utils.CLIENT_PLACEHOLDER - case olsv1alpha1.MCPHeaderSourceTypeSecret: - if header.ValueFrom.SecretRef == nil || header.ValueFrom.SecretRef.Name == "" { - r.GetLogger().Error( - fmt.Errorf("missing secretRef for type 'secret'"), - "Skipping MCP server: type is 'secret' but secretRef is not set", - "server", server.Name, - "header", headerName, - ) - invalidServer = true - continue - } - // Use consistent path structure: /etc/mcp/headers//header - headerValue = path.Join(utils.MCPHeadersMountRoot, header.ValueFrom.SecretRef.Name, utils.MCPSECRETDATAPATH) - default: - // This should never happen due to enum validation - r.GetLogger().Error( - fmt.Errorf("invalid MCP header type: %s", header.ValueFrom.Type), - "Skipping MCP server due to invalid header type", - "server", server.Name, - "header", headerName, - "type", header.ValueFrom.Type, - ) - invalidServer = true - continue - } - - headers[headerName] = headerValue - } - - // Skip this server if any header was invalid - if invalidServer { - continue - } - - if len(headers) > 0 { - mcpServer["authorization_headers"] = headers - } - } - - mcpServers = append(mcpServers, mcpServer) - } - } - - return mcpServers -} - -// buildLCoreCustomizationConfig configures system prompt customization -// Uses CR field if set, otherwise falls back to default (same as lightspeed-service) -func buildLCoreCustomizationConfig(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{} { - systemPrompt := DefaultQuerySystemPrompt - if cr.Spec.OLSConfig.QuerySystemPrompt != "" { - systemPrompt = cr.Spec.OLSConfig.QuerySystemPrompt - } - - return map[string]interface{}{ - "system_prompt": systemPrompt, - "disable_query_system_prompt": true, // Prevent users from overriding via API - } -} - -// buildLCoreConversationCacheConfig configures chat history caching -// Options: noop (disabled), memory (in-memory), sqlite (file), postgres (database) -// Useful for maintaining conversation context across requests -func buildLCoreConversationCacheConfig(r reconciler.Reconciler, _ *olsv1alpha1.OLSConfig) map[string]interface{} { - // PostgreSQL cache (shared with App Server) - return map[string]interface{}{ - "type": "postgres", - "postgres": map[string]interface{}{ - "host": utils.PostgresServiceName + "." + r.GetNamespace() + ".svc", - "port": utils.PostgresServicePort, - "db": utils.PostgresDefaultDbName, - "user": utils.PostgresDefaultUser, - "password": "${env.POSTGRES_PASSWORD}", // Environment variable substitution - "ssl_mode": utils.PostgresDefaultSSLMode, - "gss_encmode": "disable", - "ca_cert_path": "/etc/certs/postgres-ca/service-ca.crt", - "namespace": "conversation_cache", // Separate schema for conversation cache - }, - } -} - -// buildLCoreQuotaHandlersConfig configures token usage rate limiting -// Controls how many tokens users or clusters can consume -// Useful for cost management and preventing abuse -func buildLCoreQuotaHandlersConfig(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{} { - // If no quota config in CR, return nil (disabled) - if cr.Spec.OLSConfig.QuotaHandlersConfig == nil || len(cr.Spec.OLSConfig.QuotaHandlersConfig.LimitersConfig) == 0 { - return nil - } - - quotaConfig := cr.Spec.OLSConfig.QuotaHandlersConfig - - // Build limiters array from CR configuration - limiters := []interface{}{} - for _, limiter := range quotaConfig.LimitersConfig { - limiters = append(limiters, map[string]interface{}{ - "type": limiter.Type, // "user_limiter" or "cluster_limiter" - "name": limiter.Name, - "initial_quota": limiter.InitialQuota, - "quota_increase": limiter.QuotaIncrease, - "period": limiter.Period, // e.g., "1 day", "1 hour" - }) - } - - return map[string]interface{}{ - "limiters": limiters, - "scheduler": map[string]interface{}{ - "period": 300, // Check quotas every 300 seconds (5 minutes) - matches app server - }, - "enable_token_history": quotaConfig.EnableTokenHistory, - // PostgreSQL configuration at top level - quota system expects postgres/sqlite at this level - "postgres": map[string]interface{}{ - "host": utils.PostgresServiceName + "." + r.GetNamespace() + ".svc", - "port": utils.PostgresServicePort, - "db": utils.PostgresDefaultDbName, - "user": utils.PostgresDefaultUser, - "password": "${env.POSTGRES_PASSWORD}", // Environment variable substitution - "ssl_mode": utils.PostgresDefaultSSLMode, - "gss_encmode": "disable", - "ca_cert_path": "/etc/certs/postgres-ca/service-ca.crt", - "namespace": "quota", // Separate schema for quota data - }, - } -} - -// buildLCoreToolsApprovalConfig configures tool execution approval -// Controls whether tool calls require user approval before execution -func buildLCoreToolsApprovalConfig(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) map[string]interface{} { - var approvalType string - var approvalTimeout int - - if cr.Spec.OLSConfig.ToolsApprovalConfig == nil { - // Use CRD defaults (must match +kubebuilder:default markers in ToolsApprovalConfig) - approvalType = string(olsv1alpha1.ApprovalTypeToolAnnotations) // CRD default: tool_annotations - approvalTimeout = utils.ToolsApprovalDefaultTimeout // CRD default: 600 - } else { - // Use specified values, applying CRD defaults for zero values - approvalType = string(cr.Spec.OLSConfig.ToolsApprovalConfig.ApprovalType) - approvalTimeout = cr.Spec.OLSConfig.ToolsApprovalConfig.ApprovalTimeout - - if approvalType == "" { - approvalType = string(olsv1alpha1.ApprovalTypeToolAnnotations) // CRD default: tool_annotations - } - if approvalTimeout == 0 { - approvalTimeout = utils.ToolsApprovalDefaultTimeout // CRD default: 600 - } - } - - return map[string]interface{}{ - "approval_type": approvalType, // "never", "always", or "tool_annotations" - "approval_timeout": approvalTimeout, // Timeout in seconds for waiting for user approval - } -} - -// buildLCoreConfigYAML assembles the complete Lightspeed Core Service configuration and converts to YAML -func buildLCoreConfigYAML(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (string, error) { - // Build the complete config as a map - config := map[string]interface{}{ - "name": "Lightspeed Core Service (LCS)", - "service": buildLCoreServiceConfig(r, cr), - "llama_stack": buildLCoreLlamaStackConfig(r, cr), - "user_data_collection": buildLCoreUserDataCollectionConfig(r, cr), - "authentication": buildLCoreAuthenticationConfig(r, cr), - "inference": buildLCoreInferenceConfig(r, cr), - "database": buildLCoreDatabaseConfig(r, cr), // Persistent storage (SQLite/PostgreSQL) - "customization": buildLCoreCustomizationConfig(r, cr), // Same system prompt as lightspeed-service - "conversation_cache": buildLCoreConversationCacheConfig(r, cr), // Chat history caching (PostgreSQL) - } - - // Optional features - only add if configured/enabled - if quotaConfig := buildLCoreQuotaHandlersConfig(r, cr); quotaConfig != nil { - config["quota_handlers"] = quotaConfig // Token rate limiting - } - - // Tools approval configuration (requires user approval before tool execution) - if toolsApprovalConfig := buildLCoreToolsApprovalConfig(r, cr); toolsApprovalConfig != nil { - config["tools_approval"] = toolsApprovalConfig - } - - // MCP servers configuration (includes introspection + user-defined servers) - if mcpServers := buildLCoreMCPServersConfig(r, cr); len(mcpServers) > 0 { - config["mcp_servers"] = mcpServers // Model Context Protocol servers - } - - // Convert to YAML - yamlBytes, err := yaml.Marshal(config) - if err != nil { - return "", fmt.Errorf("failed to marshal LCore config to YAML: %w", err) - } - - return string(yamlBytes), nil -} diff --git a/internal/controller/lcore/config_test.go b/internal/controller/lcore/config_test.go deleted file mode 100644 index 9793ebd0c..000000000 --- a/internal/controller/lcore/config_test.go +++ /dev/null @@ -1,691 +0,0 @@ -package lcore - -import ( - "fmt" - "strings" - "testing" - - "github.com/go-logr/logr" - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// mockLogger implements a simple logger for tests -type mockLogger struct { - errorMessages []string -} - -func (m *mockLogger) Init(info logr.RuntimeInfo) {} -func (m *mockLogger) Enabled(level int) bool { return true } -func (m *mockLogger) Info(level int, msg string, keysAndValues ...any) {} -func (m *mockLogger) WithValues(keysAndValues ...any) logr.LogSink { return m } -func (m *mockLogger) WithName(name string) logr.LogSink { return m } -func (m *mockLogger) WithCallDepth(depth int) logr.LogSink { return m } -func (m *mockLogger) Error(err error, msg string, keysAndValues ...any) { - fullMsg := fmt.Sprintf("%s: %v", msg, err) - if len(keysAndValues) > 0 { - fullMsg += fmt.Sprintf(" %v", keysAndValues) - } - m.errorMessages = append(m.errorMessages, fullMsg) -} - -// mockReconcilerWithLogger extends mockReconciler with logger support -type mockReconcilerWithLogger struct { - *mockReconciler - logger logr.Logger -} - -func (m *mockReconcilerWithLogger) GetLogger() logr.Logger { - if m.logger.GetSink() == nil { - mockSink := &mockLogger{} - m.logger = logr.New(mockSink) - } - return m.logger -} - -func TestBuildLCoreMCPServersConfig_NoServers(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - }, - } - - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 0 { - t.Errorf("Expected no MCP servers, got %d", len(result)) - } -} - -func TestBuildLCoreMCPServersConfig_IntrospectionOnly(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(true), - }, - }, - } - - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 1 { - t.Fatalf("Expected 1 MCP server (OpenShift), got %d", len(result)) - } - - // Verify OpenShift MCP server configuration - openshiftServer := result[0] - if openshiftServer["name"] != "openshift" { - t.Errorf("Expected server name 'openshift', got '%v'", openshiftServer["name"]) - } - - expectedURL := fmt.Sprintf(utils.OpenShiftMCPServerURL, utils.OpenShiftMCPServerPort) - if openshiftServer["url"] != expectedURL { - t.Errorf("Expected URL '%s', got '%v'", expectedURL, openshiftServer["url"]) - } - - // Verify authorization headers - headers, ok := openshiftServer["authorization_headers"].(map[string]string) - if !ok { - t.Fatal("Expected authorization_headers to be map[string]string") - } - - if headers[utils.K8S_AUTH_HEADER] != utils.KUBERNETES_PLACEHOLDER { - t.Errorf("Expected K8s auth header value '%s', got '%s'", - utils.KUBERNETES_PLACEHOLDER, headers[utils.K8S_AUTH_HEADER]) - } -} - -func TestBuildLCoreMCPServersConfig_UserDefinedServers_KubernetesPlaceholder(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "external-server", - URL: "https://external.example.com/mcp", - Timeout: 30, - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Authorization", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - }, - }, - }, - }, - } - - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(result)) - } - - server := result[0] - - // Verify basic config - if server["name"] != "external-server" { - t.Errorf("Expected server name 'external-server', got '%v'", server["name"]) - } - if server["url"] != "https://external.example.com/mcp" { - t.Errorf("Expected URL 'https://external.example.com/mcp', got '%v'", server["url"]) - } - if server["timeout"] != 30 { - t.Errorf("Expected timeout 30, got %v", server["timeout"]) - } - - // Verify authorization headers - headers, ok := server["authorization_headers"].(map[string]string) - if !ok { - t.Fatal("Expected authorization_headers to be map[string]string") - } - - // Check Kubernetes placeholder is preserved - if headers["Authorization"] != utils.KUBERNETES_PLACEHOLDER { - t.Errorf("Expected Authorization header value '%s', got '%s'", - utils.KUBERNETES_PLACEHOLDER, headers["Authorization"]) - } -} - -func TestBuildLCoreMCPServersConfig_UserDefinedServers_WithSecretRef(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "external-server", - URL: "https://external.example.com/mcp", - Timeout: 30, - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Authorization", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - { - Name: "X-Custom", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeSecret, - SecretRef: &corev1.LocalObjectReference{Name: "mcp-auth-secret"}, - }, - }, - }, - }, - }, - }, - } - - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(result)) - } - - server := result[0] - - // Verify headers include both kubernetes and secret ref paths - headers, ok := server["authorization_headers"].(map[string]string) - if !ok { - t.Fatal("Expected authorization_headers to be map[string]string") - } - - // Check paths are correctly formatted - if headers["Authorization"] != utils.KUBERNETES_PLACEHOLDER { - t.Errorf("Expected Authorization=%s, got '%s'", utils.KUBERNETES_PLACEHOLDER, headers["Authorization"]) - } - - expectedPath := "/etc/mcp/headers/mcp-auth-secret/header" - if headers["X-Custom"] != expectedPath { - t.Errorf("Expected X-Custom path '%s', got '%s'", expectedPath, headers["X-Custom"]) - } -} - -func TestBuildLCoreMCPServersConfig_Combined(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(true), - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "user-server", - URL: "http://user-mcp.example.com", - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Authorization", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeSecret, - SecretRef: &corev1.LocalObjectReference{Name: "user-secret"}, - }, - }, - }, - }, - }, - }, - } - - // Config generation doesn't validate secrets - validation happens during deployment - result := buildLCoreMCPServersConfig(r, cr) - - // Should have both OpenShift (from introspection) + user server - if len(result) != 2 { - t.Fatalf("Expected 2 MCP servers, got %d", len(result)) - } - - // First should be OpenShift - if result[0]["name"] != "openshift" { - t.Errorf("Expected first server to be 'openshift', got '%v'", result[0]["name"]) - } - - // Second should be user-defined - if result[1]["name"] != "user-server" { - t.Errorf("Expected second server to be 'user-server', got '%v'", result[1]["name"]) - } -} - -func TestBuildLCoreMCPServersConfig_FiltersNonHTTP(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Generation: 1, - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "http-server", - URL: "http://valid.example.com", - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Authorization", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeSecret, - SecretRef: &corev1.LocalObjectReference{Name: "test-secret"}, - }, - }, - }, - }, - }, - }, - } - - // Config generation doesn't validate secrets - validation happens during deployment - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(result)) - } - - if result[0]["name"] != "http-server" { - t.Errorf("Expected server to be 'http-server', got '%v'", result[0]["name"]) - } -} - -func TestBuildLCoreMCPServersConfig_EmptyHeadersNotAdded(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "no-auth-server", - URL: "http://no-auth.example.com", - // No headers specified - }, - }, - }, - } - - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(result)) - } - - // authorization_headers should not be present when no headers configured - if _, exists := result[0]["authorization_headers"]; exists { - t.Error("Expected no authorization_headers field when no headers configured") - } -} - -func TestBuildLCoreMCPServersConfig_SkipsEmptySecretRefs(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "server-with-mixed-headers", - URL: "http://example.com", - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Valid-Header", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - { - Name: "Kubernetes-Header", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - }, - }, - }, - }, - } - - result := buildLCoreMCPServersConfig(r, cr) - - if len(result) != 1 { - t.Fatalf("Expected 1 MCP server, got %d", len(result)) - } - - headers, ok := result[0]["authorization_headers"].(map[string]string) - if !ok { - t.Fatal("Expected authorization_headers to be map[string]string") - } - - // Should have 2 headers (both kubernetes tokens) - if len(headers) != 2 { - t.Errorf("Expected 2 headers, got %d", len(headers)) - } - - // Valid headers should be present - if _, exists := headers["Valid-Header"]; !exists { - t.Error("Expected Valid-Header to be present") - } - if _, exists := headers["Kubernetes-Header"]; !exists { - t.Error("Expected Kubernetes-Header to be present") - } -} - -func TestBuildLCoreConfigYAML_WithMCPServers(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(true), - }, - }, - } - - yamlStr, err := buildLCoreConfigYAML(r, cr) - if err != nil { - t.Fatalf("buildLCoreConfigYAML returned error: %v", err) - } - - // Verify YAML contains MCP servers section - if !strings.Contains(yamlStr, "mcp_servers:") { - t.Error("Expected YAML to contain 'mcp_servers:' section") - } - - // Verify OpenShift server is present - if !strings.Contains(yamlStr, "name: openshift") { - t.Error("Expected YAML to contain OpenShift MCP server") - } -} - -func TestBuildLCoreConfigYAML_WithoutMCPServers(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - }, - } - - yamlStr, err := buildLCoreConfigYAML(r, cr) - if err != nil { - t.Fatalf("buildLCoreConfigYAML returned error: %v", err) - } - - // Verify YAML does NOT contain MCP servers section - if strings.Contains(yamlStr, "mcp_servers:") { - t.Error("Expected YAML NOT to contain 'mcp_servers:' section when no servers configured") - } -} - -func TestBuildLCoreToolsApprovalConfig_WithConfig(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - tests := []struct { - name string - config *olsv1alpha1.ToolsApprovalConfig - expectedType string - expectedTimeout int - }{ - { - name: "with never approval type", - config: &olsv1alpha1.ToolsApprovalConfig{ - ApprovalType: olsv1alpha1.ApprovalTypeNever, - ApprovalTimeout: 300, - }, - expectedType: "never", - expectedTimeout: 300, - }, - { - name: "with always approval type", - config: &olsv1alpha1.ToolsApprovalConfig{ - ApprovalType: olsv1alpha1.ApprovalTypeAlways, - ApprovalTimeout: 120, - }, - expectedType: "always", - expectedTimeout: 120, - }, - { - name: "with tool_annotations approval type", - config: &olsv1alpha1.ToolsApprovalConfig{ - ApprovalType: olsv1alpha1.ApprovalTypeToolAnnotations, - ApprovalTimeout: 600, - }, - expectedType: "tool_annotations", - expectedTimeout: 600, - }, - { - name: "with empty config (defaults applied)", - config: &olsv1alpha1.ToolsApprovalConfig{}, - expectedType: "tool_annotations", - expectedTimeout: 600, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - ToolsApprovalConfig: tt.config, - }, - }, - } - - result := buildLCoreToolsApprovalConfig(r, cr) - - if result == nil { - t.Fatal("Expected non-nil result") - } - - approvalType, ok := result["approval_type"].(string) - if !ok { - t.Fatal("Expected approval_type to be string") - } - if approvalType != tt.expectedType { - t.Errorf("Expected approval_type %q, got %q", tt.expectedType, approvalType) - } - - approvalTimeout, ok := result["approval_timeout"].(int) - if !ok { - t.Fatal("Expected approval_timeout to be int") - } - if approvalTimeout != tt.expectedTimeout { - t.Errorf("Expected approval_timeout %d, got %d", tt.expectedTimeout, approvalTimeout) - } - }) - } -} - -func TestBuildLCoreToolsApprovalConfig_WithoutConfig(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - ToolsApprovalConfig: nil, - }, - }, - } - - result := buildLCoreToolsApprovalConfig(r, cr) - - if result == nil { - t.Fatal("Expected non-nil result when ToolsApprovalConfig is nil") - } - - approvalType, ok := result["approval_type"].(string) - if !ok { - t.Fatal("Expected approval_type to be string") - } - if approvalType != "tool_annotations" { - t.Errorf("Expected approval_type %q, got %q", "tool_annotations", approvalType) - } - - approvalTimeout, ok := result["approval_timeout"].(int) - if !ok { - t.Fatal("Expected approval_timeout to be int") - } - if approvalTimeout != 600 { - t.Errorf("Expected approval_timeout %d, got %d", 600, approvalTimeout) - } -} - -func TestBuildLCoreConfigYAML_WithToolsApproval(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - ToolsApprovalConfig: &olsv1alpha1.ToolsApprovalConfig{ - ApprovalType: olsv1alpha1.ApprovalTypeAlways, - ApprovalTimeout: 300, - }, - }, - }, - } - - yamlStr, err := buildLCoreConfigYAML(r, cr) - if err != nil { - t.Fatalf("buildLCoreConfigYAML returned error: %v", err) - } - - // Verify YAML contains tools_approval section - if !strings.Contains(yamlStr, "tools_approval:") { - t.Error("Expected YAML to contain 'tools_approval:' section") - } - - // Verify approval_type is present - if !strings.Contains(yamlStr, "approval_type: always") { - t.Error("Expected YAML to contain 'approval_type: always'") - } - - // Verify approval_timeout is present - if !strings.Contains(yamlStr, "approval_timeout: 300") { - t.Error("Expected YAML to contain 'approval_timeout: 300'") - } -} - -func TestBuildLCoreConfigYAML_WithoutToolsApproval(t *testing.T) { - r := utils.NewTestReconciler(nil, logr.Discard(), nil, utils.OLSNamespaceDefault) - - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - OLSConfig: olsv1alpha1.OLSSpec{ - ToolsApprovalConfig: nil, - }, - }, - } - - yamlStr, err := buildLCoreConfigYAML(r, cr) - if err != nil { - t.Fatalf("buildLCoreConfigYAML returned error: %v", err) - } - - // Verify YAML contains tools_approval section with defaults - if !strings.Contains(yamlStr, "tools_approval:") { - t.Error("Expected YAML to contain 'tools_approval:' section with defaults when not configured") - } - - // Verify default approval_type is present - if !strings.Contains(yamlStr, "approval_type: tool_annotations") { - t.Error("Expected YAML to contain 'approval_type: tool_annotations' as default") - } - - // Verify default approval_timeout is present - if !strings.Contains(yamlStr, "approval_timeout: 600") { - t.Error("Expected YAML to contain 'approval_timeout: 600' as default") - } -} - -func TestBuildVertexAIInferenceConfig(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "google_vertex", - Type: utils.GoogleVertexType, - GoogleVertexConfig: &olsv1alpha1.VertexConfig{ - ProjectID: "testProjectID", - Location: "testLocation", - }, - }, - }, - }, - }, - } - - result := buildVertexAIInferenceConfig(&cr.Spec.LLMConfig.Providers[0]) - if result == nil { - t.Fatal("Expected non-nil result") - } - if result["project"] != "testProjectID" { - t.Errorf("Expected project to be testProjectID, got %s", result["project"]) - } - if result["location"] != "testLocation" { - t.Errorf("Expected location to be testLocation, got %s", result["location"]) - } -} diff --git a/internal/controller/lcore/deployment.go b/internal/controller/lcore/deployment.go deleted file mode 100644 index 11a02e0fb..000000000 --- a/internal/controller/lcore/deployment.go +++ /dev/null @@ -1,1421 +0,0 @@ -package lcore - -import ( - "context" - "encoding/json" - "fmt" - "path" - "slices" - "strings" - "time" - - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" -) - -// getLlamaStackResources returns resource requirements for the llama-stack container -// This container runs the Llama Stack inference service (sidecar to lightspeed-stack) -func getLlamaStackResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { - // llama-stack is a sidecar inference backend with fixed resource requirements - // It always gets default resources to ensure stable inference performance - // Users can configure the main API container (lightspeed-stack) via APIContainer.Resources - // Note: The pod must have enough resources to accommodate both containers - // - // TODO: Consider adding LlamaStackContainerConfig to the API in a future PR to allow - // users to configure llama-stack resources independently of the main API container. - // This would follow the same pattern as APIContainer, DataCollectorContainer, etc. - defaultResources := &corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("2Gi"), - corev1.ResourceCPU: resource.MustParse("1000m"), - }, - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("500m"), - corev1.ResourceMemory: resource.MustParse("512Mi"), - }, - Claims: []corev1.ResourceClaim{}, - } - - return utils.GetResourcesOrDefault(cr.Spec.OLSConfig.DeploymentConfig.LlamaStackContainer.Resources, defaultResources) -} - -// getLightspeedStackResources returns resource requirements for the lightspeed-stack container -// This is the main API container serving user requests -func getLightspeedStackResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { - return utils.GetResourcesOrDefault( - cr.Spec.OLSConfig.DeploymentConfig.APIContainer.Resources, - &corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("1Gi"), - corev1.ResourceCPU: resource.MustParse("1000m"), - }, - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("500m"), - corev1.ResourceMemory: resource.MustParse("512Mi"), - }, - Claims: []corev1.ResourceClaim{}, - }, - ) -} - -// getOLSMCPServerResources returns resource requirements for the OpenShift MCP server sidecar -func getOLSMCPServerResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { - return utils.GetResourcesOrDefault( - cr.Spec.OLSConfig.DeploymentConfig.MCPServerContainer.Resources, - &corev1.ResourceRequirements{ - Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("200Mi")}, - Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("50m"), corev1.ResourceMemory: resource.MustParse("64Mi")}, - Claims: []corev1.ResourceClaim{}, - }, - ) -} - -// getOLSDataCollectorResources returns resource requirements for the data collector sidecar -func getOLSDataCollectorResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { - return utils.GetResourcesOrDefault( - cr.Spec.OLSConfig.DeploymentConfig.DataCollectorContainer.Resources, - &corev1.ResourceRequirements{ - Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("200Mi")}, - Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("50m"), corev1.ResourceMemory: resource.MustParse("64Mi")}, - Claims: []corev1.ResourceClaim{}, - }, - ) -} - -// addOpenShiftMCPServerSidecar adds the OpenShift MCP server sidecar container to the deployment -// if introspection is enabled in the CR. This modifies the deployment in place. -// The sidecar is configured with a TOML config that denies access to Secret resources, -// preventing secret data from reaching the LLM. -func addOpenShiftMCPServerSidecar(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig, deployment *appsv1.Deployment) { - if !utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { - return - } - - configVolume, configMount := utils.GetOpenShiftMCPServerConfigVolumeAndMount() - - openshiftMCPServerContainer := corev1.Container{ - Name: utils.OpenShiftMCPServerContainerName, - Image: r.GetOpenShiftMCPServerImage(), - ImagePullPolicy: corev1.PullIfNotPresent, - SecurityContext: utils.RestrictedContainerSecurityContext(), - VolumeMounts: []corev1.VolumeMount{configMount}, - Command: []string{ - "/openshift-mcp-server", - "--config", utils.GetOpenShiftMCPServerConfigPath(), - "--port", fmt.Sprintf("%d", utils.OpenShiftMCPServerPort), - }, - Resources: *getOLSMCPServerResources(cr), - } - - deployment.Spec.Template.Spec.Volumes = append( - deployment.Spec.Template.Spec.Volumes, - configVolume, - ) - deployment.Spec.Template.Spec.Containers = append( - deployment.Spec.Template.Spec.Containers, - openshiftMCPServerContainer, - ) -} - -// addDataCollectorSidecar adds the data collector container to the deployment if data collection is enabled -func addDataCollectorSidecar(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig, deployment *appsv1.Deployment, volumeMounts []corev1.VolumeMount, dataCollectorEnabled bool) { - if !dataCollectorEnabled { - return - } - - // Get log level from CR - logLevel := cr.Spec.OLSDataCollectorConfig.LogLevel - if logLevel == "" { - logLevel = olsv1alpha1.LogLevelInfo - } - - exporterContainer := corev1.Container{ - Name: "lightspeed-to-dataverse-exporter", - Image: r.GetDataverseExporterImage(), - ImagePullPolicy: corev1.PullAlways, - SecurityContext: utils.RestrictedContainerSecurityContext(), - VolumeMounts: volumeMounts, - // running in openshift mode ensures that cluster_id is set - // as identity_id - Args: []string{ - "--mode", - "openshift", - "--config", - path.Join(utils.ExporterConfigMountPath, utils.ExporterConfigFilename), - "--log-level", - string(logLevel), - "--data-dir", - utils.LCoreUserDataMountPath, - }, - Resources: *getOLSDataCollectorResources(cr), - } - - deployment.Spec.Template.Spec.Containers = append( - deployment.Spec.Template.Spec.Containers, - exporterContainer, - ) -} - -// buildLlamaStackEnvVars builds environment variables for all LLM providers -// For Azure providers, it reads the secret to support both API key and client credentials -func buildLlamaStackEnvVars(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) ([]corev1.EnvVar, error) { - envVars := []corev1.EnvVar{} - - // Add environment variables for each LLM provider secret using iterator - err := utils.ForEachExternalSecret(cr, func(name, source string) error { - if !strings.HasPrefix(source, "llm-provider-") { - return nil - } - - // Extract provider name from source (format: "llm-provider-") - providerName := strings.TrimPrefix(source, "llm-provider-") - envVarBase := utils.ProviderNameToEnvVarName(providerName) - - // Find the provider in the CR to check its type - var provider *olsv1alpha1.ProviderSpec - for i := range cr.Spec.LLMConfig.Providers { - if cr.Spec.LLMConfig.Providers[i].Name == providerName { - provider = &cr.Spec.LLMConfig.Providers[i] - break - } - } - - // Handle credential environment variables based on provider configuration - if provider == nil { - // This should never happen in normal operation because ForEachExternalSecret - // uses "llm-provider-" sourced directly from cr.Spec.LLMConfig.Providers. - // Guard against any future refactoring that could break the invariant. - return fmt.Errorf("internal: provider '%s' not found in CR but has a registered secret '%s'", providerName, name) - } else if provider.ProviderType != "" { - // Generic provider configuration: use credentialKey field. - // Re-fetch the secret here as a fail-safe: the secret or its keys could have been - // modified after ValidateLLMCredentials ran earlier in the reconcile loop. - credentialKey := provider.CredentialKey - if credentialKey == "" { - credentialKey = utils.DefaultCredentialKey - } - - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{ - Name: name, - Namespace: r.GetNamespace(), - }, secret) - if err != nil { - return fmt.Errorf("failed to get secret %s: %w", name, err) - } - - // Create env var for the primary credential - if _, ok := secret.Data[credentialKey]; !ok { - return fmt.Errorf("secret %s missing credential key '%s' for provider '%s'", name, credentialKey, providerName) - } - envVars = append(envVars, corev1.EnvVar{ - Name: envVarBase + "_API_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: name}, - Key: credentialKey, - }, - }, - }) - - } else if provider.Type == "azure_openai" { - // Azure OpenAI provider: read secret to support both authentication methods - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{ - Name: name, - Namespace: r.GetNamespace(), - }, secret) - if err != nil { - return fmt.Errorf("failed to get secret %s: %w", name, err) - } - - // Create environment variables for each key in the secret - // Azure supports both API key (apitoken) and client credentials (client_id, tenant_id, client_secret) - keyToEnvSuffix := map[string]string{ - utils.DefaultCredentialKey: utils.EnvVarSuffixAPIKey, - "client_id": utils.EnvVarSuffixClientID, - "tenant_id": utils.EnvVarSuffixTenantID, - "client_secret": utils.EnvVarSuffixClientSecret, - } - - for key := range secret.Data { - if suffix, ok := keyToEnvSuffix[key]; ok { - envVars = append(envVars, corev1.EnvVar{ - Name: envVarBase + suffix, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: name}, - Key: key, - }, - }, - }) - } - } - - // LiteLLM requires api_key field to be present in the config for Azure - // even when using client credentials authentication. Check if we have an API_KEY env var, - // and if not, add one with a placeholder value to satisfy LiteLLM's Pydantic validation. - hasAPIKey := false - apiKeyEnvName := envVarBase + "_API_KEY" - for _, env := range envVars { - if env.Name == apiKeyEnvName { - hasAPIKey = true - break - } - } - if !hasAPIKey { - envVars = append(envVars, corev1.EnvVar{ - Name: apiKeyEnvName, - Value: "placeholder", - }) - } - } else if provider.Type == utils.GoogleVertexType || provider.Type == utils.GoogleVertexAnthropicType { - // Google Vertex / Vertex Anthropic: credentials are mounted as files; the Google client - // libraries read GOOGLE_APPLICATION_CREDENTIALS (set once below after this loop). - credentialKey := provider.CredentialKey - if credentialKey == "" { - credentialKey = utils.DefaultCredentialKey - } - if strings.TrimSpace(credentialKey) == "" { - return fmt.Errorf("LLM provider %s: credentialKey must not be empty or whitespace", providerName) - } - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{ - Name: name, - Namespace: r.GetNamespace(), - }, secret) - if err != nil { - return fmt.Errorf("failed to get secret %s: %w", name, err) - } - if _, ok := secret.Data[credentialKey]; !ok { - return fmt.Errorf("secret %s missing credential key '%s' for provider '%s'", name, credentialKey, providerName) - } - credPath := utils.ProviderCredentialsFilePath(providerName, credentialKey) - envVars = append(envVars, corev1.EnvVar{ - Name: "GOOGLE_APPLICATION_CREDENTIALS", - Value: credPath, - }) - } else { - // Standard providers: use API key from default credential key - envVars = append(envVars, corev1.EnvVar{ - Name: envVarBase + "_API_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: name}, - Key: utils.DefaultCredentialKey, - }, - }, - }) - } - - return nil - }) - - if err != nil { - return nil, err - } - - // Add PostgreSQL password environment variable - envVars = append(envVars, corev1.EnvVar{ - Name: "POSTGRES_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: utils.PostgresSecretName}, - Key: utils.OLSComponentPasswordFileName, - }, - }, - }) - - return envVars, nil -} - -// buildLightspeedStackEnvVars builds environment variables for the lightspeed-stack container -func buildLightspeedStackEnvVars(_ reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) []corev1.EnvVar { - envVars := []corev1.EnvVar{ - { - Name: "LOG_LEVEL", - Value: func() string { - if cr.Spec.OLSConfig.LogLevel != "" { - return string(cr.Spec.OLSConfig.LogLevel) - } - return string(olsv1alpha1.LogLevelInfo) - }(), - }, - // PostgreSQL password for database configuration - { - Name: "POSTGRES_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.PostgresSecretName, - }, - Key: utils.OLSComponentPasswordFileName, - }, - }, - }, - } - - return envVars -} - -// validateMCPHeaderSecret validates that a secret exists and has the required structure for MCP headers. -// This provides fail-fast validation consistent with AppServer's approach. -// -// Parameters: -// - r: Reconciler for K8s API access and logging -// - ctx: Context for the K8s API call -// - secretRef: Name of the secret to validate -// - serverName: Name of the MCP server (for error messages) -// - headerName: Name of the header (for error messages) -// -// Returns: -// - error if secret doesn't exist or has incorrect structure -func validateMCPHeaderSecret(r reconciler.Reconciler, ctx context.Context, secretRef, serverName, headerName string) error { - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Name: secretRef, Namespace: r.GetNamespace()}, secret) - if err != nil { - if apierrors.IsNotFound(err) { - r.GetLogger().Error(err, "MCP header secret not found", - "server", serverName, - "secret", secretRef, - "header", headerName) - return fmt.Errorf("MCP server %s header secret %s is not found", serverName, secretRef) - } - r.GetLogger().Error(err, "Failed to get MCP header secret", - "server", serverName, - "secret", secretRef, - "header", headerName) - return fmt.Errorf("failed to get secret %s for MCP server %s: %w", secretRef, serverName, err) - } - - // Validate secret has the required "header" key (consistent with AppServer) - if _, ok := secret.Data[utils.MCPSECRETDATAPATH]; !ok { - err := fmt.Errorf("secret missing required key '%s'", utils.MCPSECRETDATAPATH) - r.GetLogger().Error(err, "MCP header secret has incorrect structure", - "server", serverName, - "secret", secretRef, - "header", headerName, - "requiredKey", utils.MCPSECRETDATAPATH) - return fmt.Errorf("header secret %s for MCP server %s is missing key '%s'", secretRef, serverName, utils.MCPSECRETDATAPATH) - } - - return nil -} - -// ============================================================================ -// Helper functions for building common deployment components -// ============================================================================ - -// buildCommonLabels returns the standard labels for LCore deployments -func buildCommonLabels() map[string]string { - return map[string]string{ - "app": "lightspeed-stack", - "app.kubernetes.io/component": "application-server", - "app.kubernetes.io/managed-by": "lightspeed-operator", - "app.kubernetes.io/name": "lightspeed-service-api", - "app.kubernetes.io/part-of": "openshift-lightspeed", - } -} - -// buildConfigVolumes creates the base config volumes for LCore (both server and library modes need both configs) -// buildLCoreConfigVolumeAndMount creates both the volume and volume mount for lightspeed-stack config -func buildLCoreConfigVolumeAndMount(volumeDefaultMode *int32) (corev1.Volume, corev1.VolumeMount) { - volume := corev1.Volume{ - Name: "lightspeed-stack-config", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.LCoreConfigCmName, - }, - DefaultMode: volumeDefaultMode, - }, - }, - } - - volumeMount := corev1.VolumeMount{ - Name: "lightspeed-stack-config", - MountPath: utils.LCoreConfigMountPath, - SubPath: utils.LCoreConfigFilename, - ReadOnly: true, - } - - return volume, volumeMount -} - -// buildLlamaStackConfigVolumeAndMount creates both the volume and volume mount for llama-stack config -func buildLlamaStackConfigVolumeAndMount(volumeDefaultMode *int32) (corev1.Volume, corev1.VolumeMount) { - volume := corev1.Volume{ - Name: "llama-stack-config", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.LlamaStackConfigCmName, - }, - DefaultMode: volumeDefaultMode, - }, - }, - } - - volumeMount := corev1.VolumeMount{ - Name: "llama-stack-config", - MountPath: utils.LlamaStackConfigMountPath, - SubPath: utils.LlamaStackConfigFilename, - ReadOnly: true, - } - - return volume, volumeMount -} - -// addTLSVolumesAndMounts adds TLS certificate volumes and mounts if not using custom TLS -func addTLSVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, cr *olsv1alpha1.OLSConfig, volumeDefaultMode *int32) { - usesCustomTLS := cr.Spec.OLSConfig.TLSSecurityProfile != nil && string(cr.Spec.OLSConfig.TLSSecurityProfile.Type) == "Custom" - if !usesCustomTLS { - *volumes = append(*volumes, corev1.Volume{ - Name: "secret-lightspeed-tls", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: utils.OLSCertsSecretName, - DefaultMode: volumeDefaultMode, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: "secret-lightspeed-tls", - MountPath: path.Join(utils.OLSAppCertsMountRoot, "lightspeed-tls"), - ReadOnly: true, - }) - } -} - -// addOpenShiftCAVolumesAndMounts adds OpenShift service CA bundle volumes and mounts -func addOpenShiftCAVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, volumeDefaultMode *int32) { - *volumes = append(*volumes, corev1.Volume{ - Name: "openshift-service-ca", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.OLSCAConfigMap, - }, - DefaultMode: volumeDefaultMode, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: "openshift-service-ca", - MountPath: "/etc/certs/service-ca", - ReadOnly: true, - }) -} - -// addOpenShiftRootCAVolumesAndMounts adds OpenShift root CA (kube-root-ca.crt) volumes and mounts -func addOpenShiftRootCAVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, volumeDefaultMode *int32) { - *volumes = append(*volumes, corev1.Volume{ - Name: utils.OpenShiftCAVolumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "kube-root-ca.crt", - }, - DefaultMode: volumeDefaultMode, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: utils.OpenShiftCAVolumeName, - MountPath: "/etc/pki/ca-trust/extracted/pem", - ReadOnly: true, - }) -} - -// addLlamaCacheVolumesAndMounts adds llama-cache EmptyDir volume and mount for Llama Stack workspace -func addLlamaCacheVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount) { - *volumes = append(*volumes, corev1.Volume{ - Name: "llama-cache", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: "llama-cache", - MountPath: "/app-root/.llama", - ReadOnly: false, - }) -} - -// addPostgresCAVolumesAndMounts adds PostgreSQL CA ConfigMap volume and mount for TLS verification -func addPostgresCAVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, mountPath string) { - *volumes = append(*volumes, utils.GetPostgresCAConfigVolume()) - *volumeMounts = append(*volumeMounts, utils.GetPostgresCAVolumeMount(mountPath)) -} - -// addUserCAVolumesAndMounts adds user-provided CA certificate volumes and mounts -// Mounts at /etc/pki/ca-trust/source/anchors (system trust store path) -func addUserCAVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, cr *olsv1alpha1.OLSConfig, volumeDefaultMode *int32) { - _ = utils.ForEachExternalConfigMap(cr, func(name, source string) error { - if source != "additional-ca" { - return nil - } - - *volumes = append(*volumes, corev1.Volume{ - Name: utils.AdditionalCAVolumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: name, - }, - DefaultMode: volumeDefaultMode, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: utils.AdditionalCAVolumeName, - MountPath: "/etc/pki/ca-trust/source/anchors", - ReadOnly: true, - }) - return nil - }) -} - -// addProxyCACertVolumeAndMount adds the proxy CA ConfigMap volume and mount if a proxy CA is configured. -func addProxyCACertVolumeAndMount(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, cr *olsv1alpha1.OLSConfig, volumeDefaultMode *int32) { - if cr.Spec.OLSConfig.ProxyConfig == nil { - return - } - proxyCACertRef := cr.Spec.OLSConfig.ProxyConfig.ProxyCACertificateRef - cmName := utils.GetProxyCACertConfigMapName(proxyCACertRef) - if cmName == "" { - return - } - certKey := utils.GetProxyCACertKey(proxyCACertRef) - *volumes = append(*volumes, corev1.Volume{ - Name: utils.ProxyCACertVolumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, - DefaultMode: volumeDefaultMode, - Items: []corev1.KeyToPath{ - {Key: certKey, Path: certKey}, - }, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: utils.ProxyCACertVolumeName, - MountPath: path.Join(utils.OLSAppCertsMountRoot, utils.ProxyCACertVolumeName), - ReadOnly: true, - }) -} - -// addCustomTLSVolumesAndMounts adds user-provided custom TLS certificate volumes and mounts if specified -func addCustomTLSVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, cr *olsv1alpha1.OLSConfig, volumeDefaultMode *int32) { - if cr.Spec.OLSConfig.TLSConfig != nil && cr.Spec.OLSConfig.TLSConfig.KeyCertSecretRef.Name != "" { - *volumes = append(*volumes, corev1.Volume{ - Name: "secret-lightspeed-tls", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: cr.Spec.OLSConfig.TLSConfig.KeyCertSecretRef.Name, - DefaultMode: volumeDefaultMode, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: "secret-lightspeed-tls", - MountPath: path.Join(utils.OLSAppCertsMountRoot, "lightspeed-tls"), - ReadOnly: true, - }) - } -} - -// addMCPHeaderSecretVolumesAndMounts adds MCP header secret volumes and mounts for MCP servers -// This validates and mounts header secrets at /etc/mcp/headers/ -func addMCPHeaderSecretVolumesAndMounts(r reconciler.Reconciler, ctx context.Context, volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, cr *olsv1alpha1.OLSConfig, volumeDefaultMode *int32) error { - // Only add MCP header secrets if feature gate is enabled - if cr.Spec.FeatureGates == nil || !slices.Contains(cr.Spec.FeatureGates, utils.FeatureGateMCPServer) { - return nil - } - - // Mount MCP header secrets using the same pattern as appserver - err := utils.ForEachExternalSecret(cr, func(name, source string) error { - if strings.HasPrefix(source, "mcp-") { - // Validate secret exists and has correct structure - serverName := strings.TrimPrefix(source, "mcp-") - if err := validateMCPHeaderSecret(r, ctx, name, serverName, ""); err != nil { - return err - } - - *volumes = append(*volumes, corev1.Volume{ - Name: "header-" + name, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: name, - DefaultMode: volumeDefaultMode, - }, - }, - }) - - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: "header-" + name, - MountPath: path.Join(utils.MCPHeadersMountRoot, name), - ReadOnly: true, - }) - } - return nil - }) - - return err -} - -// addDataCollectorVolumesAndMounts adds volumes and mounts needed for data collection (feedback/transcripts) -// This creates a shared EmptyDir volume for OLS to write user data and the exporter to read it -func addDataCollectorVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, volumeDefaultMode *int32, dataCollectorEnabled bool) { - if !dataCollectorEnabled { - return - } - - // Shared EmptyDir volume for user data (feedback and transcripts) - *volumes = append(*volumes, corev1.Volume{ - Name: "ols-user-data", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }) - - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: "ols-user-data", - MountPath: utils.LCoreUserDataMountPath, - }) - - // Exporter config volume (ConfigMap) - *volumes = append(*volumes, corev1.Volume{ - Name: utils.ExporterConfigVolumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.ExporterConfigCmName, - }, - DefaultMode: volumeDefaultMode, - }, - }, - }) - - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: utils.ExporterConfigVolumeName, - MountPath: utils.ExporterConfigMountPath, - ReadOnly: true, - }) -} - -func addGoogleVertexVolumesAndMounts(volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, cr *olsv1alpha1.OLSConfig, volumeDefaultMode *int32) { - for _, provider := range cr.Spec.LLMConfig.Providers { - if provider.Type != utils.GoogleVertexType && provider.Type != utils.GoogleVertexAnthropicType { - continue - } - secretName := provider.CredentialsSecretRef.Name - if secretName == "" { - continue - } - volName := utils.CredentialsVolumeName(provider.Name) - mountPath := path.Join(utils.APIKeyMountRoot, utils.ProviderSubMountDir(provider.Name)) - *volumes = append(*volumes, corev1.Volume{ - Name: volName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: secretName, - DefaultMode: volumeDefaultMode, - }, - }, - }) - *volumeMounts = append(*volumeMounts, corev1.VolumeMount{ - Name: volName, - MountPath: mountPath, - ReadOnly: true, - }) - } -} - -// buildLightspeedStackLivenessProbe creates the liveness probe for lightspeed-stack container -func buildLightspeedStackLivenessProbe() *corev1.Probe { - return &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: []string{ - "sh", - "-c", - "curl -k --fail -H \"Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\" https://localhost:8443/liveness", - }, - }, - }, - InitialDelaySeconds: 20, - PeriodSeconds: 10, - TimeoutSeconds: 5, - FailureThreshold: 3, - } -} - -// buildLightspeedStackReadinessProbe creates the readiness probe for lightspeed-stack container -func buildLightspeedStackReadinessProbe() *corev1.Probe { - return &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: []string{ - "sh", - "-c", - "curl -k --fail -H \"Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\" https://localhost:8443/readiness", - }, - }, - }, - InitialDelaySeconds: 20, - PeriodSeconds: 10, - TimeoutSeconds: 5, - FailureThreshold: 3, - } -} - -// buildLightspeedStackContainer creates the base lightspeed-stack container -func buildLightspeedStackContainer(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig, volumeMounts []corev1.VolumeMount, envVars []corev1.EnvVar) corev1.Container { - lightspeedStackResources := getLightspeedStackResources(cr) - - return corev1.Container{ - Name: "lightspeed-service-api", - Image: r.GetLCoreImage(), - ImagePullPolicy: corev1.PullIfNotPresent, - Ports: []corev1.ContainerPort{ - { - ContainerPort: utils.OLSAppServerContainerPort, - Name: "https", - Protocol: corev1.ProtocolTCP, - }, - }, - Env: envVars, - VolumeMounts: volumeMounts, - Resources: *lightspeedStackResources, - LivenessProbe: buildLightspeedStackLivenessProbe(), - ReadinessProbe: buildLightspeedStackReadinessProbe(), - } -} - -// ============================================================================ -// Deployment generation functions -// ============================================================================ - -// GenerateLCoreDeployment generates the Deployment for LCore based on the server mode -func GenerateLCoreDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { - if r.GetLCoreServerMode() { - return generateLCoreServerDeployment(r, ctx, cr) - } - return generateLCoreLibraryDeployment(r, ctx, cr) -} - -// generateLCoreServerDeployment generates the Deployment for LCore in server mode (llama-stack + lightspeed-stack) -func generateLCoreServerDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { - revisionHistoryLimit := int32(1) - volumeDefaultMode := utils.VolumeDefaultMode - - // Check if data collector is enabled - dataCollectorEnabled, err := dataCollectorEnabled(r, ctx, cr) - if err != nil { - return nil, fmt.Errorf("failed to check data collector status: %w", err) - } - - llamaStackResources := getLlamaStackResources(cr) - lightspeedStackResources := getLightspeedStackResources(cr) - - // Get ResourceVersions for tracking - these resources should already exist - // If they don't exist (NotFound), we'll get empty strings which is fine for initial creation - // However, we should not ignore other errors (like API failures) - lcoreConfigMapResourceVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.LCoreConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get LCore ConfigMap resource version: %w", err) - } - llamaStackConfigMapResourceVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.LlamaStackConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get Llama Stack ConfigMap resource version: %w", err) - } - mcpConfigMapResourceVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.OpenShiftMCPServerConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get MCP Server ConfigMap resource version: %w", err) - } - proxyCACMResourceVersion, err := utils.GetProxyCACertHash(r, ctx, cr) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get Proxy CA certificate hash: %w", err) - } - - // Use helper functions to build common components - labels := buildCommonLabels() - - // Build config volumes and mounts using helper functions - llamaStackVolume, llamaStackConfigMount := buildLlamaStackConfigVolumeAndMount(&volumeDefaultMode) - lcoreVolume, lcoreConfigMount := buildLCoreConfigVolumeAndMount(&volumeDefaultMode) - - // Define volumes - volumes := []corev1.Volume{ - llamaStackVolume, - lcoreVolume, - } - - // Add PostgreSQL CA ConfigMap volume and mount (for TLS certificate verification) - var postgresCAMounts []corev1.VolumeMount - addPostgresCAVolumesAndMounts(&volumes, &postgresCAMounts, "/etc/certs/postgres-ca") - - // Add llama-cache EmptyDir for Llama Stack workspace - var llamaCacheMounts []corev1.VolumeMount - addLlamaCacheVolumesAndMounts(&volumes, &llamaCacheMounts) - - // Add TLS volumes and mounts (custom if provided, default otherwise) - var tlsVolumeMounts []corev1.VolumeMount - addCustomTLSVolumesAndMounts(&volumes, &tlsVolumeMounts, cr, &volumeDefaultMode) - if len(tlsVolumeMounts) == 0 { - // No custom TLS, add default service-ca TLS - addTLSVolumesAndMounts(&volumes, &tlsVolumeMounts, cr, &volumeDefaultMode) - } - - // Add OpenShift CA bundles (both service-ca and root CA) - var openShiftCAMounts []corev1.VolumeMount - addOpenShiftCAVolumesAndMounts(&volumes, &openShiftCAMounts, &volumeDefaultMode) - addOpenShiftRootCAVolumesAndMounts(&volumes, &openShiftCAMounts, &volumeDefaultMode) - - // llama-stack container volume mounts - llamaStackVolumeMounts := []corev1.VolumeMount{ - llamaStackConfigMount, - } - - // Add PostgreSQL CA mount to llama-stack container - llamaStackVolumeMounts = append(llamaStackVolumeMounts, postgresCAMounts...) - - // Add llama-cache mount to llama-stack container - llamaStackVolumeMounts = append(llamaStackVolumeMounts, llamaCacheMounts...) - - // Add OpenShift CA mounts to llama-stack container - llamaStackVolumeMounts = append(llamaStackVolumeMounts, openShiftCAMounts...) - - // Add user-provided CA certificates to llama-stack container - addUserCAVolumesAndMounts(&volumes, &llamaStackVolumeMounts, cr, &volumeDefaultMode) - - // Proxy CA ConfigMap volume and mount (for proxy certificate verification) - addProxyCACertVolumeAndMount(&volumes, &llamaStackVolumeMounts, cr, &volumeDefaultMode) - - // Build environment variables for LLM providers - llamaStackEnvVars, err := buildLlamaStackEnvVars(r, ctx, cr) - if err != nil { - return nil, fmt.Errorf("failed to build Llama Stack environment variables: %w", err) - } - - llamaStackContainer := corev1.Container{ - Name: "llama-stack", - Image: r.GetLCoreImage(), - ImagePullPolicy: corev1.PullAlways, - Ports: []corev1.ContainerPort{ - { - ContainerPort: 8321, - Name: "llama-stack", - Protocol: corev1.ProtocolTCP, - }, - }, - Env: llamaStackEnvVars, - VolumeMounts: llamaStackVolumeMounts, - Command: []string{"bash", "-c", ` - # Start llama stack in background - llama stack run run.yaml & - LLAMA_PID=$! - - # Wait for llama stack to be healthy - echo "Waiting for Llama Stack to start..." - max_attempts=60 - attempt=0 - until curl -f http://localhost:8321/v1/health 2>/dev/null; do - attempt=$((attempt + 1)) - if [ $attempt -ge $max_attempts ]; then - echo "Llama Stack failed to start within timeout" - exit 1 - fi - sleep 2 - done - echo "Llama Stack is healthy" - - # Warm up the embedding model (sentence-transformers) - # This pre-loads the model used for RAG/vector search - echo "Warming up embedding model (sentence-transformers)..." - EMBEDDING_MODEL=$(grep -A 5 "model_type: embedding" /app-root/run.yaml | grep "model_id:" | head -1 | sed 's/.*model_id: *//' | tr -d ' ') - if [ -n "$EMBEDDING_MODEL" ]; then - echo "Using embedding model: $EMBEDDING_MODEL" - curl -s -X POST http://localhost:8321/v1/inference/embeddings \ - -H "Content-Type: application/json" \ - -d "{\"model_id\": \"$EMBEDDING_MODEL\", \"contents\": [\"warmup\"]}" \ - > /dev/null 2>&1 && echo "Embedding warmup succeeded" || echo "Embedding warmup completed" - fi - - # Warm up the safety model by making a test inference call - # This forces Llama Guard to download and load into memory - echo "Warming up safety model (Llama Guard via LLM inference)..." - LLM_MODEL=$(grep -A 5 "model_type: llm" /app-root/run.yaml | grep "model_id:" | head -1 | sed 's/.*model_id: *//' | tr -d ' ') - if [ -n "$LLM_MODEL" ]; then - echo "Using LLM model: $LLM_MODEL" - curl -s -X POST http://localhost:8321/v1/inference/chat-completion \ - -H "Content-Type: application/json" \ - -d "{\"model_id\": \"$LLM_MODEL\", \"messages\": [{\"role\": \"user\", \"content\": \"test\"}], \"stream\": false}" \ - > /dev/null 2>&1 && echo "LLM warmup succeeded" || echo "LLM warmup completed" - else - echo "No LLM model found in config, skipping LLM warmup" - fi - echo "Warmup complete, ready to serve traffic" - - # Keep running in foreground - wait $LLAMA_PID - `}, - LivenessProbe: &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/v1/health", - Port: intstr.FromInt(8321), - Scheme: corev1.URISchemeHTTP, - }, - }, - InitialDelaySeconds: 60, // Increased to account for model download + warmup - PeriodSeconds: 10, - TimeoutSeconds: 5, - FailureThreshold: 3, - }, - ReadinessProbe: &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/v1/health", - Port: intstr.FromInt(8321), - Scheme: corev1.URISchemeHTTP, - }, - }, - InitialDelaySeconds: 60, // Increased to account for model download + warmup - PeriodSeconds: 10, - TimeoutSeconds: 5, - FailureThreshold: 3, - }, - Resources: *llamaStackResources, - } - - // lightspeed-stack container - lightspeedStackContainer := corev1.Container{ - Name: "lightspeed-stack", - Image: r.GetLCoreImage(), - ImagePullPolicy: corev1.PullAlways, - Ports: []corev1.ContainerPort{ - { - ContainerPort: utils.OLSAppServerContainerPort, - Name: "https", - Protocol: corev1.ProtocolTCP, - }, - }, - Env: buildLightspeedStackEnvVars(r, cr), - } - - // Build lightspeed-stack volume mounts - lightspeedStackVolumeMounts := []corev1.VolumeMount{ - lcoreConfigMount, - } - - // Add TLS volume mounts from external secrets - lightspeedStackVolumeMounts = append(lightspeedStackVolumeMounts, tlsVolumeMounts...) - - // PostgreSQL CA ConfigMap (service-ca.crt for OpenShift CA) - lightspeedStackVolumeMounts = append(lightspeedStackVolumeMounts, utils.GetPostgresCAVolumeMount(path.Join(utils.OLSAppCertsMountRoot, "postgres-ca"))) - - // Mount MCP server header secrets - only for HTTP-compatible servers - if err := addMCPHeaderSecretVolumesAndMounts(r, ctx, &volumes, &lightspeedStackVolumeMounts, cr, &volumeDefaultMode); err != nil { - return nil, err - } - - // Add data collector volumes and mounts if enabled - addDataCollectorVolumesAndMounts(&volumes, &lightspeedStackVolumeMounts, &volumeDefaultMode, dataCollectorEnabled) - - // Add Google Vertex volumes and mounts if enabled - addGoogleVertexVolumesAndMounts(&volumes, &lightspeedStackVolumeMounts, cr, &volumeDefaultMode) - - lightspeedStackContainer.VolumeMounts = lightspeedStackVolumeMounts - lightspeedStackContainer.LivenessProbe = buildLightspeedStackLivenessProbe() - lightspeedStackContainer.ReadinessProbe = buildLightspeedStackReadinessProbe() - lightspeedStackContainer.Resources = *lightspeedStackResources - - deployment := appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.LCoreDeploymentName, - Namespace: r.GetNamespace(), - Labels: labels, - Annotations: map[string]string{ - utils.LCoreConfigMapResourceVersionAnnotation: lcoreConfigMapResourceVersion, - utils.LlamaStackConfigMapResourceVersionAnnotation: llamaStackConfigMapResourceVersion, - utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation: mcpConfigMapResourceVersion, - utils.ProxyCACertHashAnnotation: proxyCACMResourceVersion, - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "lightspeed-stack", - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: labels, - }, - Spec: corev1.PodSpec{ - ServiceAccountName: utils.OLSAppServerServiceAccountName, - InitContainers: []corev1.Container{ - utils.GeneratePostgresWaitInitContainer(r.GetPostgresImage()), - }, - Containers: []corev1.Container{ - llamaStackContainer, - lightspeedStackContainer, - }, - Volumes: volumes, - }, - }, - RevisionHistoryLimit: &revisionHistoryLimit, - }, - } - - // Apply pod-level scheduling constraints (replicas configurable for lcore) - utils.ApplyPodDeploymentConfig(&deployment, cr.Spec.OLSConfig.DeploymentConfig.APIContainer, true) - - if len(cr.Spec.OLSConfig.RAG) > 0 { - if cr.Spec.OLSConfig.ImagePullSecrets != nil { - deployment.Spec.Template.Spec.ImagePullSecrets = cr.Spec.OLSConfig.ImagePullSecrets - } - } - - if err := controllerutil.SetControllerReference(cr, &deployment, r.GetScheme()); err != nil { - return nil, err - } - - // Add OpenShift MCP server sidecar container if introspection is enabled - addOpenShiftMCPServerSidecar(r, cr, &deployment) - - // Add data collector sidecar container if data collection is enabled - addDataCollectorSidecar(r, cr, &deployment, lightspeedStackVolumeMounts, dataCollectorEnabled) - - return &deployment, nil -} - -// RestartLCore triggers a rolling restart of the LCore deployment by updating its pod template annotation. -// This is useful when configuration changes require a pod restart (e.g., ConfigMap or Secret updates). -func RestartLCore(r reconciler.Reconciler, ctx context.Context, deployment ...*appsv1.Deployment) error { - var dep *appsv1.Deployment - var err error - - // If deployment is provided, use it; otherwise fetch it - if len(deployment) > 0 && deployment[0] != nil { - dep = deployment[0] - } else { - // Get the LCore deployment - dep = &appsv1.Deployment{} - err = r.Get(ctx, client.ObjectKey{Name: utils.LCoreDeploymentName, Namespace: r.GetNamespace()}, dep) - if err != nil { - r.GetLogger().Info("failed to get deployment", "deploymentName", utils.LCoreDeploymentName, "error", err) - return err - } - } - - // Initialize annotations map if empty - if dep.Spec.Template.Annotations == nil { - dep.Spec.Template.Annotations = make(map[string]string) - } - - // Bump the annotation to trigger a rolling update (new template hash) - dep.Spec.Template.Annotations[utils.ForceReloadAnnotationKey] = time.Now().Format(time.RFC3339Nano) - - // Update the deployment - r.GetLogger().Info("triggering LCore rolling restart", "deployment", dep.Name) - err = r.Update(ctx, dep) - if err != nil { - r.GetLogger().Info("failed to update deployment", "deploymentName", dep.Name, "error", err) - return err - } - - return nil -} - -// updateLCoreDeployment updates the LCore deployment based on CustomResource configuration -func updateLCoreDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig, existingDeployment, desiredDeployment *appsv1.Deployment) error { - // Step 1: Check if deployment spec has changed - utils.SetDefaults_Deployment(desiredDeployment) - changed := !utils.DeploymentSpecEqual(&existingDeployment.Spec, &desiredDeployment.Spec, true) - - // Step 2: Check ConfigMap ResourceVersions - // Check if LCore ConfigMap ResourceVersion has changed - currentLCoreConfigMapVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.LCoreConfigCmName) - if err != nil { - r.GetLogger().Info("failed to get LCore ConfigMap ResourceVersion", "error", err) - changed = true - } else { - storedLCoreConfigMapVersion := existingDeployment.Annotations[utils.LCoreConfigMapResourceVersionAnnotation] - if storedLCoreConfigMapVersion != currentLCoreConfigMapVersion { - changed = true - } - } - - // Check if Llama Stack ConfigMap ResourceVersion has changed - currentLlamaStackConfigMapVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.LlamaStackConfigCmName) - if err != nil { - r.GetLogger().Info("failed to get Llama Stack ConfigMap ResourceVersion", "error", err) - changed = true - } else { - storedLlamaStackConfigMapVersion := existingDeployment.Annotations[utils.LlamaStackConfigMapResourceVersionAnnotation] - if storedLlamaStackConfigMapVersion != currentLlamaStackConfigMapVersion { - changed = true - } - } - - // Check if MCP Server ConfigMap ResourceVersion has changed - currentMCPConfigMapVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.OpenShiftMCPServerConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - r.GetLogger().Info("failed to get MCP Server ConfigMap ResourceVersion", "error", err) - changed = true - } else { - storedMCPConfigMapVersion := existingDeployment.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - if storedMCPConfigMapVersion != currentMCPConfigMapVersion { - r.GetLogger().Info("MCP Server ConfigMap changed, updating deployment") - changed = true - } - } - - // Check if Proxy CA certificate content has changed - currentProxyCACMHash, err := utils.GetProxyCACertHash(r, ctx, cr) - if err != nil && !apierrors.IsNotFound(err) { - r.GetLogger().Info("failed to get Proxy CA certificate hash", "error", err) - changed = true - } else { - storedProxyCACMHash := existingDeployment.Annotations[utils.ProxyCACertHashAnnotation] - if storedProxyCACMHash != currentProxyCACMHash { - r.GetLogger().Info("Proxy CA certificate content changed, updating deployment") - changed = true - } - } - - // If nothing changed, skip update - if !changed { - return nil - } - - // Apply changes - always update spec and annotations since something changed - existingDeployment.Spec = desiredDeployment.Spec - - // Initialize annotations if nil - if existingDeployment.Annotations == nil { - existingDeployment.Annotations = make(map[string]string) - } - - existingDeployment.Annotations[utils.LCoreConfigMapResourceVersionAnnotation] = desiredDeployment.Annotations[utils.LCoreConfigMapResourceVersionAnnotation] - existingDeployment.Annotations[utils.LlamaStackConfigMapResourceVersionAnnotation] = desiredDeployment.Annotations[utils.LlamaStackConfigMapResourceVersionAnnotation] - existingDeployment.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] = desiredDeployment.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - existingDeployment.Annotations[utils.ProxyCACertHashAnnotation] = currentProxyCACMHash - - r.GetLogger().Info("updating LCore deployment", "name", existingDeployment.Name) - - if err := RestartLCore(r, ctx, existingDeployment); err != nil { - return err - } - - return nil -} - -// generateLCoreLibraryDeployment generates the Deployment for LCore in library mode (single container) -func generateLCoreLibraryDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { - revisionHistoryLimit := int32(1) - volumeDefaultMode := utils.VolumeDefaultMode - - // Check if data collector is enabled - dataCollectorEnabled, err := dataCollectorEnabled(r, ctx, cr) - if err != nil { - return nil, fmt.Errorf("failed to check data collector status: %w", err) - } - - // Get ResourceVersions for tracking - these resources should already exist - // If they don't exist (NotFound), we'll get empty strings which is fine for initial creation - // However, we should not ignore other errors (like API failures) - lcoreConfigMapResourceVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.LCoreConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get LCore ConfigMap resource version: %w", err) - } - llamaStackConfigMapResourceVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.LlamaStackConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get Llama Stack ConfigMap resource version: %w", err) - } - mcpConfigMapResourceVersion, err := utils.GetConfigMapResourceVersion(r, ctx, utils.OpenShiftMCPServerConfigCmName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get MCP Server ConfigMap resource version: %w", err) - } - proxyCACMResourceVersion, err := utils.GetProxyCACertHash(r, ctx, cr) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get Proxy CA certificate hash: %w", err) - } - - // Use helper functions to build common components - labels := buildCommonLabels() - - // Build config volumes and mounts using helper functions - llamaStackVolume, llamaStackConfigMount := buildLlamaStackConfigVolumeAndMount(&volumeDefaultMode) - lcoreVolume, lcoreConfigMount := buildLCoreConfigVolumeAndMount(&volumeDefaultMode) - - // Library mode needs both volumes - volumes := []corev1.Volume{ - llamaStackVolume, - lcoreVolume, - } - - // Library mode container needs both config mounts - volumeMounts := []corev1.VolumeMount{ - llamaStackConfigMount, - lcoreConfigMount, - } - - // Add llama-cache EmptyDir for Llama Stack workspace - addLlamaCacheVolumesAndMounts(&volumes, &volumeMounts) - - // Add TLS volumes and mounts (custom if provided, default otherwise) - var tlsVolumeMounts []corev1.VolumeMount - addCustomTLSVolumesAndMounts(&volumes, &tlsVolumeMounts, cr, &volumeDefaultMode) - if len(tlsVolumeMounts) == 0 { - // No custom TLS, add default service-ca TLS - addTLSVolumesAndMounts(&volumes, &tlsVolumeMounts, cr, &volumeDefaultMode) - } - volumeMounts = append(volumeMounts, tlsVolumeMounts...) - - // Add OpenShift CA bundles (both service-ca and root CA) - addOpenShiftCAVolumesAndMounts(&volumes, &volumeMounts, &volumeDefaultMode) - addOpenShiftRootCAVolumesAndMounts(&volumes, &volumeMounts, &volumeDefaultMode) - - // Add PostgreSQL CA ConfigMap (for database TLS verification) - addPostgresCAVolumesAndMounts(&volumes, &volumeMounts, "/etc/certs/postgres-ca") - - // Add user CA certificates - addUserCAVolumesAndMounts(&volumes, &volumeMounts, cr, &volumeDefaultMode) - - // Proxy CA ConfigMap volume and mount (for proxy certificate verification) - addProxyCACertVolumeAndMount(&volumes, &volumeMounts, cr, &volumeDefaultMode) - - // Add MCP header secrets for HTTP MCP servers - if err := addMCPHeaderSecretVolumesAndMounts(r, ctx, &volumes, &volumeMounts, cr, &volumeDefaultMode); err != nil { - return nil, err - } - - // Add data collector volumes and mounts if enabled - addDataCollectorVolumesAndMounts(&volumes, &volumeMounts, &volumeDefaultMode, dataCollectorEnabled) - - // Build environment variables for library mode - // Library mode needs env vars from both llama-stack and lightspeed-stack - llamaStackEnvVars, err := buildLlamaStackEnvVars(r, ctx, cr) - if err != nil { - return nil, fmt.Errorf("failed to build llama-stack env vars: %w", err) - } - lightspeedStackEnvVars := buildLightspeedStackEnvVars(r, cr) - - // Combine env vars (llama-stack + lightspeed-stack) - combinedEnvVars := append(llamaStackEnvVars, lightspeedStackEnvVars...) - - // Create the lightspeed-stack container using helper - lightspeedStackContainer := buildLightspeedStackContainer(r, cr, volumeMounts, combinedEnvVars) - - deployment := appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.LCoreDeploymentName, - Namespace: r.GetNamespace(), - Labels: labels, - Annotations: map[string]string{ - utils.LCoreConfigMapResourceVersionAnnotation: lcoreConfigMapResourceVersion, - utils.LlamaStackConfigMapResourceVersionAnnotation: llamaStackConfigMapResourceVersion, - utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation: mcpConfigMapResourceVersion, - utils.ProxyCACertHashAnnotation: proxyCACMResourceVersion, - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "lightspeed-stack", - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: labels, - }, - Spec: corev1.PodSpec{ - ServiceAccountName: utils.OLSAppServerServiceAccountName, - Containers: []corev1.Container{ - lightspeedStackContainer, - }, - Volumes: volumes, - }, - }, - RevisionHistoryLimit: &revisionHistoryLimit, - }, - } - - // Apply pod-level scheduling constraints - utils.ApplyPodDeploymentConfig(&deployment, cr.Spec.OLSConfig.DeploymentConfig.APIContainer, true) - - if len(cr.Spec.OLSConfig.RAG) > 0 { - if cr.Spec.OLSConfig.ImagePullSecrets != nil { - deployment.Spec.Template.Spec.ImagePullSecrets = cr.Spec.OLSConfig.ImagePullSecrets - } - } - - if err := controllerutil.SetControllerReference(cr, &deployment, r.GetScheme()); err != nil { - return nil, err - } - - // Add OpenShift MCP server sidecar container if introspection is enabled - addOpenShiftMCPServerSidecar(r, cr, &deployment) - - // Add data collector sidecar container if data collection is enabled - addDataCollectorSidecar(r, cr, &deployment, volumeMounts, dataCollectorEnabled) - - return &deployment, nil -} - -func dataCollectorEnabled(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (bool, error) { - // Data collector is enabled when: - // 1. User data collection is enabled in OLS configuration (feedback OR transcripts) - // 2. AND telemetry is enabled (pull secret contains cloud.openshift.com auth) - - // Check if data collection is enabled in CR - configEnabled := !cr.Spec.OLSConfig.UserDataCollection.FeedbackDisabled || !cr.Spec.OLSConfig.UserDataCollection.TranscriptsDisabled - if !configEnabled { - return false, nil - } - - // Check if telemetry is enabled - // Telemetry enablement is determined by the presence of the telemetry pull secret - // the presence of the field '.auths."cloud.openshift.com"' indicates that telemetry is enabled - // use this command to check in an Openshift cluster: - // oc get secret/pull-secret -n openshift-config --template='{{index .data ".dockerconfigjson" | base64decode}}' | jq '.auths."cloud.openshift.com"' - pullSecret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Namespace: utils.TelemetryPullSecretNamespace, Name: utils.TelemetryPullSecretName}, pullSecret) - - if err != nil { - if apierrors.IsNotFound(err) { - return false, nil - } - return false, err - } - - dockerconfigjson, ok := pullSecret.Data[".dockerconfigjson"] - if !ok { - return false, fmt.Errorf("pull secret does not contain .dockerconfigjson") - } - - dockerconfigjsonDecoded := map[string]interface{}{} - err = json.Unmarshal(dockerconfigjson, &dockerconfigjsonDecoded) - if err != nil { - return false, err - } - - _, telemetryEnabled := dockerconfigjsonDecoded["auths"].(map[string]interface{})["cloud.openshift.com"] - return telemetryEnabled, nil -} diff --git a/internal/controller/lcore/deployment_test.go b/internal/controller/lcore/deployment_test.go deleted file mode 100644 index 715154706..000000000 --- a/internal/controller/lcore/deployment_test.go +++ /dev/null @@ -1,1615 +0,0 @@ -package lcore - -import ( - "context" - "fmt" - "reflect" - "strings" - "testing" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" -) - -// mockReconciler is a minimal mock for testing deployment generation -type mockReconciler struct { - reconciler.Reconciler - namespace string - scheme *runtime.Scheme - image string - lcoreServerMode bool -} - -func (m *mockReconciler) GetNamespace() string { - if m.namespace == "" { - return utils.OLSNamespaceDefault - } - return m.namespace -} - -func (m *mockReconciler) GetScheme() *runtime.Scheme { - if m.scheme == nil { - scheme := runtime.NewScheme() - _ = olsv1alpha1.AddToScheme(scheme) - _ = appsv1.AddToScheme(scheme) - _ = corev1.AddToScheme(scheme) - return scheme - } - return m.scheme -} - -func (m *mockReconciler) GetAppServerImage() string { - if m.image == "" { - return utils.LlamaStackImageDefault - } - return m.image -} - -func (m *mockReconciler) GetLCoreImage() string { - if m.image == "" { - return utils.LlamaStackImageDefault - } - return m.image -} - -func (m *mockReconciler) GetOpenShiftMCPServerImage() string { - return utils.OpenShiftMCPServerImageDefault -} - -func (m *mockReconciler) GetPostgresImage() string { - return utils.PostgresServerImageDefault -} - -func (m *mockReconciler) GetLCoreServerMode() bool { - return m.lcoreServerMode -} - -func (m *mockReconciler) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - // Return NotFound error for all Get calls in tests - // This simulates the ConfigMaps not existing yet during deployment generation - return errors.NewNotFound(schema.GroupResource{}, key.Name) -} - -func TestGenerateLCoreDeployment(t *testing.T) { - // Create a minimal OLSConfig CR - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - }, - } - - // Create a mock reconciler - r := &mockReconciler{ - lcoreServerMode: true, // Test server mode (2 containers) - } - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - // Verify basic metadata - if deployment.Name != utils.LCoreDeploymentName { - t.Errorf("Expected deployment name '%s', got '%s'", utils.LCoreDeploymentName, deployment.Name) - } - if deployment.Namespace != utils.OLSNamespaceDefault { - t.Errorf("Expected namespace '%s', got '%s'", utils.OLSNamespaceDefault, deployment.Namespace) - } - - // Verify labels - expectedLabels := map[string]string{ - "app": utils.LCoreAppLabel, - "app.kubernetes.io/component": "application-server", - "app.kubernetes.io/managed-by": "lightspeed-operator", - "app.kubernetes.io/name": "lightspeed-service-api", - "app.kubernetes.io/part-of": "openshift-lightspeed", - } - for key, expectedValue := range expectedLabels { - if actualValue, ok := deployment.Labels[key]; !ok { - t.Errorf("Missing label '%s'", key) - } else if actualValue != expectedValue { - t.Errorf("Label '%s': expected '%s', got '%s'", key, expectedValue, actualValue) - } - } - - // Verify replicas - if deployment.Spec.Replicas == nil { - t.Error("Replicas is nil") - } else if *deployment.Spec.Replicas != 1 { - t.Errorf("Expected 1 replica, got %d", *deployment.Spec.Replicas) - } - - // Verify selector - if deployment.Spec.Selector == nil { - t.Fatal("Selector is nil") - } - if appLabel, ok := deployment.Spec.Selector.MatchLabels["app"]; !ok || appLabel != utils.LCoreAppLabel { - t.Errorf("Expected selector matchLabel 'app: %s', got %v", utils.LCoreAppLabel, deployment.Spec.Selector.MatchLabels) - } - - // Verify service account - if deployment.Spec.Template.Spec.ServiceAccountName != utils.OLSAppServerServiceAccountName { - t.Errorf("Expected ServiceAccountName '%s', got '%s'", - utils.OLSAppServerServiceAccountName, - deployment.Spec.Template.Spec.ServiceAccountName) - } - - // Verify containers - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 2 { - t.Fatalf("Expected 2 containers, got %d", len(containers)) - } - - // Verify llama-stack container - llamaStackContainer := containers[0] - if llamaStackContainer.Name != utils.LlamaStackContainerName { - t.Errorf("Expected first container name '%s', got '%s'", utils.LlamaStackContainerName, llamaStackContainer.Name) - } - if len(llamaStackContainer.Ports) != 1 || llamaStackContainer.Ports[0].ContainerPort != utils.LlamaStackContainerPort { - t.Errorf("Expected llama-stack container port %d, got %v", utils.LlamaStackContainerPort, llamaStackContainer.Ports) - } - // Verify env vars are generated for all providers + POSTGRES_PASSWORD - expectedEnvVars := len(cr.Spec.LLMConfig.Providers) + 1 // +1 for POSTGRES_PASSWORD - if len(llamaStackContainer.Env) != expectedEnvVars { - t.Errorf("Expected %d env vars (one per provider + POSTGRES_PASSWORD), got %d", expectedEnvVars, len(llamaStackContainer.Env)) - } - // Check first provider's env var - if len(llamaStackContainer.Env) > 0 { - // Expected env var name using the ProviderNameToEnvVarName helper - expectedEnvVarName := utils.ProviderNameToEnvVarName(cr.Spec.LLMConfig.Providers[0].Name) + "_API_KEY" - if llamaStackContainer.Env[0].Name != expectedEnvVarName { - t.Errorf("Expected env var '%s', got '%s'", expectedEnvVarName, llamaStackContainer.Env[0].Name) - } - if llamaStackContainer.Env[0].ValueFrom == nil || llamaStackContainer.Env[0].ValueFrom.SecretKeyRef == nil { - t.Error("Expected env var to reference a secret") - } else if llamaStackContainer.Env[0].ValueFrom.SecretKeyRef.Name != cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name { - t.Errorf("Expected secret ref '%s', got '%s'", - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name, - llamaStackContainer.Env[0].ValueFrom.SecretKeyRef.Name) - } - } - if llamaStackContainer.LivenessProbe == nil || llamaStackContainer.LivenessProbe.HTTPGet == nil { - t.Error("llama-stack container missing liveness probe") - } else if llamaStackContainer.LivenessProbe.HTTPGet.Path != utils.LlamaStackHealthPath { - t.Errorf("Expected liveness probe path '%s', got '%s'", - utils.LlamaStackHealthPath, llamaStackContainer.LivenessProbe.HTTPGet.Path) - } - if llamaStackContainer.ReadinessProbe == nil || llamaStackContainer.ReadinessProbe.HTTPGet == nil { - t.Error("llama-stack container missing readiness probe") - } - - // Verify lightspeed-stack container - lightspeedStackContainer := containers[1] - if lightspeedStackContainer.Name != utils.LCoreContainerName { - t.Errorf("Expected second container name '%s', got '%s'", utils.LCoreContainerName, lightspeedStackContainer.Name) - } - if len(lightspeedStackContainer.Ports) != 1 || lightspeedStackContainer.Ports[0].ContainerPort != utils.OLSAppServerContainerPort { - t.Errorf("Expected lightspeed-stack container port %d, got %v", - utils.OLSAppServerContainerPort, lightspeedStackContainer.Ports) - } - if lightspeedStackContainer.LivenessProbe == nil || lightspeedStackContainer.LivenessProbe.Exec == nil { - t.Error("lightspeed-stack container missing liveness probe") - } - if lightspeedStackContainer.ReadinessProbe == nil || lightspeedStackContainer.ReadinessProbe.Exec == nil { - t.Error("lightspeed-stack container missing readiness probe") - } - - // Verify volumes - volumes := deployment.Spec.Template.Spec.Volumes - expectedVolumes := map[string]bool{ - utils.LlamaStackConfigCmName: false, - utils.LCoreConfigCmName: false, - utils.LlamaCacheVolumeName: false, - "secret-lightspeed-tls": false, - } - for _, vol := range volumes { - if _, expected := expectedVolumes[vol.Name]; expected { - expectedVolumes[vol.Name] = true - } - } - for volName, found := range expectedVolumes { - if !found { - t.Errorf("Missing expected volume: %s", volName) - } - } - - // Verify volume mounts in llama-stack container - llamaStackMounts := llamaStackContainer.VolumeMounts - if len(llamaStackMounts) < 3 { - t.Errorf("Expected at least 3 volume mounts in llama-stack container (config, cache, CA), got %d", len(llamaStackMounts)) - } - llamaStackMountNames := make(map[string]bool) - for _, mount := range llamaStackMounts { - llamaStackMountNames[mount.Name] = true - } - if !llamaStackMountNames[utils.LlamaStackConfigCmName] { - t.Errorf("Missing '%s' volume mount in llama-stack container", utils.LlamaStackConfigCmName) - } - if !llamaStackMountNames[utils.LlamaCacheVolumeName] { - t.Errorf("Missing '%s' volume mount in llama-stack container", utils.LlamaCacheVolumeName) - } - if !llamaStackMountNames[utils.OpenShiftCAVolumeName] { - t.Errorf("Missing '%s' volume mount in llama-stack container", utils.OpenShiftCAVolumeName) - } - - // Verify volume mounts in lightspeed-stack container - lightspeedStackMounts := lightspeedStackContainer.VolumeMounts - if len(lightspeedStackMounts) != 3 { - t.Errorf("Expected 3 volume mounts in lightspeed-stack container, got %d", len(lightspeedStackMounts)) - } - lightspeedStackMountNames := make(map[string]bool) - for _, mount := range lightspeedStackMounts { - lightspeedStackMountNames[mount.Name] = true - } - if !lightspeedStackMountNames[utils.LCoreConfigCmName] { - t.Errorf("Missing '%s' volume mount in lightspeed-stack container", utils.LCoreConfigCmName) - } - if !lightspeedStackMountNames["secret-lightspeed-tls"] { - t.Error("Missing 'secret-lightspeed-tls' volume mount in lightspeed-stack container") - } - if !lightspeedStackMountNames[utils.PostgresCAVolume] { - t.Errorf("Missing '%s' volume mount in lightspeed-stack container", utils.PostgresCAVolume) - } - - // Verify that deployment can be marshaled to YAML (valid k8s object) - yamlBytes, err := yaml.Marshal(deployment) - if err != nil { - t.Fatalf("Failed to marshal deployment to YAML: %v", err) - } - - // Verify we can unmarshal it back - var unmarshaledDeployment appsv1.Deployment - err = yaml.Unmarshal(yamlBytes, &unmarshaledDeployment) - if err != nil { - t.Fatalf("Failed to unmarshal deployment YAML: %v", err) - } - - t.Logf("Successfully validated LCore Deployment (%d bytes of YAML)", len(yamlBytes)) -} - -func TestGenerateLCoreDeploymentWithAdditionalCA(t *testing.T) { - // Create an OLSConfig CR with additionalCAConfigMapRef - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - AdditionalCAConfigMapRef: &corev1.LocalObjectReference{ - Name: "custom-ca-bundle", - }, - }, - }, - } - - // Create a mock reconciler - r := &mockReconciler{ - lcoreServerMode: true, // Test server mode (2 containers) - } - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - // Verify volumes include both kube-root-ca and additional CA - volumes := deployment.Spec.Template.Spec.Volumes - volumeNames := make(map[string]bool) - for _, vol := range volumes { - volumeNames[vol.Name] = true - } - - // Should have kube-root-ca.crt - if !volumeNames[utils.OpenShiftCAVolumeName] { - t.Error("Missing 'kube-root-ca' volume") - } - - // Should have additional CA volume - if !volumeNames[utils.AdditionalCAVolumeName] { - t.Error("Missing 'additional-ca' volume") - } - - // Verify the additional CA volume is properly configured - var additionalCAVolume *corev1.Volume - for _, vol := range volumes { - if vol.Name == utils.AdditionalCAVolumeName { - additionalCAVolume = &vol - break - } - } - if additionalCAVolume == nil { - t.Fatal("Additional CA volume not found") - } - if additionalCAVolume.ConfigMap == nil { - t.Fatal("Additional CA volume is not a ConfigMap") - } - if additionalCAVolume.ConfigMap.Name != "custom-ca-bundle" { - t.Errorf("Expected ConfigMap name 'custom-ca-bundle', got '%s'", additionalCAVolume.ConfigMap.Name) - } - - // Verify llama-stack container has the additional CA volume mount - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 2 { - t.Fatalf("Expected 2 containers, got %d", len(containers)) - } - - llamaStackContainer := containers[0] - if llamaStackContainer.Name != utils.LlamaStackContainerName { - t.Fatalf("Expected first container to be '%s', got '%s'", utils.LlamaStackContainerName, llamaStackContainer.Name) - } - - // Verify volume mounts include both kube-root-ca and additional CA - volumeMounts := llamaStackContainer.VolumeMounts - if len(volumeMounts) < 4 { - t.Errorf("Expected at least 4 volume mounts (config, cache, kube-root-ca, additional-ca), got %d", len(volumeMounts)) - } - - volumeMountNames := make(map[string]string) - for _, mount := range volumeMounts { - volumeMountNames[mount.Name] = mount.MountPath - } - - // Verify kube-root-ca mount - if mountPath, ok := volumeMountNames[utils.OpenShiftCAVolumeName]; !ok { - t.Errorf("Missing '%s' volume mount in llama-stack container", utils.OpenShiftCAVolumeName) - } else if mountPath != utils.KubeRootCAMountPath { - t.Errorf("Expected kube-root-ca mount path '%s', got '%s'", utils.KubeRootCAMountPath, mountPath) - } - - // Verify additional CA mount - if mountPath, ok := volumeMountNames[utils.AdditionalCAVolumeName]; !ok { - t.Errorf("Missing '%s' volume mount in llama-stack container", utils.AdditionalCAVolumeName) - } else if mountPath != utils.AdditionalCAMountPath { - t.Errorf("Expected additional-ca mount path '%s', got '%s'", utils.AdditionalCAMountPath, mountPath) - } - - // Verify all mounts are read-only - for _, mount := range volumeMounts { - if mount.Name == utils.OpenShiftCAVolumeName || mount.Name == utils.AdditionalCAVolumeName { - if !mount.ReadOnly { - t.Errorf("CA volume mount '%s' should be read-only", mount.Name) - } - } - } - - t.Logf("Successfully validated LCore Deployment with Additional CA") -} - -func TestGenerateLCoreDeploymentWithIntrospection(t *testing.T) { - // Create an OLSConfig CR with introspection enabled - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(true), - }, - }, - } - - // Create a mock reconciler with OpenShift MCP server image - r := &mockReconciler{ - lcoreServerMode: true, // Test server mode (2 containers + MCP sidecar) - } - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - // Verify containers - should have 3: llama-stack, lightspeed-stack, openshift-mcp-server - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 3 { - t.Fatalf("Expected 3 containers (llama-stack, lightspeed-stack, openshift-mcp-server), got %d", len(containers)) - } - - // Find the OpenShift MCP server container - var openshiftMCPContainer *corev1.Container - for i := range containers { - if containers[i].Name == utils.OpenShiftMCPServerContainerName { - openshiftMCPContainer = &containers[i] - break - } - } - - if openshiftMCPContainer == nil { - t.Fatal("OpenShift MCP server container not found in deployment") - } - - // Verify container configuration - if openshiftMCPContainer.Image == "" { - t.Error("OpenShift MCP server container has empty image") - } - - if openshiftMCPContainer.ImagePullPolicy != corev1.PullIfNotPresent { - t.Errorf("Expected ImagePullPolicy PullIfNotPresent, got %v", openshiftMCPContainer.ImagePullPolicy) - } - - // Verify command includes port and config flags - if len(openshiftMCPContainer.Command) == 0 { - t.Error("OpenShift MCP server container has no command") - } else { - commandStr := strings.Join(openshiftMCPContainer.Command, " ") - expectedPort := fmt.Sprintf("%d", utils.OpenShiftMCPServerPort) - if !strings.Contains(commandStr, "--port") || !strings.Contains(commandStr, expectedPort) { - t.Errorf("Expected command to include '--port %s', got: %s", expectedPort, commandStr) - } - if !strings.Contains(commandStr, "--config") || !strings.Contains(commandStr, utils.GetOpenShiftMCPServerConfigPath()) { - t.Errorf("Expected command to include '--config %s', got: %s", utils.GetOpenShiftMCPServerConfigPath(), commandStr) - } - } - - // Verify MCP server config volume mount - if len(openshiftMCPContainer.VolumeMounts) == 0 { - t.Error("OpenShift MCP server container has no volume mounts") - } else { - hasMCPConfigMount := false - for _, mount := range openshiftMCPContainer.VolumeMounts { - if mount.Name == utils.OpenShiftMCPServerConfigVolumeName { - hasMCPConfigMount = true - if !mount.ReadOnly { - t.Error("MCP server config volume mount should be read-only") - } - } - } - if !hasMCPConfigMount { - t.Error("Missing MCP server config volume mount in openshift-mcp-server container") - } - } - - // Verify MCP server config volume is added to deployment - volumes := deployment.Spec.Template.Spec.Volumes - hasMCPConfigVolume := false - for _, vol := range volumes { - if vol.Name == utils.OpenShiftMCPServerConfigVolumeName { - hasMCPConfigVolume = true - if vol.ConfigMap == nil || vol.ConfigMap.Name != utils.OpenShiftMCPServerConfigCmName { - t.Errorf("MCP server config volume should reference ConfigMap '%s'", utils.OpenShiftMCPServerConfigCmName) - } - } - } - if !hasMCPConfigVolume { - t.Error("Missing MCP server config volume in deployment") - } - - // Verify security context matches the restricted profile - expectedSC := utils.RestrictedContainerSecurityContext() - if !reflect.DeepEqual(openshiftMCPContainer.SecurityContext, expectedSC) { - t.Errorf("Expected restricted security context, got %+v", openshiftMCPContainer.SecurityContext) - } - - // Verify resource requirements are set - if openshiftMCPContainer.Resources.Limits == nil || openshiftMCPContainer.Resources.Requests == nil { - t.Error("OpenShift MCP server container missing resource limits or requests") - } - - t.Logf("Successfully validated LCore Deployment with OpenShift MCP server sidecar") -} - -func TestGenerateLCoreDeploymentWithMCPHeaderSecrets(t *testing.T) { - // Create an OLSConfig CR with MCP servers that use KUBERNETES_PLACEHOLDER only - // (secrets will be validated in integration/e2e tests with real secrets) - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - FeatureGates: []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer}, - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - MCPServers: []olsv1alpha1.MCPServerConfig{ - { - Name: "external-mcp-kubernetes-auth", - URL: "http://external1.example.com", - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Authorization", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - }, - }, - { - Name: "external-mcp-mixed-auth", - URL: "https://external2.example.com", - Headers: []olsv1alpha1.MCPHeader{ - { - Name: "Authorization", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - { - Name: "X-Custom", - ValueFrom: olsv1alpha1.MCPHeaderValueSource{ - Type: olsv1alpha1.MCPHeaderSourceTypeKubernetes, - }, - }, - }, - }, - }, - }, - } - - // Create a mock reconciler - r := &mockReconciler{ - lcoreServerMode: true, // Test server mode (2 containers) - } - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - // Verify volumes - should NOT have any MCP header secret volumes - // since we only use KUBERNETES_PLACEHOLDER - volumes := deployment.Spec.Template.Spec.Volumes - volumeNames := make(map[string]bool) - for _, vol := range volumes { - volumeNames[vol.Name] = true - } - - // Should NOT have header secret volumes (only using KUBERNETES_PLACEHOLDER) - if volumeNames["header-mcp-auth-secret-1"] { - t.Error("Should not have volume for KUBERNETES_PLACEHOLDER") - } - if volumeNames["header-"+utils.KUBERNETES_PLACEHOLDER] { - t.Errorf("KUBERNETES_PLACEHOLDER should not create a volume, but found: header-%s", utils.KUBERNETES_PLACEHOLDER) - } - - // Verify lightspeed-stack container has NO MCP header volume mounts - containers := deployment.Spec.Template.Spec.Containers - var lightspeedStackContainer *corev1.Container - for i := range containers { - if containers[i].Name == utils.LCoreContainerName { - lightspeedStackContainer = &containers[i] - break - } - } - - if lightspeedStackContainer == nil { - t.Fatal("lightspeed-stack container not found") - } - - // Check that no MCP header mounts exist (all use KUBERNETES_PLACEHOLDER) - for _, mount := range lightspeedStackContainer.VolumeMounts { - if strings.HasPrefix(mount.Name, "header-") { - t.Errorf("Should not have MCP header volume mount, found: %s", mount.Name) - } - } - - t.Logf("Successfully validated LCore Deployment with KUBERNETES_PLACEHOLDER MCP headers (no secret volumes)") -} - -func TestGenerateLCoreDeploymentWithoutIntrospection(t *testing.T) { - // Create an OLSConfig CR with introspection disabled - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - }, - } - - // Create a mock reconciler - r := &mockReconciler{ - lcoreServerMode: true, // Test server mode (2 containers) - } - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - // Verify containers - should have 2: llama-stack, lightspeed-stack (NO openshift-mcp-server) - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 2 { - t.Fatalf("Expected 2 containers (llama-stack, lightspeed-stack), got %d", len(containers)) - } - - // Verify OpenShift MCP server container is NOT present - for i := range containers { - if containers[i].Name == utils.OpenShiftMCPServerContainerName { - t.Error("OpenShift MCP server container should not be present when introspection is disabled") - } - } - - // Verify MCP server config volume is NOT present - for _, vol := range deployment.Spec.Template.Spec.Volumes { - if vol.Name == utils.OpenShiftMCPServerConfigVolumeName { - t.Error("MCP server config volume should not be present when introspection is disabled") - } - } - - t.Logf("Successfully validated LCore Deployment without OpenShift MCP server sidecar") -} - -func TestGetOLSMCPServerResources(t *testing.T) { - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - } - - resources := getOLSMCPServerResources(cr) - - if resources == nil { - t.Fatal("getOLSMCPServerResources returned nil") - } - - // Verify limits - if resources.Limits == nil { - t.Error("Resources limits is nil") - } else { - memLimit := resources.Limits[corev1.ResourceMemory] - if memLimit.IsZero() { - t.Error("Memory limit is not set") - } - expectedMemLimit := "200Mi" - if memLimit.String() != expectedMemLimit { - t.Errorf("Expected memory limit '%s', got '%s'", expectedMemLimit, memLimit.String()) - } - } - - // Verify requests - if resources.Requests == nil { - t.Error("Resources requests is nil") - } else { - cpuRequest := resources.Requests[corev1.ResourceCPU] - memRequest := resources.Requests[corev1.ResourceMemory] - - if cpuRequest.IsZero() { - t.Error("CPU request is not set") - } - if memRequest.IsZero() { - t.Error("Memory request is not set") - } - - expectedCPU := "50m" - expectedMem := "64Mi" - if cpuRequest.String() != expectedCPU { - t.Errorf("Expected CPU request '%s', got '%s'", expectedCPU, cpuRequest.String()) - } - if memRequest.String() != expectedMem { - t.Errorf("Expected memory request '%s', got '%s'", expectedMem, memRequest.String()) - } - } -} - -func TestGenerateLCoreDeploymentLibraryMode(t *testing.T) { - // Create a minimal OLSConfig CR - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - }, - }, - } - - // Create a mock reconciler in library mode - r := &mockReconciler{ - lcoreServerMode: false, // Test library mode (1 container) - } - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - // Verify basic metadata - if deployment.Name != "lightspeed-stack-deployment" { - t.Errorf("Expected deployment name 'lightspeed-stack-deployment', got '%s'", deployment.Name) - } - if deployment.Namespace != utils.OLSNamespaceDefault { - t.Errorf("Expected namespace '%s', got '%s'", utils.OLSNamespaceDefault, deployment.Namespace) - } - - // Verify labels - expectedLabels := map[string]string{ - "app": "lightspeed-stack", - "app.kubernetes.io/component": "application-server", - "app.kubernetes.io/managed-by": "lightspeed-operator", - "app.kubernetes.io/name": "lightspeed-service-api", - "app.kubernetes.io/part-of": "openshift-lightspeed", - } - for key, expectedValue := range expectedLabels { - if actualValue, ok := deployment.Labels[key]; !ok { - t.Errorf("Missing label '%s'", key) - } else if actualValue != expectedValue { - t.Errorf("Label '%s': expected '%s', got '%s'", key, expectedValue, actualValue) - } - } - - // Verify service account - if deployment.Spec.Template.Spec.ServiceAccountName != utils.OLSAppServerServiceAccountName { - t.Errorf("Expected ServiceAccountName '%s', got '%s'", - utils.OLSAppServerServiceAccountName, - deployment.Spec.Template.Spec.ServiceAccountName) - } - - // Verify containers - should have ONLY 1 (lightspeed-stack with embedded llama-stack) - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 1 { - t.Fatalf("Expected 1 container in library mode (lightspeed-stack), got %d", len(containers)) - } - - // Verify lightspeed-stack container - lightspeedStackContainer := containers[0] - if lightspeedStackContainer.Name != "lightspeed-service-api" { - t.Errorf("Expected container name 'lightspeed-service-api', got '%s'", lightspeedStackContainer.Name) - } - if len(lightspeedStackContainer.Ports) != 1 || lightspeedStackContainer.Ports[0].ContainerPort != utils.OLSAppServerContainerPort { - t.Errorf("Expected container port %d, got %v", - utils.OLSAppServerContainerPort, lightspeedStackContainer.Ports) - } - if lightspeedStackContainer.LivenessProbe == nil { - t.Error("lightspeed-stack container missing liveness probe") - } - if lightspeedStackContainer.ReadinessProbe == nil { - t.Error("lightspeed-stack container missing readiness probe") - } - - // Verify volumes - library mode needs BOTH config volumes (LCore + Llama Stack) - volumes := deployment.Spec.Template.Spec.Volumes - volumeNames := make(map[string]bool) - for _, vol := range volumes { - volumeNames[vol.Name] = true - } - - // Both configs must be present - if !volumeNames[utils.LCoreConfigCmName] { - t.Error("Missing LCore config volume in library mode") - } - if !volumeNames[utils.LlamaStackConfigCmName] { - t.Error("Missing Llama Stack config volume in library mode") - } - // Library mode also needs llama-cache for model downloads - if !volumeNames[utils.LlamaCacheVolumeName] { - t.Error("Missing llama-cache volume in library mode") - } - // Should have TLS - if !volumeNames["secret-lightspeed-tls"] { - t.Error("Missing TLS volume in library mode") - } - // Should have OpenShift root CA - if !volumeNames[utils.OpenShiftCAVolumeName] { - t.Error("Missing OpenShift root CA volume in library mode") - } - // Should have Postgres CA - if !volumeNames[utils.PostgresCAVolume] { - t.Error("Missing Postgres CA volume in library mode") - } - - // Verify volume mounts in lightspeed-stack container - volumeMounts := lightspeedStackContainer.VolumeMounts - volumeMountNames := make(map[string]bool) - for _, mount := range volumeMounts { - volumeMountNames[mount.Name] = true - } - - // Verify both configs are mounted - if !volumeMountNames[utils.LCoreConfigCmName] { - t.Error("Missing LCore config mount in library mode") - } - if !volumeMountNames[utils.LlamaStackConfigCmName] { - t.Error("Missing Llama Stack config mount in library mode") - } - if !volumeMountNames[utils.LlamaCacheVolumeName] { - t.Error("Missing llama-cache mount in library mode") - } - if !volumeMountNames["secret-lightspeed-tls"] { - t.Error("Missing TLS mount in library mode") - } - - // Verify that deployment can be marshaled to YAML (valid k8s object) - yamlBytes, err := yaml.Marshal(deployment) - if err != nil { - t.Fatalf("Failed to marshal deployment to YAML: %v", err) - } - - // Verify we can unmarshal it back - var unmarshaledDeployment appsv1.Deployment - err = yaml.Unmarshal(yamlBytes, &unmarshaledDeployment) - if err != nil { - t.Fatalf("Failed to unmarshal deployment YAML: %v", err) - } - - t.Logf("Successfully validated LCore Deployment in Library Mode (%d bytes of YAML)", len(yamlBytes)) -} - -func TestGenerateLCoreDeploymentWithRAG(t *testing.T) { - imagePullSecrets := []corev1.LocalObjectReference{ - { - Name: "byok-image-pull-secret-1", - }, - { - Name: "byok-image-pull-secret-2", - }, - } - - // Create an OLSConfig CR with additionalCAConfigMapRef - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - ImagePullSecrets: imagePullSecrets, - RAG: []olsv1alpha1.RAGSpec{ - { - Image: "byok-rag-image-1", - IndexID: "byok-index-id-1", - IndexPath: "byok-index-path-1", - }, - }, - }, - }, - } - - // Create a mock reconciler - r := &mockReconciler{} - - // Generate the deployment - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Verify deployment is not nil - if deployment == nil { - t.Fatal("GenerateLCoreDeployment returned nil deployment") - } - - if !reflect.DeepEqual(deployment.Spec.Template.Spec.ImagePullSecrets, imagePullSecrets) { - t.Fatalf("Expected ImagePullSecrets: %+v, got %+v", imagePullSecrets, deployment.Spec.Template.Spec.ImagePullSecrets) - } -} - -func TestDataCollectorSidecar_Enabled(t *testing.T) { - // Create CR with data collection enabled - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - UserDataCollection: olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: false, - TranscriptsDisabled: false, - }, - }, - }, - } - - // Create mock with telemetry enabled - r := &mockReconcilerWithTelemetry{ - mockReconciler: mockReconciler{ - lcoreServerMode: true, - }, - telemetryEnabled: true, - } - - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Should have 3 containers: llama-stack, lightspeed-stack, data-collector - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 3 { - t.Errorf("Expected 3 containers (llama-stack, lightspeed-stack, data-collector), got %d", len(containers)) - } - - // Verify data collector container exists - var hasDataCollector bool - for _, container := range containers { - if container.Name == "lightspeed-to-dataverse-exporter" { - hasDataCollector = true - // Verify data-dir arg uses LCoreUserDataMountPath - found := false - for i, arg := range container.Args { - if arg == "--data-dir" && i+1 < len(container.Args) { - if container.Args[i+1] != utils.LCoreUserDataMountPath { - t.Errorf("Expected data-dir %s, got %s", utils.LCoreUserDataMountPath, container.Args[i+1]) - } - found = true - } - } - if !found { - t.Error("Data collector container missing --data-dir argument") - } - } - } - - if !hasDataCollector { - t.Error("Expected data collector sidecar container when data collection is enabled") - } - - // Verify user data volume exists - var hasUserDataVolume bool - for _, volume := range deployment.Spec.Template.Spec.Volumes { - if volume.Name == "ols-user-data" { - hasUserDataVolume = true - if volume.EmptyDir == nil { - t.Error("Expected ols-user-data volume to be EmptyDir") - } - } - } - if !hasUserDataVolume { - t.Error("Expected ols-user-data volume when data collection is enabled") - } - - // Verify user data volume mount exists in lightspeed-stack container - lightspeedStackContainer := containers[1] // Second container in server mode - var hasUserDataMount bool - for _, mount := range lightspeedStackContainer.VolumeMounts { - if mount.Name == "ols-user-data" { - hasUserDataMount = true - if mount.MountPath != utils.LCoreUserDataMountPath { - t.Errorf("Expected mount path %s, got %s", utils.LCoreUserDataMountPath, mount.MountPath) - } - } - } - if !hasUserDataMount { - t.Error("Expected ols-user-data volume mount in lightspeed-stack container") - } -} - -func TestDataCollectorSidecar_Disabled(t *testing.T) { - // Create CR with data collection disabled - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - UserDataCollection: olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: true, - TranscriptsDisabled: true, - }, - }, - }, - } - - r := &mockReconciler{ - lcoreServerMode: true, - } - - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Should have 2 containers: llama-stack, lightspeed-stack (no data-collector) - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 2 { - t.Errorf("Expected 2 containers (llama-stack, lightspeed-stack), got %d", len(containers)) - } - - // Verify data collector container does NOT exist - for _, container := range containers { - if container.Name == "lightspeed-to-dataverse-exporter" { - t.Error("Data collector sidecar should not be present when data collection is disabled") - } - } - - // Verify user data volume does NOT exist - for _, volume := range deployment.Spec.Template.Spec.Volumes { - if volume.Name == "ols-user-data" { - t.Error("ols-user-data volume should not be present when data collection is disabled") - } - } -} - -func TestDataCollectorSidecar_LibraryMode(t *testing.T) { - // Create CR with data collection enabled - cr := &olsv1alpha1.OLSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - }, - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "test-provider", - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "test-secret", - }, - }, - }, - }, - OLSConfig: olsv1alpha1.OLSSpec{ - IntrospectionEnabled: utils.BoolPtr(false), - UserDataCollection: olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: false, - TranscriptsDisabled: false, - }, - }, - }, - } - - // Create mock with library mode and telemetry enabled - r := &mockReconcilerWithTelemetry{ - mockReconciler: mockReconciler{ - lcoreServerMode: false, // Library mode - }, - telemetryEnabled: true, - } - - deployment, err := GenerateLCoreDeployment(r, context.Background(), cr) - if err != nil { - t.Fatalf("GenerateLCoreDeployment returned error: %v", err) - } - - // Should have 2 containers: lightspeed-stack, data-collector - containers := deployment.Spec.Template.Spec.Containers - if len(containers) != 2 { - t.Errorf("Expected 2 containers (lightspeed-stack, data-collector), got %d", len(containers)) - } - - // Verify data collector container exists - var hasDataCollector bool - for _, container := range containers { - if container.Name == "lightspeed-to-dataverse-exporter" { - hasDataCollector = true - } - } - - if !hasDataCollector { - t.Error("Expected data collector sidecar in library mode when data collection is enabled") - } - - // Verify user data volume mount in single container - lightspeedStackContainer := containers[0] - var hasUserDataMount bool - for _, mount := range lightspeedStackContainer.VolumeMounts { - if mount.Name == "ols-user-data" && mount.MountPath == utils.LCoreUserDataMountPath { - hasUserDataMount = true - } - } - if !hasUserDataMount { - t.Error("Expected ols-user-data volume mount in library mode") - } -} - -// mockReconcilerWithTelemetry extends mockReconciler to simulate telemetry pull secret -type mockReconcilerWithTelemetry struct { - mockReconciler - telemetryEnabled bool -} - -func (m *mockReconcilerWithTelemetry) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - // Simulate telemetry pull secret lookup - if key.Namespace == utils.TelemetryPullSecretNamespace && key.Name == utils.TelemetryPullSecretName { - if m.telemetryEnabled { - // Return a mock secret with cloud.openshift.com auth - if secret, ok := obj.(*corev1.Secret); ok { - secret.Data = map[string][]byte{ - ".dockerconfigjson": []byte(`{"auths":{"cloud.openshift.com":{}}}`), - } - return nil - } - } - // Telemetry disabled - return not found - return errors.NewNotFound(schema.GroupResource{}, key.Name) - } - - // For other resources, use the parent mock behavior - return m.mockReconciler.Get(ctx, key, obj, opts...) -} - -func (m *mockReconcilerWithTelemetry) GetDataverseExporterImage() string { - return utils.DataverseExporterImageDefault -} - -// mockReconcilerWithSecrets extends mockReconciler to return pre-configured fake -// secrets keyed by name. Use this when the code under test calls r.Get for a Secret. -type mockReconcilerWithSecrets struct { - mockReconciler - secrets map[string]*corev1.Secret // name -> secret -} - -func (m *mockReconcilerWithSecrets) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - if secret, ok := obj.(*corev1.Secret); ok { - if fakeSecret, found := m.secrets[key.Name]; found { - *secret = *fakeSecret - return nil - } - } - return m.mockReconciler.Get(ctx, key, obj, opts...) -} - -// TestBuildLlamaStackEnvVars_GenericProvider_CustomCredentialKey verifies that when -// a generic provider sets CredentialKey to a non-default value, buildLlamaStackEnvVars -// wires the env var to SecretKeyRef.Key equal to that custom key name. -// This is the authoritative test for the CredentialKey field's runtime behaviour. -func TestBuildLlamaStackEnvVars_GenericProvider_CustomCredentialKey(t *testing.T) { - const secretName = "custom-provider-creds" - const customKey = "bearer_token" // non-default; default is "apitoken" - - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "custom-provider", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - CredentialKey: customKey, - CredentialsSecretRef: corev1.LocalObjectReference{Name: secretName}, - Config: &runtime.RawExtension{ - Raw: []byte(`{"url": "https://api.example.com/v1"}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - }, - }, - }, - } - - fakeSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName}, - Data: map[string][]byte{ - customKey: []byte("my-custom-token"), - }, - } - - r := &mockReconcilerWithSecrets{ - secrets: map[string]*corev1.Secret{secretName: fakeSecret}, - } - - ctx := context.Background() - envVars, err := buildLlamaStackEnvVars(r, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackEnvVars returned error: %v", err) - } - - // Find the CUSTOM_PROVIDER_API_KEY env var - var apiKeyEnv *corev1.EnvVar - for i := range envVars { - if envVars[i].Name == "CUSTOM_PROVIDER_API_KEY" { - apiKeyEnv = &envVars[i] - break - } - } - - if apiKeyEnv == nil { - t.Fatal("CUSTOM_PROVIDER_API_KEY env var not found in generated env vars") - } - - // CRITICAL: The SecretKeyRef.Key must be the custom key, not the default "apitoken" - if apiKeyEnv.ValueFrom == nil || apiKeyEnv.ValueFrom.SecretKeyRef == nil { - t.Fatal("CUSTOM_PROVIDER_API_KEY env var does not reference a secret") - } - if apiKeyEnv.ValueFrom.SecretKeyRef.Name != secretName { - t.Errorf("Expected SecretKeyRef.Name=%q, got %q", secretName, apiKeyEnv.ValueFrom.SecretKeyRef.Name) - } - if apiKeyEnv.ValueFrom.SecretKeyRef.Key != customKey { - t.Errorf("Expected SecretKeyRef.Key=%q (custom credentialKey), got %q", customKey, apiKeyEnv.ValueFrom.SecretKeyRef.Key) - } - - t.Logf("✓ CredentialKey=%q correctly wired to SecretKeyRef.Key", customKey) -} - -// TestBuildLlamaStackEnvVars_GenericProvider_DefaultCredentialKey verifies that when -// CredentialKey is not set, buildLlamaStackEnvVars falls back to the default "apitoken" key. -func TestBuildLlamaStackEnvVars_GenericProvider_DefaultCredentialKey(t *testing.T) { - const secretName = "generic-provider-creds" - - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "generic-provider", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::custom", - // CredentialKey intentionally not set — should default to "apitoken" - CredentialsSecretRef: corev1.LocalObjectReference{Name: secretName}, - Config: &runtime.RawExtension{ - Raw: []byte(`{"url": "https://api.example.com/v1"}`), - }, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - }, - }, - }, - } - - fakeSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName}, - Data: map[string][]byte{ - "apitoken": []byte("my-generic-token"), - }, - } - - r := &mockReconcilerWithSecrets{ - secrets: map[string]*corev1.Secret{secretName: fakeSecret}, - } - - ctx := context.Background() - envVars, err := buildLlamaStackEnvVars(r, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackEnvVars returned error: %v", err) - } - - var apiKeyEnv *corev1.EnvVar - for i := range envVars { - if envVars[i].Name == "GENERIC_PROVIDER_API_KEY" { - apiKeyEnv = &envVars[i] - break - } - } - - if apiKeyEnv == nil { - t.Fatal("GENERIC_PROVIDER_API_KEY env var not found") - } - - if apiKeyEnv.ValueFrom == nil || apiKeyEnv.ValueFrom.SecretKeyRef == nil { - t.Fatal("GENERIC_PROVIDER_API_KEY does not reference a secret") - } - if apiKeyEnv.ValueFrom.SecretKeyRef.Key != "apitoken" { - t.Errorf("Expected default SecretKeyRef.Key=%q, got %q", "apitoken", apiKeyEnv.ValueFrom.SecretKeyRef.Key) - } - - t.Logf("✓ Default credentialKey 'apitoken' correctly wired to SecretKeyRef.Key") -} - -// TestBuildLlamaStackEnvVars_GenericProvider_MissingCredentialKey verifies that when -// the secret does not contain the expected credentialKey, an error is returned rather -// than silently omitting the env var. -func TestBuildLlamaStackEnvVars_GenericProvider_MissingCredentialKey(t *testing.T) { - const secretName = "broken-creds" - - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "broken", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::broken", - CredentialKey: "my_api_key", - CredentialsSecretRef: corev1.LocalObjectReference{Name: secretName}, - Config: &runtime.RawExtension{Raw: []byte(`{}`)}, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }, - }, - }, - }, - } - - // Secret exists but does NOT contain the expected key - fakeSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: secretName}, - Data: map[string][]byte{ - "wrong_key": []byte("some-token"), - }, - } - - r := &mockReconcilerWithSecrets{ - secrets: map[string]*corev1.Secret{secretName: fakeSecret}, - } - - ctx := context.Background() - _, err := buildLlamaStackEnvVars(r, ctx, cr) - if err == nil { - t.Fatal("Expected error when credentialKey is absent from secret, got nil") - } - if !strings.Contains(err.Error(), "my_api_key") { - t.Errorf("Error should mention the missing key 'my_api_key', got: %v", err) - } - - t.Logf("✓ Missing credentialKey correctly returns error: %v", err) -} - -// TestAddGoogleVertexVolumesAndMounts tests that the addGoogleVertexVolumesAndMounts function -// adds the correct volumes and mounts for Google Vertex AI -func TestAddGoogleVertexVolumesAndMounts(t *testing.T) { - volumeDefaultMode := int32(0644) - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "google_vertex", - Type: utils.GoogleVertexType, - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "google-vertex-credential-secret", - }, - CredentialKey: "google-vertex-credential-key", - GoogleVertexConfig: &olsv1alpha1.VertexConfig{ - ProjectID: "google-vertex-project-id", - Location: "google-vertex-location", - }, - }, - }, - }, - }, - } - - volumes := []corev1.Volume{} - volumeMounts := []corev1.VolumeMount{} - addGoogleVertexVolumesAndMounts(&volumes, &volumeMounts, cr, &volumeDefaultMode) - - if len(volumes) == 0 { - t.Error("Expected volumes to be non-empty") - } - if len(volumeMounts) == 0 { - t.Error("Expected volume mounts to be non-empty") - } - wantVol := "creds-google-vertex" - if volumes[0].Name != wantVol { - t.Errorf("Expected volume name %q, got %s", wantVol, volumes[0].Name) - } - if volumeMounts[0].Name != wantVol { - t.Errorf("Expected volume mount name %q, got %s", wantVol, volumeMounts[0].Name) - } - wantPath := "/etc/apikeys/google-vertex" - if volumeMounts[0].MountPath != wantPath { - t.Errorf("Expected volume mount path %q, got %s", wantPath, volumeMounts[0].MountPath) - } - if volumeMounts[0].ReadOnly != true { - t.Errorf("Expected volume mount read only to be true, got %t", volumeMounts[0].ReadOnly) - } - if volumes[0].Secret.SecretName != "google-vertex-credential-secret" { - t.Errorf("Expected secret name google-vertex-credential-secret, got %s", volumes[0].Secret.SecretName) - } -} - -func TestAddGoogleVertexVolumesAndMounts_MultipleProviders(t *testing.T) { - volumeDefaultMode := int32(0644) - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "vertex_a", - Type: utils.GoogleVertexType, - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "secret-a", - }, - GoogleVertexConfig: &olsv1alpha1.VertexConfig{ - ProjectID: "p1", - Location: "us-central1", - }, - }, - { - Name: "vertex_b", - Type: utils.GoogleVertexAnthropicType, - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "secret-b", - }, - GoogleVertexAnthropicConfig: &olsv1alpha1.VertexConfig{ - ProjectID: "p2", - Location: "europe-west1", - }, - }, - }, - }, - }, - } - - volumes := []corev1.Volume{} - volumeMounts := []corev1.VolumeMount{} - addGoogleVertexVolumesAndMounts(&volumes, &volumeMounts, cr, &volumeDefaultMode) - - if len(volumes) != 2 || len(volumeMounts) != 2 { - t.Fatalf("expected 2 volumes and 2 mounts, got %d volumes %d mounts", len(volumes), len(volumeMounts)) - } - if volumes[0].Secret.SecretName != "secret-a" || volumes[1].Secret.SecretName != "secret-b" { - t.Errorf("unexpected secret refs: %#v %#v", volumes[0].Secret, volumes[1].Secret) - } - if volumeMounts[0].MountPath == volumeMounts[1].MountPath { - t.Errorf("expected distinct mount paths, both were %s", volumeMounts[0].MountPath) - } -} - -// TestGoogleVertexEnvVars tests that the buildLlamaStackEnvVars function -// adds the correct environment variables for Google Vertex AI -func TestGoogleVertexEnvVars(t *testing.T) { - r := &mockReconcilerWithSecrets{ - secrets: map[string]*corev1.Secret{ - "google-vertex-credential-secret": { - ObjectMeta: metav1.ObjectMeta{Name: "google-vertex-credential-secret"}, - Data: map[string][]byte{ - "google-vertex-credential-key": []byte("google-vertex-credential-value"), - }, - }, - }, - } - ctx := context.Background() - cr := &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{ - Providers: []olsv1alpha1.ProviderSpec{ - { - Name: "google_vertex", - Type: utils.GoogleVertexType, - CredentialsSecretRef: corev1.LocalObjectReference{ - Name: "google-vertex-credential-secret", - }, - CredentialKey: "google-vertex-credential-key", - GoogleVertexConfig: &olsv1alpha1.VertexConfig{ - ProjectID: "google-vertex-project-id", - Location: "google-vertex-location", - }, - }, - }, - }, - }, - } - envVars, err := buildLlamaStackEnvVars(r, ctx, cr) - if err != nil { - t.Fatalf("buildLlamaStackEnvVars returned error: %v", err) - } - var foundPostgres bool - wantGAC := "/etc/apikeys/google-vertex/google-vertex-credential-key" - var foundGAC bool - for _, ev := range envVars { - if ev.Name == "POSTGRES_PASSWORD" { - foundPostgres = true - } - if ev.Name == "GOOGLE_APPLICATION_CREDENTIALS" { - foundGAC = true - if ev.Value != wantGAC { - t.Errorf("GOOGLE_APPLICATION_CREDENTIALS = %q, want %q", ev.Value, wantGAC) - } - } - } - if !foundPostgres { - t.Error("expected POSTGRES_PASSWORD env var to be present") - } - if !foundGAC { - t.Errorf("expected GOOGLE_APPLICATION_CREDENTIALS=%q to be present", wantGAC) - } -} diff --git a/internal/controller/lcore/lcore_generic_provider_test.go b/internal/controller/lcore/lcore_generic_provider_test.go deleted file mode 100644 index 5218c275d..000000000 --- a/internal/controller/lcore/lcore_generic_provider_test.go +++ /dev/null @@ -1,391 +0,0 @@ -package lcore - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" -) - -// ─── helpers ──────────────────────────────────────────────────────────────── - -// genericProvider builds a llamaStackGeneric ProviderSpec. -// Pass secretName="" to simulate a public/unauthenticated endpoint. -// Pass configRaw=nil to omit the Config field entirely. -func genericProvider(name, providerType string, configRaw []byte, secretName string) olsv1alpha1.ProviderSpec { - p := olsv1alpha1.ProviderSpec{ - Name: name, - Type: utils.LlamaStackGenericType, - ProviderType: providerType, - CredentialsSecretRef: corev1.LocalObjectReference{Name: secretName}, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - } - if configRaw != nil { - p.Config = &runtime.RawExtension{Raw: configRaw} - } - return p -} - -// crWith wraps providers into a minimal OLSConfig. -func crWith(providers ...olsv1alpha1.ProviderSpec) *olsv1alpha1.OLSConfig { - return &olsv1alpha1.OLSConfig{ - Spec: olsv1alpha1.OLSConfigSpec{ - LLMConfig: olsv1alpha1.LLMSpec{Providers: providers}, - }, - } -} - -// providerConfig extracts the "config" map from providers[idx]. -// [0] is always sentence-transformers; user providers start at [1]. -func providerConfig(providers []interface{}, idx int) map[string]interface{} { - return providers[idx].(map[string]interface{})["config"].(map[string]interface{}) -} - -// ─── tests ────────────────────────────────────────────────────────────────── - -var _ = Describe("Generic provider", func() { - - // ── buildLlamaStackInferenceProviders ──────────────────────────────────── - Describe("buildLlamaStackInferenceProviders", func() { - - It("returns only sentence-transformers for a nil CR", func() { - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), nil) - Expect(err).NotTo(HaveOccurred()) - Expect(providers).To(HaveLen(1)) - Expect(providers[0].(map[string]interface{})["provider_id"]).To(Equal("sentence-transformers")) - }) - - It("returns a clear error for invalid JSON in config", func() { - cr := crWith(genericProvider("p", "remote::openai", []byte(`{invalid`), "s")) - _, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal config")) - }) - - It("auto-injects api_key when credentials are configured", func() { - cr := crWith(genericProvider("my-provider", "remote::openai", - []byte(`{"url":"https://example.com"}`), "my-secret")) - - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - // [0] sentence-transformers, [1] my-provider - Expect(providers).To(HaveLen(2)) - - p := providers[1].(map[string]interface{}) - Expect(p["provider_id"]).To(Equal("my-provider")) - Expect(p["provider_type"]).To(Equal("remote::openai")) - Expect(providerConfig(providers, 1)["api_key"]).To(Equal("${env.MY_PROVIDER_API_KEY}")) - }) - - It("does not overwrite an api_key the user already supplied", func() { - cr := crWith(genericProvider("my-provider", "remote::openai", - []byte(`{"api_key":"${env.CUSTOM_KEY}"}`), "my-secret")) - - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - Expect(providerConfig(providers, 1)["api_key"]).To(Equal("${env.CUSTOM_KEY}")) - }) - - It("does not inject api_key for a public/unauthenticated provider", func() { - cr := crWith(genericProvider("pub", "remote::ollama", - []byte(`{"url":"http://localhost:11434"}`), "" /* no secret */)) - - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - Expect(providerConfig(providers, 1)).NotTo(HaveKey("api_key")) - }) - - It("injects api_key even for an empty config object {}", func() { - cr := crWith(genericProvider("p", "remote::openai", []byte(`{}`), "my-secret")) - - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - Expect(providerConfig(providers, 1)).To(HaveKey("api_key")) - }) - - It("configures multiple generic providers independently", func() { - cr := crWith( - genericProvider("alpha", "remote::openai", - []byte(`{"url":"https://alpha.example.com"}`), "s-alpha"), - genericProvider("beta", "remote::vllm", - []byte(`{"url":"https://beta.example.com"}`), "s-beta"), - ) - - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - // [0] sentence-transformers, [1] alpha, [2] beta - Expect(providers).To(HaveLen(3)) - - alpha := providers[1].(map[string]interface{}) - Expect(alpha["provider_id"]).To(Equal("alpha")) - Expect(alpha["provider_type"]).To(Equal("remote::openai")) - - beta := providers[2].(map[string]interface{}) - Expect(beta["provider_id"]).To(Equal("beta")) - Expect(beta["provider_type"]).To(Equal("remote::vllm")) - - Expect(providerConfig(providers, 1)["api_key"]).To(Equal("${env.ALPHA_API_KEY}")) - Expect(providerConfig(providers, 2)["api_key"]).To(Equal("${env.BETA_API_KEY}")) - }) - - DescribeTable("produces the correct env-var substitution for various provider names", - func(providerName, wantAPIKey string) { - cr := crWith(genericProvider(providerName, "remote::openai", []byte(`{}`), "s")) - providers, err := buildLlamaStackInferenceProviders(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - Expect(providerConfig(providers, 1)["api_key"]).To(Equal(wantAPIKey)) - }, - Entry("hyphenated name", "my-openai", "${env.MY_OPENAI_API_KEY}"), - Entry("multiple hyphens", "provider-with-hyphens", "${env.PROVIDER_WITH_HYPHENS_API_KEY}"), - Entry("plain lowercase", "simpleprovider", "${env.SIMPLEPROVIDER_API_KEY}"), - Entry("leading digits are prefixed with underscore", "123provider", "${env._123PROVIDER_API_KEY}"), - ) - }) - - // ── buildLlamaStackYAML ────────────────────────────────────────────────── - Describe("buildLlamaStackYAML", func() { - - It("propagates a generic-provider invalid-JSON error to the caller", func() { - cr := crWith(genericProvider("p", "remote::openai", []byte(`{invalid`), "s")) - _, err := buildLlamaStackYAML(nil, context.Background(), cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to build inference providers")) - }) - - It("generates YAML containing the credential injection placeholder for a generic provider", func() { - cr := crWith(genericProvider("my-provider", "remote::openai", - []byte(`{"url":"https://example.com"}`), "my-secret")) - - yamlStr, err := buildLlamaStackYAML(nil, context.Background(), cr) - Expect(err).NotTo(HaveOccurred()) - // The auto-injected api_key must use the env-var substitution pattern. - Expect(yamlStr).To(ContainSubstring("${env.MY_PROVIDER_API_KEY}")) - // Provider metadata must appear in the YAML. - Expect(yamlStr).To(ContainSubstring("remote::openai")) - Expect(yamlStr).To(ContainSubstring("my-provider")) - }) - }) - - // ── buildLCoreConfigYAML ───────────────────────────────────────────────── - Describe("buildLCoreConfigYAML", func() { - - It("uses the generic provider name as inference default_provider", func() { - cr := crWith(genericProvider("my-generic-provider", "remote::openai", []byte(`{}`), "")) - cr.Spec.OLSConfig.DefaultProvider = "my-generic-provider" - cr.Spec.OLSConfig.DefaultModel = "test-model" - - yamlStr, err := buildLCoreConfigYAML(testReconcilerInstance, cr) - Expect(err).NotTo(HaveOccurred()) - Expect(yamlStr).To(ContainSubstring("my-generic-provider")) - Expect(yamlStr).To(ContainSubstring("test-model")) - }) - }) - - // ── getProviderType ────────────────────────────────────────────────────── - Describe("getProviderType", func() { - - DescribeTable("returns an error for unsupported or misused provider types", - func(providerType, expectedMsg string) { - p := &olsv1alpha1.ProviderSpec{Name: "test", Type: providerType} - _, err := getProviderType(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(expectedMsg)) - }, - Entry("llamaStackGeneric without providerType field", - utils.LlamaStackGenericType, "requires providerType and config fields"), - Entry("watsonx is unsupported", "watsonx", "not currently supported by Llama Stack"), - Entry("bam is unsupported", "bam", "not currently supported by Llama Stack"), - Entry("completely unknown type", "totally-unknown", "unknown provider type"), - ) - }) - - // ── deepCopyMap ────────────────────────────────────────────────────────── - Describe("deepCopyMap", func() { - - It("returns nil for a nil input", func() { - Expect(deepCopyMap(nil)).To(BeNil()) - }) - - It("returns an empty (non-nil) map for an empty input", func() { - result := deepCopyMap(map[string]interface{}{}) - Expect(result).NotTo(BeNil()) - Expect(result).To(BeEmpty()) - }) - - It("copies primitive values correctly", func() { - src := map[string]interface{}{"s": "hello", "n": 42, "b": true} - Expect(deepCopyMap(src)).To(Equal(src)) - }) - - It("prevents mutation of nested maps", func() { - src := map[string]interface{}{ - "nested": map[string]interface{}{"key": "original"}, - } - result := deepCopyMap(src) - result["nested"].(map[string]interface{})["key"] = "modified" - - Expect(src["nested"].(map[string]interface{})["key"]).To(Equal("original"), - "modifying the copy must not affect the original") - }) - - It("prevents mutation of slice values", func() { - src := map[string]interface{}{"items": []interface{}{"a", "b", "c"}} - result := deepCopyMap(src) - result["items"].([]interface{})[0] = "modified" - - Expect(src["items"].([]interface{})[0]).To(Equal("a"), - "modifying the copy's slice must not affect the original") - }) - - It("deep-copies three levels of nesting", func() { - src := map[string]interface{}{ - "l1": map[string]interface{}{ - "l2": map[string]interface{}{"value": "deep"}, - }, - } - result := deepCopyMap(src) - result["l1"].(map[string]interface{})["l2"].(map[string]interface{})["value"] = "changed" - - got := src["l1"].(map[string]interface{})["l2"].(map[string]interface{})["value"] - Expect(got).To(Equal("deep"), "3-level deep value must not be mutated") - }) - - It("deep-copies mixed nested slices and maps", func() { - src := map[string]interface{}{ - "config": map[string]interface{}{ - "tags": []interface{}{"tag1", "tag2"}, - "meta": map[string]interface{}{"version": "1.0"}, - }, - } - result := deepCopyMap(src) - result["config"].(map[string]interface{})["tags"].([]interface{})[0] = "mutated" - result["config"].(map[string]interface{})["meta"].(map[string]interface{})["version"] = "2.0" - - Expect(src["config"].(map[string]interface{})["tags"].([]interface{})[0]).To(Equal("tag1")) - Expect(src["config"].(map[string]interface{})["meta"].(map[string]interface{})["version"]).To(Equal("1.0")) - }) - }) - - // ── CRD validation – ProviderSpec CEL rules ────────────────────────────── - Describe("CRD validation – ProviderSpec CEL rules", Ordered, func() { - - // validGenericProvider returns a fully valid llamaStackGeneric ProviderSpec - // that satisfies all five CRD CEL rules. - validGenericProvider := func() olsv1alpha1.ProviderSpec { - return olsv1alpha1.ProviderSpec{ - Name: "test-provider", - Type: utils.LlamaStackGenericType, - ProviderType: "remote::openai", - Config: &runtime.RawExtension{Raw: []byte(`{}`)}, - CredentialsSecretRef: corev1.LocalObjectReference{Name: "test-secret"}, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - } - } - - // withInvalidProvider attempts to update the singleton "cluster" CR with the - // given provider spec. Because the CRD enforces name == "cluster", all - // tests use Update so the name constraint never interferes with the CEL - // assertion under test. - withInvalidProvider := func(provider olsv1alpha1.ProviderSpec) error { - existing := &olsv1alpha1.OLSConfig{} - Expect(k8sClient.Get(ctx, crNamespacedName, existing)).To(Succeed()) - updated := existing.DeepCopy() - updated.Spec.LLMConfig.Providers = []olsv1alpha1.ProviderSpec{provider} - return k8sClient.Update(ctx, updated) - } - - // Capture the original spec once before any test in this block runs and - // restore it afterwards so the reconciler tests that follow are unaffected. - var savedSpec olsv1alpha1.OLSConfigSpec - - BeforeAll(func() { - existing := &olsv1alpha1.OLSConfig{} - Expect(k8sClient.Get(ctx, crNamespacedName, existing)).To(Succeed()) - savedSpec = *existing.Spec.DeepCopy() - }) - - AfterAll(func() { - existing := &olsv1alpha1.OLSConfig{} - Expect(k8sClient.Get(ctx, crNamespacedName, existing)).To(Succeed()) - restored := existing.DeepCopy() - restored.Spec = savedSpec - Expect(k8sClient.Update(ctx, restored)).To(Succeed()) - }) - - // Rule 1: !has(self.providerType) || has(self.config) - It("rejects a provider that sets providerType without config (Rule 1)", func() { - p := validGenericProvider() - p.Config = nil // violates Rule 1 - Expect(withInvalidProvider(p)).To(HaveOccurred()) - }) - - // Rule 2: !has(self.config) || has(self.providerType) - It("rejects a provider that sets config without providerType (Rule 2)", func() { - p := validGenericProvider() - p.ProviderType = "" // violates Rule 2 - err := withInvalidProvider(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("config")) - }) - - // Rule 3: !has(self.providerType) || self.type == "llamaStackGeneric" - It("rejects a provider that sets providerType with type != llamaStackGeneric (Rule 3)", func() { - p := validGenericProvider() - p.Type = "openai" // violates Rule 3 - err := withInvalidProvider(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("llamaStackGeneric")) - }) - - // Rule 4: self.type != "llamaStackGeneric" || (!has(self.deploymentName) && !has(self.projectID) && !has(self.url) && !has(self.apiVersion)) - It("rejects a llamaStackGeneric provider that also sets deploymentName (Rule 4)", func() { - p := validGenericProvider() - p.AzureDeploymentName = "my-deployment" // violates Rule 4 - err := withInvalidProvider(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("legacy")) - }) - - It("rejects a llamaStackGeneric provider that also sets url (Rule 4)", func() { - p := validGenericProvider() - p.URL = "https://my-endpoint.example.com" // violates Rule 4 - err := withInvalidProvider(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("legacy")) - }) - - It("rejects a llamaStackGeneric provider that also sets apiVersion (Rule 4)", func() { - p := validGenericProvider() - p.APIVersion = "2024-01" // violates Rule 4 - err := withInvalidProvider(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("legacy")) - }) - - // Rule 5: !has(self.credentialKey) || !self.credentialKey.matches('^[ \t\n\r\v\f]*$') - It("rejects a provider with a whitespace-only credentialKey (Rule 5)", func() { - p := validGenericProvider() - p.CredentialKey = " " // violates Rule 5 - err := withInvalidProvider(p) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("credentialKey")) - }) - - // Positive: a fully valid generic provider must be accepted. - // This is the only test that successfully mutates the CR; AfterAll restores it. - It("accepts a fully valid llamaStackGeneric provider", func() { - existing := &olsv1alpha1.OLSConfig{} - Expect(k8sClient.Get(ctx, crNamespacedName, existing)).To(Succeed()) - good := existing.DeepCopy() - good.Spec.LLMConfig.Providers = []olsv1alpha1.ProviderSpec{validGenericProvider()} - good.Spec.OLSConfig.DefaultProvider = "test-provider" - good.Spec.OLSConfig.DefaultModel = "test-model" - Expect(k8sClient.Update(ctx, good)).To(Succeed()) - }) - }) -}) diff --git a/internal/controller/lcore/reconciler.go b/internal/controller/lcore/reconciler.go deleted file mode 100644 index f38b81fd0..000000000 --- a/internal/controller/lcore/reconciler.go +++ /dev/null @@ -1,632 +0,0 @@ -// Package lcore provides reconciliation logic for the LightSpeed Core (LCore) component. -// -// This package handles the complete lifecycle of the LCore stack, which includes: -// - Llama Stack for AI model serving -// - Lightspeed Stack for OLS integration -// - Deployment and pod management for both containers -// - Service account and RBAC configuration -// - ConfigMap generation for both Llama Stack and OLS configuration -// - Service and networking setup -// - Service monitors and Prometheus rules for observability -// - Network policies for security -// -// The main entry point is ReconcileLCore, which orchestrates all sub-tasks required -// to ensure the LCore stack is running with the correct configuration. -package lcore - -import ( - "context" - "fmt" - "time" - - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - - monv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - rbacv1 "k8s.io/api/rbac/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/wait" - "sigs.k8s.io/controller-runtime/pkg/client" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" -) - -// ReconcileLCoreResources reconciles all resources except the deployment (Phase 1) -// Uses continue-on-error pattern since these resources are independent -func ReconcileLCoreResources(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - r.GetLogger().Info("reconcileLCoreResources starts") - tasks := []utils.ReconcileTask{ - { - Name: "reconcile LCore ServiceAccount", - Task: reconcileServiceAccount, - }, - { - Name: "reconcile LCore SARRole", - Task: reconcileSARRole, - }, - { - Name: "reconcile LCore SARRoleBinding", - Task: reconcileSARRoleBinding, - }, - { - Name: "reconcile Llama Stack ConfigMap", - Task: reconcileLlamaStackConfigMap, - }, - { - Name: "reconcile LCore ConfigMap", - Task: reconcileLcoreConfigMap, - }, - { - Name: "reconcile Exporter ConfigMap", - Task: reconcileExporterConfigMap, - }, - { - Name: "reconcile OLS Additional CA ConfigMap", - Task: utils.ReconcileOLSAdditionalCAConfigMap, - }, - { - Name: "reconcile Proxy CA ConfigMap", - Task: utils.ReconcileProxyCAConfigMap, - }, - { - Name: "reconcile Metrics Reader Secret", - Task: reconcileMetricsReaderSecret, - }, - { - Name: "reconcile LCore NetworkPolicy", - Task: reconcileNetworkPolicy, - }, - { - Name: "reconcile MCP Server ConfigMap", - Task: reconcileMCPServerConfigMap, - }, - } - - failedTasks := make(map[string]error) - - for _, task := range tasks { - err := task.Task(r, ctx, olsconfig) - if err != nil { - r.GetLogger().Error(err, "reconcileLCoreResources error", "task", task.Name) - failedTasks[task.Name] = err - } - } - - if len(failedTasks) > 0 { - taskNames := make([]string, 0, len(failedTasks)) - for taskName, err := range failedTasks { - taskNames = append(taskNames, taskName) - r.GetLogger().Error(err, "Task failed in reconcileLCoreResources", "task", taskName) - } - return fmt.Errorf("failed tasks: %v", taskNames) - } - - r.GetLogger().Info("reconcileLCoreResources completes") - return nil -} - -// ReconcileLCoreDeployment reconciles the deployment and related resources (Phase 2) -func ReconcileLCoreDeployment(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - r.GetLogger().Info("reconcileLCoreDeployment starts") - - tasks := []utils.ReconcileTask{ - { - Name: "reconcile LCore Deployment", - Task: reconcileDeployment, - }, - { - Name: "reconcile LCore Service", - Task: reconcileService, - }, - { - Name: "reconcile LCore TLS Certs", - Task: reconcileTLSSecret, - }, - { - Name: "reconcile LCore ServiceMonitor", - Task: reconcileServiceMonitor, - }, - { - Name: "reconcile LCore PrometheusRule", - Task: reconcilePrometheusRule, - }, - } - - for _, task := range tasks { - err := task.Task(r, ctx, olsconfig) - if err != nil { - r.GetLogger().Error(err, "reconcileLCoreDeployment error", "task", task.Name) - return fmt.Errorf("failed to %s: %w", task.Name, err) - } - } - - r.GetLogger().Info("reconcileLCoreDeployment completes") - return nil -} - -func reconcileServiceAccount(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - sa, err := GenerateServiceAccount(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAPIServiceAccount, err) - } - foundSa := &corev1.ServiceAccount{} - err = r.Get(ctx, client.ObjectKey{Name: utils.OLSAppServerServiceAccountName, Namespace: r.GetNamespace()}, foundSa) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new ServiceAccount", "ServiceAccount", sa.Name) - err = r.Create(ctx, sa) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateAPIServiceAccount, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetAPIServiceAccount, err) - } - r.GetLogger().Info("ServiceAccount reconciliation skipped", "ServiceAccount", sa.Name) - return nil -} - -func reconcileSARRole(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - cr_role, err := GenerateSARClusterRole(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateSARClusterRole, err) - } - foundCr := &rbacv1.ClusterRole{} - err = r.Get(ctx, client.ObjectKey{Name: utils.OLSAppServerSARRoleName}, foundCr) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new ClusterRole", "ClusterRole", cr_role.Name) - err = r.Create(ctx, cr_role) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateSARClusterRole, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetSARClusterRole, err) - } - r.GetLogger().Info("ClusterRole reconciliation skipped", "ClusterRole", cr_role.Name) - return nil -} - -func reconcileSARRoleBinding(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - rb, err := generateSARClusterRoleBinding(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateSARClusterRoleBinding, err) - } - foundRb := &rbacv1.ClusterRoleBinding{} - err = r.Get(ctx, client.ObjectKey{Name: utils.OLSAppServerSARRoleBindingName}, foundRb) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new ClusterRoleBinding", "ClusterRoleBinding", rb.Name) - err = r.Create(ctx, rb) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateSARClusterRoleBinding, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetSARClusterRoleBinding, err) - } - r.GetLogger().Info("ClusterRoleBinding reconciliation skipped", "ClusterRoleBinding", rb.Name) - return nil -} - -func reconcileLlamaStackConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - cm, err := GenerateLlamaStackConfigMap(r, ctx, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateLlamaStackConfigMap, err) - } - - foundCm := &corev1.ConfigMap{} - err = r.Get(ctx, client.ObjectKey{Name: utils.LlamaStackConfigCmName, Namespace: r.GetNamespace()}, foundCm) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new Llama Stack ConfigMap", "ConfigMap", cm.Name) - err = r.Create(ctx, cm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateLlamaStackConfigMap, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetLlamaStackConfigMap, err) - } - - if utils.ConfigMapEqual(foundCm, cm) { - r.GetLogger().Info("Llama Stack ConfigMap reconciliation skipped", "configmap", foundCm.Name) - return nil - } - - foundCm.Data = cm.Data - foundCm.Annotations = cm.Annotations - err = r.Update(ctx, foundCm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateLlamaStackConfigMap, err) - } - - r.GetLogger().Info("Llama Stack ConfigMap reconciled", "ConfigMap", cm.Name) - return nil -} - -func reconcileLcoreConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - cm, err := GenerateLcoreConfigMap(r, ctx, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAPIConfigmap, err) - } - - foundCm := &corev1.ConfigMap{} - err = r.Get(ctx, client.ObjectKey{Name: utils.LCoreConfigCmName, Namespace: r.GetNamespace()}, foundCm) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new LCore ConfigMap", "ConfigMap", cm.Name) - err = r.Create(ctx, cm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateAPIConfigmap, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetAPIConfigmap, err) - } - - if utils.ConfigMapEqual(foundCm, cm) { - r.GetLogger().Info("LCore ConfigMap reconciliation skipped", "configmap", foundCm.Name) - return nil - } - - foundCm.Data = cm.Data - foundCm.Annotations = cm.Annotations - err = r.Update(ctx, foundCm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateAPIConfigmap, err) - } - - r.GetLogger().Info("LCore ConfigMap reconciled", "ConfigMap", cm.Name) - return nil -} - -func reconcileExporterConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - // Check if data collector is enabled - enabled, err := dataCollectorEnabled(r, ctx, cr) - if err != nil { - return fmt.Errorf("failed to check if data collector is enabled: %w", err) - } - - foundCm := &corev1.ConfigMap{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ExporterConfigCmName, Namespace: r.GetNamespace()}, foundCm) - cmExists := err == nil - - if !enabled { - // Data collector is disabled, delete the configmap if it exists - if cmExists { - r.GetLogger().Info("deleting exporter configmap", "configmap", utils.ExporterConfigCmName) - err = r.Delete(ctx, foundCm) - if err != nil { - return fmt.Errorf("failed to delete exporter configmap: %w", err) - } - } else { - r.GetLogger().Info("data collector disabled, exporter configmap reconciliation skipped") - } - return nil - } - - // Data collector is enabled, ensure configmap exists - cm, err := generateExporterConfigMap(r, cr) - if err != nil { - return fmt.Errorf("failed to generate exporter configmap: %w", err) - } - - if !cmExists { - r.GetLogger().Info("creating exporter configmap", "configmap", cm.Name) - err = r.Create(ctx, cm) - if err != nil { - return fmt.Errorf("failed to create exporter configmap: %w", err) - } - return nil - } - - // ConfigMap exists, check if it needs update - if utils.ConfigMapEqual(foundCm, cm) { - r.GetLogger().Info("exporter configmap reconciliation skipped", "configmap", foundCm.Name) - return nil - } - - foundCm.Data = cm.Data - foundCm.Annotations = cm.Annotations - err = r.Update(ctx, foundCm) - if err != nil { - return fmt.Errorf("failed to update exporter configmap: %w", err) - } - - r.GetLogger().Info("exporter configmap reconciled", "configmap", cm.Name) - return nil -} - -func reconcileOLSAdditionalCAConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - if cr.Spec.OLSConfig.AdditionalCAConfigMapRef == nil { - // no additional CA certs, skip - r.GetLogger().Info("Additional CA not configured, reconciliation skipped") - return nil - } - - // Verify the configmap exists (annotation is handled by main controller) - cm := &corev1.ConfigMap{} - err := r.Get(ctx, client.ObjectKey{Name: cr.Spec.OLSConfig.AdditionalCAConfigMapRef.Name, Namespace: r.GetNamespace()}, cm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetAdditionalCACM, err) - } - - r.GetLogger().Info("additional CA configmap reconciled", "configmap", cm.Name) - return nil -} - -func reconcileMCPServerConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - return utils.ReconcileOpenShiftMCPServerConfigMap(r, ctx, cr, buildCommonLabels()) -} - -func reconcileProxyCAConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - if cr.Spec.OLSConfig.ProxyConfig == nil || cr.Spec.OLSConfig.ProxyConfig.ProxyCACertificateRef == nil { - // no proxy CA certs, skip - r.GetLogger().Info("Proxy CA not configured, reconciliation skipped") - return nil - } - - cm := &corev1.ConfigMap{} - err := r.Get(ctx, client.ObjectKey{Name: cr.Spec.OLSConfig.ProxyConfig.ProxyCACertificateRef.Name, Namespace: r.GetNamespace()}, cm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetProxyCACM, err) - } - - r.GetLogger().Info("proxy CA configmap reconciled", "configmap", cm.Name) - return nil -} - -func reconcileService(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - service, err := GenerateService(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAPIService, err) - } - foundService := &corev1.Service{} - err = r.Get(ctx, client.ObjectKey{Name: utils.OLSAppServerServiceName, Namespace: r.GetNamespace()}, foundService) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new Service", "Service", service.Name) - err = r.Create(ctx, service) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateAPIService, err) - } - r.GetLogger().Info("Service created", "Service", service.Name) - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetAPIService, err) - } - - if utils.ServiceEqual(foundService, service) && foundService.Annotations != nil { - // Check if service-ca annotation matches (or both absent for custom TLS mode) - if cr.Spec.OLSConfig.TLSConfig != nil && cr.Spec.OLSConfig.TLSConfig.KeyCertSecretRef.Name != "" { - // Custom TLS mode - no service-ca annotation expected - r.GetLogger().Info("Service reconciliation skipped", "Service", service.Name) - return nil - } else if foundService.Annotations[utils.ServingCertSecretAnnotationKey] == service.Annotations[utils.ServingCertSecretAnnotationKey] { - // Service-ca mode - check annotation matches - r.GetLogger().Info("Service reconciliation skipped", "Service", service.Name) - return nil - } - } - - // Update the existing Service with desired spec - foundService.Spec = service.Spec - foundService.Annotations = service.Annotations - foundService.Labels = service.Labels - err = r.Update(ctx, foundService) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateAPIService, err) - } - - r.GetLogger().Info("Service reconciled", "Service", service.Name) - return nil -} - -func reconcileDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - desiredDeployment, err := GenerateLCoreDeployment(r, ctx, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAPIDeployment, err) - } - - existingDeployment := &appsv1.Deployment{} - err = r.Get(ctx, client.ObjectKey{Name: "lightspeed-stack-deployment", Namespace: r.GetNamespace()}, existingDeployment) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new deployment", "deployment", desiredDeployment.Name) - err = r.Create(ctx, desiredDeployment) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateAPIDeployment, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetAPIDeployment, err) - } - - err = updateLCoreDeployment(r, ctx, cr, existingDeployment, desiredDeployment) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateAPIDeployment, err) - } - - return nil -} - -func reconcileMetricsReaderSecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - secret, err := GenerateMetricsReaderSecret(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateMetricsReaderSecret, err) - } - foundSecret := &corev1.Secret{} - err = r.Get(ctx, client.ObjectKey{Name: secret.Name, Namespace: r.GetNamespace()}, foundSecret) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new metrics reader secret", "secret", secret.Name) - err = r.Create(ctx, secret) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateMetricsReaderSecret, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetMetricsReaderSecret, err) - } - - if foundSecret.Type != secret.Type || foundSecret.Annotations["kubernetes.io/service-account.name"] != utils.MetricsReaderServiceAccountName { - foundSecret.Type = secret.Type - if foundSecret.Annotations == nil { - foundSecret.Annotations = make(map[string]string) - } - foundSecret.Annotations["kubernetes.io/service-account.name"] = utils.MetricsReaderServiceAccountName - err = r.Update(ctx, foundSecret) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateMetricsReaderSecret, err) - } - } - r.GetLogger().Info("OLS metrics reader secret reconciled", "secret", secret.Name) - return nil -} - -func reconcileServiceMonitor(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - if !r.IsPrometheusAvailable() { - r.GetLogger().Info("Prometheus Operator not available, skipping LCore ServiceMonitor reconciliation") - return nil - } - - sm, err := GenerateServiceMonitor(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateServiceMonitor, err) - } - foundSm := &monv1.ServiceMonitor{} - err = r.Get(ctx, client.ObjectKey{Name: utils.AppServerServiceMonitorName, Namespace: r.GetNamespace()}, foundSm) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new ServiceMonitor", "ServiceMonitor", sm.Name) - err = r.Create(ctx, sm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateServiceMonitor, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetServiceMonitor, err) - } - if utils.ServiceMonitorEqual(sm, foundSm) { - r.GetLogger().Info("ServiceMonitor reconciliation skipped", "ServiceMonitor", sm.Name) - return nil - } - foundSm.Spec = sm.Spec - err = r.Update(ctx, foundSm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateServiceMonitor, err) - } - r.GetLogger().Info("ServiceMonitor reconciled", "ServiceMonitor", sm.Name) - return nil -} - -func reconcilePrometheusRule(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - if !r.IsPrometheusAvailable() { - r.GetLogger().Info("Prometheus Operator not available, skipping LCore PrometheusRule reconciliation") - return nil - } - - pr, err := GeneratePrometheusRule(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGeneratePrometheusRule, err) - } - foundPr := &monv1.PrometheusRule{} - err = r.Get(ctx, client.ObjectKey{Name: utils.AppServerPrometheusRuleName, Namespace: r.GetNamespace()}, foundPr) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new PrometheusRule", "PrometheusRule", pr.Name) - err = r.Create(ctx, pr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreatePrometheusRule, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetPrometheusRule, err) - } - if utils.PrometheusRuleEqual(pr, foundPr) { - r.GetLogger().Info("PrometheusRule reconciliation skipped", "PrometheusRule", pr.Name) - return nil - } - foundPr.Spec = pr.Spec - err = r.Update(ctx, foundPr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdatePrometheusRule, err) - } - r.GetLogger().Info("PrometheusRule reconciled", "PrometheusRule", pr.Name) - return nil -} - -func reconcileTLSSecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - var lastErr error - foundSecret := &corev1.Secret{} - secretName := utils.OLSCertsSecretName - if cr.Spec.OLSConfig.TLSConfig != nil && cr.Spec.OLSConfig.TLSConfig.KeyCertSecretRef.Name != "" { - secretName = cr.Spec.OLSConfig.TLSConfig.KeyCertSecretRef.Name - } - err := wait.PollUntilContextTimeout(ctx, 1*time.Second, utils.ResourceCreationTimeout, true, func(ctx context.Context) (bool, error) { - var getErr error - _, getErr = utils.GetSecretContent(r, ctx, secretName, r.GetNamespace(), []string{"tls.key", "tls.crt"}, foundSecret) - if getErr != nil { - lastErr = fmt.Errorf("secret: %s does not have expected tls.key or tls.crt. error: %w", secretName, getErr) - return false, nil - } - return true, nil - }) - if err != nil { - return fmt.Errorf("%s -%s - wait err %w; last error: %w", utils.ErrGetTLSSecret, utils.OLSCertsSecretName, err, lastErr) - } - - r.GetLogger().Info("LCore TLS secret reconciled", "secret", secretName) - return nil -} - -func reconcileNetworkPolicy(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - np, err := GenerateAppServerNetworkPolicy(r, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAppServerNetworkPolicy, err) - } - foundNp := &networkingv1.NetworkPolicy{} - err = r.Get(ctx, client.ObjectKey{Name: utils.OLSAppServerNetworkPolicyName, Namespace: r.GetNamespace()}, foundNp) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating a new NetworkPolicy", "NetworkPolicy", np.Name) - err = r.Create(ctx, np) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateAppServerNetworkPolicy, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetAppServerNetworkPolicy, err) - } - if utils.NetworkPolicyEqual(np, foundNp) { - r.GetLogger().Info("NetworkPolicy reconciliation skipped", "NetworkPolicy", np.Name) - return nil - } - foundNp.Spec = np.Spec - err = r.Update(ctx, foundNp) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateAppServerNetworkPolicy, err) - } - r.GetLogger().Info("NetworkPolicy reconciled", "NetworkPolicy", np.Name) - return nil -} - -// ============================================================================= -// Test Helper Functions -// ============================================================================= -// The following functions are convenience wrappers used primarily by unit tests. -// Production code should call ReconcileLCoreResources and ReconcileLCoreDeployment directly. - -// ReconcileLCore reconciles all LCore resources in the original order. -// This function is maintained for backward compatibility with existing tests. -// New code should call ReconcileLCoreResources and ReconcileLCoreDeployment separately. -func ReconcileLCore(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - r.GetLogger().Info("reconcileLCore starts") - - // Call Resources phase - if err := ReconcileLCoreResources(r, ctx, olsconfig); err != nil { - return err - } - - // Call Deployment phase - if err := ReconcileLCoreDeployment(r, ctx, olsconfig); err != nil { - return err - } - - r.GetLogger().Info("reconcileLCore completes") - return nil -} diff --git a/internal/controller/lcore/reconciler_test.go b/internal/controller/lcore/reconciler_test.go deleted file mode 100644 index c81eee3f1..000000000 --- a/internal/controller/lcore/reconciler_test.go +++ /dev/null @@ -1,552 +0,0 @@ -package lcore - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - monv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - rbacv1 "k8s.io/api/rbac/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" -) - -var _ = Describe("LCore reconciliator", Ordered, func() { - - Context("Creation logic", Ordered, func() { - BeforeAll(func() { - By("set the OLSConfig custom resource to default") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - crDefault := utils.GetDefaultOLSConfigCR() - cr.Spec = crDefault.Spec - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - }) - - It("should reconcile from OLSConfig custom resource", func() { - By("Reconcile the OLSConfig custom resource") - err := ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - // Note: Status conditions are managed by the main OLSConfigReconciler, - // not by the component-specific reconcilers - }) - - It("should create a service account lightspeed-app-server", func() { - By("Get the service account") - sa := &corev1.ServiceAccount{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerServiceAccountName, Namespace: utils.OLSNamespaceDefault}, sa) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should create a cluster role lightspeed-app-server-sar", func() { - By("Get the cluster role") - cr := &rbacv1.ClusterRole{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerSARRoleName}, cr) - Expect(err).NotTo(HaveOccurred()) - Expect(cr.Rules).NotTo(BeEmpty()) - }) - - It("should create a cluster role binding lightspeed-app-server-sar", func() { - By("Get the cluster role binding") - crb := &rbacv1.ClusterRoleBinding{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerSARRoleBindingName}, crb) - Expect(err).NotTo(HaveOccurred()) - Expect(crb.Subjects).NotTo(BeEmpty()) - Expect(crb.RoleRef.Name).To(Equal(utils.OLSAppServerSARRoleName)) - }) - - It("should create a config map llama-stack-config", func() { - By("Get the config map") - cm := &corev1.ConfigMap{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.LlamaStackConfigCmName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).NotTo(HaveOccurred()) - Expect(cm.Data).To(HaveKey(utils.LlamaStackConfigFilename)) - }) - - It("should create a config map lightspeed-stack-config", func() { - By("Get the config map") - cm := &corev1.ConfigMap{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreConfigCmName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).NotTo(HaveOccurred()) - Expect(cm.Data).To(HaveKey(utils.LCoreConfigFilename)) - }) - - It("should create a service lightspeed-app-server", func() { - By("Get the service") - svc := &corev1.Service{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerServiceName, Namespace: utils.OLSNamespaceDefault}, svc) - Expect(err).NotTo(HaveOccurred()) - Expect(svc.Spec.Ports).NotTo(BeEmpty()) - }) - - It("should create a deployment lightspeed-stack-deployment", func() { - By("Get the deployment") - dep := &appsv1.Deployment{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(2)) - Expect(dep.Spec.Template.Spec.Containers[0].Name).To(Equal(utils.LlamaStackContainerName)) - Expect(dep.Spec.Template.Spec.Containers[1].Name).To(Equal(utils.LCoreContainerName)) - }) - - It("should create a metrics reader secret", func() { - By("Get the metrics reader secret") - secret := &corev1.Secret{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.MetricsReaderServiceAccountTokenSecretName, Namespace: utils.OLSNamespaceDefault}, secret) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should create a service monitor lightspeed-app-server-monitor", func() { - By("Get the service monitor") - sm := &monv1.ServiceMonitor{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.AppServerServiceMonitorName, Namespace: utils.OLSNamespaceDefault}, sm) - Expect(err).NotTo(HaveOccurred()) - Expect(sm.Spec.Endpoints).NotTo(BeEmpty()) - }) - - It("should create a prometheus rule lightspeed-app-server-prometheus-rule", func() { - By("Get the prometheus rule") - pr := &monv1.PrometheusRule{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.AppServerPrometheusRuleName, Namespace: utils.OLSNamespaceDefault}, pr) - Expect(err).NotTo(HaveOccurred()) - Expect(pr.Spec.Groups).NotTo(BeEmpty()) - }) - - It("should create a network policy lightspeed-app-server", func() { - By("Get the network policy") - np := &networkingv1.NetworkPolicy{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerNetworkPolicyName, Namespace: utils.OLSNamespaceDefault}, np) - Expect(err).NotTo(HaveOccurred()) - }) - - // Note: LLM credential validation is now done in annotateExternalResources - // and tested in utils_misc_test.go and olsconfig_helpers_test.go - - }) - - Context("Additional CA ConfigMap reconciliation", Ordered, func() { - const additionalCACMName = "additional-ca-test" - - BeforeAll(func() { - By("Create an additional CA ConfigMap") - cm := &corev1.ConfigMap{} - cm.Name = additionalCACMName - cm.Namespace = utils.OLSNamespaceDefault - cm.Data = map[string]string{ - "ca-cert.crt": "test-ca-cert-content", - } - err := k8sClient.Create(ctx, cm) - Expect(err).NotTo(HaveOccurred()) - }) - - AfterAll(func() { - By("Delete the additional CA ConfigMap") - cm := &corev1.ConfigMap{} - cm.Name = additionalCACMName - cm.Namespace = utils.OLSNamespaceDefault - _ = k8sClient.Delete(ctx, cm) - }) - - It("should reconcile additional CA configmap", func() { - By("Set up an additional CA cert in CR") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.AdditionalCAConfigMapRef = &corev1.LocalObjectReference{ - Name: additionalCACMName, - } - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile the additional CA ConfigMap") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Verify the additional CA configmap exists") - cm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: additionalCACMName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).NotTo(HaveOccurred()) - // Note: Annotation is now handled by main controller, not component reconciler - }) - - It("should skip reconciliation when additional CA is not configured", func() { - By("Remove additional CA from CR") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.AdditionalCAConfigMapRef = nil - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed and skip") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should reconcile successfully with additional CA configured", func() { - By("Set up an additional CA cert in CR") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.AdditionalCAConfigMapRef = &corev1.LocalObjectReference{ - Name: additionalCACMName, - } - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Second reconciliation should also succeed") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - }) - }) - - Context("Proxy CA ConfigMap reconciliation", Ordered, func() { - const proxyCACMName = "proxy-ca-test" - - BeforeAll(func() { - By("Reset the CR to default state") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - crDefault := utils.GetDefaultOLSConfigCR() - cr.Spec = crDefault.Spec - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Create a proxy CA ConfigMap") - cm := &corev1.ConfigMap{} - cm.Name = proxyCACMName - cm.Namespace = utils.OLSNamespaceDefault - cm.Data = map[string]string{ - utils.ProxyCACertFileName: "test-proxy-ca-cert-content", - } - err = k8sClient.Create(ctx, cm) - Expect(err).NotTo(HaveOccurred()) - }) - - AfterAll(func() { - By("Delete the proxy CA ConfigMap") - cm := &corev1.ConfigMap{} - cm.Name = proxyCACMName - cm.Namespace = utils.OLSNamespaceDefault - _ = k8sClient.Delete(ctx, cm) - }) - - It("should annotate proxy CA configmap with watcher annotation", func() { - By("Set up a proxy CA cert in CR") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.ProxyConfig = &olsv1alpha1.ProxyConfig{ - ProxyURL: "https://proxy.example.com:8443", - ProxyCACertificateRef: &olsv1alpha1.ProxyCACertConfigMapRef{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: proxyCACMName, - }, - // Key is omitted - will default to "proxy-ca.crt" for backward compatibility - }, - } - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile the proxy CA ConfigMap") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Verify the proxy CA configmap exists") - cm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: proxyCACMName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).NotTo(HaveOccurred()) - // Note: Annotation is now handled by main controller, not component reconciler - }) - - It("should skip reconciliation when proxy CA is not configured", func() { - By("Remove proxy CA from CR") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.ProxyConfig = nil - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed and skip") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should skip reconciliation when proxy config exists but CA ref is nil", func() { - By("Set up proxy config without CA ref") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.ProxyConfig = &olsv1alpha1.ProxyConfig{ - ProxyURL: "http://proxy.example.com:8080", - ProxyCACertificateRef: nil, - } - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed and skip") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - }) - }) - - Context("Data collector exporter ConfigMap reconciliation", Ordered, func() { - BeforeAll(func() { - By("Create telemetry pull secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.TelemetryPullSecretName, - Namespace: utils.TelemetryPullSecretNamespace, - }, - Data: map[string][]byte{ - ".dockerconfigjson": []byte(`{"auths":{"cloud.openshift.com":{}}}`), - }, - } - err := k8sClient.Create(ctx, secret) - Expect(err).NotTo(HaveOccurred()) - }) - - AfterAll(func() { - By("Delete telemetry pull secret") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.TelemetryPullSecretName, - Namespace: utils.TelemetryPullSecretNamespace, - }, - } - _ = k8sClient.Delete(ctx, secret) - }) - - It("should skip exporter ConfigMap creation when data collection is disabled", func() { - By("Create CR with data collection disabled") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: true, - TranscriptsDisabled: true, - } - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Exporter ConfigMap should not exist") - cm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.ExporterConfigCmName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).To(HaveOccurred()) - }) - - It("should create exporter ConfigMap when data collection is enabled and telemetry is available", func() { - By("Create CR with data collection enabled") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: false, - TranscriptsDisabled: false, - } - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Exporter ConfigMap should exist") - cm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.ExporterConfigCmName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).NotTo(HaveOccurred()) - Expect(cm.Data).To(HaveKey(utils.ExporterConfigFilename)) - Expect(cm.Data[utils.ExporterConfigFilename]).To(ContainSubstring("service_id")) - Expect(cm.Data[utils.ExporterConfigFilename]).To(ContainSubstring("ingress_server_url")) - }) - - It("should delete exporter ConfigMap when data collection is disabled after being enabled", func() { - By("Update CR to disable data collection") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: true, - TranscriptsDisabled: true, - } - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Exporter ConfigMap should be deleted") - cm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.ExporterConfigCmName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).To(HaveOccurred()) - }) - }) - - Context("MCP Server ConfigMap ResourceVersion tracking", Ordered, func() { - BeforeAll(func() { - By("Reset the CR to default state with supported provider") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - crDefault := utils.GetDefaultOLSConfigCR() - cr.Spec = crDefault.Spec - // LCore requires supported Llama Stack provider types - cr.Spec.LLMConfig.Providers[0].Type = "openai" - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile should succeed") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should track MCP Server ConfigMap ResourceVersion and trigger restart on introspection toggle", func() { - By("Disable introspection initially") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile with introspection disabled") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Verify MCP Server ConfigMap does not exist") - mcpCm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OpenShiftMCPServerConfigCmName, Namespace: utils.OLSNamespaceDefault}, mcpCm) - Expect(apierrors.IsNotFound(err)).To(BeTrue(), "MCP ConfigMap should not exist when introspection is disabled") - - By("Get LCore deployment and verify MCP annotation is empty") - dep := &appsv1.Deployment{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - mcpAnnotation := dep.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - Expect(mcpAnnotation).To(BeEmpty(), "MCP annotation should be empty when ConfigMap doesn't exist") - - By("Enable introspection") - err = k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(true) - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile with introspection enabled") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Verify MCP Server ConfigMap was created") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OpenShiftMCPServerConfigCmName, Namespace: utils.OLSNamespaceDefault}, mcpCm) - Expect(err).NotTo(HaveOccurred(), "MCP ConfigMap should exist after enabling introspection") - firstResourceVersion := mcpCm.ResourceVersion - Expect(firstResourceVersion).NotTo(BeEmpty()) - - By("Verify deployment annotation tracks MCP ConfigMap ResourceVersion") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - mcpAnnotation = dep.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - Expect(mcpAnnotation).To(Equal(firstResourceVersion), "Deployment annotation should match ConfigMap ResourceVersion") - - By("Disable introspection again") - err = k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile with introspection disabled again") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Verify MCP Server ConfigMap was deleted") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OpenShiftMCPServerConfigCmName, Namespace: utils.OLSNamespaceDefault}, mcpCm) - Expect(apierrors.IsNotFound(err)).To(BeTrue(), "MCP ConfigMap should be deleted when introspection is disabled") - - By("Verify deployment annotation changed (triggering restart)") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - newMcpAnnotation := dep.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - Expect(newMcpAnnotation).NotTo(Equal(firstResourceVersion), "Annotation should change when ConfigMap is deleted to trigger pod restart") - - By("Verify pod template restart annotation is set") - forceReloadValue := dep.Spec.Template.Annotations[utils.ForceReloadAnnotationKey] - Expect(forceReloadValue).NotTo(BeEmpty(), "Pod template restart annotation should be set to trigger rolling update") - }) - - It("should trigger rolling update when MCP ConfigMap is modified externally", func() { - By("Enable introspection") - err := k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(true) - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Reconcile to create MCP ConfigMap") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Get the MCP ConfigMap and capture initial ResourceVersion") - mcpCm := &corev1.ConfigMap{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OpenShiftMCPServerConfigCmName, Namespace: utils.OLSNamespaceDefault}, mcpCm) - Expect(err).NotTo(HaveOccurred()) - initialResourceVersion := mcpCm.ResourceVersion - - By("Get deployment and capture initial annotation") - dep := &appsv1.Deployment{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - initialAnnotation := dep.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - Expect(initialAnnotation).To(Equal(initialResourceVersion)) - - By("Manually modify MCP ConfigMap data (simulating external change)") - mcpCm.Data["mcp-server-config.toml"] = "# Modified externally" - err = k8sClient.Update(ctx, mcpCm) - Expect(err).NotTo(HaveOccurred()) - - By("Get updated ResourceVersion after manual modification") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OpenShiftMCPServerConfigCmName, Namespace: utils.OLSNamespaceDefault}, mcpCm) - Expect(err).NotTo(HaveOccurred()) - modifiedResourceVersion := mcpCm.ResourceVersion - Expect(modifiedResourceVersion).NotTo(Equal(initialResourceVersion), "ResourceVersion should change after update") - - By("Reconcile again to correct the ConfigMap") - err = ReconcileLCore(testReconcilerInstance, ctx, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Verify ConfigMap was corrected and has new ResourceVersion") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OpenShiftMCPServerConfigCmName, Namespace: utils.OLSNamespaceDefault}, mcpCm) - Expect(err).NotTo(HaveOccurred()) - correctedResourceVersion := mcpCm.ResourceVersion - Expect(correctedResourceVersion).NotTo(Equal(modifiedResourceVersion), "ResourceVersion should change after reconciler correction") - Expect(mcpCm.Data["mcp-server-config.toml"]).NotTo(ContainSubstring("Modified externally"), "ConfigMap should be corrected to proper content") - - By("Verify deployment annotation updated to new ResourceVersion") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.LCoreDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - newAnnotation := dep.Annotations[utils.OpenShiftMCPServerConfigMapResourceVersionAnnotation] - Expect(newAnnotation).To(Equal(correctedResourceVersion), "Deployment annotation should track corrected ConfigMap ResourceVersion") - Expect(newAnnotation).NotTo(Equal(initialAnnotation), "Deployment annotation should change to trigger restart") - - By("Verify pod template restart annotation is set") - forceReloadValue := dep.Spec.Template.Annotations[utils.ForceReloadAnnotationKey] - Expect(forceReloadValue).NotTo(BeEmpty(), "Pod template restart annotation should be set to trigger rolling update") - }) - }) -}) diff --git a/internal/controller/lcore/suite_test.go b/internal/controller/lcore/suite_test.go deleted file mode 100644 index 535959bfc..000000000 --- a/internal/controller/lcore/suite_test.go +++ /dev/null @@ -1,217 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package lcore - -import ( - "context" - "path/filepath" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - configv1 "github.com/openshift/api/config/v1" - monv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "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" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" - //+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 ( - ctx context.Context - cfg *rest.Config - k8sClient client.Client - testEnv *envtest.Environment - cr *olsv1alpha1.OLSConfig - testReconcilerInstance reconciler.Reconciler - crNamespacedName types.NamespacedName -) - -func TestLCore(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "LCore Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - By("bootstrapping test environment") - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{ - filepath.Join("..", "..", "..", "config", "crd", "bases"), - filepath.Join("..", "..", "..", ".testcrds"), - }, - ErrorIfCRDPathMissing: true, - } - - var err error - // cfg is defined in this file globally. - cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) - - err = olsv1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - err = configv1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - err = monv1.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()) - - ctx = context.Background() - - By("Create the ClusterVersion object") - clusterVersion := &configv1.ClusterVersion{ - ObjectMeta: metav1.ObjectMeta{ - Name: "version", - }, - Spec: configv1.ClusterVersionSpec{ - ClusterID: "foobar", - }, - } - err = k8sClient.Create(context.TODO(), clusterVersion) - Expect(err).NotTo(HaveOccurred()) - - clusterVersion.Status = configv1.ClusterVersionStatus{ - Desired: configv1.Release{ - Version: "123.456.789", - }, - } - err = k8sClient.Status().Update(context.TODO(), clusterVersion) - Expect(err).NotTo(HaveOccurred()) - - By("Create the namespace openshift-lightspeed") - ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.OLSNamespaceDefault, - }, - } - err = k8sClient.Create(ctx, ns) - Expect(err).NotTo(HaveOccurred()) - - By("Create the namespace openshift-config") - ns = &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "openshift-config", - }, - } - err = k8sClient.Create(ctx, ns) - Expect(err).NotTo(HaveOccurred()) - - testReconcilerInstance = utils.NewTestReconciler( - k8sClient, - logf.Log.WithName("controller").WithName("OLSConfig"), - scheme.Scheme, - utils.OLSNamespaceDefault, - ) - - // Set default flags for test reconciler (can be overridden in specific tests) - if tr, ok := testReconcilerInstance.(*utils.TestReconciler); ok { - tr.PrometheusAvailable = true - tr.SetLCoreServerMode(true) // Default to server mode (2 containers) - } - - cr = &olsv1alpha1.OLSConfig{} - crNamespacedName = types.NamespacedName{ - Name: "cluster", - } - - By("Create a complete OLSConfig custom resource") - err = k8sClient.Get(ctx, crNamespacedName, cr) - if err != nil && errors.IsNotFound(err) { - cr = utils.GetDefaultOLSConfigCR() - err = k8sClient.Create(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - } else if err == nil { - cr = utils.GetDefaultOLSConfigCR() - err = k8sClient.Update(ctx, cr) - Expect(err).NotTo(HaveOccurred()) - } else { - Fail("Failed to create or update the OLSConfig custom resource") - } - - By("Get the OLSConfig custom resource") - err = k8sClient.Get(ctx, crNamespacedName, cr) - Expect(err).NotTo(HaveOccurred()) - - By("Create the LLM provider credential secret") - secret, _ := utils.GenerateRandomSecret() - secret.Name = "test-secret" - secret.Namespace = utils.OLSNamespaceDefault - secret.SetOwnerReferences([]metav1.OwnerReference{ - { - Kind: "Secret", - APIVersion: "v1", - UID: "ownerUID", - Name: "test-secret", - }, - }) - err = k8sClient.Create(ctx, secret) - Expect(err).NotTo(HaveOccurred()) - - By("Create the TLS secret for LCore") - tlsSecret, _ := utils.GenerateRandomTLSSecret() - tlsSecret.Name = utils.OLSCertsSecretName - tlsSecret.Namespace = utils.OLSNamespaceDefault - tlsSecret.SetOwnerReferences([]metav1.OwnerReference{ - { - Kind: "Secret", - APIVersion: "v1", - UID: "ownerUID", - Name: utils.OLSCertsSecretName, - }, - }) - err = k8sClient.Create(ctx, tlsSecret) - Expect(err).NotTo(HaveOccurred()) -}) - -var _ = AfterSuite(func() { - By("Delete the namespace openshift-lightspeed") - ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.OLSNamespaceDefault, - }, - } - _ = k8sClient.Delete(ctx, ns) - - By("tearing down the test environment") - err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) -}) diff --git a/internal/controller/olsconfig_controller.go b/internal/controller/olsconfig_controller.go index c537a1340..0cad0cef4 100644 --- a/internal/controller/olsconfig_controller.go +++ b/internal/controller/olsconfig_controller.go @@ -19,7 +19,7 @@ limitations under the License. // // This package contains the OLSConfigReconciler, which is the central orchestrator // for the entire operator. It coordinates reconciliation across all components -// (appserver/lcore, postgres, console) and manages the OLSConfig custom resource. +// (appserver, postgres, console) and manages the OLSConfig custom resource. // // The controller code is organized into multiple files: // - olsconfig_controller.go: Core type definition, Reconcile(), and SetupWithManager() @@ -29,7 +29,7 @@ limitations under the License. // // Key Responsibilities: // - Reconcile the OLSConfig custom resource -// - Coordinate component reconciliation (console, postgres, appserver/lcore) +// - Coordinate component reconciliation (console, postgres, appserver) // - Manage status conditions and CR status updates // - Set up resource watchers for automatic updates (secrets, configmaps) // - Manage operator-level resources (service monitors, network policies) @@ -75,7 +75,6 @@ import ( olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" "github.com/openshift/lightspeed-operator/internal/controller/appserver" "github.com/openshift/lightspeed-operator/internal/controller/console" - "github.com/openshift/lightspeed-operator/internal/controller/lcore" "github.com/openshift/lightspeed-operator/internal/controller/postgres" "github.com/openshift/lightspeed-operator/internal/controller/utils" "github.com/openshift/lightspeed-operator/internal/controller/watchers" @@ -297,22 +296,12 @@ func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, }}, } - // Conditionally add either LCore or AppServer resource reconciliation - if r.Options.UseLCore { - resourceSteps = append(resourceSteps, utils.ReconcileSteps{ - Name: "LCore resources", - Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - return lcore.ReconcileLCoreResources(r, ctx, cr) - }, - }) - } else { - resourceSteps = append(resourceSteps, utils.ReconcileSteps{ - Name: "application server resources", - Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - return appserver.ReconcileAppServerResources(r, ctx, cr) - }, - }) - } + resourceSteps = append(resourceSteps, utils.ReconcileSteps{ + Name: "application server resources", + Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return appserver.ReconcileAppServerResources(r, ctx, cr) + }, + }) // Reconcile all independent resources (continue on error to reconcile as many as possible) resourceFailures := make(map[string]error) @@ -380,26 +369,14 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, }, ConditionType: utils.TypeCacheReady, Deployment: utils.PostgresDeploymentName}, } - // Conditionally add either LCore or AppServer deployment reconciliation - if r.Options.UseLCore { - deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ - Name: "LCore deployment", - Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - return lcore.ReconcileLCoreDeployment(r, ctx, cr) - }, - ConditionType: utils.TypeApiReady, - Deployment: "lightspeed-stack-deployment", - }) - } else { - deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ - Name: "application server deployment", - Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - return appserver.ReconcileAppServerDeployment(r, ctx, cr) - }, - ConditionType: utils.TypeApiReady, - Deployment: utils.OLSAppServerDeploymentName, - }) - } + deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ + Name: "application server deployment", + Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return appserver.ReconcileAppServerDeployment(r, ctx, cr) + }, + ConditionType: utils.TypeApiReady, + Deployment: utils.OLSAppServerDeploymentName, + }) // Execute deployment reconciliation (fail-fast on errors) // Create status structure to populate as we check each deployment @@ -530,7 +507,7 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, // 3. Reconciles operator-level resources (ServiceMonitor, NetworkPolicy) // 4. Annotates external resources (secrets, configmaps) for watching // 5. Phase 1: Reconciles independent resources (ConfigMaps, Secrets, ServiceAccounts, Roles, etc.) -// 6. Phase 2: Reconciles deployments (Console UI, Postgres, LCore/AppServer) and updates status +// 6. Phase 2: Reconciles deployments (Console UI, Postgres, AppServer) and updates status // // Returns: // - ctrl.Result{}, nil: Reconciliation completed successfully diff --git a/internal/controller/olsconfig_helpers.go b/internal/controller/olsconfig_helpers.go index 20c4bfaf5..07f50c085 100644 --- a/internal/controller/olsconfig_helpers.go +++ b/internal/controller/olsconfig_helpers.go @@ -26,7 +26,7 @@ import ( // - External resource annotation: Mark user-provided secrets/configmaps for change tracking // Interface implementation methods for reconciler.Reconciler -// These getters allow component packages (appserver, postgres, console, lcore) to access +// These getters allow component packages (appserver, postgres, console) to access // reconciler capabilities without importing the controller package, preventing circular dependencies. func (r *OLSConfigReconciler) GetScheme() *runtime.Scheme { @@ -69,10 +69,6 @@ func (r *OLSConfigReconciler) GetDataverseExporterImage() string { return r.Options.DataverseExporterImage } -func (r *OLSConfigReconciler) GetLCoreImage() string { - return r.Options.LightspeedCoreImage -} - func (r *OLSConfigReconciler) IsPrometheusAvailable() bool { return r.Options.PrometheusAvailable } @@ -81,14 +77,6 @@ func (r *OLSConfigReconciler) GetWatcherConfig() interface{} { return r.WatcherConfig } -func (r *OLSConfigReconciler) UseLCore() bool { - return r.Options.UseLCore -} - -func (r *OLSConfigReconciler) GetLCoreServerMode() bool { - return r.Options.LCoreServerMode -} - // Status management // UpdateStatusCondition updates the complete status of the OLSConfig Custom Resource instance. diff --git a/internal/controller/olsconfig_reconciler_test.go b/internal/controller/olsconfig_reconciler_test.go new file mode 100644 index 000000000..86c65f7cc --- /dev/null +++ b/internal/controller/olsconfig_reconciler_test.go @@ -0,0 +1,261 @@ +package controller + +import ( + "context" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +var _ = Describe("OLSConfig Reconciler Helper Functions", Ordered, func() { + var ( + reconciler *OLSConfigReconciler + cr *olsv1alpha1.OLSConfig + namespace string + ctx context.Context + ) + + BeforeAll(func() { + ctx = context.Background() + namespace = utils.OLSNamespaceDefault + // Set LOCAL_DEV_MODE to skip ServiceMonitor in tests + os.Setenv("LOCAL_DEV_MODE", "true") + }) + + AfterAll(func() { + os.Unsetenv("LOCAL_DEV_MODE") + }) + + BeforeEach(func() { + reconciler = &OLSConfigReconciler{ + Client: k8sClient, + Options: getDefaultReconcilerOptions(namespace), + Logger: logf.Log.WithName("test.reconciler"), + } + + // Create test secret for LLM credentials + testSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "apitoken": []byte("test-token"), + }, + } + _ = k8sClient.Create(ctx, testSecret) + + cr = &olsv1alpha1.OLSConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.OLSConfigName, + }, + Spec: olsv1alpha1.OLSConfigSpec{ + LLMConfig: olsv1alpha1.LLMSpec{ + Providers: []olsv1alpha1.ProviderSpec{ + { + Name: "test-provider", + Type: "openai", + Models: []olsv1alpha1.ModelSpec{ + {Name: "test-model"}, + }, + CredentialsSecretRef: corev1.LocalObjectReference{ + Name: "test-secret", + }, + }, + }, + }, + OLSConfig: olsv1alpha1.OLSSpec{ + DefaultProvider: "test-provider", + DefaultModel: "test-model", + }, + }, + } + }) + + AfterEach(func() { + // Cleanup CR + cleanupOLSConfig(ctx, cr) + + // Cleanup test secret + testSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + } + _ = k8sClient.Delete(ctx, testSecret) + }) + + Describe("getAndValidateCR", func() { + Context("with valid CR name", func() { + It("should return CR when it exists", func() { + err := k8sClient.Create(ctx, cr) + Expect(err).NotTo(HaveOccurred()) + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{Name: utils.OLSConfigName}, + } + + fetchedCR, err := reconciler.getAndValidateCR(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(fetchedCR).NotTo(BeNil()) + Expect(fetchedCR.Name).To(Equal(utils.OLSConfigName)) + }) + + It("should return nil when CR doesn't exist", func() { + req := reconcile.Request{ + NamespacedName: types.NamespacedName{Name: utils.OLSConfigName}, + } + + fetchedCR, err := reconciler.getAndValidateCR(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(fetchedCR).To(BeNil()) + }) + }) + + Context("with invalid CR name", func() { + It("should return nil and not fetch CR", func() { + req := reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "wrong-name"}, + } + + fetchedCR, err := reconciler.getAndValidateCR(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(fetchedCR).To(BeNil()) + }) + }) + }) + + Describe("handleFinalizer", func() { + var req reconcile.Request + + BeforeEach(func() { + req = reconcile.Request{ + NamespacedName: types.NamespacedName{Name: utils.OLSConfigName}, + } + }) + + Context("when finalizer is missing", func() { + It("should add finalizer and return early", func() { + err := k8sClient.Create(ctx, cr) + Expect(err).NotTo(HaveOccurred()) + + // Fetch latest version + err = k8sClient.Get(ctx, types.NamespacedName{Name: cr.Name}, cr) + Expect(err).NotTo(HaveOccurred()) + + result, err := reconciler.handleFinalizer(ctx, req, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(result).NotTo(BeNil(), "should return non-nil to stop reconciliation") + + // Verify finalizer was added + updatedCR := &olsv1alpha1.OLSConfig{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: cr.Name}, updatedCR) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.ContainsFinalizer(updatedCR, utils.OLSConfigFinalizer)).To(BeTrue()) + }) + }) + + Context("when finalizer exists and CR not being deleted", func() { + It("should return nil to continue reconciliation", func() { + controllerutil.AddFinalizer(cr, utils.OLSConfigFinalizer) + err := k8sClient.Create(ctx, cr) + Expect(err).NotTo(HaveOccurred()) + + // Fetch latest version + err = k8sClient.Get(ctx, types.NamespacedName{Name: cr.Name}, cr) + Expect(err).NotTo(HaveOccurred()) + + result, err := reconciler.handleFinalizer(ctx, req, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeNil(), "should return nil to continue reconciliation") + }) + }) + + Context("when CR is being deleted", func() { + It("should run cleanup and remove finalizer", func() { + controllerutil.AddFinalizer(cr, utils.OLSConfigFinalizer) + err := k8sClient.Create(ctx, cr) + Expect(err).NotTo(HaveOccurred()) + + // Delete CR (sets DeletionTimestamp) + err = k8sClient.Delete(ctx, cr) + Expect(err).NotTo(HaveOccurred()) + + // Re-fetch to get DeletionTimestamp + err = k8sClient.Get(ctx, types.NamespacedName{Name: cr.Name}, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(cr.DeletionTimestamp.IsZero()).To(BeFalse()) + + result, err := reconciler.handleFinalizer(ctx, req, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(result).NotTo(BeNil(), "should return non-nil after cleanup") + + // Verify CR is eventually deleted (finalizer removed) + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: cr.Name}, cr) + return apierrors.IsNotFound(err) + }, "10s", "100ms").Should(BeTrue()) + }) + }) + }) + + Describe("reconcileOperatorResources", func() { + It("should not return errors", func() { + err := reconciler.reconcileOperatorResources(ctx) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should be idempotent", func() { + // First call + err := reconciler.reconcileOperatorResources(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Second call should not error + err = reconciler.reconcileOperatorResources(ctx) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should skip ServiceMonitor in LOCAL_DEV_MODE", func() { + os.Setenv("LOCAL_DEV_MODE", "true") + defer os.Unsetenv("LOCAL_DEV_MODE") + + err := reconciler.reconcileOperatorResources(ctx) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("reconcileIndependentResources", func() { + BeforeEach(func() { + err := k8sClient.Create(ctx, cr) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not panic and returns error or success", func() { + err := reconciler.reconcileIndependentResources(ctx, cr) + // May succeed or fail depending on test environment setup + // The important part is it doesn't panic + _ = err + }) + }) + + // reconcileDeploymentsAndStatus is too integration-heavy to test in isolation + // It's extensively tested via the full reconciliation loop in other test files + // Unit testing this function would require mocking entire subsystems + PDescribe("reconcileDeploymentsAndStatus", func() { + It("integration test - skipped in unit tests", func() { + // This function is tested via integration/E2E tests + }) + }) +}) diff --git a/internal/controller/postgres/assets_test.go b/internal/controller/postgres/assets_test.go index 1cf61fe0e..ef33d66c3 100644 --- a/internal/controller/postgres/assets_test.go +++ b/internal/controller/postgres/assets_test.go @@ -289,7 +289,24 @@ var _ = Describe("App postgres server assets", func() { Context("complete custom resource", func() { BeforeEach(func() { - testCr = utils.GetOLSConfigWithCacheCR() + testCr = &olsv1alpha1.OLSConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + Namespace: utils.OLSNamespaceDefault, + UID: "OLSConfig_created_in_getOLSConfigWithCacheCR", + }, + Spec: olsv1alpha1.OLSConfigSpec{ + OLSConfig: olsv1alpha1.OLSSpec{ + ConversationCache: olsv1alpha1.ConversationCacheSpec{ + Type: olsv1alpha1.Postgres, + Postgres: olsv1alpha1.PostgresSpec{ + SharedBuffers: utils.PostgresSharedBuffers, + MaxConnections: utils.PostgresMaxConnections, + }, + }, + }, + }, + } }) It("should generate the OLS postgres deployment", func() { @@ -469,7 +486,13 @@ var _ = Describe("App postgres server assets", func() { Context("empty custom resource", func() { BeforeEach(func() { - testCr = utils.GetNoCacheCR() + testCr = &olsv1alpha1.OLSConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + Namespace: utils.OLSNamespaceDefault, + UID: "OLSConfig_created_in_getNoCacheCR", + }, + } }) It("should generate the OLS postgres deployment", func() { diff --git a/internal/controller/postgres/reconciler_test.go b/internal/controller/postgres/reconciler_test.go index 6ce8b204b..5c6ef9a43 100644 --- a/internal/controller/postgres/reconciler_test.go +++ b/internal/controller/postgres/reconciler_test.go @@ -49,7 +49,19 @@ var _ = Describe("Postgres server reconciliator", Ordered, func() { Expect(err).NotTo(HaveOccurred()) By("Creating default StorageClass") - sc = utils.BuildDefaultStorageClass() + trueVal := true + immediate := storagev1.VolumeBindingImmediate + sc = &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "standard", + Annotations: map[string]string{ + "storageclass.kubernetes.io/is-default-class": "true", + }, + }, + Provisioner: "kubernetes.io/no-provisioner", + AllowVolumeExpansion: &trueVal, + VolumeBindingMode: &immediate, + } storageClassCreationErr := testReconcilerInstance.Create(ctx, sc) Expect(storageClassCreationErr).NotTo(HaveOccurred()) diff --git a/internal/controller/reconciler/interface.go b/internal/controller/reconciler/interface.go index 55db2b822..c525b1e84 100644 --- a/internal/controller/reconciler/interface.go +++ b/internal/controller/reconciler/interface.go @@ -1,6 +1,10 @@ // Package reconciler defines the interface contract between the main OLSConfigReconciler // and component-specific reconcilers (appserver, postgres, console). // +// This package contains only type definitions (no functions), so Go coverage reports +// "no statements" for production code here. *utils.TestReconciler (envtest) and the +// production reconciler satisfy Reconciler wherever components are wired or tested. +// // The Reconciler interface provides a clean abstraction that allows component packages // to access only the functionality they need from the main controller, without creating // circular dependencies or exposing internal implementation details. @@ -60,18 +64,9 @@ type Reconciler interface { // GetDataverseExporterImage returns the OpenShift MCP server image to use GetDataverseExporterImage() string - // GetLCoreImage returns the LCore image to use - GetLCoreImage() string - // IsPrometheusAvailable returns whether Prometheus Operator CRDs are available IsPrometheusAvailable() bool // GetWatcherConfig returns the watcher configuration for external resource monitoring GetWatcherConfig() interface{} - - // UseLCore returns whether LCore backend is enabled instead of AppServer - UseLCore() bool - - // GetLCoreServerMode returns whether LCore should run in server mode (true) or library mode (false) - GetLCoreServerMode() bool } diff --git a/internal/controller/utils/constants.go b/internal/controller/utils/constants.go index 15554160e..793f8b838 100644 --- a/internal/controller/utils/constants.go +++ b/internal/controller/utils/constants.go @@ -229,8 +229,14 @@ const ( PostgresMaxConnections = 2000 // PostgresDefaultSSLMode is the default ssl mode for postgres PostgresDefaultSSLMode = "require" + // LlamaStackDatabaseName is the PostgreSQL database name for llama-stack conversation storage. + // CRITICAL: This value is HARDCODED in llama-stack's internal PostgreSQL adapter. + // DO NOT CHANGE THIS VALUE UNDER ANY CIRCUMSTANCES - llama-stack expects exactly "llamastack". + // Changing this will break llama-stack's database connectivity. + // This database is created in PostgresBootStrapScriptContent. + LlamaStackDatabaseName = "llamastack" // PostgresBootStrapScriptContent is the postgres's bootstrap script content - // NOTE: Database name must match LlamaStackDatabaseName constant (hardcoded by llama-stack) + // NOTE: Database name must match LlamaStackDatabaseName (hardcoded by llama-stack) PostgresBootStrapScriptContent = ` #!/bin/bash @@ -252,10 +258,6 @@ echo "CREATE EXTENSION IF NOT EXISTS pg_trgm;" | _psql -d $POSTGRESQL_DATABASE # Create pg_trgm extension in llama-stack database (for text search if needed) echo "CREATE EXTENSION IF NOT EXISTS pg_trgm;" | _psql -d $DB_NAME -# Create schemas for isolating different components' data -# lcore schema: main lightspeed-stack data (general database operations) -echo "CREATE SCHEMA IF NOT EXISTS lcore;" | _psql -d $POSTGRESQL_DATABASE - # quota schema: token quota tracking and limits echo "CREATE SCHEMA IF NOT EXISTS quota;" | _psql -d $POSTGRESQL_DATABASE @@ -346,8 +348,6 @@ ssl_ca_file = '/etc/certs/cm-olspostgresca/service-ca.crt' ExporterConfigFilename = "config.yaml" // OLSUserDataMountPath is the path where user data is mounted in the app server container OLSUserDataMountPath = "/app-root/ols-user-data" - // LCoreUserDataMountPath is the path where user data is mounted in the lcore container - LCoreUserDataMountPath = "/tmp/data" // ServiceIDOLS is the service ID used by the data exporter ServiceIDOLS = "ols" // RHOSOLightspeedOwnerIDLabel is the label used to identify RHOSO Lightspeed deployment @@ -366,56 +366,10 @@ ssl_ca_file = '/etc/certs/cm-olspostgresca/service-ca.crt' PostgresContainerName = "lightspeed-postgres-server" // OpenShiftMCPServerContainerName is the name of the OpenShift MCP server container OpenShiftMCPServerContainerName = "openshift-mcp-server" - - /*** LCore specific Settings ***/ - // LlamaStackConfigCmName name for the Llama stack config map - LlamaStackConfigCmName = "llama-stack-config" - // LCoreConfigCmName name for the LCore config map - LCoreConfigCmName = "lightspeed-stack-config" - // LlamaStackImageDefault default image for Llama Stack - LlamaStackImageDefault = "quay.io/lightspeed-core/lightspeed-stack:dev-20260125-5f817cd" - // LlamaStackConfigHashKey is the key of the hash value of the Llama Stack configmap - LlamaStackConfigHashKey = "hash/llamastackconfig" - // LCoreDeploymentName is the name of the LCore deployment (used for testing) - LCoreDeploymentName = "lightspeed-stack-deployment" - // LCoreAppLabel is the app label for LCore resources (used for testing) - LCoreAppLabel = "lightspeed-stack" - // LlamaStackContainerName is the name of the Llama Stack container (used for testing) - LlamaStackContainerName = "llama-stack" - // LCoreContainerName is the name of the LCore container (used for testing) - LCoreContainerName = "lightspeed-stack" - // LlamaStackContainerPort is the port for the Llama Stack container (used for testing) - LlamaStackContainerPort = 8321 - // LlamaCacheVolumeName is the name of the Llama cache volume (used for testing) - LlamaCacheVolumeName = "llama-cache" - // LlamaStackConfigFilename is the filename for Llama Stack config (used for testing) - LlamaStackConfigFilename = "run.yaml" - // LCoreConfigFilename is the filename for LCore config (used for testing) - LCoreConfigFilename = "lightspeed-stack.yaml" - // LlamaStackConfigMountPath is the mount path for Llama Stack config file - LlamaStackConfigMountPath = "/app-root/run.yaml" - // LCoreConfigMountPath is the mount path for LCore config file - LCoreConfigMountPath = "/app-root/lightspeed-stack.yaml" - // KubeRootCAMountPath is the mount path for kube-root-ca.crt (used for testing) - KubeRootCAMountPath = "/etc/pki/ca-trust/extracted/pem" - // AdditionalCAMountPath is the mount path for additional CA certificates (used for testing) - AdditionalCAMountPath = "/etc/pki/ca-trust/source/anchors" - // LlamaStackHealthPath is the health check path for Llama Stack (used for testing) - LlamaStackHealthPath = "/v1/health" // OLSConfigMapResourceVersionAnnotation is the annotation key for tracking OLS ConfigMap ResourceVersion OLSConfigMapResourceVersionAnnotation = "ols.openshift.io/olsconfig-configmap-version" - // LlamaStackConfigMapResourceVersionAnnotation is the annotation key for tracking Llama Stack ConfigMap ResourceVersion - LlamaStackConfigMapResourceVersionAnnotation = "ols.openshift.io/llamastack-configmap-version" - // LCoreConfigMapResourceVersionAnnotation is the annotation key for tracking LCore ConfigMap ResourceVersion - LCoreConfigMapResourceVersionAnnotation = "ols.openshift.io/lcore-configmap-version" // OpenShiftMCPServerConfigMapResourceVersionAnnotation is the annotation key for tracking MCP Server ConfigMap ResourceVersion OpenShiftMCPServerConfigMapResourceVersionAnnotation = "ols.openshift.io/mcp-server-configmap-version" - // LlamaStackDatabaseName is the PostgreSQL database name for llama-stack conversation storage. - // CRITICAL: This value is HARDCODED in llama-stack's internal PostgreSQL adapter. - // DO NOT CHANGE THIS VALUE UNDER ANY CIRCUMSTANCES - llama-stack expects exactly "llamastack". - // Changing this will break llama-stack's database connectivity. - // This database is created in PostgresBootStrapScriptContent. - LlamaStackDatabaseName = "llamastack" /*** Environment Variable Suffixes ***/ // EnvVarSuffixAPIKey is the environment variable suffix for API key credentials diff --git a/internal/controller/utils/credentials.go b/internal/controller/utils/credentials.go deleted file mode 100644 index 2c4ecc98d..000000000 --- a/internal/controller/utils/credentials.go +++ /dev/null @@ -1,55 +0,0 @@ -package utils - -import ( - "path" - "strings" -) - -// ProviderSubMountDir returns a filesystem-safe subdirectory name under APIKeyMountRoot -// for mounting a single provider's Vertex credential secret. -func ProviderSubMountDir(providerName string) string { - s := strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - return r - case r == '-': - return r - case r == '_': - return '-' - default: - return '-' - } - }, providerName) - s = strings.ToLower(strings.Trim(s, "-")) - for strings.Contains(s, "--") { - s = strings.ReplaceAll(s, "--", "-") - } - if s == "" { - return "vertex-provider" - } - if len(s) > 63 { - return s[:63] - } - return s -} - -// CredentialsVolumeName returns a unique Kubernetes volume name for a provider's -// Vertex credential secret (DNS-1123 label, max 63 characters). -func CredentialsVolumeName(providerName string) string { - const prefix = "creds-" - suffix := ProviderSubMountDir(providerName) - maxSuffix := 63 - len(prefix) - if len(suffix) > maxSuffix { - suffix = suffix[:maxSuffix] - } - return prefix + suffix -} - -// ProviderCredentialsFilePath is the absolute path to the credential file inside -// the LCore container for the given provider name and secret data key (credentialKey). -func ProviderCredentialsFilePath(providerName, credentialKey string) string { - if credentialKey == "" { - credentialKey = DefaultCredentialKey - } - return path.Join(APIKeyMountRoot, ProviderSubMountDir(providerName), credentialKey) -} diff --git a/internal/controller/utils/resource_defaults_test.go b/internal/controller/utils/resource_defaults_test.go new file mode 100644 index 000000000..98c0b004d --- /dev/null +++ b/internal/controller/utils/resource_defaults_test.go @@ -0,0 +1,72 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +var _ = Describe("Resource defaults and test reconciler", func() { + It("GetResourcesOrDefault returns custom resources when set", func() { + custom := &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")}, + } + def := &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, + } + Expect(GetResourcesOrDefault(custom, def)).To(BeIdenticalTo(custom)) + }) + + It("GetResourcesOrDefault returns defaults when custom is nil", func() { + def := &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")}, + } + Expect(GetResourcesOrDefault(nil, def)).To(BeIdenticalTo(def)) + }) + + It("RestrictedContainerSecurityContext matches restricted pod security expectations", func() { + sc := RestrictedContainerSecurityContext() + Expect(sc).NotTo(BeNil()) + Expect(sc.AllowPrivilegeEscalation).NotTo(BeNil()) + Expect(*sc.AllowPrivilegeEscalation).To(BeFalse()) + Expect(sc.ReadOnlyRootFilesystem).NotTo(BeNil()) + Expect(*sc.ReadOnlyRootFilesystem).To(BeTrue()) + Expect(sc.RunAsNonRoot).NotTo(BeNil()) + Expect(*sc.RunAsNonRoot).To(BeTrue()) + Expect(sc.SeccompProfile).NotTo(BeNil()) + Expect(sc.SeccompProfile.Type).To(Equal(corev1.SeccompProfileTypeRuntimeDefault)) + Expect(sc.Capabilities).NotTo(BeNil()) + Expect(sc.Capabilities.Drop).To(ConsistOf(corev1.Capability("ALL"))) + }) + + It("TestReconciler getters and watcher config reflect NewTestReconciler defaults", func() { + sch := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(sch)) + log := logf.Log.WithName("utils-test") + k8s := fake.NewClientBuilder().WithScheme(sch).Build() + r := NewTestReconciler(k8s, log, sch, "test-ns") + + Expect(r.GetScheme()).To(Equal(sch)) + Expect(r.GetLogger()).To(Equal(log)) + Expect(r.GetNamespace()).To(Equal("test-ns")) + Expect(r.GetPostgresImage()).To(Equal(PostgresServerImageDefault)) + Expect(r.GetConsoleUIImage()).To(Equal(ConsoleUIImageDefault)) + Expect(r.GetOpenShiftMajor()).To(Equal("123")) + Expect(r.GetOpenshiftMinor()).To(Equal("456")) + Expect(r.GetAppServerImage()).To(Equal(OLSAppServerImageDefault)) + Expect(r.GetOpenShiftMCPServerImage()).To(Equal(OLSAppServerImageDefault)) + Expect(r.GetDataverseExporterImage()).To(Equal(DataverseExporterImageDefault)) + Expect(r.IsPrometheusAvailable()).To(BeTrue()) + Expect(r.GetWatcherConfig()).To(BeNil()) + + r.SetWatcherConfig(map[string]string{"k": "v"}) + cfg, ok := r.GetWatcherConfig().(map[string]string) + Expect(ok).To(BeTrue()) + Expect(cfg["k"]).To(Equal("v")) + }) +}) diff --git a/internal/controller/utils/test_fixtures.go b/internal/controller/utils/test_fixtures.go index 05b87c2fc..e6644d4ec 100644 --- a/internal/controller/utils/test_fixtures.go +++ b/internal/controller/utils/test_fixtures.go @@ -4,12 +4,9 @@ import ( "context" "crypto/rand" "encoding/base64" - "path" - "strings" "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -148,43 +145,6 @@ func GetNoCacheCR() *olsv1alpha1.OLSConfig { // OLSConfig Modifier Functions (Builder Pattern) // ======================================== -// WithQueryFilters adds test query filters to an OLSConfig CR. -// This modifies the CR in place and returns it for chaining. -func WithQueryFilters(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.OLSConfig { - cr.Spec.OLSConfig.QueryFilters = []olsv1alpha1.QueryFiltersSpec{ - { - Name: "testFilter", - Pattern: "testPattern", - ReplaceWith: "testReplace", - }, - } - return cr -} - -// WithQuotaLimiters adds test quota limiters to an OLSConfig CR. -// This modifies the CR in place and returns it for chaining. -func WithQuotaLimiters(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.OLSConfig { - cr.Spec.OLSConfig.QuotaHandlersConfig = &olsv1alpha1.QuotaHandlersConfig{ - LimitersConfig: []olsv1alpha1.LimiterConfig{ - { - Name: "my_user_limiter", - Type: "user_limiter", - InitialQuota: 10000, - QuotaIncrease: 100, - Period: "1d", - }, - { - Name: "my_cluster_limiter", - Type: "cluster_limiter", - InitialQuota: 20000, - QuotaIncrease: 200, - Period: "30d", - }, - }, - } - return cr -} - // WithAzureOpenAIProvider configures the first LLM provider as Azure OpenAI. // This modifies the CR in place and returns it for chaining. // Requires that Providers[0] already exists. @@ -196,34 +156,6 @@ func WithAzureOpenAIProvider(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.OLSConfig { return cr } -// WithWatsonxProvider configures the first LLM provider as IBM Watsonx. -// This modifies the CR in place and returns it for chaining. -// Requires that Providers[0] already exists. -func WithWatsonxProvider(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.OLSConfig { - cr.Spec.LLMConfig.Providers[0].Name = "watsonx" - cr.Spec.LLMConfig.Providers[0].Type = "watsonx" - cr.Spec.LLMConfig.Providers[0].WatsonProjectID = "testProjectID" - return cr -} - -// WithRHOAIProvider configures the first LLM provider as RHOAI vLLM. -// This modifies the CR in place and returns it for chaining. -// Requires that Providers[0] already exists. -func WithRHOAIProvider(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.OLSConfig { - cr.Spec.LLMConfig.Providers[0].Name = "rhoai_vllm" - cr.Spec.LLMConfig.Providers[0].Type = "rhoai_vllm" - return cr -} - -// WithRHELAIProvider configures the first LLM provider as RHELAI vLLM. -// This modifies the CR in place and returns it for chaining. -// Requires that Providers[0] already exists. -func WithRHELAIProvider(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.OLSConfig { - cr.Spec.LLMConfig.Providers[0].Name = "rhelai_vllm" - cr.Spec.LLMConfig.Providers[0].Type = "rhelai_vllm" - return cr -} - // WithGoogleVertexProvider configures the first LLM provider as Google Vertex. // This modifies the CR in place and returns it for chaining. // Requires that Providers[0] already exists. @@ -250,20 +182,6 @@ func WithGoogleVertexAnthropicProvider(cr *olsv1alpha1.OLSConfig) *olsv1alpha1.O return cr } -// WithProviderType is a generic helper to configure the first LLM provider with a specific type. -// This is useful when you need to test custom provider configurations. -// This modifies the CR in place and returns it for chaining. -// Requires that Providers[0] already exists. -// -// Example: -// -// cr = utils.WithProviderType(cr, "custom_provider", "custom") -func WithProviderType(cr *olsv1alpha1.OLSConfig, name, providerType string) *olsv1alpha1.OLSConfig { - cr.Spec.LLMConfig.Providers[0].Name = name - cr.Spec.LLMConfig.Providers[0].Type = providerType - return cr -} - // ======================================== // Kubernetes Resource Generators // ======================================== @@ -408,69 +326,3 @@ func DeleteTelemetryPullSecret(ctx context.Context, k8sClient client.Client) { gomega.Expect(err).NotTo(gomega.HaveOccurred()) } } - -// CreateMCPHeaderSecret creates a secret for MCP server header configuration. -// If withValidHeader is true, creates a secret with the correct header key. -// If withValidHeader is false, creates a secret with incorrect/garbage key (for negative tests). -// This function is idempotent - ignores "already exists" errors. -func CreateMCPHeaderSecret(ctx context.Context, k8sClient client.Client, name string, withValidHeader bool) { - headerSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: OLSNamespaceDefault, - }, - } - - if withValidHeader { - headerSecret.Data = map[string][]byte{ - MCPSECRETDATAPATH: []byte(name), - } - } else { - headerSecret.Data = map[string][]byte{ - "garbage": []byte(name), - } - } - - err := k8sClient.Create(ctx, headerSecret) - // Ignore "already exists" errors since the secret may have been created by another test - if err != nil && !apierrors.IsAlreadyExists(err) { - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - } -} - -// ======================================== -// Kubernetes Resource Builders -// ======================================== - -// BuildDefaultStorageClass creates a test StorageClass with standard configuration. -// This is useful for testing PVC-related functionality. -func BuildDefaultStorageClass() *storagev1.StorageClass { - trueVal := true - immediate := storagev1.VolumeBindingImmediate - - return &storagev1.StorageClass{ - ObjectMeta: metav1.ObjectMeta{ - Name: "standard", - Annotations: map[string]string{ - "storageclass.kubernetes.io/is-default-class": "true", - }, - }, - Provisioner: "kubernetes.io/no-provisioner", - AllowVolumeExpansion: &trueVal, - VolumeBindingMode: &immediate, - } -} - -// GetTestPostgresCacheConfig creates a PostgresCacheConfig with default test values. -// This is useful for creating test OLSConfig CRs with Postgres conversation cache. -func GetTestPostgresCacheConfig() PostgresCacheConfig { - return PostgresCacheConfig{ - Host: strings.Join([]string{PostgresServiceName, OLSNamespaceDefault, "svc"}, "."), - Port: PostgresServicePort, - User: PostgresDefaultUser, - DbName: PostgresDefaultDbName, - PasswordPath: path.Join(CredentialsMountRoot, PostgresSecretName, OLSComponentPasswordFileName), - SSLMode: PostgresDefaultSSLMode, - CACertPath: path.Join(OLSAppCertsMountRoot, "postgres-ca", "service-ca.crt"), - } -} diff --git a/internal/controller/utils/testing.go b/internal/controller/utils/testing.go index 648a3d41e..37136fdb3 100644 --- a/internal/controller/utils/testing.go +++ b/internal/controller/utils/testing.go @@ -21,13 +21,10 @@ type TestReconciler struct { AppServerImage string McpServerImage string DataverseExporter string - LCoreImage string openShiftMajor string openShiftMinor string PrometheusAvailable bool watcherConfig interface{} - useLCore bool - lcoreServerMode bool } func (r *TestReconciler) GetScheme() *runtime.Scheme { @@ -70,10 +67,6 @@ func (r *TestReconciler) GetDataverseExporterImage() string { return r.DataverseExporter } -func (r *TestReconciler) GetLCoreImage() string { - return r.LCoreImage -} - func (r *TestReconciler) IsPrometheusAvailable() bool { return r.PrometheusAvailable } @@ -82,26 +75,10 @@ func (r *TestReconciler) GetWatcherConfig() interface{} { return r.watcherConfig } -func (r *TestReconciler) UseLCore() bool { - return r.useLCore -} - -func (r *TestReconciler) GetLCoreServerMode() bool { - return r.lcoreServerMode -} - -func (r *TestReconciler) SetLCoreServerMode(lcoreServerMode bool) { - r.lcoreServerMode = lcoreServerMode -} - func (r *TestReconciler) SetWatcherConfig(config interface{}) { r.watcherConfig = config } -func (r *TestReconciler) SetUseLCore(useLCore bool) { - r.useLCore = useLCore -} - // NewTestReconciler creates a new TestReconciler instance with the provided parameters func NewTestReconciler( client client.Client, @@ -118,7 +95,6 @@ func NewTestReconciler( ConsoleImage: ConsoleUIImageDefault, AppServerImage: OLSAppServerImageDefault, McpServerImage: OLSAppServerImageDefault, - LCoreImage: LlamaStackImageDefault, DataverseExporter: DataverseExporterImageDefault, openShiftMajor: "123", openShiftMinor: "456", diff --git a/internal/controller/utils/types.go b/internal/controller/utils/types.go index 9fd5588da..791993931 100644 --- a/internal/controller/utils/types.go +++ b/internal/controller/utils/types.go @@ -23,9 +23,6 @@ type OLSConfigReconcilerOptions struct { ConsoleUIImage string DataverseExporterImage string OpenShiftMCPServerImage string - LightspeedCoreImage string - UseLCore bool - LCoreServerMode bool Namespace string PrometheusAvailable bool } diff --git a/internal/controller/utils/utils.go b/internal/controller/utils/utils.go index a61a97747..14756a92f 100644 --- a/internal/controller/utils/utils.go +++ b/internal/controller/utils/utils.go @@ -11,7 +11,7 @@ // - Configuration data structures for OLS components // // The utilities in this package are designed to be reusable across all operator -// components (appserver, postgres, console) and promote consistency in resource +// components (`appserver`, `postgres`, `console`) and promote consistency in resource // naming, labeling, and error handling throughout the codebase. package utils @@ -21,7 +21,6 @@ import ( "crypto/sha256" "crypto/x509" "encoding/hex" - "encoding/json" "encoding/pem" "fmt" "os" @@ -45,82 +44,6 @@ import ( "github.com/openshift/lightspeed-operator/internal/controller/reconciler" ) -// setDeploymentContainerEnvs sets the envs for a specific container in a given deployment. -func SetDeploymentContainerEnvs(deployment *appsv1.Deployment, desiredEnvs []corev1.EnvVar, containerName string) (bool, error) { - containerIndex, err := GetContainerIndex(deployment, containerName) - if err != nil { - return false, err - } - existingEnvs := deployment.Spec.Template.Spec.Containers[containerIndex].Env - if !apiequality.Semantic.DeepEqual(existingEnvs, desiredEnvs) { - deployment.Spec.Template.Spec.Containers[containerIndex].Env = desiredEnvs - return true, nil - } - return false, nil -} - -// setDeploymentContainerResources sets the resource requirements for a specific container in a given deployment. -// setDeploymentContainerVolumeMounts sets the volume mounts for a specific container in a given deployment. -func SetDeploymentContainerVolumeMounts(deployment *appsv1.Deployment, containerName string, volumeMounts []corev1.VolumeMount) (bool, error) { - containerIndex, err := GetContainerIndex(deployment, containerName) - if err != nil { - return false, err - } - existingVolumeMounts := deployment.Spec.Template.Spec.Containers[containerIndex].VolumeMounts - if !apiequality.Semantic.DeepEqual(existingVolumeMounts, volumeMounts) { - deployment.Spec.Template.Spec.Containers[containerIndex].VolumeMounts = volumeMounts - return true, nil - } - - return false, nil -} - -// getContainerIndex returns the index of the container with the specified name in a given deployment. -func GetContainerIndex(deployment *appsv1.Deployment, containerName string) (int, error) { - for i, container := range deployment.Spec.Template.Spec.Containers { - if container.Name == containerName { - return i, nil - } - } - return -1, fmt.Errorf("container %s not found in deployment %s", containerName, deployment.Name) -} - -// ProviderNameToEnvVarName converts a provider name to a valid environment variable name. -// Kubernetes resource names typically use hyphens (DNS-1123), but environment variable -// names cannot contain hyphens. This function replaces hyphens with underscores and -// converts to uppercase for consistency with environment variable naming conventions. -// Characters that are not valid in POSIX environment variable names ([A-Za-z0-9_]) -// are stripped before conversion. Hyphens are replaced with underscores. -// IMPORTANT: Names that sanitize to empty or start with digits are prefixed with an underscore -// to ensure compliance with POSIX environment variable naming rules (must not be empty, -// must not start with a digit). -// -// Example: "my-provider" -> "MY_PROVIDER" -// Example: "provider@test" -> "PROVIDERTEST" -// Example: "123provider" -> "_123PROVIDER" -// Example: "!@#$%" -> "_" -func ProviderNameToEnvVarName(providerName string) string { - // Strip characters that are invalid in POSIX environment variable names. - // Only alphanumeric, hyphens (converted below), and underscores are kept. - sanitized := envVarSanitizeRegex.ReplaceAllString(providerName, "") - // Replace hyphens with underscores for valid environment variable names - envVarName := strings.ReplaceAll(sanitized, "-", "_") - // Convert to uppercase for standard environment variable convention - result := strings.ToUpper(envVarName) - - // POSIX env var names must not be empty and must not start with a digit. - // Prefix with underscore to satisfy both rules. - if len(result) == 0 || (result[0] >= '0' && result[0] <= '9') { - result = "_" + result - } - - return result -} - -// envVarSanitizeRegex strips characters that are not valid in environment variable names. -// Keeps alphanumeric, hyphens (converted to underscores later), and underscores. -var envVarSanitizeRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]`) - // GetResourcesOrDefault returns custom resources from CR if specified, otherwise returns defaults. // This is a common pattern used across all component resource getters to avoid repetitive // null-checking logic. It provides a consistent way to handle user-configurable container resources @@ -165,17 +88,17 @@ func RestrictedContainerSecurityContext() *corev1.SecurityContext { // Parameters: // - deployment: The deployment to modify // - config: The PodDeploymentConfig containing the desired settings -// - applyReplicas: Whether to apply the Replicas field (only true for appserver/lcore) +// - applyReplicas: Whether to apply the Replicas field (only true for appserver) // // Usage: // // // For console/postgres (replicas always 1): // utils.ApplyPodDeploymentConfig(deployment, cr.Spec.OLSConfig.DeploymentConfig.ConsoleContainer, false) // -// // For appserver/lcore (replicas configurable): +// // For appserver (replicas configurable): // utils.ApplyPodDeploymentConfig(deployment, cr.Spec.OLSConfig.DeploymentConfig.APIContainer, true) func ApplyPodDeploymentConfig(deployment *appsv1.Deployment, config olsv1alpha1.Config, applyReplicas bool) { - // Apply replicas if allowed (only for appserver/lcore) + // Apply replicas if allowed (only for appserver) if applyReplicas && config.Replicas != nil { deployment.Spec.Replicas = config.Replicas } else { @@ -214,20 +137,6 @@ func GetSecretContent(rclient client.Client, ctx context.Context, secretName str return secretValues, nil } -func GetAllSecretContent(rclient client.Client, ctx context.Context, secretName string, namespace string, foundSecret *corev1.Secret) (map[string]string, error) { - err := rclient.Get(ctx, client.ObjectKey{Name: secretName, Namespace: namespace}, foundSecret) - if err != nil { - return nil, fmt.Errorf("secret not found: %s. error: %w", secretName, err) - } - - secretValues := make(map[string]string) - for key, value := range foundSecret.Data { - secretValues[key] = string(value) - } - - return secretValues, nil -} - // podVolumEqual compares two slices of corev1.Volume and returns true if they are equal. // covers 3 volume types: Secret, ConfigMap, EmptyDir func PodVolumeEqual(a, b []corev1.Volume) bool { @@ -587,29 +496,6 @@ func GetPostgresCAVolumeMount(mountPath string) corev1.VolumeMount { } } -// GetCAFromConfigMap retrieves CA certificate content from a ConfigMap. -// It accepts any key name in the ConfigMap and returns the first value found. -func GetCAFromConfigMap(rclient client.Client, ctx context.Context, namespace, configMapName string) (string, error) { - configMap := &corev1.ConfigMap{} - err := rclient.Get(ctx, client.ObjectKey{ - Name: configMapName, - Namespace: namespace, - }, configMap) - if err != nil { - return "", fmt.Errorf("ConfigMap not found: %s. error: %w", configMapName, err) - } - - if len(configMap.Data) == 0 { - return "", fmt.Errorf("ConfigMap %s is empty (no keys found)", configMapName) - } - - // Use first key found (works for single or multiple keys) - for _, value := range configMap.Data { - return value, nil - } - return "", nil -} - // GetCAFromSecret retrieves CA certificate content from a Secret. // It looks for the "ca.crt" key in the Secret's Data field. // Returns empty string if the key doesn't exist (not an error - CA is optional). @@ -632,29 +518,19 @@ func GetCAFromSecret(rclient client.Client, ctx context.Context, namespace, secr return string(caCert), nil } -// ValidateLLMCredentials validates that all LLM provider credentials are present and valid. -// It checks that each provider's credential secret exists and contains the required keys. -// For generic providers with custom config, it validates the config JSON is well-formed. +// ValidateLLMCredentials validates that all LLM provider credentials are present and usable. +// It rejects unsupported provider type llamaStackGeneric (defensive, for stale CR data). +// For each provider it requires credentialsSecretRef, loads the secret, then checks Data keys: +// Azure OpenAI accepts the default credential key or client_id/tenant_id/client_secret; +// Google Vertex (and Anthropic) use credentialKey when set, otherwise the default key; +// all other supported types require the default credential key. func ValidateLLMCredentials(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { for _, provider := range cr.Spec.LLMConfig.Providers { - // Llama Stack Generic providers require LCore backend (AppServer does not support llamaStackGeneric providers) - // LCore is the future direction; AppServer is being deprecated - if provider.Type == LlamaStackGenericType && !r.UseLCore() { - return fmt.Errorf("LLM provider '%s' uses type '%s' which requires LCore backend. Enable LCore with --enable-lcore operator flag", provider.Name, LlamaStackGenericType) + if provider.Type == LlamaStackGenericType { + return fmt.Errorf("LLM provider '%s' uses type '%s' which is not supported in this operator build", provider.Name, LlamaStackGenericType) } - // Generic providers may operate without credentials (public/unauthenticated endpoints) if provider.CredentialsSecretRef.Name == "" { - if provider.Type == LlamaStackGenericType { - // Still validate Config JSON for public endpoints, even without credentials - if provider.Config != nil && provider.Config.Raw != nil { - var config map[string]interface{} - if err := json.Unmarshal(provider.Config.Raw, &config); err != nil { - return fmt.Errorf("LLM provider %s config is not valid JSON: %w", provider.Name, err) - } - } - continue - } return fmt.Errorf("provider %s missing credentials secret", provider.Name) } @@ -668,33 +544,7 @@ func ValidateLLMCredentials(r reconciler.Reconciler, ctx context.Context, cr *ol } // Validate credential keys based on provider configuration - if provider.ProviderType != "" { - // Generic provider configuration: validate credentialKey exists - credentialKey := provider.CredentialKey - if credentialKey == "" { - credentialKey = DefaultCredentialKey - } - - // Validate credentialKey is not empty (should be caught by CRD validation but double-check) - if strings.TrimSpace(credentialKey) == "" { - return fmt.Errorf("LLM provider %s: credentialKey must not be empty or whitespace", provider.Name) - } - - // Check if the specified credential key exists in secret - if _, ok := secret.Data[credentialKey]; !ok { - return fmt.Errorf("LLM provider %s credential secret %s missing key '%s'", provider.Name, provider.CredentialsSecretRef.Name, credentialKey) - } - - // Validate provider config JSON is well-formed - if provider.Config != nil && provider.Config.Raw != nil { - var config map[string]interface{} - if err := json.Unmarshal(provider.Config.Raw, &config); err != nil { - // Strict validation: reject malformed JSON in generic provider config - return fmt.Errorf("LLM provider %s config is not valid JSON: %w", provider.Name, err) - } - } - - } else if provider.Type == AzureOpenAIType { + if provider.Type == AzureOpenAIType { // Azure OpenAI provider: secret must contain default credential key or 3 keys named "client_id", "tenant_id", "client_secret" if _, ok := secret.Data[DefaultCredentialKey]; ok { continue @@ -840,16 +690,6 @@ func GetProxyCACertHash(r reconciler.Reconciler, ctx context.Context, cr *olsv1a return hex.EncodeToString(hash[:]), nil } -// GetSecretResourceVersion returns the ResourceVersion of a Secret. -func GetSecretResourceVersion(r reconciler.Reconciler, ctx context.Context, secretName string) (string, error) { - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Name: secretName, Namespace: r.GetNamespace()}, secret) - if err != nil { - return "", err - } - return secret.ResourceVersion, nil -} - // The callback function receives: // - name: the secret name // - source: a descriptive identifier of where the secret is used (e.g., "llm-provider-openai", "tls", "mcp-myserver") @@ -940,9 +780,7 @@ func ForEachExternalConfigMap(cr *olsv1alpha1.OLSConfig, fn func(name string, so // ImageStream name length limits (RFC 1123 DNS subdomain label). const ( - // ImageStreamNameMaxLength is the max length for an ImageStream name. - imageStreamNameMaxLength = 63 - // ImageStreamSlugMaxLength is the max length of the slug part in ImageStreamNameFor (NameMaxLength - 1 - 6-char suffix). + // ImageStreamSlugMaxLength is the max length of the slug part in ImageStreamNameFor (max DNS label 63 − 1 − 6-char suffix). imageStreamSlugMaxLength = 55 // imageStreamSHA1SuffixLength is the length of the SHA1 suffix in ImageStreamNameFor. imageStreamSHA1SuffixLength = 6 @@ -954,9 +792,9 @@ var imageStreamNameRegex = regexp.MustCompile(`[^a-z0-9-]+`) // ImageStreamNameFor converts a container image reference (e.g. "quay.io/org/my-image:v1.0") // into a Kubernetes-compatible name suitable for an ImageStream. // Kubernetes names that are used as DNS subdomain labels must follow RFC 1123: a single label -// can be at most ImageStreamNameMaxLength (63) characters. The final name is slug + "-" + suffix, so: +// can be at most 63 characters (DNS label max). The final name is slug + "-" + suffix, so: // -// ImageStreamSlugMaxLength (55) + 1 (hyphen) + 6 (suffix) ≤ ImageStreamNameMaxLength. +// ImageStreamSlugMaxLength (55) + 1 (hyphen) + 6 (suffix) ≤ 63. // // It lowercases the string, // replaces "/", ":", and "@" with underscores, replaces any character that is not [a-z0-9-] diff --git a/internal/controller/utils/utils_deployment_test.go b/internal/controller/utils/utils_deployment_test.go index 578afcc42..044d6724b 100644 --- a/internal/controller/utils/utils_deployment_test.go +++ b/internal/controller/utils/utils_deployment_test.go @@ -235,44 +235,6 @@ var _ = Describe("Deployment Manipulation Functions", func() { }) }) - Describe("SetDeploymentContainerEnvs", func() { - It("should set environment variables when different", func() { - newEnvs := []corev1.EnvVar{ - {Name: "ENV2", Value: "value2"}, - {Name: "ENV3", Value: "value3"}, - } - changed, err := SetDeploymentContainerEnvs(deployment, newEnvs, "app-container") - - Expect(err).NotTo(HaveOccurred()) - Expect(changed).To(BeTrue()) - Expect(deployment.Spec.Template.Spec.Containers[0].Env).To(Equal(newEnvs)) - }) - - It("should not update when envs are the same", func() { - existingEnvs := deployment.Spec.Template.Spec.Containers[0].Env - changed, err := SetDeploymentContainerEnvs(deployment, existingEnvs, "app-container") - - Expect(err).NotTo(HaveOccurred()) - Expect(changed).To(BeFalse()) - }) - - It("should return error for non-existent container", func() { - envs := []corev1.EnvVar{{Name: "ENV1", Value: "value1"}} - _, err := SetDeploymentContainerEnvs(deployment, envs, "non-existent") - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("container non-existent not found")) - }) - - It("should handle empty env vars", func() { - changed, err := SetDeploymentContainerEnvs(deployment, []corev1.EnvVar{}, "app-container") - - Expect(err).NotTo(HaveOccurred()) - Expect(changed).To(BeTrue()) - Expect(deployment.Spec.Template.Spec.Containers[0].Env).To(BeEmpty()) - }) - }) - Describe("DeploymentSpecEqual - Resources", func() { It("should detect when resources are different", func() { desiredDeployment := deployment.DeepCopy() @@ -297,59 +259,6 @@ var _ = Describe("Deployment Manipulation Functions", func() { }) }) - Describe("SetDeploymentContainerVolumeMounts", func() { - It("should set volume mounts when different", func() { - newMounts := []corev1.VolumeMount{ - {Name: "vol2", MountPath: "/config"}, - } - changed, err := SetDeploymentContainerVolumeMounts(deployment, "app-container", newMounts) - - Expect(err).NotTo(HaveOccurred()) - Expect(changed).To(BeTrue()) - Expect(deployment.Spec.Template.Spec.Containers[0].VolumeMounts).To(Equal(newMounts)) - }) - - It("should not update when volume mounts are the same", func() { - existingMounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - changed, err := SetDeploymentContainerVolumeMounts(deployment, "app-container", existingMounts) - - Expect(err).NotTo(HaveOccurred()) - Expect(changed).To(BeFalse()) - }) - - It("should return error for non-existent container", func() { - mounts := []corev1.VolumeMount{{Name: "vol1", MountPath: "/data"}} - _, err := SetDeploymentContainerVolumeMounts(deployment, "non-existent", mounts) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("container non-existent not found")) - }) - }) - - Describe("GetContainerIndex", func() { - It("should return correct index for existing container", func() { - index, err := GetContainerIndex(deployment, "app-container") - - Expect(err).NotTo(HaveOccurred()) - Expect(index).To(Equal(0)) - }) - - It("should return correct index for second container", func() { - index, err := GetContainerIndex(deployment, "sidecar-container") - - Expect(err).NotTo(HaveOccurred()) - Expect(index).To(Equal(1)) - }) - - It("should return error for non-existent container", func() { - _, err := GetContainerIndex(deployment, "non-existent") - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("container non-existent not found")) - Expect(err.Error()).To(ContainSubstring("test-deployment")) - }) - }) - Describe("SetDefaults_Deployment", func() { var deployment *appsv1.Deployment diff --git a/internal/controller/utils/utils_generic_provider_test.go b/internal/controller/utils/utils_generic_provider_test.go deleted file mode 100644 index 25fed77cf..000000000 --- a/internal/controller/utils/utils_generic_provider_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package utils - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - logf "sigs.k8s.io/controller-runtime/pkg/log" -) - -// createTestSecret creates a Secret in the test namespace and registers a -// DeferCleanup to delete it at the end of the enclosing It block. -// This eliminates the need for a shared AfterEach + nil-check pattern. -func createTestSecret(name string, data map[string][]byte) { - s := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: OLSNamespaceDefault}, - Data: data, - } - Expect(k8sClient.Create(testCtx, s)).To(Succeed()) - DeferCleanup(k8sClient.Delete, testCtx, s) -} - -// newGenericReconciler returns a fresh TestReconciler with LCore enabled. -// Calling this at the start of each It block prevents state leaking between tests. -func newGenericReconciler() *TestReconciler { - r := NewTestReconciler(k8sClient, logf.Log.WithName("test"), k8sClient.Scheme(), OLSNamespaceDefault) - r.SetUseLCore(true) - return r -} - -// ─── ProviderNameToEnvVarName – edge cases ─────────────────────────────────── - -var _ = Describe("ProviderNameToEnvVarName", func() { - - DescribeTable("converts provider names to valid env-var identifiers", - func(input, expected string) { - Expect(ProviderNameToEnvVarName(input)).To(Equal(expected)) - }, - // Existing cases (canonical examples) - Entry("hyphen becomes underscore", "my-provider", "MY_PROVIDER"), - Entry("all uppercase passthrough", "PROVIDER", "PROVIDER"), - Entry("lowercase to uppercase", "provider", "PROVIDER"), - Entry("empty string gets underscore prefix (POSIX compliance)", "", "_"), - Entry("mixed case with hyphen", "OpenAI-Provider", "OPENAI_PROVIDER"), - // Edge cases with leading digits - Entry("leading digit gets underscore prefix (POSIX compliance)", "123provider", "_123PROVIDER"), - Entry("digit-only name gets underscore prefix (POSIX compliance)", "12345", "_12345"), - // Special character stripping - Entry("dot is stripped (not alphanumeric/hyphen/underscore)", "my.provider", "MYPROVIDER"), - Entry("at-sign is stripped", "provider@test", "PROVIDERTEST"), - // All special characters result in underscore (collision prevention) - Entry("all special characters sanitize to empty, then prefix", "!@#$%", "_"), - Entry("whitespace-only sanitizes to empty, then prefix", " ", "_"), - ) -}) - -// ─── ValidateLLMCredentials – generic provider scenarios ───────────────────── - -var _ = Describe("ValidateLLMCredentials – generic provider", func() { - - It("fails when the credential secret does not exist", func() { - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialKey = DefaultCredentialKey - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "does-not-exist" - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("does-not-exist not found")) - }) - - It("succeeds when no credentials are configured (public/unauthenticated endpoint)", func() { - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::ollama" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "" - - Expect(ValidateLLMCredentials(r, testCtx, cr)).To(Succeed()) - }) - - It("fails when public generic provider has malformed JSON config", func() { - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::ollama" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "" - cr.Spec.LLMConfig.Providers[0].Config = &runtime.RawExtension{Raw: []byte(`{invalid json`)} - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not valid JSON")) - }) - - It("succeeds when public generic provider has valid JSON config", func() { - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::ollama" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "" - cr.Spec.LLMConfig.Providers[0].Config = &runtime.RawExtension{Raw: []byte(`{"url":"http://localhost:11434"}`)} - - Expect(ValidateLLMCredentials(r, testCtx, cr)).To(Succeed()) - }) - - It("succeeds when public generic provider has no config at all", func() { - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::ollama" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "" - cr.Spec.LLMConfig.Providers[0].Config = nil - - Expect(ValidateLLMCredentials(r, testCtx, cr)).To(Succeed()) - }) - - It("succeeds with a custom credentialKey that exists in the secret", func() { - createTestSecret("gen-custom-key-secret", map[string][]byte{ - "bearer_token": []byte("tok"), - }) - - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialKey = "bearer_token" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "gen-custom-key-secret" - - Expect(ValidateLLMCredentials(r, testCtx, cr)).To(Succeed()) - }) - - It("succeeds with the default credentialKey (apitoken) when credentialKey is omitted", func() { - createTestSecret("gen-default-key-secret", map[string][]byte{ - DefaultCredentialKey: []byte("tok"), - }) - - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialKey = "" // omitted → defaults to "apitoken" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "gen-default-key-secret" - - Expect(ValidateLLMCredentials(r, testCtx, cr)).To(Succeed()) - }) - - It("fails when the secret exists but lacks the specified credentialKey", func() { - createTestSecret("gen-missing-key-secret", map[string][]byte{ - "wrong_key": []byte("tok"), - }) - - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialKey = "expected_key" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "gen-missing-key-secret" - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing key 'expected_key'")) - }) - - It("fails at runtime when credentialKey is whitespace-only", func() { - createTestSecret("gen-whitespace-key-secret", map[string][]byte{ - DefaultCredentialKey: []byte("tok"), - }) - - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialKey = " " - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "gen-whitespace-key-secret" - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("credentialKey must not be empty or whitespace")) - }) - - It("fails when config contains malformed JSON", func() { - createTestSecret("gen-invalid-json-secret", map[string][]byte{ - DefaultCredentialKey: []byte("tok"), - }) - - r := newGenericReconciler() - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialKey = DefaultCredentialKey - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "gen-invalid-json-secret" - cr.Spec.LLMConfig.Providers[0].Config = &runtime.RawExtension{Raw: []byte(`{invalid`)} - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not valid JSON")) - }) - - It("fails when generic provider requires LCore backend but LCore is disabled", func() { - createTestSecret("gen-lcore-required-secret", map[string][]byte{ - DefaultCredentialKey: []byte("tok"), - }) - - r := newGenericReconciler() - r.SetUseLCore(false) // override to disable LCore - - cr := GetDefaultOLSConfigCR() - cr.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - cr.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "gen-lcore-required-secret" - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("requires LCore backend")) - Expect(err.Error()).To(ContainSubstring("--enable-lcore")) - }) - - It("fails on the generic provider even when a valid legacy provider precedes it", func() { - // Scenario: [legacy openai, generic llamaStackGeneric] with LCore disabled. - // The legacy provider passes secret validation; the generic provider must - // still be rejected because LCore is disabled. - createTestSecret("mix-legacy-secret", map[string][]byte{ - DefaultCredentialKey: []byte("tok"), - }) - - r := newGenericReconciler() - r.SetUseLCore(false) - - cr := GetDefaultOLSConfigCR() - // Make Provider[0] a valid legacy openai provider so it passes validation. - cr.Spec.LLMConfig.Providers[0].Type = "openai" - cr.Spec.LLMConfig.Providers[0].ProviderType = "" - cr.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "mix-legacy-secret" - - // Append Provider[1] as a llamaStackGeneric provider. - cr.Spec.LLMConfig.Providers = append(cr.Spec.LLMConfig.Providers, olsv1alpha1.ProviderSpec{ - Name: "generic-mixed", - Type: LlamaStackGenericType, - ProviderType: "remote::openai", - CredentialsSecretRef: corev1.LocalObjectReference{Name: "mix-legacy-secret"}, - Models: []olsv1alpha1.ModelSpec{{Name: "test-model"}}, - }) - - err := ValidateLLMCredentials(r, testCtx, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("requires LCore backend")) - Expect(err.Error()).To(ContainSubstring("generic-mixed")) - }) -}) diff --git a/internal/controller/utils/utils_misc_test.go b/internal/controller/utils/utils_misc_test.go index 71a3e82e1..6a34f77ab 100644 --- a/internal/controller/utils/utils_misc_test.go +++ b/internal/controller/utils/utils_misc_test.go @@ -10,7 +10,6 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -161,38 +160,6 @@ var _ = Describe("StatusHasCondition", func() { }) var _ = Describe("Utility Functions", func() { - Describe("ProviderNameToEnvVarName", func() { - It("should convert provider name to uppercase env var format", func() { - result := ProviderNameToEnvVarName("my-provider") - Expect(result).To(Equal("MY_PROVIDER")) - }) - - It("should handle multiple hyphens", func() { - result := ProviderNameToEnvVarName("my-test-provider-name") - Expect(result).To(Equal("MY_TEST_PROVIDER_NAME")) - }) - - It("should handle already uppercase names", func() { - result := ProviderNameToEnvVarName("PROVIDER") - Expect(result).To(Equal("PROVIDER")) - }) - - It("should handle names without hyphens", func() { - result := ProviderNameToEnvVarName("provider") - Expect(result).To(Equal("PROVIDER")) - }) - - It("should handle empty string", func() { - result := ProviderNameToEnvVarName("") - Expect(result).To(Equal("_")) - }) - - It("should handle mixed case with hyphens", func() { - result := ProviderNameToEnvVarName("OpenAI-Provider") - Expect(result).To(Equal("OPENAI_PROVIDER")) - }) - }) - Describe("SetDefaults_Deployment", func() { var deployment *appsv1.Deployment @@ -979,187 +946,6 @@ cNHlzbRSivTDuHmXJdCYIdd8cnH6EbPm3zNg0jU5Au6OrvDZYifP+DtuiLmJct4= Expect(err).NotTo(HaveOccurred()) }) - It("should validate generic provider with custom credentialKey", func() { - By("Create a test secret with custom key") - testSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-generic-custom-key-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{ - "bearer_token": []byte("test-token"), - }, - } - err := k8sClient.Create(testCtx, testSecret) - Expect(err).NotTo(HaveOccurred()) - - By("Create a test CR with custom credentialKey") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testCR.Spec.LLMConfig.Providers[0].CredentialKey = "bearer_token" - testCR.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "test-generic-custom-key-secret" - testReconciler.SetUseLCore(true) - - By("Check LLM credentials - should succeed") - err = ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should validate generic provider with default credentialKey", func() { - By("Create a test secret with apitoken key") - testSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-generic-default-key-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{ - DefaultCredentialKey: []byte("test-token"), - }, - } - err := k8sClient.Create(testCtx, testSecret) - Expect(err).NotTo(HaveOccurred()) - - By("Create a test CR with default credentialKey (apitoken)") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testCR.Spec.LLMConfig.Providers[0].CredentialKey = DefaultCredentialKey - testCR.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "test-generic-default-key-secret" - testReconciler.SetUseLCore(true) - - By("Check LLM credentials - should succeed") - err = ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should fail when generic provider has whitespace-only credentialKey", func() { - By("Create a test secret") - testSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-generic-whitespace-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{ - DefaultCredentialKey: []byte("test-token"), - }, - } - err := k8sClient.Create(testCtx, testSecret) - Expect(err).NotTo(HaveOccurred()) - - By("Create a test CR with whitespace-only credentialKey") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testCR.Spec.LLMConfig.Providers[0].CredentialKey = " " - testCR.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "test-generic-whitespace-secret" - testReconciler.SetUseLCore(true) - - By("Check LLM credentials - should fail") - err = ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("credentialKey must not be empty or whitespace")) - }) - - It("should fail when generic provider has invalid JSON in Config", func() { - By("Create a test secret") - testSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-generic-invalid-json-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{ - DefaultCredentialKey: []byte("test-token"), - }, - } - err := k8sClient.Create(testCtx, testSecret) - Expect(err).NotTo(HaveOccurred()) - - By("Create a test CR with invalid JSON config") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testCR.Spec.LLMConfig.Providers[0].CredentialKey = DefaultCredentialKey - testCR.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "test-generic-invalid-json-secret" - testCR.Spec.LLMConfig.Providers[0].Config = &runtime.RawExtension{ - Raw: []byte(`{invalid json`), - } - testReconciler.SetUseLCore(true) - - By("Check LLM credentials - should fail due to invalid JSON") - err = ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not valid JSON")) - }) - - It("should fail when generic provider secret is missing the specified credential key", func() { - By("Create a test secret without the expected key") - testSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-generic-missing-key-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{ - "wrong_key": []byte("test-token"), - }, - } - err := k8sClient.Create(testCtx, testSecret) - Expect(err).NotTo(HaveOccurred()) - - By("Create a test CR with credentialKey that doesn't exist in secret") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testCR.Spec.LLMConfig.Providers[0].CredentialKey = "my_api_key" - testCR.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "test-generic-missing-key-secret" - testReconciler.SetUseLCore(true) - - By("Check LLM credentials - should fail due to missing key") - err = ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing key 'my_api_key'")) - }) - - It("should fail when llamaStackGeneric provider is used with LCore disabled", func() { - By("Create a test CR with llamaStackGeneric provider") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testReconciler.SetUseLCore(false) - - By("Check LLM credentials - should fail because LCore is disabled") - err := ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("requires LCore backend")) - Expect(err.Error()).To(ContainSubstring("--enable-lcore")) - }) - - It("should succeed when llamaStackGeneric provider is used with LCore enabled", func() { - By("Create a test secret with custom credentialKey") - testSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-generic-lcore-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{ - DefaultCredentialKey: []byte("test-token"), - }, - } - err := k8sClient.Create(testCtx, testSecret) - Expect(err).NotTo(HaveOccurred()) - - By("Create a test CR with llamaStackGeneric provider and LCore enabled") - testCR := GetDefaultOLSConfigCR() - testCR.Spec.LLMConfig.Providers[0].Type = LlamaStackGenericType - testCR.Spec.LLMConfig.Providers[0].ProviderType = "remote::openai" - testCR.Spec.LLMConfig.Providers[0].CredentialKey = DefaultCredentialKey - testCR.Spec.LLMConfig.Providers[0].CredentialsSecretRef.Name = "test-generic-lcore-secret" - testReconciler.SetUseLCore(true) - - By("Check LLM credentials - should succeed") - err = ValidateLLMCredentials(testReconciler, testCtx, testCR) - Expect(err).NotTo(HaveOccurred()) - }) }) }) @@ -1168,7 +954,7 @@ var _ = Describe("ImageStreamNameFor", func() { image := "quay.io/org/my-image:v1.0" got := ImageStreamNameFor(image) Expect(got).To(MatchRegexp(`^[a-z0-9_-]+-[a-f0-9]{6}$`)) - Expect(len(got)).To(BeNumerically("<=", imageStreamNameMaxLength)) + Expect(len(got)).To(BeNumerically("<=", 63)) // RFC 1123 DNS label max length Expect(got[:len(got)-7]).To(Equal("quay-io-org-my-image-v1-0")) }) @@ -1201,7 +987,7 @@ var _ = Describe("ImageStreamNameFor", func() { got := ImageStreamNameFor(longImage) slugPart := strings.TrimSuffix(got, "-"+got[strings.LastIndex(got, "-")+1:]) Expect(len(slugPart)).To(BeNumerically("<=", imageStreamSlugMaxLength)) - Expect(len(got)).To(BeNumerically("<=", imageStreamNameMaxLength)) + Expect(len(got)).To(BeNumerically("<=", 63)) // RFC 1123 DNS label max length }) It("replaces non-RFC1123 characters in the slug with hyphens", func() { diff --git a/internal/controller/utils/utils_secrets_test.go b/internal/controller/utils/utils_secrets_test.go index 77d5a738b..6ea2b957b 100644 --- a/internal/controller/utils/utils_secrets_test.go +++ b/internal/controller/utils/utils_secrets_test.go @@ -81,47 +81,6 @@ var _ = Describe("Secret Functions", func() { }) }) - Describe("GetAllSecretContent", func() { - It("should retrieve all fields from secret", func() { - foundSecret := &corev1.Secret{} - - result, err := GetAllSecretContent(testClient, ctx, "test-secret-utils", OLSNamespaceDefault, foundSecret) - - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(HaveLen(3)) - Expect(result["username"]).To(Equal("admin")) - Expect(result["password"]).To(Equal("secret123")) - Expect(result["apitoken"]).To(Equal("token456")) - }) - - It("should return error for non-existent secret", func() { - foundSecret := &corev1.Secret{} - - _, err := GetAllSecretContent(testClient, ctx, "non-existent", OLSNamespaceDefault, foundSecret) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("secret not found")) - }) - - It("should handle empty secret", func() { - emptySecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty-secret", - Namespace: OLSNamespaceDefault, - }, - Data: map[string][]byte{}, - } - Expect(testClient.Create(ctx, emptySecret)).To(Succeed()) - defer testClient.Delete(ctx, emptySecret) - - foundSecret := &corev1.Secret{} - result, err := GetAllSecretContent(testClient, ctx, "empty-secret", OLSNamespaceDefault, foundSecret) - - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(BeEmpty()) - }) - }) - Describe("AnnotateSecretWatcher", func() { It("should add watcher annotation to secret with nil annotations", func() { secret := &corev1.Secret{ diff --git a/internal/controller/watchers/watchers.go b/internal/controller/watchers/watchers.go index edcb4e0a0..e91455fff 100644 --- a/internal/controller/watchers/watchers.go +++ b/internal/controller/watchers/watchers.go @@ -16,7 +16,6 @@ import ( olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" "github.com/openshift/lightspeed-operator/internal/controller/appserver" "github.com/openshift/lightspeed-operator/internal/controller/console" - "github.com/openshift/lightspeed-operator/internal/controller/lcore" "github.com/openshift/lightspeed-operator/internal/controller/postgres" "github.com/openshift/lightspeed-operator/internal/controller/reconciler" "github.com/openshift/lightspeed-operator/internal/controller/utils" @@ -238,9 +237,8 @@ func SecretWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj clien inClusterValue = inCluster[0] } - // Get watcherConfig and useLCore from reconciler + // Get watcherConfig from reconciler watcherConfig, _ := r.GetWatcherConfig().(*utils.WatcherConfig) - useLCore := r.UseLCore() // Check 1: Check against configured system secrets (no hardcoded values!) if watcherConfig != nil { @@ -253,7 +251,7 @@ func SecretWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj clien // Restart all affected deployments if inClusterValue { - restartDeployment(r, ctx, systemSecret.AffectedDeployments, systemSecret.Namespace, systemSecret.Name, useLCore) + restartDeployment(r, ctx, systemSecret.AffectedDeployments, systemSecret.Namespace, systemSecret.Name) } return } @@ -283,7 +281,7 @@ func SecretWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj clien "secret", secretName, "affectedDeployments", affectedDeployments) if inClusterValue { - restartDeployment(r, ctx, affectedDeployments, obj.GetNamespace(), secretName, useLCore) + restartDeployment(r, ctx, affectedDeployments, obj.GetNamespace(), secretName) } return } @@ -309,9 +307,8 @@ func ConfigMapWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj cl inClusterValue = inCluster[0] } - // Get watcherConfig and useLCore from reconciler + // Get watcherConfig from reconciler watcherConfig, _ := r.GetWatcherConfig().(*utils.WatcherConfig) - useLCore := r.UseLCore() // Check 1: Check against configured system configmaps (no hardcoded values!) if watcherConfig != nil { @@ -324,7 +321,7 @@ func ConfigMapWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj cl // Restart all affected deployments if inClusterValue { - restartDeployment(r, ctx, systemCM.AffectedDeployments, systemCM.Namespace, systemCM.Name, useLCore) + restartDeployment(r, ctx, systemCM.AffectedDeployments, systemCM.Namespace, systemCM.Name) } return } @@ -354,7 +351,7 @@ func ConfigMapWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj cl "configmap", configMapName, "affectedDeployments", affectedDeployments) if inClusterValue { - restartDeployment(r, ctx, affectedDeployments, obj.GetNamespace(), configMapName, useLCore) + restartDeployment(r, ctx, affectedDeployments, obj.GetNamespace(), configMapName) } return } @@ -367,22 +364,17 @@ type RestartFunc func(reconciler.Reconciler, context.Context, ...*appsv1.Deploym // restartFuncs maps deployment names to their restart functions var restartFuncs = map[string]RestartFunc{ utils.OLSAppServerDeploymentName: appserver.RestartAppServer, - utils.LCoreDeploymentName: lcore.RestartLCore, utils.PostgresDeploymentName: postgres.RestartPostgres, utils.ConsoleUIDeploymentName: console.RestartConsoleUI, } // restart corresponding deployment -func restartDeployment(r reconciler.Reconciler, ctx context.Context, affectedDeployments []string, namespace string, name string, useLCore bool) { +func restartDeployment(r reconciler.Reconciler, ctx context.Context, affectedDeployments []string, namespace string, name string) { for _, depName := range affectedDeployments { // Resolve ACTIVE_BACKEND to actual deployment name if depName == "ACTIVE_BACKEND" { - if useLCore { - depName = utils.LCoreDeploymentName - } else { - depName = utils.OLSAppServerDeploymentName - } + depName = utils.OLSAppServerDeploymentName } // Restart the deployment using the appropriate function diff --git a/internal/controller/watchers/watchers_test.go b/internal/controller/watchers/watchers_test.go index 1b5138120..cd940603d 100644 --- a/internal/controller/watchers/watchers_test.go +++ b/internal/controller/watchers/watchers_test.go @@ -1,18 +1,25 @@ package watchers import ( + "context" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/openshift/lightspeed-operator/internal/controller/reconciler" - "github.com/openshift/lightspeed-operator/internal/controller/utils" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log/zap" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/utils" ) func TestWatchers(t *testing.T) { @@ -20,112 +27,336 @@ func TestWatchers(t *testing.T) { RunSpecs(t, "Watchers Suite") } -// Helper function to create a test reconciler -func createTestReconciler() reconciler.Reconciler { +func testScheme() *runtime.Scheme { s := runtime.NewScheme() - _ = scheme.AddToScheme(s) - _ = corev1.AddToScheme(s) + utilruntime.Must(clientgoscheme.AddToScheme(s)) + utilruntime.Must(olsv1alpha1.AddToScheme(s)) + return s +} - fakeClient := fake.NewClientBuilder().WithScheme(s).Build() +func createTestReconciler(objs ...client.Object) reconciler.Reconciler { + s := testScheme() + b := fake.NewClientBuilder().WithScheme(s) + for _, o := range objs { + b = b.WithObjects(o) + } + fakeClient := b.Build() logger := zap.New(zap.UseDevMode(true)) + tr := utils.NewTestReconciler(fakeClient, logger, s, utils.OLSNamespaceDefault) - testReconciler := utils.NewTestReconciler(fakeClient, logger, s, "default") - - // Create a minimal WatcherConfig for testing watcherConfig := &utils.WatcherConfig{ ConfigMaps: utils.ConfigMapWatcherConfig{ SystemResources: []utils.SystemConfigMap{ - {Name: utils.DefaultOpenShiftCerts, AffectedDeployments: []string{"ACTIVE_BACKEND"}}, + { + Name: utils.DefaultOpenShiftCerts, + Namespace: utils.OLSNamespaceDefault, + AffectedDeployments: []string{"ACTIVE_BACKEND"}, + Description: "test openshift CA", + }, }, }, Secrets: utils.SecretWatcherConfig{ SystemResources: []utils.SystemSecret{ - {Namespace: utils.TelemetryPullSecretNamespace, Name: utils.TelemetryPullSecretName, AffectedDeployments: []string{utils.ConsoleUIDeploymentName}}, + { + Namespace: utils.TelemetryPullSecretNamespace, + Name: utils.TelemetryPullSecretName, + AffectedDeployments: []string{utils.ConsoleUIDeploymentName}, + Description: "test telemetry", + }, }, }, - AnnotatedSecretMapping: make(map[string][]string), - AnnotatedConfigMapMapping: make(map[string][]string), + AnnotatedSecretMapping: map[string][]string{ + "mapped-secret": {utils.PostgresDeploymentName}, + }, + AnnotatedConfigMapMapping: map[string][]string{ + "mapped-cm": {utils.OLSAppServerDeploymentName}, + }, } - - testReconciler.SetWatcherConfig(watcherConfig) - - return testReconciler + tr.SetWatcherConfig(watcherConfig) + return tr } var _ = Describe("Watchers", func() { + ctx := context.Background() - Context("secret event handler", Ordered, func() { - It("should handle secret updates with annotation", func() { - r := createTestReconciler() - handler := &SecretUpdateHandler{Reconciler: r} + Describe("isSecretReferencedInCR", func() { + It("returns true for LLM credential secret referenced on the CR", func() { + cr := utils.GetDefaultOLSConfigCR() + Expect(isSecretReferencedInCR(cr, "test-secret")).To(BeTrue()) + Expect(isSecretReferencedInCR(cr, "not-referenced")).To(BeFalse()) + }) + }) + + Describe("isConfigMapReferencedInCR", func() { + It("returns true only when the CR references the configmap", func() { + cr := utils.GetDefaultOLSConfigCR() + Expect(isConfigMapReferencedInCR(cr, "any")).To(BeFalse()) - oldSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, - Data: map[string][]byte{"key": []byte("old-value")}, + cr.Spec.OLSConfig.AdditionalCAConfigMapRef = &corev1.LocalObjectReference{Name: "extra-ca"} + Expect(isConfigMapReferencedInCR(cr, "extra-ca")).To(BeTrue()) + Expect(isConfigMapReferencedInCR(cr, "other")).To(BeFalse()) + }) + }) + + Describe("SecretWatcherFilter", func() { + It("matches a system secret from WatcherConfig without calling restart when inCluster is false", func() { + r := createTestReconciler() + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.TelemetryPullSecretNamespace, + Name: utils.TelemetryPullSecretName, + }, + Data: map[string][]byte{".dockerconfigjson": []byte("{}")}, } - utils.AnnotateSecretWatcher(oldSecret) + Expect(func() { SecretWatcherFilter(r, ctx, sec, false) }).NotTo(Panic()) + }) - newSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, - Data: map[string][]byte{"key": []byte("new-value")}, + It("matches an annotated secret using AnnotatedSecretMapping when inCluster is false", func() { + r := createTestReconciler() + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.OLSNamespaceDefault, + Name: "mapped-secret", + Annotations: map[string]string{ + utils.WatcherAnnotationKey: "1", + }, + }, + Data: map[string][]byte{"k": []byte("v")}, } - utils.AnnotateSecretWatcher(newSecret) + Expect(func() { SecretWatcherFilter(r, ctx, sec, false) }).NotTo(Panic()) + }) - // The handler's Update method doesn't return anything, it triggers reconciliation - // We can't easily test the reconciliation trigger in a unit test without mocking the queue - // So we just verify the handler can be created and called without panicking - Expect(handler).NotTo(BeNil()) + It("uses default ACTIVE_BACKEND when annotation present but name not in mapping", func() { + r := createTestReconciler() + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.OLSNamespaceDefault, + Name: "unmapped-secret", + Annotations: map[string]string{ + utils.WatcherAnnotationKey: "1", + }, + }, + Data: map[string][]byte{"k": []byte("v")}, + } + Expect(func() { SecretWatcherFilter(r, ctx, sec, false) }).NotTo(Panic()) }) + }) - It("should handle telemetry pull secret by namespace and name", func() { + Describe("ConfigMapWatcherFilter", func() { + It("matches a system configmap from WatcherConfig when inCluster is false", func() { r := createTestReconciler() - handler := &SecretUpdateHandler{Reconciler: r} + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.OLSNamespaceDefault, + Name: utils.DefaultOpenShiftCerts, + }, + Data: map[string]string{"ca-bundle.crt": "dummy"}, + } + Expect(func() { ConfigMapWatcherFilter(r, ctx, cm, false) }).NotTo(Panic()) + }) - _ = &corev1.Secret{ + It("matches an annotated configmap using AnnotatedConfigMapMapping when inCluster is false", func() { + r := createTestReconciler() + cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Namespace: utils.TelemetryPullSecretNamespace, - Name: utils.TelemetryPullSecretName, + Namespace: utils.OLSNamespaceDefault, + Name: "mapped-cm", + Annotations: map[string]string{ + utils.WatcherAnnotationKey: "1", + }, }, - Data: map[string][]byte{"key": []byte("value")}, + Data: map[string]string{"k": "v"}, } + Expect(func() { ConfigMapWatcherFilter(r, ctx, cm, false) }).NotTo(Panic()) + }) + }) - Expect(handler).NotTo(BeNil()) - // Telemetry pull secret should be recognized by the handler + Describe("restartDeployment", func() { + It("skips unknown deployment keys without panicking", func() { + r := createTestReconciler() + Expect(func() { + restartDeployment(r, ctx, []string{"not-a-real-deployment-key"}, utils.OLSNamespaceDefault, "res") + }).NotTo(Panic()) }) }) - Context("configmap event handler", Ordered, func() { - It("should handle configmap updates with annotation", func() { + Describe("SecretUpdateHandler", func() { + It("Update returns early when secret data is unchanged", func() { r := createTestReconciler() - handler := &ConfigMapUpdateHandler{Reconciler: r} + h := &SecretUpdateHandler{Reconciler: r} + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "s"}, + Data: map[string][]byte{"k": []byte("same")}, + } + other := sec.DeepCopy() + h.Update(ctx, event.UpdateEvent{ObjectOld: sec, ObjectNew: other}, nil) + }) + + It("Create is a no-op for non-Secret objects", func() { + r := createTestReconciler() + h := &SecretUpdateHandler{Reconciler: r} + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "c"}} + h.Create(ctx, event.CreateEvent{Object: cm}, nil) + }) - oldConfigMap := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-configmap"}, - Data: map[string]string{"key": "old-value"}, + It("Create skips secrets owned by OLSConfig", func() { + r := createTestReconciler() + h := &SecretUpdateHandler{Reconciler: r} + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "owned", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: utils.OLSConfigAPIVersion, + Kind: utils.OLSConfigKind, + Name: utils.OLSConfigName, + UID: "1", + }}, + }, } - utils.AnnotateConfigMapWatcher(oldConfigMap) + h.Create(ctx, event.CreateEvent{Object: sec}, nil) + }) - newConfigMap := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-configmap"}, - Data: map[string]string{"key": "new-value"}, + It("Update runs when secret data changes (may log deployment get errors)", func() { + cr := utils.GetDefaultOLSConfigCR() + r := createTestReconciler(cr) + h := &SecretUpdateHandler{Reconciler: r} + oldS := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: utils.OLSNamespaceDefault, Name: "mapped-secret"}, + Data: map[string][]byte{"k": []byte("old")}, } - utils.AnnotateConfigMapWatcher(newConfigMap) + utils.AnnotateSecretWatcher(oldS) + newS := oldS.DeepCopy() + newS.Data["k"] = []byte("new") + utils.AnnotateSecretWatcher(newS) + h.Update(ctx, event.UpdateEvent{ObjectOld: oldS, ObjectNew: newS}, nil) + }) - Expect(handler).NotTo(BeNil()) + It("Delete and Generic are no-ops", func() { + r := createTestReconciler() + h := &SecretUpdateHandler{Reconciler: r} + sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "x"}} + h.Delete(ctx, event.DeleteEvent{Object: sec}, nil) + h.Generic(ctx, event.GenericEvent{Object: sec}, nil) + }) + }) + + Describe("ConfigMapUpdateHandler", func() { + It("Update returns early when data and binaryData are unchanged", func() { + r := createTestReconciler() + h := &ConfigMapUpdateHandler{Reconciler: r} + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "c"}, + Data: map[string]string{"k": "v"}, + } + h.Update(ctx, event.UpdateEvent{ObjectOld: cm, ObjectNew: cm.DeepCopy()}, nil) + }) + + It("Create is a no-op for non-ConfigMap objects", func() { + r := createTestReconciler() + h := &ConfigMapUpdateHandler{Reconciler: r} + sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s"}} + h.Create(ctx, event.CreateEvent{Object: sec}, nil) + }) + + It("Create skips configmaps owned by OLSConfig", func() { + r := createTestReconciler() + h := &ConfigMapUpdateHandler{Reconciler: r} + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "owned-cm", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: utils.OLSConfigAPIVersion, + Kind: utils.OLSConfigKind, + Name: utils.OLSConfigName, + UID: "1", + }}, + }, + } + h.Create(ctx, event.CreateEvent{Object: cm}, nil) }) - It("should handle OpenShift default certs configmap by name", func() { + It("Delete and Generic are no-ops", func() { r := createTestReconciler() - handler := &ConfigMapUpdateHandler{Reconciler: r} + h := &ConfigMapUpdateHandler{Reconciler: r} + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "c"}} + h.Delete(ctx, event.DeleteEvent{Object: cm}, nil) + h.Generic(ctx, event.GenericEvent{Object: cm}, nil) + }) + }) - _ = &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: utils.DefaultOpenShiftCerts}, - Data: map[string]string{"ca-bundle.crt": "cert-data"}, + Describe("SecretUpdateHandler Create annotates referenced external secret", func() { + It("annotates and updates a referenced secret when OLSConfig exists", func() { + cr := utils.GetDefaultOLSConfigCR() + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.OLSNamespaceDefault, + Name: "test-secret", + }, + StringData: map[string]string{"x": "y"}, } + r := createTestReconciler(cr, sec) + h := &SecretUpdateHandler{Reconciler: r} + h.Create(ctx, event.CreateEvent{Object: sec}, nil) - Expect(handler).NotTo(BeNil()) - // OpenShift certs configmap should be recognized by the handler + got := &corev1.Secret{} + Expect(r.Get(ctx, client.ObjectKeyFromObject(sec), got)).To(Succeed()) + Expect(got.Annotations).To(HaveKey(utils.WatcherAnnotationKey)) }) }) + Describe("ConfigMapUpdateHandler Create annotates referenced external configmap", func() { + It("annotates and updates a referenced configmap when OLSConfig exists", func() { + cr := utils.GetDefaultOLSConfigCR() + cr.Spec.OLSConfig.AdditionalCAConfigMapRef = &corev1.LocalObjectReference{Name: "user-ca"} + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.OLSNamespaceDefault, + Name: "user-ca", + }, + Data: map[string]string{"ca": "pem"}, + } + r := createTestReconciler(cr, cm) + h := &ConfigMapUpdateHandler{Reconciler: r} + h.Create(ctx, event.CreateEvent{Object: cm}, nil) + + got := &corev1.ConfigMap{} + Expect(r.Get(ctx, client.ObjectKeyFromObject(cm), got)).To(Succeed()) + Expect(got.Annotations).To(HaveKey(utils.WatcherAnnotationKey)) + }) + }) + + Describe("restartDeployment with in-cluster restart", func() { + It("resolves ACTIVE_BACKEND and attempts app server restart", func() { + cr := utils.GetDefaultOLSConfigCR() + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.OLSAppServerDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "ols"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "ols"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "img"}}}, + }, + }, + } + r := createTestReconciler(cr, dep) + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: utils.OLSNamespaceDefault, + Name: "annot", + Annotations: map[string]string{utils.WatcherAnnotationKey: "1"}, + }, + Data: map[string][]byte{"k": []byte("v")}, + } + SecretWatcherFilter(r, ctx, sec, true) + + updated := &appsv1.Deployment{} + Expect(r.Get(ctx, client.ObjectKeyFromObject(dep), updated)).To(Succeed()) + Expect(updated.Spec.Template.Annotations).To(HaveKey(utils.ForceReloadAnnotationKey)) + }) + }) }) diff --git a/test/e2e/client.go b/test/e2e/client.go index e44524a8a..63270e0a9 100644 --- a/test/e2e/client.go +++ b/test/e2e/client.go @@ -497,7 +497,7 @@ func (c *Client) ForwardPortV2(serviceName, namespaceName string, port int) (str // Find the port that matches the requested port for _, svcPort := range service.Spec.Ports { - if svcPort.Port == int32(port) { + if int(svcPort.Port) == port { if svcPort.TargetPort.IntVal != 0 { targetPort = svcPort.TargetPort.IntVal } else { diff --git a/test/e2e/constants.go b/test/e2e/constants.go index 2aadb7114..1cca65636 100644 --- a/test/e2e/constants.go +++ b/test/e2e/constants.go @@ -34,7 +34,7 @@ const ( // AzureClientID is the environment variable containing the client id for azure openai authentication AzureClientID = "AZUREOPENAI_ENTRA_ID_CLIENT_ID" // AzureClientSecret is the environment variable containing the client secret for azure openai authentication - AzureClientSecret = "AZUREOPENAI_ENTRA_ID_CLIENT_SECRET" + AzureClientSecret = "AZUREOPENAI_ENTRA_ID_CLIENT_SECRET" //nolint:gosec // env var name, not a credential // AzureOpenaiTenantID AzureOpenaiTenantID = "tenant_id" // AzureOpenaiClientID @@ -75,7 +75,7 @@ const ( ConditionTimeoutEnvVar = "CONDITION_TIMEOUT" // ServiceAnnotationKeyTLSSecret is the annotation key for TLS secret - ServiceAnnotationKeyTLSSecret = "service.beta.openshift.io/serving-cert-secret-name" + ServiceAnnotationKeyTLSSecret = "service.beta.openshift.io/serving-cert-secret-name" //nolint:gosec // well-known OpenShift annotation key // TestSAName is the name of the test service account TestSAName = "test-sa" // TestSAOutsiderName is the name of the test service account for outsider tests diff --git a/test/e2e/utils.go b/test/e2e/utils.go index 2395e8bb9..a5ddc7ea8 100644 --- a/test/e2e/utils.go +++ b/test/e2e/utils.go @@ -363,7 +363,7 @@ func UpdateRapidastConfig(hostURL, token string) error { newContent := strings.ReplaceAll(string(configContent), "$HOST", hostURL) newContent = strings.ReplaceAll(newContent, "$BEARER_TOKEN", token) - err = os.WriteFile("../../ols-rapidast-config-updated.yaml", []byte(newContent), 0644) + err = os.WriteFile("../../ols-rapidast-config-updated.yaml", []byte(newContent), 0600) //nolint:gosec // test artifact path if err != nil { return fmt.Errorf("error writing config file: %w", err) } @@ -436,13 +436,13 @@ func WriteResourceToFile(client *Client, clusterDir string, filename string, res ctx, cancel := context.WithCancel(client.ctx) defer cancel() // Create file and file handler - f, err := os.OpenFile(clusterDir+"/"+filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(clusterDir+"/"+filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // test artifact dir if err != nil { return fmt.Errorf("failed to create %s: %w", filename, err) } defer func() { _ = f.Close() }() // Execute command and write output to file - cmd, err := exec.CommandContext(ctx, "oc", "get", resource, "-n", OLSNameSpace, "--kubeconfig", client.kubeconfigPath, "-o", "yaml").Output() + cmd, err := exec.CommandContext(ctx, "oc", "get", resource, "-n", OLSNameSpace, "--kubeconfig", client.kubeconfigPath, "-o", "yaml").Output() //nolint:gosec // test helper: oc get if err != nil { return fmt.Errorf("failed to write to %s: %w", filename, err) } @@ -456,19 +456,19 @@ func WriteLogsToFile(client *Client, clusterDir string) error { // Create file and file handler // Execute command and write output to file - pod_names, err := exec.CommandContext(ctx, "oc", "get", "pods", "-o", "name", "--no-headers", "-n", OLSNameSpace, "--kubeconfig", client.kubeconfigPath).Output() + pod_names, err := exec.CommandContext(ctx, "oc", "get", "pods", "-o", "name", "--no-headers", "-n", OLSNameSpace, "--kubeconfig", client.kubeconfigPath).Output() //nolint:gosec // test helper: oc get pods if err != nil { fmt.Printf("failed to get pods: %s \n", err) } pods := strings.Split(string(pod_names), "\n") for _, SinglePod := range pods { if SinglePod != "" { - f, err := os.OpenFile(clusterDir+"/"+SinglePod+".txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(clusterDir+"/"+SinglePod+".txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // test artifact path if err != nil { return fmt.Errorf("failed to create %s: %w", SinglePod, err) } defer func() { _ = f.Close() }() - cmd, err := exec.CommandContext(ctx, "oc", "logs", "-n", OLSNameSpace, SinglePod, "--kubeconfig", client.kubeconfigPath).Output() + cmd, err := exec.CommandContext(ctx, "oc", "logs", "-n", OLSNameSpace, SinglePod, "--kubeconfig", client.kubeconfigPath).Output() //nolint:gosec // test helper: oc logs if err != nil { fmt.Printf("failed to get logs: %s \n", err) } @@ -482,7 +482,7 @@ func WriteLogsToFile(client *Client, clusterDir string) error { // QueryPostgresDB executes a SQL query in the postgres pod and returns the output. func QueryPostgresDB(c *Client, podName, sqlQuery string) (string, error) { - cmd := exec.CommandContext( + cmd := exec.CommandContext( //nolint:gosec // test helper: oc exec psql context.TODO(), "oc", "--kubeconfig", c.kubeconfigPath, @@ -548,11 +548,11 @@ func mustGather(test_case string) error { } llmProvider := os.Getenv(LLMProviderEnvVar) clusterDir := artifact_dir + "/" + llmProvider + "/" + test_case - err = os.MkdirAll(clusterDir, os.ModePerm) + err = os.MkdirAll(clusterDir, 0750) //nolint:gosec // test artifact dir from env if err != nil { return fmt.Errorf("failed to create folder %w", err) } - err = os.MkdirAll(clusterDir+"/pod", os.ModePerm) + err = os.MkdirAll(clusterDir+"/pod", 0750) //nolint:gosec // test artifact dir from env if err != nil { return fmt.Errorf("failed to create folder %w", err) }