From 690b8ed01dd2081f10d45836e43597ef6caed9ab Mon Sep 17 00:00:00 2001 From: blublinsky Date: Thu, 30 Jul 2026 16:22:52 +0100 Subject: [PATCH] OLS-3726 Replace RHOKP sidecar with standalone HTTPS Deployment --- .ai/spec/how/project-structure.md | 14 +- .ai/spec/how/reconciliation.md | 6 +- .ai/spec/what/agentic-sandbox-profile.md | 11 +- .ai/spec/what/app-server.md | 24 +- .ai/spec/what/crd-api.md | 12 +- .ai/spec/what/ocpmcp.md | 4 +- .ai/spec/what/reconciliation.md | 12 +- .ai/spec/what/resource-lifecycle.md | 4 +- .ai/spec/what/rhokp.md | 18 +- .ai/spec/what/tls.md | 10 +- AGENTS.md | 8 +- api/v1alpha1/olsconfig_types.go | 4 +- cmd/main.go | 24 +- .../bases/ols.openshift.io_olsconfigs.yaml | 60 +++- .../controller/agenticintegration/assets.go | 4 + .../agenticintegration/assets_test.go | 19 ++ internal/controller/appserver/assets.go | 205 ++++++------- internal/controller/appserver/assets_test.go | 40 ++- internal/controller/appserver/deployment.go | 145 ++------- .../controller/appserver/deployment_test.go | 105 +------ internal/controller/appserver/reconciler.go | 8 +- .../controller/appserver/reconciler_test.go | 92 +----- internal/controller/appserver/rhokp.go | 52 ---- internal/controller/appserver/rhokp_test.go | 33 --- internal/controller/appserver/suite_test.go | 7 +- internal/controller/ocpmcp/reconciler.go | 9 - internal/controller/ocpmcp/reconciler_test.go | 10 +- internal/controller/olsconfig_controller.go | 74 ++++- internal/controller/olsconfig_helpers.go | 15 +- internal/controller/olsconfig_helpers_test.go | 6 +- internal/controller/rhokp/assets.go | 97 ++++++ internal/controller/rhokp/deployment.go | 277 ++++++++++++++++++ internal/controller/rhokp/reconciler.go | 143 +++++++++ internal/controller/rhokp/reconciler_test.go | 240 +++++++++++++++ internal/controller/rhokp/suite_test.go | 126 ++++++++ internal/controller/utils/constants.go | 49 +++- internal/controller/utils/errors.go | 44 ++- internal/controller/utils/types.go | 7 + internal/controller/watchers/watchers.go | 25 +- internal/controller/watchers/watchers_test.go | 6 +- 40 files changed, 1416 insertions(+), 633 deletions(-) delete mode 100644 internal/controller/appserver/rhokp.go delete mode 100644 internal/controller/appserver/rhokp_test.go create mode 100644 internal/controller/rhokp/assets.go create mode 100644 internal/controller/rhokp/deployment.go create mode 100644 internal/controller/rhokp/reconciler.go create mode 100644 internal/controller/rhokp/reconciler_test.go create mode 100644 internal/controller/rhokp/suite_test.go diff --git a/.ai/spec/how/project-structure.md b/.ai/spec/how/project-structure.md index 3bc728e4f..b6e40b3fd 100644 --- a/.ai/spec/how/project-structure.md +++ b/.ai/spec/how/project-structure.md @@ -114,9 +114,10 @@ External secret/configmap changes -> If changed: SecretWatcherFilter() / ConfigMapWatcherFilter() -> Match against SystemResources list (by name+namespace) -> OR match against WatcherAnnotationKey annotation - -> Resolve "ACTIVE_BACKEND" to appserver deployment name - -> Call RestartAppServer() / RestartPostgres() / RestartConsoleUI() / RestartAgenticConsoleUI() / RestartAlertsAdapter() + -> Call restart function for each affected deployment (appserver, OTEL, MCP, RHOKP, etc.) -> Set force-reload annotation with current timestamp + -> If applicable, call TouchAgenticConfiguration() to update the handoff ConfigMap timestamp + -> If applicable, call TouchAgenticConfiguration() to update the handoff ConfigMap timestamp ``` ## Key Abstractions @@ -127,16 +128,17 @@ Default images are stored in a `defaultImages` map in `cmd/main.go` keyed by log ### WatcherConfig Declarative configuration for external resource watching. Built in `cmd/main.go` and passed via `OLSConfigReconcilerOptions.WatcherConfig`. Contains: - `Secrets.SystemResources`: Fixed list of system secrets with affected deployment names: - - Telemetry pull secret → app server (`ACTIVE_BACKEND`) + - Telemetry pull secret → app server (`lightspeed-app-server`) - `lightspeed-console-plugin-cert` → chat console deployment - `lightspeed-agentic-console-plugin-cert` → agentic console deployment (`AgenticConsoleUIDeploymentName`) - Postgres TLS cert → postgres + app server - - `lightspeed-otel-collector-cert` → OTEL Collector + app server (`ACTIVE_BACKEND`); `RestartAppServer` refreshes client CA Secrets and touches the handoff ConfigMap - - `openshift-mcp-server-tls` → OpenShift MCP server + app server (`ACTIVE_BACKEND`); static SystemResources entry, gated by `OpenShiftMCPServerTLSWatchEnabled` when `spec.ols.introspectionEnabled` is true; same app-server refresh+touch path + - `lightspeed-otel-collector-cert` → OTEL Collector + app server + agentic ConfigMap; `RestartAppServer` refreshes client CA Secrets and touches the handoff ConfigMap + - `openshift-mcp-server-tls` → OpenShift MCP server + app server + agentic ConfigMap; static SystemResources entry, gated by `OpenShiftMCPServerTLSWatchEnabled` when `spec.ols.introspectionEnabled` is true; same app-server refresh+touch path + - `lightspeed-rhokp-tls` → RHOKP + app server + agentic ConfigMap; gated by `RHOKPTLSWatchEnabled` when `!byokRAGOnly`; same refresh+touch path - `ConfigMaps.SystemResources`: Fixed list of system configmaps (kube-root-ca.crt, service-ca bundle) - `AnnotatedSecretMapping`: Dynamic map populated from CR spec at runtime (maps secret name to deployment names) - `AnnotatedConfigMapMapping`: Dynamic map populated from CR spec at runtime (maps configmap name to deployment names) -The special deployment name `"ACTIVE_BACKEND"` resolves to the AppServer deployment name (`lightspeed-app-server`). +All deployment names in `AffectedDeployments` are explicit (e.g. `lightspeed-app-server`, `lightspeed-rhokp`). When the service-ca operator rotates or populates a watched TLS secret, `SecretUpdateHandler` restarts the mapped deployment via `RestartConsoleUI()` or `RestartAgenticConsoleUI()` (registered in `watchers/watchers.go`). diff --git a/.ai/spec/how/reconciliation.md b/.ai/spec/how/reconciliation.md index 014db5348..6819ff015 100644 --- a/.ai/spec/how/reconciliation.md +++ b/.ai/spec/how/reconciliation.md @@ -27,6 +27,9 @@ Reconcile(ctx, req) | | mount at /etc/alerts-adapter when CM exists) | |-- otelcollector.ReconcileOtelCollectorResources() | |-- ocpmcp.ReconcileResources() + | | (when introspectionEnabled; else ocpmcp.Remove()) + | |-- rhokp.ReconcileResources() + | | (when !byokRAGOnly; else rhokp.Remove()) | |-- appserver.ReconcileAppServerResources() | +-- alertsadapter.ReconcileAlertsAdapterResources() | (opt-in via configMapRef; RemoveAlertsAdapter() when disabled; no ConfigMap validation; @@ -37,6 +40,7 @@ Reconcile(ctx, req) |-- postgres.ReconcilePostgresDeployment() |-- otelcollector.ReconcileOtelCollectorDeployment() # OtelCollectorReady |-- ocpmcp.ReconcileDeployment() # MCPServerReady / NotConfigured + |-- rhokp.ReconcileDeployment() # RHOKPReady / NotConfigured |-- appserver.ReconcileAppServerDeployment() |-- alertsadapter.ReconcileAlertsAdapterDeployment() # when configMapRef set | (each deployment step above: checkDeploymentStatus → conditions) @@ -47,7 +51,7 @@ Reconcile(ctx, req) ## Key Abstractions ### Reconciler Interface -The `reconciler.Reconciler` interface breaks the circular dependency between the main controller and component packages. Component packages (appserver, postgres, otelcollector, ocpmcp, agenticintegration, console, agenticconsole, alertsadapter) receive this interface instead of importing the controller package directly. It embeds `client.Client` and adds getter methods for images, namespace, and OpenShift version. +The `reconciler.Reconciler` interface breaks the circular dependency between the main controller and component packages. Component packages (appserver, postgres, otelcollector, ocpmcp, rhokp, agenticintegration, console, agenticconsole, alertsadapter) receive this interface instead of importing the controller package directly. It embeds `client.Client` and adds getter methods for images, namespace, and OpenShift version. ### ReconcileSteps Pattern Both phases use a slice of `ReconcileSteps` structs, each containing a Name, reconcile function, and (for Phase 2) a ConditionType and Deployment name. Phase 1 iterates with continue-on-error; Phase 2 iterates but tracks all conditions and diagnostics. diff --git a/.ai/spec/what/agentic-sandbox-profile.md b/.ai/spec/what/agentic-sandbox-profile.md index 1d17d262d..7445c573a 100644 --- a/.ai/spec/what/agentic-sandbox-profile.md +++ b/.ai/spec/what/agentic-sandbox-profile.md @@ -47,10 +47,13 @@ See also: `templog.md` (collector), `ocpmcp.md` (MCP Service/CA), `crd-api.md` ( 18. App-server mounts these Secrets at `/etc/certs/otel-collector-ca/` and `/etc/certs/openshift-mcp-server-ca/` (projected filename `service-ca.crt` for path compatibility). There is no dedicated MCP inject-cabundle ConfigMap. ### Refresh / rotation -19. Serving-cert watchers restart the server Deployment then the app-server (`ACTIVE_BACKEND`): - - OTEL: `lightspeed-otel-collector-cert` → `RestartOtelCollector` → `RestartAppServer` - - MCP: `openshift-mcp-server-tls` → MCP restart → `RestartAppServer` -20. `RestartAppServer` order: (1) refresh client CA Secrets from `openshift-service-ca.crt` (`RefreshClientCASecrets`), (2) bump annotation `ols.openshift.io/client-ca-reload` on the handoff ConfigMap (`TouchAgenticConfiguration`), (3) **re-Get** the app-server Deployment (current resourceVersion), apply any caller Spec mutations, bump `force-reload`, Update. **Fail-closed:** if step (1) fails (source CA ConfigMap missing/empty), steps (2)–(3) are skipped so pods are not rolled with stale CA material. Retry happens on a later OLSConfig reconcile or watcher event once the source CA is ready. +19. Serving-cert watchers restart the server Deployment then the app-server (`lightspeed-app-server`) and touch `lightspeed-agentic-configuration`: + - OTEL: `lightspeed-otel-collector-cert` → `RestartOtelCollector` + `RestartAppServer` + `TouchAgenticConfiguration` + - MCP: `openshift-mcp-server-tls` → MCP restart + `RestartAppServer` + `TouchAgenticConfiguration` + - RHOKP: `lightspeed-rhokp-tls` → RHOKP restart + `RestartAppServer` + `TouchAgenticConfiguration` + All three targets are declared in `AffectedDeployments` for each secret; the watcher invokes them independently. +20. `RestartAppServer` order: (1) refresh client CA Secrets from `openshift-service-ca.crt` (`RefreshClientCASecrets`), (2) **re-Get** the app-server Deployment (current resourceVersion), apply any caller Spec mutations, bump `force-reload`, Update. **Fail-closed:** if step (1) fails (source CA ConfigMap missing/empty), the app-server roll is skipped so pods are not rolled with stale CA material. Retry happens on a later OLSConfig reconcile or watcher event once the source CA is ready. +21. `TouchAgenticConfiguration` bumps `ols.openshift.io/client-ca-reload` annotation on the handoff ConfigMap so agentic-operator detects the change. It is a separate watcher callback (not part of `RestartAppServer`). 21. `RestartOtelCollector` only rolls the collector; it does **not** refresh agentic artifacts (that work is on the app-server restart path). 22. Agenticintegration ConfigMap reconcile preserves the cert-reload annotation when updating Data/Labels. 23. Content equality skips Secret/ConfigMap updates when Data, Labels, and OwnerReferences are unchanged. diff --git a/.ai/spec/what/app-server.md b/.ai/spec/what/app-server.md index ec38f511d..f8f25d57c 100644 --- a/.ai/spec/what/app-server.md +++ b/.ai/spec/what/app-server.md @@ -5,11 +5,11 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l ## Behavioral Rules ### Deployment Composition -1. The deployment contains a primary API container and up to two sidecar containers (data collector, RHOKP). +1. The deployment contains a primary API container and an optional sidecar container (data collector). 2. The primary container (lightspeed-service-api) runs the OLS service, listening on HTTPS. 3. The data collector sidecar (lightspeed-to-dataverse-exporter) is added when data collection is enabled AND the telemetry pull secret exists in the openshift-config namespace with a cloud.openshift.com auth entry. 4. The OpenShift MCP server runs as a standalone HTTPS Deployment/Service (`ocpmcp` package) when `spec.ols.introspectionEnabled` is true. The app-server connects via `https://openshift-mcp-server..svc:8443/mcp` and trusts client CA Secret `lightspeed-agentic-mcp-ca` (cluster service-ca PEM). See `ocpmcp.md`. -5. [PLANNED: OLS-3697] OKP (Offline Knowledge Portal) / Solr hybrid RAG is operator-managed (no CR toggle besides `byokRAGOnly`). When OKP is enabled, the RHOKP standalone Deployment serves Solr via HTTPS at `https://lightspeed-rhokp..svc:8443`. The app-server connects as a client, trusting the RHOKP service-ca cert via `extra_ca`. OKP is on by default; set `spec.ols.byokRAGOnly` to true to skip the RHOKP standalone operand, `solr_hybrid` config, and OCP documentation retrieval via Solr. See `rhokp.md`. +5. OKP (Offline Knowledge Portal) / Solr hybrid RAG is operator-managed (no CR toggle besides `byokRAGOnly`). When OKP is enabled, the RHOKP standalone Deployment serves Solr via HTTPS at `https://lightspeed-rhokp..svc:8443`. The app-server connects as a client, trusting client CA Secret `lightspeed-agentic-rhokp-ca` (cluster service-ca PEM) via `extra_ca`. OKP is on by default; set `spec.ols.byokRAGOnly` to true to skip the RHOKP standalone operand, `solr_hybrid` config, and OCP documentation retrieval via Solr. See `rhokp.md`. 6. A PostgreSQL wait init container always runs before the main containers to ensure database readiness. 7. When `spec.ols.rag` is configured, additional init containers copy BYOK RAG data from container images into a shared volume. @@ -20,7 +20,7 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l 11. PostgreSQL connection settings are hardcoded to point to the operator-managed PostgreSQL service within the same namespace. 12. If `spec.ols.querySystemPrompt` is set, the custom prompt is written as a second key in the config ConfigMap and referenced by file path in the config. 13. BYOK reference content indexes from `spec.ols.rag` are written to `reference_content.indexes` when present. OCP product documentation is served exclusively via `solr_hybrid` (OKP); the operator does not emit a built-in OCP FAISS index. -14. [PLANNED: OLS-3697] Unless `byokRAGOnly` is true, the operator generates a `solr_hybrid` config section in `olsconfig.yaml` pointing to `https://lightspeed-rhokp..svc:8443` with default hybrid retrieval tuning parameters. +14. Unless `byokRAGOnly` is true, the operator generates a `solr_hybrid` config section in `olsconfig.yaml` pointing to `https://lightspeed-rhokp..svc:8443` with default hybrid retrieval tuning parameters. 15a. Unless `byokRAGOnly` is true, the app-server container receives `OCP_CLUSTER_VERSION` (`.` from the operator's cluster-version lookup) for Solr `chunk_filter_query` resolution in lightspeed-service. ### ROSA-Aware OKP Retrieval @@ -47,8 +47,9 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l 23. The app-server service account can read the cluster version and the telemetry pull secret. ### Change Detection -24. Deployment updates are triggered when: the deployment spec changes, the config ConfigMap resource version changes, the proxy CA certificate hash changes, or (when introspection is enabled) the MCP client CA Secret content hash changes. -25. When any of these change, the operator forces a rolling restart by updating a pod template annotation with the current timestamp. +24. Deployment updates are triggered when: the deployment spec changes, the config ConfigMap resource version changes, or the proxy CA certificate hash changes. +25. Client CA Secrets (OTEL, MCP, RHOKP) are refreshed via the table-driven `RefreshClientCASecrets` in `RestartAppServer`. The watcher detects TLS secret rotation and invokes `RestartAppServer`, which re-reads the service-ca ConfigMap and updates each enabled client CA Secret. No hash annotation is stored on the Deployment. +26. When any change is detected, the operator forces a rolling restart by updating a pod template annotation with the current timestamp. ### Health Probes [PLANNED: OLS-3221] 26. The app server deployment's liveness probe must point to the `/liveness` endpoint with `failureThreshold: 3` and `periodSeconds: 30`, giving the pod 90 seconds to self-heal via the background health-check loop before Kubernetes restarts it. These values are not currently user-configurable. @@ -68,7 +69,7 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l | `spec.ols.deployment.api.nodeSelector` | Node selector constraints | | `spec.ols.deployment.dataCollector.resources` | Data collector container resources | | `spec.ols.deployment.mcpServer` | Standalone MCP Deployment settings (`Config`: replicas, resources, tolerations, nodeSelector) | -| `spec.ols.deployment.rhokp` | Standalone RHOKP Deployment settings (`Config`: replicas, resources, tolerations, nodeSelector) [PLANNED: OLS-3697] | +| `spec.ols.deployment.rhokp` | Standalone RHOKP Deployment settings (`Config`: replicas, resources, tolerations, nodeSelector) | | `spec.ols.defaultModel` | Default LLM model name | | `spec.ols.defaultProvider` | Default LLM provider name | | `spec.ols.logLevel` | Logging level for all service components | @@ -92,15 +93,15 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l 2. Tool filtering requires MCP servers to be configured (either introspection or user-defined). 3. The service always connects to PostgreSQL via the internal cluster service DNS. 4. RAG init containers run in index order, copying data to subdirectories of the shared RAG volume. -5. [PLANNED: OLS-3697] RHOKP runs as a standalone Deployment (`lightspeed-rhokp`) with its own 75 GiB EmptyDir. The app-server pod no longer requires ephemeral storage for OKP. See `rhokp.md`. +5. RHOKP runs as a standalone Deployment (`lightspeed-rhokp`) with its own 75 GiB EmptyDir. The app-server pod no longer requires ephemeral storage for OKP. See `rhokp.md`. ### Resource Conventions [OLS-3397] 30. All operator-managed container defaults follow the [OpenShift resource conventions](https://github.com/openshift/enhancements/blob/master/CONVENTIONS.md#resources-and-limits): defaults declare CPU and memory requests only, and do not set resource limits. This applies to the primary API container, sidecars (data collector), the standalone MCP Deployment, and the standalone RHOKP Deployment. 31. Users may still set limits via the CRD (`spec.ols.deployment..resources`, including `spec.ols.deployment.rhokp.resources`) if their environment requires it. The CRD uses standard `corev1.ResourceRequirements` which accepts both requests and limits. -32. [PLANNED: OLS-3697] The RHOKP standalone Deployment's ~75 GiB EmptyDir sizeLimit is unchanged by this convention — it applies only to CPU and memory. +32. The RHOKP standalone Deployment's ~75 GiB EmptyDir sizeLimit is unchanged by this convention — it applies only to CPU and memory. ### RHOKP Image -33. [PLANNED: OLS-3697] The RHOKP standalone Deployment image is set via the operator `--rhokp-image` startup flag. Default comes from `related_images.json` entry `rhokp` (`utils.RHOOKPImageDefault` / `imageDefaultOr`). The OLM bundle lists it in CSV `spec.relatedImages` and passes the image via `--rhokp-image` on the manager deployment. See `rhokp.md`. +33. The RHOKP standalone Deployment image is set via the operator `--rhokp-image` startup flag. Default comes from `related_images.json` entry `rhokp` (`utils.RHOOKPImageDefault` / `imageDefaultOr`). The OLM bundle lists it in CSV `spec.relatedImages` and passes the image via `--rhokp-image` on the manager deployment. See `rhokp.md`. ### Agentic Sandbox Configuration Handoff [PLANNED: OLS-3572] @@ -109,7 +110,7 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l - `sandbox-mode`: `bare-pod` or `sandbox-claim` from `OLSConfig.spec.agenticOLS.sandboxMode`. - `mcp-endpoint`: MCP server endpoint URL (when ocp-mcp is deployed as standalone HTTPS service). - `otel-endpoint`: OTEL collector gRPC endpoint (when templog collector is deployed). - - `rhokp-endpoint`: RHOKP Solr HTTPS endpoint URL (when OKP is enabled, i.e., `!byokRAGOnly`). [PLANNED: OLS-3697] + - `rhokp-endpoint`: RHOKP Solr HTTPS endpoint URL (when OKP is enabled, i.e., `!byokRAGOnly`). 35. The ConfigMap is always created during reconciliation. Keys are absent when the corresponding feature is not enabled. The `sandbox-pod-spec` key is always present. @@ -118,6 +119,5 @@ The App Server is the backend deployment for OpenShift Lightspeed. It runs the l ## Planned Changes - [PLANNED: OLS-3221] Liveness probe now checks PostgreSQL health via the service's background health-check loop status. Probe configuration (failureThreshold, periodSeconds) added to deployment generation. See Rules 24–25. -- Classic→agentic sandbox handoff: appserver owns client CA Secrets (`lightspeed-agentic-otel-ca` / `lightspeed-agentic-mcp-ca`) and mounts them; `agenticintegration` owns the handoff ConfigMap — see `agentic-sandbox-profile.md` (OLS-3683 / OLS-3684). Optional agentic auto-injection remains deferred ([OLS-3594](https://redhat.atlassian.net/browse/OLS-3594)). +- Classic→agentic sandbox handoff: appserver owns client CA Secrets (`lightspeed-agentic-otel-ca` / `lightspeed-agentic-mcp-ca` / `lightspeed-agentic-rhokp-ca`) and mounts them; `agenticintegration` owns the handoff ConfigMap — see `agentic-sandbox-profile.md` (OLS-3683 / OLS-3684). Optional agentic auto-injection remains deferred ([OLS-3594](https://redhat.atlassian.net/browse/OLS-3594)). - [PLANNED: OLS-3572] Agentic sandbox configuration handoff — classic operator builds base PodSpec and writes `lightspeed-sandbox-config` ConfigMap for the agentic operator. See Rules 34–36. -- [PLANNED: OLS-3697] RHOKP standalone HTTPS cutover — sidecar removed from app-server, standalone `lightspeed-rhokp` Deployment/Service created. `solr_hybrid.solr_http_base` changes to HTTPS cluster DNS. RHOKP CA added to `extra_ca`. `spec.ols.deployment.rhokp` type changes from `ContainerConfig` to `Config`. Handoff ConfigMap gains `rhokp-endpoint`. See `rhokp.md`. diff --git a/.ai/spec/what/crd-api.md b/.ai/spec/what/crd-api.md index 86e256e36..07cc11368 100644 --- a/.ai/spec/what/crd-api.md +++ b/.ai/spec/what/crd-api.md @@ -153,7 +153,7 @@ Field path (relative to `spec.ols.deployment`) | JSON key | Go type | Notes `api` | `api` | `Config` | API container. Replicas configurable (default 1, min 0) `dataCollector` | `dataCollector` | `ContainerConfig` | Data collector container. Resources only `mcpServer` | `mcpServer` | `Config` | Standalone OpenShift MCP server Deployment (replicas, resources, tolerations, nodeSelector) -`rhokp` | `rhokp` | `Config` | [PLANNED: OLS-3697] Standalone RHOKP Deployment (Solr / OKP). Replicas (forced to 1), resources, tolerations, nodeSelector +`rhokp` | `rhokp` | `Config` | Standalone RHOKP Deployment (Solr / OKP). Replicas (forced to 1), resources, tolerations, nodeSelector `console` | `console` | `Config` | Console container. Has replicas field but operator forces 1 `database` | `database` | `Config` | Database container. Has replicas field but operator forces 1 `alertsAdapter` | `alertsAdapter` | `AlertsAdapterSpec` | Agentic alerts adapter deployment and user-managed runtime config reference. Replicas forced to 1 @@ -162,7 +162,7 @@ Field path (relative to `spec.ols.deployment`) | JSON key | Go type | Notes `agenticConsole` | `agenticConsole` | `Config` | Agentic console plugin container. Replicas forced to 1 `otelCollector` | `otelCollector` | `Config` | OTEL Collector container ([OLS-3510](https://redhat.atlassian.net/browse/OLS-3510)). Replicas forced to 1 -20. Replicas are user-configurable for the API container (`spec.ols.deployment.api.replicas`). For console, database, alerts adapter, agentic console, otel collector, and RHOKP [PLANNED: OLS-3697], the operator always overrides replicas to 1 regardless of spec value. +20. Replicas are user-configurable for the API container (`spec.ols.deployment.api.replicas`). For console, database, alerts adapter, agentic console, otel collector, and RHOKP, the operator always overrides replicas to 1 regardless of spec value. ##### Config Fields @@ -269,11 +269,11 @@ Field | JSON key | Go type | Required | Validation #### Operator-managed OKP (not on CR) OKP / Solr hybrid RAG has no `spec.ols.solrHybrid` (or similar) field. It is enabled by default and turned off only via `byokRAGOnly`. When active, the operator: -- [PLANNED: OLS-3697] deploys the standalone RHOKP Deployment/Service (`lightspeed-rhokp`) and writes `ols_config.solr_hybrid` with operator defaults (`https://lightspeed-rhokp..svc:8443`, hybrid tuning); +- deploys the standalone RHOKP Deployment/Service (`lightspeed-rhokp`) and writes `ols_config.solr_hybrid` with operator defaults (`https://lightspeed-rhokp..svc:8443`, hybrid tuning); - sets `OCP_CLUSTER_VERSION` on the app-server container for Solr version filtering; - serves OCP product documentation via Solr hybrid only; `reference_content.indexes` lists BYOK FAISS indexes from `spec.ols.rag` only. -[PLANNED: OLS-3697] RHOKP standalone Deployment resources are overridable via `spec.ols.deployment.rhokp` (`Config`: replicas forced to 1, resources, tolerations, nodeSelector). Default resource requests: 2 CPU, 2 GiB memory. Storage: 75 GiB EmptyDir with sizeLimit. +RHOKP standalone Deployment resources are overridable via `spec.ols.deployment.rhokp` (`Config`: replicas forced to 1, resources, tolerations, nodeSelector). Default resource requests: 2 CPU, 2 GiB memory. Storage: 75 GiB EmptyDir with sizeLimit. 42. `spec.ols.querySystemPrompt` -- `string`, optional. Custom system prompt for LLM queries. If unset, the default OpenShift Lightspeed prompt is used. 43. `spec.ols.maxIterations` -- `int`. Default: `5`. Minimum=1. Maximum number of iterations for agent execution. 44. `spec.ols.imagePullSecrets` -- `[]corev1.LocalObjectReference`, optional. Pull secrets for BYOK RAG images. @@ -421,7 +421,7 @@ Path | Type | Default | Required | Validation | Description `spec.ols.deployment.dataCollector.resources` | `*ResourceRequirements` | -- | No | -- | Data collector resources `spec.ols.deployment.mcpServer` | `Config` | -- | No | -- | Standalone OpenShift MCP server Deployment `spec.ols.deployment.mcpServer.resources` | `*ResourceRequirements` | -- | No | -- | MCP server resources -`spec.ols.deployment.rhokp` | `Config` | -- | No | -- | [PLANNED: OLS-3697] Standalone RHOKP Deployment +`spec.ols.deployment.rhokp` | `Config` | -- | No | -- | Standalone RHOKP Deployment `spec.ols.deployment.rhokp.replicas` | `*int32` | `1` | No | Min=0 | RHOKP replicas (operator forces 1) `spec.ols.deployment.rhokp.resources` | `*ResourceRequirements` | -- | No | -- | RHOKP resources (default requests: 2 CPU, 2 GiB memory) `spec.ols.deployment.rhokp.tolerations` | `[]Toleration` | -- | No | -- | RHOKP tolerations @@ -543,6 +543,6 @@ Path | Type | Default | Required | Validation | Description - [PLANNED: OLS-3442] Add `reasoningConfig` field (`map[string]interface{}`) to `ModelParametersSpec`. Freeform map passed through to the service as `reasoning_config` for provider-specific reasoning/thinking parameters. Includes release notes and user-facing documentation for valid keys per provider. - [DONE: OLS-3683 / OLS-3684] `spec.agenticOLS` (`sandboxMode`, `agenticSandboxConfig`), appserver-owned client CA Secrets, and handoff ConfigMap (`lightspeed-agentic-configuration`). See `agentic-sandbox-profile.md`. +- [DONE: OLS-3697] Change `spec.ols.deployment.rhokp` from `ContainerConfig` to `Config`. RHOKP becomes a standalone Deployment with replicas (forced to 1), resources, tolerations, and nodeSelector. See `rhokp.md`. - [PLANNED: OLS-3594] Optional agentic auto-injection of MCP into agent runs (deferred). - [PLANNED: OLS-3685+] Agentic-operator consumption of the handoff ConfigMap/Secrets. -- [PLANNED: OLS-3697] Change `spec.ols.deployment.rhokp` from `ContainerConfig` to `Config`. RHOKP becomes a standalone Deployment with replicas (forced to 1), resources, tolerations, and nodeSelector. See design spec `docs/superpowers/specs/2026-07-27-rhokp-standalone.md`. diff --git a/.ai/spec/what/ocpmcp.md b/.ai/spec/what/ocpmcp.md index 848b4ec85..33fd8d55b 100644 --- a/.ai/spec/what/ocpmcp.md +++ b/.ai/spec/what/ocpmcp.md @@ -41,8 +41,8 @@ Gated by `spec.ols.introspectionEnabled` (default `true` when absent). When fals ### Watching and Restarts 12. Secret `openshift-mcp-server-tls` is listed statically in `WatcherConfig.Secrets.SystemResources`. Watching is gated by `OpenShiftMCPServerTLSWatchEnabled` (`syncOpenShiftMCPServerTLSWatcher`), set from `introspectionEnabled`, so enable/disable does not rewrite the SystemResources slice under the informer. -13. On TLS Secret data change, the watcher restarts both `openshift-mcp-server` and `ACTIVE_BACKEND` (app-server). `RestartAppServer` refreshes client CA Secrets from `openshift-service-ca.crt` and touches the handoff ConfigMap (fail-closed if CA refresh fails — see `agentic-sandbox-profile.md`). -14. ConfigMap `openshift-service-ca.crt` changes also restart `ACTIVE_BACKEND`, refreshing both OTEL and MCP client CA Secrets. +13. On TLS Secret data change, the watcher restarts `openshift-mcp-server`, `lightspeed-app-server`, and touches `lightspeed-agentic-configuration`. `RestartAppServer` refreshes client CA Secrets from `openshift-service-ca.crt` and touches the handoff ConfigMap (fail-closed if CA refresh fails — see `agentic-sandbox-profile.md`). +14. ConfigMap `openshift-service-ca.crt` changes also restart `lightspeed-app-server`, refreshing all client CA Secrets (OTEL, MCP, RHOKP). 15. MCP Deployment also tracks ConfigMap and TLS Secret ResourceVersions and rolls when they change. ### Security diff --git a/.ai/spec/what/reconciliation.md b/.ai/spec/what/reconciliation.md index 56c480ae9..5467f714b 100644 --- a/.ai/spec/what/reconciliation.md +++ b/.ai/spec/what/reconciliation.md @@ -17,7 +17,7 @@ The operator reconciles the OLSConfig CR into Kubernetes resources through a two 8. Step 6 (Phase 2): Reconcile deployments and dependent resources -- Deployments, Services, TLS certificates, ServiceMonitors, PrometheusRules. After reconciliation, check deployment health and update CR status. ### Phase 1: Independent Resources -9. Phase 1 component groups, in order: PostgreSQL, chat Console UI, agentic console plugin, agentic alerts adapter (when enabled), OTEL Collector, OpenShift MCP server (when introspection enabled), RHOKP (when OKP enabled, i.e. `!byokRAGOnly`) [PLANNED: OLS-3697], and the application server. +9. Phase 1 component groups, in order: PostgreSQL, chat Console UI, agentic console plugin, agentic alerts adapter (when enabled), OTEL Collector, OpenShift MCP server (when introspection enabled), RHOKP (when OKP enabled, i.e. `!byokRAGOnly`), and the application server. 10. Phase 1 uses continue-on-error: reconcile as many groups as possible, then report all failures. 11. If any Phase 1 resource fails, the operator continues reconciling the remaining resources, then reports all failures in the CR status with ResourceReconciliation conditions. 11a. Alerts adapter (OLS-3348) is **opt-in** via `spec.ols.deployment.alertsAdapter.configMapRef`. When unset, `ReconcileAlertsAdapterResources()` calls `RemoveAlertsAdapter()` to delete operator-managed operand resources (deployment, SA, namespaced RBAC, NetworkPolicy, monitoring RoleBinding; AgenticRun ClusterRole/ClusterRoleBinding when the platform allows delete) and Phase 2 is skipped with `AlertsAdapterReady=True`, `Reason=NotConfigured`. @@ -25,14 +25,14 @@ The operator reconciles the OLSConfig CR into Kubernetes resources through a two 11c. Agentic console Phase 1 resources: ServiceAccount, ConfigMap (nginx.conf), NetworkPolicy. 11d. OTEL Collector Phase 1 resources (OLS-3510 / OLS-3656): ConfigMap (collector runtime YAML `lightspeed-otel-collector-config`, including localhost metrics pull and `https_metrics`), ServiceAccount, Postgres DSN Secret, NetworkPolicy (in-namespace OTLP/admin plus Prometheus metrics ingress). 11e. OpenShift MCP Phase 1 resources (`ocpmcp`, when `introspectionEnabled`): ConfigMap (TOML), ServiceAccount, NetworkPolicy. When introspection is disabled, Phase 1 tears down those resources via `ocpmcp.Remove()` (including leftover legacy CA ConfigMap `openshift-mcp-server-ca`) and Phase 2 sets `MCPServerReady=True`, `Reason=NotConfigured`. -11f. [PLANNED: OLS-3697] RHOKP Phase 1 resources (`rhokp`, when `!byokRAGOnly`): NetworkPolicy. When `byokRAGOnly` is true, Phase 1 tears down RHOKP resources via `rhokp.Remove()`. +11f. RHOKP Phase 1 resources (`rhokp`, when `!byokRAGOnly`): NetworkPolicy. When `byokRAGOnly` is true, Phase 1 tears down RHOKP resources via `rhokp.Remove()`. ### Phase 2: Deployments and Status -12. Deployments reconciled in Phase 2: chat Console UI (condition: `ConsolePluginReady`), agentic console plugin (condition: `AgenticConsolePluginReady`), PostgreSQL (condition: `CacheReady`), OTEL Collector (condition: `OtelCollectorReady`), OpenShift MCP server when introspection enabled (condition: `MCPServerReady`, else `NotConfigured`), RHOKP when OKP enabled [PLANNED: OLS-3697], the active backend (condition: `ApiReady`), and (when `configMapRef` set) the agentic alerts adapter (condition: `AlertsAdapterReady`). MCP is reconciled before the app-server so the MCP Service exists for client wiring; appserver publishes the MCP client CA Secret. +12. Deployments reconciled in Phase 2: chat Console UI (condition: `ConsolePluginReady`), agentic console plugin (condition: `AgenticConsolePluginReady`), PostgreSQL (condition: `CacheReady`), OTEL Collector (condition: `OtelCollectorReady`), OpenShift MCP server when introspection enabled (condition: `MCPServerReady`, else `NotConfigured`), RHOKP when OKP enabled (condition: `RHOKPReady`, else `NotConfigured`), the app-server (condition: `ApiReady`), and (when `configMapRef` set) the agentic alerts adapter (condition: `AlertsAdapterReady`). MCP and RHOKP are reconciled before the app-server so their Services exist for client wiring; appserver publishes client CA Secrets conditionally (OTEL always, MCP when introspection enabled, RHOKP when OKP enabled). 12a. Alerts adapter Phase 2 (OLS-3348): Deployment (1 replica, `ALERTMANAGER_URL` env hardcoded to `https://alertmanager-main.openshift-monitoring.svc:9094`, `POD_NAMESPACE` via downward API). 12b. Agentic console Phase 2: Deployment (1 replica, nginx with TLS via service-ca cert), Service (port 9443, serving-cert annotation), ConsolePlugin CR, Console CR activation. 12c. OTEL Collector Phase 2 (OLS-3510 / OLS-3656): Service (OTLP gRPC `:4317`, HTTP `:4318`, admin HTTPS `:8080`, metrics HTTPS `:8888`, serving-cert annotation), TLS secret wait (`lightspeed-otel-collector-cert`), Deployment (1 replica, metrics container port), ServiceMonitor (`lightspeed-otel-collector-monitor`, skipped if Prometheus CRDs unavailable). Reconciled after PostgreSQL and before the app-server. Agentic OTEL connectivity is published via appserver client CA Secrets + `agenticintegration` handoff ConfigMap (not a collector client ConfigMap). -12d. Appserver Phase 2 creates/refreshes client-only CA Secrets (`lightspeed-agentic-otel-ca`, and `lightspeed-agentic-mcp-ca` when introspection enabled) from service-ca source ConfigMaps, mounts them into the app-server Deployment, then reconciles Service/TLS/Deployment. +12d. Appserver Phase 2 creates/refreshes client-only CA Secrets (`lightspeed-agentic-otel-ca`, `lightspeed-agentic-mcp-ca` when introspection enabled, and `lightspeed-agentic-rhokp-ca` when OKP enabled) from service-ca source ConfigMaps via table-driven `RefreshClientCASecrets`, mounts them into the app-server Deployment, then reconciles Service/TLS/Deployment. 12e. Agentic integration (`agenticintegration`, OLS-3684) runs **last in Phase 2** after appserver: handoff ConfigMap only (`lightspeed-agentic-configuration`) with sandbox mode/PodSpec, OTEL/MCP endpoints, and CA Secret **names**. Failure marks OverallStatus NotReady and requeues. See `agentic-sandbox-profile.md`. 13. After each deployment reconciliation, the operator checks the deployment's health status. 14. Deployment health has three states: Ready (Available condition true), Progressing (not yet available, no terminal failures), Failed (terminal pod failures detected). @@ -49,7 +49,7 @@ The operator reconciles the OLSConfig CR into Kubernetes resources through a two 23. Console UI and agentic component removal errors during finalization are logged but do not block finalization. ### Status Conditions -24. The operator sets these condition types: `ApiReady`, `CacheReady`, `ConsolePluginReady`, `AgenticConsolePluginReady`, `OtelCollectorReady`, `MCPServerReady` (`NotConfigured` when introspection is disabled; does not block `OverallStatus=Ready`), `AlertsAdapterReady` (`NotConfigured` when `configMapRef` unset; does not block `OverallStatus=Ready`), `ResourceReconciliation`. +24. The operator sets these condition types: `ApiReady`, `CacheReady`, `ConsolePluginReady`, `AgenticConsolePluginReady`, `OtelCollectorReady`, `MCPServerReady` (`NotConfigured` when introspection is disabled; does not block `OverallStatus=Ready`), `RHOKPReady` (`Disabled` when `byokRAGOnly` is true; does not block `OverallStatus=Ready`), `AlertsAdapterReady` (`NotConfigured` when `configMapRef` unset; does not block `OverallStatus=Ready`), `ResourceReconciliation`. 25. OverallStatus is Ready only when all deployment conditions are True. 26. OverallStatus is NotReady if any condition is False. 27. When deployments are not ready, diagnosticInfo is populated with per-pod failure details including container name, reason, message, exit code, and diagnostic type. @@ -77,4 +77,4 @@ Reconciliation behavior is not directly user-configurable. It is driven by the O | OLS-3236 | Remove duplicate agentic console deployment from agentic-operator CSV; productize agentic operand images | | OLS-3594 | Deferred optional agentic auto-injection for MCP into agent runs | | OLS-3685+ | Agentic-operator consumption of `lightspeed-agentic-configuration` / CA Secrets | -| OLS-3697 | RHOKP standalone HTTPS cutover — new Phase 1/2 component group, reconciled before app-server. ServiceMonitor for RHOKP and MCP. | +| OLS-3697 | [DONE] RHOKP standalone HTTPS cutover — new Phase 1/2 component group, reconciled before app-server. [PLANNED] ServiceMonitor for RHOKP and MCP (separate ticket). | diff --git a/.ai/spec/what/resource-lifecycle.md b/.ai/spec/what/resource-lifecycle.md index 4e91d855e..c46f9dc8b 100644 --- a/.ai/spec/what/resource-lifecycle.md +++ b/.ai/spec/what/resource-lifecycle.md @@ -30,9 +30,9 @@ The operator manages two categories of Kubernetes resources: owned resources (cr 14. When a watched secret's `.data` changes (compared via `apiequality.Semantic.DeepEqual`), the `SecretUpdateHandler` triggers restarts of affected deployments directly, without triggering a full reconciliation. 15. When a watched configmap's `.data` or `.binaryData` changes, the `ConfigMapUpdateHandler` triggers restarts of affected deployments directly. -16. Each external resource has a list of affected deployments configured in `WatcherConfig`. The special value `ACTIVE_BACKEND` resolves to the application server deployment name (`lightspeed-app-server`). +16. Each external resource has a list of affected deployments configured in `WatcherConfig`. All deployment names are explicit (e.g. `lightspeed-app-server`, `lightspeed-rhokp`). 17. Restarts are triggered by updating the `ols.openshift.io/force-reload` annotation on the deployment's pod template with the current timestamp (RFC3339Nano), causing a rolling update. Alerts adapter runtime ConfigMap changes restart `lightspeed-agentic-alerts-adapter` via `RestartAlertsAdapter()`. -18. TLS secrets are mapped to affect both `lightspeed-console-plugin` and `ACTIVE_BACKEND` deployments. All other user-provided secrets default to `ACTIVE_BACKEND` only. +18. TLS secrets are mapped to affect the relevant operand deployment plus the app-server and the agentic configuration ConfigMap. User-provided secrets default to app-server only. ### Validation diff --git a/.ai/spec/what/rhokp.md b/.ai/spec/what/rhokp.md index 2d17b28b1..33961b511 100644 --- a/.ai/spec/what/rhokp.md +++ b/.ai/spec/what/rhokp.md @@ -8,7 +8,7 @@ Standalone HTTPS RHOKP operand managed by the `rhokp` package ([OLS-3697](https: lightspeed-service (app-server) └─ HTTPS Solr client url: https://lightspeed-rhokp..svc:8443/solr/portal-rag/hybrid-search - trust: /etc/certs/lightspeed-rhokp-ca/service-ca.crt (extra_ca) + trust: /etc/certs/rhokp-ca/service-ca.crt (extra_ca, from Secret lightspeed-agentic-rhokp-ca) │ ▼ lightspeed-rhokp Deployment + ClusterIP Service (:8443) @@ -23,7 +23,7 @@ Gated by `!spec.ols.byokRAGOnly` (default: OKP enabled). When `byokRAGOnly` is t ### Activation 1. When `spec.ols.byokRAGOnly` is false (or absent), Phase 1 and Phase 2 reconcile the standalone RHOKP operand. -2. When true, Phase 1 calls `rhokp.Remove()`; Phase 2 skips deployment reconciliation. No status condition is emitted (RHOKP readiness is implicit in `ApiReady` — the app-server cannot become ready if its configured Solr backend is unreachable). +2. When true, Phase 1 calls `rhokp.Remove()`; Phase 2 skips deployment reconciliation. The status condition `RHOKPReady=False, Reason=Disabled` is emitted to signal that RHOKP is intentionally off. ### Phase 1 Resources 3. ConfigMap `lightspeed-rhokp-ca` — empty ConfigMap with `service.beta.openshift.io/inject-cabundle: "true"` for client trust. Reconcile must not wipe injected `Data`. @@ -45,22 +45,22 @@ Gated by `!spec.ols.byokRAGOnly` (default: OKP enabled). When `byokRAGOnly` is t ### App-server Integration 15. `olsconfig.yaml` `solr_hybrid.solr_http_base` is set to `https://lightspeed-rhokp..svc:8443` (replaces former `http://localhost:9080`). -16. App-server mounts `lightspeed-rhokp-ca` at `/etc/certs/lightspeed-rhokp-ca/` and adds `service-ca.crt` to `extra_ca`. See `tls.md`. -17. App-server Deployment tracks RHOKP CA content hash (`ols.openshift.io/rhokp-ca-configmap-hash`) only while OKP is enabled. +16. App-server mounts Secret `lightspeed-agentic-rhokp-ca` at `/etc/certs/rhokp-ca/` and adds `service-ca.crt` to `extra_ca`. See `tls.md`. +17. Client CA Secrets for RHOKP are refreshed via the table-driven `RefreshClientCASecrets` in `RestartAppServer`. No hash annotation is stored on the app-server Deployment. ### Monitoring -18. ServiceMonitor `lightspeed-rhokp-monitor` — scrapes RHOKP metrics via HTTPS on port 8443, path `/solr/admin/metrics` (Solr built-in Prometheus metrics reporter). Uses service-ca TLS for the scrape connection. Skipped if Prometheus Operator CRDs are not installed. +18. [PLANNED: separate ticket] ServiceMonitor `lightspeed-rhokp-monitor` — will scrape RHOKP metrics via HTTPS on port 8443, path `/solr/admin/metrics` (Solr built-in Prometheus metrics reporter). Uses service-ca TLS for the scrape connection. Skipped if Prometheus Operator CRDs are not installed. ### Agentic Handoff -19. When OKP is enabled, the inter-operator handoff ConfigMap (`lightspeed-sandbox-config`) includes `rhokp-endpoint` key with the HTTPS URL and the RHOKP CA cert volume + mount in the base `sandbox-pod-spec` PodSpec. When `byokRAGOnly` is true, both are absent. +19. When OKP is enabled, the inter-operator handoff ConfigMap (`lightspeed-agentic-configuration`) includes `rhokp-endpoint` and `rhokp-ca-secret` keys. When `byokRAGOnly` is true, both are absent. ### Watching and Restarts 20. Secret `lightspeed-rhokp-tls` is watched via the operator's watcher infrastructure (same pattern as `openshift-mcp-server-tls`). -21. On TLS Secret data change, the watcher restarts both `lightspeed-rhokp` and `ACTIVE_BACKEND` (app-server). +21. On TLS Secret data change, the watcher restarts `lightspeed-rhokp`, `lightspeed-app-server` (app-server), and touches the `lightspeed-agentic-configuration` ConfigMap. 22. RHOKP Deployment tracks TLS Secret ResourceVersion and rolls when it changes. ### Finalizer -23. On CR deletion, `rhokp.Remove()` deletes Deployment, Service, NetworkPolicy, CA ConfigMap, TLS Secret (`lightspeed-rhokp-tls`), and ServiceMonitor (`lightspeed-rhokp-monitor`) before owned-resource sweep. +23. On CR deletion, `rhokp.Remove()` deletes Deployment, Service, NetworkPolicy, CA ConfigMap, and TLS Secret (`lightspeed-rhokp-tls`) before owned-resource sweep. ## Configuration Surface @@ -79,4 +79,4 @@ Gated by `!spec.ols.byokRAGOnly` (default: OKP enabled). When `byokRAGOnly` is t ## Planned Changes -None for the standalone HTTPS cutover itself. Agentic handoff remains planned (OLS-3572). +- ServiceMonitor for RHOKP metrics (deferred from standalone cutover). diff --git a/.ai/spec/what/tls.md b/.ai/spec/what/tls.md index 176ff6cc8..bde184a07 100644 --- a/.ai/spec/what/tls.md +++ b/.ai/spec/what/tls.md @@ -19,7 +19,7 @@ The operator manages TLS certificates for all inter-component communication and 10. The OpenShift root CA (`kube-root-ca.crt`) is always mounted at `/etc/certs/ols-additional-ca/` in the backend container. 11. Appserver copies the OpenShift service-ca bundle (`openshift-service-ca.crt` / `service-ca.crt`) into client-only Secret `lightspeed-agentic-otel-ca` (`otel-ca.crt`), mounts that Secret at `/etc/certs/otel-collector-ca/service-ca.crt`, and adds the path to `extra_ca` in `olsconfig.yaml`. The app-server also sets `OTEL_EXPORTER_OTLP_CERTIFICATE` to that path so the OTLP/gRPC exporter trusts the collector (gRPC does not use the certifi/`extra_ca` store). The same Secret is the agentic-operator CA source (see `agentic-sandbox-profile.md`). 11a. When introspection is enabled, appserver copies the same cluster service-ca PEM into client-only Secret `lightspeed-agentic-mcp-ca` (`mcp-ca.crt`), mounts it at `/etc/certs/openshift-mcp-server-ca/service-ca.crt`, and adds the path to `extra_ca` so lightspeed-service can verify TLS to the standalone MCP Service on `:8443`. There is no dedicated MCP CA ConfigMap. -11b. [PLANNED: OLS-3697] When OKP is enabled (`!byokRAGOnly`), appserver copies the cluster service-ca PEM into a client-only Secret for RHOKP CA, mounts it in the app-server, and adds the path to `extra_ca` so lightspeed-service can verify TLS to the standalone RHOKP Service on `:8443`. +11b. When OKP is enabled (`!byokRAGOnly`), appserver copies the cluster service-ca PEM into client-only Secret `lightspeed-agentic-rhokp-ca` (`rhokp-ca.crt`), mounts it at `/etc/certs/rhokp-ca/service-ca.crt`, and adds the path to `extra_ca` so lightspeed-service can verify TLS to the standalone RHOKP Service on `:8443`. ### Additional CA Certificates 12. If `spec.ols.additionalCAConfigMapRef` is set, the operator mounts the referenced ConfigMap in the backend container at `/etc/certs/ols-user-ca/` and each certificate file path is added to the `extra_ca` list in the OLS config. @@ -34,9 +34,9 @@ The operator manages TLS certificates for all inter-component communication and ### Certificate Rotation 18. Service-ca certificates are automatically rotated by the service-ca operator. The operator's watchers detect the Secret data change and trigger a deployment rolling restart via the `ols.openshift.io/force-reload` pod template annotation. 19. Custom certificate rotation requires the user to update the referenced Secret. The operator detects the data change via the watcher and triggers a rolling restart using the same annotation mechanism. -20. When `lightspeed-otel-collector-cert` rotates, the watcher restarts the OTEL Collector deployment then the app-server (`ACTIVE_BACKEND`). `RestartAppServer` refreshes client CA Secrets from `openshift-service-ca.crt`, touches the handoff ConfigMap (`ols.openshift.io/client-ca-reload`), then rolls the app-server. If CA refresh fails, touch and roll are skipped (fail-closed); see `agentic-sandbox-profile.md`. -21. When `openshift-mcp-server-tls` rotates, the watcher restarts the OpenShift MCP server deployment then the app-server (`ACTIVE_BACKEND`) with the same fail-closed refresh+touch+roll path. -22. [PLANNED: OLS-3697] When `lightspeed-rhokp-tls` rotates, the watcher restarts the RHOKP standalone deployment then the app-server (`ACTIVE_BACKEND`) with the same fail-closed refresh+touch+roll path. +20. When `lightspeed-otel-collector-cert` rotates, the watcher restarts the OTEL Collector deployment then the app-server. `RestartAppServer` refreshes client CA Secrets from `openshift-service-ca.crt`, touches the handoff ConfigMap (`ols.openshift.io/client-ca-reload`), then rolls the app-server. If CA refresh fails, touch and roll are skipped (fail-closed); see `agentic-sandbox-profile.md`. +21. When `openshift-mcp-server-tls` rotates, the watcher restarts the OpenShift MCP server deployment then the app-server with the same fail-closed refresh+touch+roll path, and also touches the `lightspeed-agentic-configuration` ConfigMap. +22. When `lightspeed-rhokp-tls` rotates, the watcher restarts the RHOKP standalone deployment then the app-server with the same fail-closed refresh+touch+roll path, and also touches the `lightspeed-agentic-configuration` ConfigMap. ## Configuration Surface @@ -57,4 +57,4 @@ The operator manages TLS certificates for all inter-component communication and ## Planned Changes -- [PLANNED: OLS-3697] RHOKP standalone TLS — service-ca serving cert `lightspeed-rhokp-tls`, CA ConfigMap `lightspeed-rhokp-ca`, app-server CA mount + `extra_ca`, cert rotation watcher. See `rhokp.md`. +None. diff --git a/AGENTS.md b/AGENTS.md index 77920deeb..4abda2950 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,9 @@ OLSConfigReconciler.Reconcile() → │ │ mount at /etc/alerts-adapter when CM exists, adapter reads config.yaml) │ ├── otelcollector.ReconcileOtelCollectorResources() │ ├── ocpmcp.ReconcileResources() +│ │ (when introspectionEnabled; else ocpmcp.Remove()) +│ ├── rhokp.ReconcileResources() +│ │ (when !byokRAGOnly; else rhokp.Remove()) │ └── appserver.ReconcileAppServerResources() └── Phase 2 — reconcileDeploymentsAndStatus() ├── console.ReconcileConsoleUIDeploymentAndPlugin() → ConsolePluginReady @@ -62,6 +65,8 @@ OLSConfigReconciler.Reconcile() → ├── otelcollector.ReconcileOtelCollectorDeployment() → OtelCollectorReady ├── ocpmcp.ReconcileDeployment() → MCPServerReady │ (when introspectionEnabled; else MCPServerReady=True, Reason=NotConfigured) + ├── rhokp.ReconcileDeployment() → RHOKPReady + │ (when !byokRAGOnly; else RHOKPReady=False, Reason=Disabled) ├── appserver.ReconcileAppServerDeployment() → ApiReady ├── alertsadapter.ReconcileAlertsAdapterDeployment() → AlertsAdapterReady │ (only when configMapRef set; else AlertsAdapterReady=True, Reason=NotConfigured) @@ -95,11 +100,12 @@ make test-e2e # E2E tests (requires cluster) ### Controllers - `internal/controller/olsconfig_controller.go` - Main reconciler with finalizer logic -- `internal/controller/appserver/` - App server (also owns client CA Secrets `lightspeed-agentic-otel-ca` / `lightspeed-agentic-mcp-ca`; `RestartAppServer` refreshes them and touches the handoff ConfigMap) +- `internal/controller/appserver/` - App server (also owns client CA Secrets `lightspeed-agentic-otel-ca` / `lightspeed-agentic-mcp-ca` / `lightspeed-agentic-rhokp-ca`; `RestartAppServer` calls `RefreshClientCASecrets` and touches the handoff ConfigMap) - `internal/controller/postgres/` - PostgreSQL - `internal/controller/otelcollector/` - OTEL Collector (always deployed; Postgres audit log storage, optional trace forwarding, HTTPS metrics `:8888` + ServiceMonitor) - `internal/controller/agenticintegration/` - Classic→agentic handoff ConfigMap (`lightspeed-agentic-configuration`) only; end of Phase 2 - `internal/controller/ocpmcp/` - Standalone OpenShift MCP server (gated by `introspectionEnabled`; HTTPS via service-ca) +- `internal/controller/rhokp/` - Standalone RHOKP (Red Hat Offline Knowledge Portal) for Solr RAG (gated by `!byokRAGOnly`; HTTPS via service-ca) - `internal/controller/console/` - Chat console plugin (Lightspeed assistant UI) - `internal/controller/agenticconsole/` - Agentic console plugin (AI Hub / proposals UI) - `internal/controller/alertsadapter/` - Agentic alerts adapter (opt-in via `configMapRef`; mounts user CM at `/etc/alerts-adapter` when present; adapter validates config) diff --git a/api/v1alpha1/olsconfig_types.go b/api/v1alpha1/olsconfig_types.go index ea51f80c6..7abcd1cbc 100644 --- a/api/v1alpha1/olsconfig_types.go +++ b/api/v1alpha1/olsconfig_types.go @@ -407,9 +407,9 @@ type DeploymentConfig struct { // MCP server deployment settings. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="MCP Server Deployment" MCPServerContainer Config `json:"mcpServer,omitempty"` - // RHOKP sidecar container settings (Solr / OKP). + // RHOKP standalone deployment settings (Solr / OKP). // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="RHOKP Container" - RHOKPContainer ContainerConfig `json:"rhokp,omitempty"` + RHOKPContainer Config `json:"rhokp,omitempty"` // Console container settings. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Console Deployment" ConsoleContainer Config `json:"console,omitempty"` diff --git a/cmd/main.go b/cmd/main.go index ca8fa3463..641feffad 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -376,7 +376,7 @@ func main() { Name: utils.TelemetryPullSecretName, Namespace: utils.TelemetryPullSecretNamespace, Description: "OpenShift telemetry pull secret", - AffectedDeployments: []string{"ACTIVE_BACKEND"}, + AffectedDeployments: []string{utils.OLSAppServerDeploymentName}, }, { Name: utils.ConsoleUIServiceCertSecretName, @@ -394,13 +394,13 @@ func main() { Name: utils.PostgresCertsSecretName, Namespace: namespace, Description: "PostgreSQL TLS certificate (created by Service CA Operator)", - AffectedDeployments: []string{utils.PostgresDeploymentName, "ACTIVE_BACKEND"}, + AffectedDeployments: []string{utils.PostgresDeploymentName, utils.OLSAppServerDeploymentName}, }, { Name: utils.OtelCollectorCertsSecretName, Namespace: namespace, Description: "OTEL Collector TLS certificate (created by Service CA Operator)", - AffectedDeployments: []string{utils.OtelCollectorDeploymentName, "ACTIVE_BACKEND"}, + AffectedDeployments: []string{utils.OtelCollectorDeploymentName, utils.OLSAppServerDeploymentName, utils.AgenticConfigurationConfigMapName}, }, { // Gated at runtime by WatcherConfig.OpenShiftMCPServerTLSWatchEnabled @@ -408,7 +408,15 @@ func main() { Name: utils.OpenShiftMCPServerCertsSecretName, Namespace: namespace, Description: "OpenShift MCP server serving certificate (created by Service CA Operator)", - AffectedDeployments: []string{utils.OpenShiftMCPServerDeploymentName, "ACTIVE_BACKEND"}, + AffectedDeployments: []string{utils.OpenShiftMCPServerDeploymentName, utils.OLSAppServerDeploymentName, utils.AgenticConfigurationConfigMapName}, + }, + { + // Gated at runtime by WatcherConfig.RHOKPTLSWatchEnabled + // (!byokRAGOnly). Keep the entry static to avoid SystemResources races. + Name: utils.RHOKPCertsSecretName, + Namespace: namespace, + Description: "RHOKP serving certificate (created by Service CA Operator)", + AffectedDeployments: []string{utils.RHOKPDeploymentName, utils.OLSAppServerDeploymentName, utils.AgenticConfigurationConfigMapName}, }, }, }, @@ -420,26 +428,26 @@ func main() { Name: utils.DefaultOpenShiftCerts, Namespace: namespace, Description: "OpenShift default CA bundle", - AffectedDeployments: []string{"ACTIVE_BACKEND"}, + AffectedDeployments: []string{utils.OLSAppServerDeploymentName}, }, { Name: utils.OLSCAConfigMap, Namespace: namespace, Description: "OpenShift Service CA certificate bundle", - AffectedDeployments: []string{"ACTIVE_BACKEND", utils.PostgresDeploymentName}, + AffectedDeployments: []string{utils.OLSAppServerDeploymentName, utils.PostgresDeploymentName}, }, }, }, // 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). + // Key: secret name, Value: list of deployment names. // 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) + // Key: configmap name, Value: list of deployment names. // Only list configmaps here that need to restart specific deployments beyond the active backend. AnnotatedConfigMapMapping: map[string][]string{}, } diff --git a/config/crd/bases/ols.openshift.io_olsconfigs.yaml b/config/crd/bases/ols.openshift.io_olsconfigs.yaml index a7aa7d825..27bbe04a2 100644 --- a/config/crd/bases/ols.openshift.io_olsconfigs.yaml +++ b/config/crd/bases/ols.openshift.io_olsconfigs.yaml @@ -1600,8 +1600,24 @@ spec: type: array type: object rhokp: - description: RHOKP sidecar container settings (Solr / OKP). + description: RHOKP standalone deployment settings (Solr / + OKP). properties: + nodeSelector: + additionalProperties: + type: string + description: Node selector constraints + type: object + replicas: + default: 1 + description: |- + Defines the number of desired pods. Default: "1" + Note: Replicas are configurable for APIContainer and MCP server (mcpServer). + For PostgreSQL, Console, Agentic Console, Alerts Adapter, OTEL Collector, and + Agentic Sandbox (spec.agenticOLS.agenticSandboxConfig), the number of replicas is always set to 1. + format: int32 + minimum: 0 + type: integer resources: description: |- Resource requirements (CPU, memory) @@ -1664,6 +1680,48 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + tolerations: + description: |- + Tolerations for pod scheduling + Uses standard corev1.Toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array type: object type: object imagePullSecrets: diff --git a/internal/controller/agenticintegration/assets.go b/internal/controller/agenticintegration/assets.go index db6089f18..6e958ace1 100644 --- a/internal/controller/agenticintegration/assets.go +++ b/internal/controller/agenticintegration/assets.go @@ -114,6 +114,10 @@ func GenerateAgenticConfigurationConfigMap(r reconciler.Reconciler, cr *olsv1alp data[utils.AgenticConfigurationMCPEndpointKey] = utils.OpenShiftMCPServerServiceURL(ns) data[utils.AgenticConfigurationMCPCASecretKey] = utils.AgenticMCPCASecretName } + if !cr.Spec.OLSConfig.ByokRAGOnly { + data[utils.AgenticConfigurationRHOKPEndpointKey] = utils.RHOKPServiceURL(ns) + data[utils.AgenticConfigurationRHOKPCASecretKey] = utils.AgenticRHOKPCASecretName + } cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controller/agenticintegration/assets_test.go b/internal/controller/agenticintegration/assets_test.go index a446c6564..0ca0f8eb3 100644 --- a/internal/controller/agenticintegration/assets_test.go +++ b/internal/controller/agenticintegration/assets_test.go @@ -82,6 +82,11 @@ var _ = Describe("Agentic integration assets", func() { Expect(cm.Data).NotTo(HaveKey(utils.AgenticConfigurationOtelCollectorEndpointKey)) Expect(cm.Data).NotTo(HaveKey(utils.AgenticConfigurationOtelAdminEndpointKey)) Expect(cm.Data).NotTo(HaveKey(utils.AgenticConfigurationOtelCASecretKey)) + // RHOKP keys present (byokRAGOnly defaults to false) + Expect(cm.Data[utils.AgenticConfigurationRHOKPEndpointKey]).To(Equal( + utils.RHOKPServiceURL(utils.OLSNamespaceDefault), + )) + Expect(cm.Data[utils.AgenticConfigurationRHOKPCASecretKey]).To(Equal(utils.AgenticRHOKPCASecretName)) var podSpec corev1.PodSpec Expect(json.Unmarshal([]byte(cm.Data[utils.AgenticConfigurationSandboxPodSpecKey]), &podSpec)).To(Succeed()) @@ -96,6 +101,20 @@ var _ = Describe("Agentic integration assets", func() { utils.OpenShiftMCPServerServiceURL(utils.OLSNamespaceDefault), )) Expect(cm.Data[utils.AgenticConfigurationMCPCASecretKey]).To(Equal(utils.AgenticMCPCASecretName)) + // RHOKP also present (byokRAGOnly defaults to false) + Expect(cm.Data[utils.AgenticConfigurationRHOKPEndpointKey]).To(Equal( + utils.RHOKPServiceURL(utils.OLSNamespaceDefault), + )) + Expect(cm.Data[utils.AgenticConfigurationRHOKPCASecretKey]).To(Equal(utils.AgenticRHOKPCASecretName)) + }) + + It("should omit RHOKP keys when byokRAGOnly is true", func() { + testCR.Spec.OLSConfig.ByokRAGOnly = true + cm, err := GenerateAgenticConfigurationConfigMap(testReconcilerInstance, testCR) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data).NotTo(HaveKey(utils.AgenticConfigurationRHOKPEndpointKey)) + Expect(cm.Data).NotTo(HaveKey(utils.AgenticConfigurationRHOKPCASecretKey)) + testCR.Spec.OLSConfig.ByokRAGOnly = false }) It("should touch the ConfigMap annotation to bump resourceVersion", func() { diff --git a/internal/controller/appserver/assets.go b/internal/controller/appserver/assets.go index 9c469d44f..c3480a3d1 100644 --- a/internal/controller/appserver/assets.go +++ b/internal/controller/appserver/assets.go @@ -2,8 +2,6 @@ package appserver import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" "path" "path/filepath" @@ -346,7 +344,7 @@ func buildOLSConfig(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha olsConfig.Audit = buildServiceAuditConfig(cr, r.GetNamespace()) if !cr.Spec.OLSConfig.ByokRAGOnly { - olsConfig.SolrHybrid = buildSolrHybridSettings() + olsConfig.SolrHybrid = buildSolrHybridSettings(r.GetNamespace()) } return olsConfig, nil @@ -373,9 +371,9 @@ func buildServiceAuditConfig(cr *olsv1alpha1.OLSConfig, namespace string) *utils } } -func buildSolrHybridSettings() *utils.SolrHybridSettings { +func buildSolrHybridSettings(namespace string) *utils.SolrHybridSettings { return &utils.SolrHybridSettings{ - SolrHTTPBase: fmt.Sprintf("http://localhost:%d", utils.RHOOKPHTTPPort), + SolrHTTPBase: utils.RHOKPServiceURL(namespace), MaxResults: utils.SolrHybridMaxResultsDefault, HybridVectorBoost: utils.SolrHybridVectorBoostDefault, HybridPoolDocs: utils.SolrHybridPoolDocsDefault, @@ -548,6 +546,15 @@ func GenerateOLSConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv )) } + // Trust the standalone RHOKP service-ca cert when OKP is enabled + if !cr.Spec.OLSConfig.ByokRAGOnly { + olsConfig.ExtraCAs = append(olsConfig.ExtraCAs, path.Join( + utils.OLSAppCertsMountRoot, + utils.AppRHOKPCACertDir, + utils.AppRHOKPCACertFile, + )) + } + // Append user-provided additional CA certificates if configured if cr.Spec.OLSConfig.AdditionalCAConfigMapRef != nil { extraCAs, err := addAdditionalCAFileNames(r, ctx, cr.Spec.OLSConfig.AdditionalCAConfigMapRef, utils.UserCACertDir) @@ -1041,103 +1048,127 @@ func getQueryFilters(cr *olsv1alpha1.OLSConfig) []utils.QueryFilters { return filters } -// GenerateOtelClientCASecret copies the public service-ca PEM into a client-only Secret. -func GenerateOtelClientCASecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*corev1.Secret, error) { - caCM := &corev1.ConfigMap{} - if err := r.Get(ctx, client.ObjectKey{Name: utils.OLSCAConfigMap, Namespace: r.GetNamespace()}, caCM); err != nil { - return nil, fmt.Errorf("%s: %w", utils.ErrGetAgenticOtelCASourceConfigMap, err) - } - caPEM, ok := caCM.Data[utils.AppOtelCollectorCACertFile] - if !ok || caPEM == "" { - return nil, fmt.Errorf("%s: key %q missing or empty in ConfigMap %s", - utils.ErrGetAgenticOtelCASourceConfigMap, utils.AppOtelCollectorCACertFile, utils.OLSCAConfigMap) - } +// clientCAConfig describes a single client CA Secret that the app-server owns. +// Each entry copies the cluster service-ca PEM into a dedicated Secret so that +// consumers (app-server, sandbox) can mount it independently. +type clientCAConfig struct { + SecretName string + DataKey string + Enabled func(*olsv1alpha1.OLSConfig) bool + ErrSource string + ErrOwnerRef string + ErrCreate string + ErrGet string + ErrUpdate string + ErrDelete string +} - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.AgenticOtelCASecretName, - Namespace: r.GetNamespace(), - Labels: utils.GenerateAppServerSelectorLabels(), - }, - Type: corev1.SecretTypeOpaque, - Data: map[string][]byte{ - utils.AgenticOtelCASecretDataKey: []byte(caPEM), +// clientCASecrets enumerates all client CA Secrets managed by the app-server. +var clientCASecrets = []clientCAConfig{ + { + SecretName: utils.AgenticOtelCASecretName, + DataKey: utils.AgenticOtelCASecretDataKey, + Enabled: func(_ *olsv1alpha1.OLSConfig) bool { return true }, + ErrSource: utils.ErrGetAgenticOtelCASourceConfigMap, + ErrOwnerRef: utils.ErrSetAgenticOtelCASecretOwnerRef, + ErrCreate: utils.ErrCreateAgenticOtelCASecret, + ErrGet: utils.ErrGetAgenticOtelCASecret, + ErrUpdate: utils.ErrUpdateAgenticOtelCASecret, + ErrDelete: utils.ErrDeleteAgenticOtelCASecret, + }, + { + SecretName: utils.AgenticMCPCASecretName, + DataKey: utils.AgenticMCPCASecretDataKey, + Enabled: func(cr *olsv1alpha1.OLSConfig) bool { + return utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) }, - } - if err := controllerutil.SetControllerReference(cr, secret, r.GetScheme()); err != nil { - return nil, fmt.Errorf("%s: %w", utils.ErrSetAgenticOtelCASecretOwnerRef, err) - } - return secret, nil + ErrSource: utils.ErrAgenticMCPCANotReady, + ErrOwnerRef: utils.ErrSetAgenticMCPCASecretOwnerRef, + ErrCreate: utils.ErrCreateAgenticMCPCASecret, + ErrGet: utils.ErrGetAgenticMCPCASecret, + ErrUpdate: utils.ErrUpdateAgenticMCPCASecret, + ErrDelete: utils.ErrDeleteAgenticMCPCASecret, + }, + { + SecretName: utils.AgenticRHOKPCASecretName, + DataKey: utils.AgenticRHOKPCASecretDataKey, + Enabled: func(cr *olsv1alpha1.OLSConfig) bool { return !cr.Spec.OLSConfig.ByokRAGOnly }, + ErrSource: utils.ErrGetAgenticRHOKPCASourceConfigMap, + ErrOwnerRef: utils.ErrSetAgenticRHOKPCASecretOwnerRef, + ErrCreate: utils.ErrCreateAgenticRHOKPCASecret, + ErrGet: utils.ErrGetAgenticRHOKPCASecret, + ErrUpdate: utils.ErrUpdateAgenticRHOKPCASecret, + ErrDelete: utils.ErrDeleteAgenticRHOKPCASecret, + }, } -// GenerateMCPClientCASecret copies the public service-ca PEM into a client-only Secret -// for verifying the OpenShift MCP server (same cluster CA source as OTEL). -// Returns (nil, nil) when introspection is disabled. -func GenerateMCPClientCASecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*corev1.Secret, error) { - if !utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { +func generateClientCA(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig, cfg clientCAConfig) (*corev1.Secret, error) { + if !cfg.Enabled(cr) { return nil, nil } caCM := &corev1.ConfigMap{} if err := r.Get(ctx, client.ObjectKey{Name: utils.OLSCAConfigMap, Namespace: r.GetNamespace()}, caCM); err != nil { - return nil, fmt.Errorf("%s: %w", utils.ErrAgenticMCPCANotReady, err) + return nil, fmt.Errorf("%s: %w", cfg.ErrSource, err) } caPEM, ok := caCM.Data[utils.AppOtelCollectorCACertFile] if !ok || caPEM == "" { return nil, fmt.Errorf("%s: key %q missing or empty in ConfigMap %s", - utils.ErrAgenticMCPCANotReady, utils.AppOtelCollectorCACertFile, utils.OLSCAConfigMap) + cfg.ErrSource, utils.AppOtelCollectorCACertFile, utils.OLSCAConfigMap) } secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: utils.AgenticMCPCASecretName, + Name: cfg.SecretName, Namespace: r.GetNamespace(), Labels: utils.GenerateAppServerSelectorLabels(), }, Type: corev1.SecretTypeOpaque, Data: map[string][]byte{ - utils.AgenticMCPCASecretDataKey: []byte(caPEM), + cfg.DataKey: []byte(caPEM), }, } if err := controllerutil.SetControllerReference(cr, secret, r.GetScheme()); err != nil { - return nil, fmt.Errorf("%s: %w", utils.ErrSetAgenticMCPCASecretOwnerRef, err) + return nil, fmt.Errorf("%s: %w", cfg.ErrOwnerRef, err) } return secret, nil } -func reconcileOtelClientCASecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - secret, err := GenerateOtelClientCASecret(r, ctx, cr) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAgenticOtelCASecret, err) - } - return reconcileClientCASecret(r, ctx, secret, - utils.ErrCreateAgenticOtelCASecret, - utils.ErrGetAgenticOtelCASecret, - utils.ErrUpdateAgenticOtelCASecret, - ) -} - -func reconcileMCPClientCASecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - secret, err := GenerateMCPClientCASecret(r, ctx, cr) +func deleteClientCA(r reconciler.Reconciler, ctx context.Context, cfg clientCAConfig) error { + secret := &corev1.Secret{} + err := r.Get(ctx, client.ObjectKey{Name: cfg.SecretName, Namespace: r.GetNamespace()}, secret) if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGenerateAgenticMCPCASecret, err) + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("%s: %w", cfg.ErrGet, err) } - if secret == nil { - return deleteMCPClientCASecret(r, ctx) + r.GetLogger().Info("deleting client CA secret (component disabled)", "secret", secret.Name) + if err := r.Delete(ctx, secret); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("%s: %w", cfg.ErrDelete, err) } - return reconcileClientCASecret(r, ctx, secret, - utils.ErrCreateAgenticMCPCASecret, - utils.ErrGetAgenticMCPCASecret, - utils.ErrUpdateAgenticMCPCASecret, - ) + return nil } -// RefreshClientCASecrets updates OTEL/MCP client CA Secrets from their source ConfigMaps. +// RefreshClientCASecrets updates all client CA Secrets from the cluster service-ca ConfigMap. +// When a component is disabled, the corresponding Secret is deleted. func RefreshClientCASecrets(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - if err := reconcileOtelClientCASecret(r, ctx, cr); err != nil { - return err + for _, cfg := range clientCASecrets { + secret, err := generateClientCA(r, ctx, cr, cfg) + if err != nil { + return err + } + if secret == nil { + if err := deleteClientCA(r, ctx, cfg); err != nil { + return err + } + continue + } + if err := reconcileClientCASecret(r, ctx, secret, cfg.ErrCreate, cfg.ErrGet, cfg.ErrUpdate); err != nil { + return err + } } - return reconcileMCPClientCASecret(r, ctx, cr) + return nil } func reconcileClientCASecret( @@ -1175,45 +1206,3 @@ func reconcileClientCASecret( r.GetLogger().Info("client CA secret reconciled", "secret", desired.Name) return nil } - -// GetMCPClientCACertHash returns a SHA256 hash of the MCP client CA Secret when -// introspection is enabled. Empty string when disabled. Returns not-ready when -// the Secret is missing or empty so callers requeue. -func GetMCPClientCACertHash(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (string, error) { - if !utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { - return "", nil - } - - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Name: utils.AgenticMCPCASecretName, Namespace: r.GetNamespace()}, secret) - if err != nil { - if errors.IsNotFound(err) { - return "", fmt.Errorf("%s: %w", utils.ErrOpenShiftMCPServerCANotReady, err) - } - return "", err - } - - certData, ok := secret.Data[utils.AgenticMCPCASecretDataKey] - if !ok || len(certData) == 0 { - return "", fmt.Errorf("%s: waiting for MCP client CA secret %s", utils.ErrOpenShiftMCPServerCANotReady, utils.AgenticMCPCASecretName) - } - - hash := sha256.Sum256(certData) - return hex.EncodeToString(hash[:]), nil -} - -func deleteMCPClientCASecret(r reconciler.Reconciler, ctx context.Context) error { - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Name: utils.AgenticMCPCASecretName, Namespace: r.GetNamespace()}, secret) - if err != nil { - if errors.IsNotFound(err) { - return nil - } - return fmt.Errorf("%s: %w", utils.ErrGetAgenticMCPCASecret, err) - } - r.GetLogger().Info("deleting MCP client CA secret (introspection disabled)", "secret", secret.Name) - if err := r.Delete(ctx, secret); err != nil && !errors.IsNotFound(err) { - return fmt.Errorf("%s: %w", utils.ErrDeleteAgenticMCPCASecret, err) - } - return nil -} diff --git a/internal/controller/appserver/assets_test.go b/internal/controller/appserver/assets_test.go index bd841cfb1..f43cf8701 100644 --- a/internal/controller/appserver/assets_test.go +++ b/internal/controller/appserver/assets_test.go @@ -117,7 +117,7 @@ var _ = Describe("App server assets", func() { MinTLSVersion: string(configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion), Ciphers: configv1.TLSProfiles[configv1.TLSProfileIntermediateType].Ciphers, }, - SolrHybrid: buildSolrHybridSettings(), + SolrHybrid: buildSolrHybridSettings(utils.OLSNamespaceDefault), UserDataCollection: utils.UserDataCollectionConfig{ FeedbackDisabled: false, FeedbackStorage: "/app-root/ols-user-data/feedback", @@ -127,6 +127,7 @@ var _ = Describe("App server assets", func() { ExtraCAs: []string{ "/etc/certs/ols-additional-ca/service-ca.crt", "/etc/certs/otel-collector-ca/service-ca.crt", + "/etc/certs/rhokp-ca/service-ca.crt", }, CertificateDirectory: "/etc/certs/cert-bundle", ToolsApproval: &utils.ToolsApprovalConfig{ @@ -584,7 +585,7 @@ var _ = Describe("App server assets", func() { Expect(err).NotTo(HaveOccurred()) Expect(appSrvConfigFile.OLSConfig.SolrHybrid).NotTo(BeNil()) Expect(appSrvConfigFile.OLSConfig.SolrHybrid).To(PointTo(MatchFields(IgnoreExtras, Fields{ - "SolrHTTPBase": Equal(fmt.Sprintf("http://localhost:%d", utils.RHOOKPHTTPPort)), + "SolrHTTPBase": Equal(utils.RHOKPServiceURL(utils.OLSNamespaceDefault)), "MaxResults": Equal(utils.SolrHybridMaxResultsDefault), "HybridVectorBoost": Equal(utils.SolrHybridVectorBoostDefault), "HybridPoolDocs": Equal(utils.SolrHybridPoolDocsDefault), @@ -811,7 +812,7 @@ var _ = Describe("App server assets", func() { Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("50m"), corev1.ResourceMemory: resource.MustParse("64Mi")}, Claims: []corev1.ResourceClaim{}, })) - Expect(len(dep.Spec.Template.Spec.Volumes)).To(Equal(11)) + Expect(len(dep.Spec.Template.Spec.Volumes)).To(Equal(12)) Expect(dep.Spec.Selector.MatchLabels).To(Equal(utils.GenerateAppServerSelectorLabels())) By("generate deployment without data collector when telemetry pull secret does not exist") @@ -834,7 +835,7 @@ var _ = Describe("App server assets", func() { })) Expect(dep.Spec.Template.Spec.Containers[0].Env).To(Equal(expectedAppServerEnv())) Expect(dep.Spec.Template.Spec.Containers[0].VolumeMounts).To(ConsistOf(get8RequiredVolumeMounts())) - Expect(len(dep.Spec.Template.Spec.Volumes)).To(Equal(9)) + Expect(len(dep.Spec.Template.Spec.Volumes)).To(Equal(10)) By("generate deployment without data collector when telemetry pull secret does not contain telemetry token") utils.CreateTelemetryPullSecret(ctx, k8sClient, false) @@ -857,7 +858,7 @@ var _ = Describe("App server assets", func() { })) Expect(dep.Spec.Template.Spec.Containers[0].Env).To(Equal(expectedAppServerEnv())) Expect(dep.Spec.Template.Spec.Containers[0].VolumeMounts).To(ConsistOf(get8RequiredVolumeMounts())) - Expect(len(dep.Spec.Template.Spec.Volumes)).To(Equal(9)) + Expect(len(dep.Spec.Template.Spec.Volumes)).To(Equal(10)) utils.DeleteTelemetryPullSecret(ctx, k8sClient) }) @@ -1341,7 +1342,6 @@ var _ = Describe("App server assets", func() { Expect(c.Name).NotTo(Equal(utils.OpenShiftMCPServerContainerName)) } Expect(dep.Spec.Template.Spec.Volumes).To(ContainElement(HaveField("Name", utils.AppOpenShiftMCPServerCACertVolumeName))) - Expect(dep.Annotations).To(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) By("Disabling introspection") cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) @@ -1349,7 +1349,6 @@ var _ = Describe("App server assets", func() { dep, err = GenerateOLSDeployment(testReconcilerInstance, cr) Expect(err).NotTo(HaveOccurred()) Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(deploymentContainerCount(2))) - Expect(dep.Annotations).NotTo(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) }) It("should mount MCP CA independently of data collection settings", func() { @@ -1428,7 +1427,6 @@ var _ = Describe("App server assets", func() { Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(deploymentContainerCount(1))) Expect(dep.Spec.Template.Spec.Containers[0].Name).To(Equal(utils.OLSAppServerContainerName)) Expect(dep.Spec.Template.Spec.Volumes).To(ContainElement(HaveField("Name", utils.AppOpenShiftMCPServerCACertVolumeName))) - Expect(dep.Annotations).To(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) }) It("should generate exporter configmap with service_id 'ols' by default", func() { @@ -1497,7 +1495,7 @@ var _ = Describe("App server assets", func() { err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &olsconfigGenerated) Expect(err).NotTo(HaveOccurred()) Expect(olsconfigGenerated.LLMProviders).To(BeEmpty()) - Expect(olsconfigGenerated.OLSConfig.SolrHybrid).To(Equal(buildSolrHybridSettings())) + Expect(olsconfigGenerated.OLSConfig.SolrHybrid).To(Equal(buildSolrHybridSettings(utils.OLSNamespaceDefault))) Expect(cm.Data[utils.OLSConfigFilename]).NotTo(ContainSubstring("reference_content:")) Expect(olsconfigGenerated.OLSConfig.Audit).To(Equal(buildServiceAuditConfig(cr, utils.OLSNamespaceDefault))) Expect(olsconfigGenerated.OLSConfig.UserDataCollection.FeedbackDisabled).To(BeFalse()) @@ -1520,7 +1518,7 @@ var _ = Describe("App server assets", func() { var olsconfigGenerated utils.AppSrvConfigFile err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &olsconfigGenerated) Expect(err).NotTo(HaveOccurred()) - Expect(olsconfigGenerated.OLSConfig.SolrHybrid).To(Equal(buildSolrHybridSettings())) + Expect(olsconfigGenerated.OLSConfig.SolrHybrid).To(Equal(buildSolrHybridSettings(utils.OLSNamespaceDefault))) Expect(cm.Data[utils.OLSConfigFilename]).NotTo(ContainSubstring("reference_content:")) Expect(olsconfigGenerated.OLSConfig.UserDataCollection.FeedbackDisabled).To(BeTrue()) Expect(olsconfigGenerated.OLSConfig.UserDataCollection.TranscriptsDisabled).To(BeTrue()) @@ -1780,7 +1778,7 @@ var _ = Describe("App server assets", func() { olsCm, err := GenerateOLSConfigMap(testReconcilerInstance, ctx, cr) Expect(err).NotTo(HaveOccurred()) - Expect(olsCm.Data[utils.OLSConfigFilename]).To(ContainSubstring("extra_ca:\n - /etc/certs/ols-additional-ca/service-ca.crt\n - /etc/certs/otel-collector-ca/service-ca.crt\n - /etc/certs/ols-user-ca/additional-ca.crt")) + Expect(olsCm.Data[utils.OLSConfigFilename]).To(ContainSubstring("extra_ca:\n - /etc/certs/ols-additional-ca/service-ca.crt\n - /etc/certs/otel-collector-ca/service-ca.crt\n - /etc/certs/rhokp-ca/service-ca.crt\n - /etc/certs/ols-user-ca/additional-ca.crt")) Expect(olsCm.Data[utils.OLSConfigFilename]).To(ContainSubstring("certificate_directory: /etc/certs/cert-bundle")) dep, err = GenerateOLSDeployment(testReconcilerInstance, cr) @@ -2056,6 +2054,11 @@ func get7RequiredVolumeMounts() []corev1.VolumeMount { ReadOnly: false, MountPath: "/etc/certs/cert-bundle", }, + { + Name: utils.AppRHOKPCACertVolumeName, + ReadOnly: true, + MountPath: "/etc/certs/rhokp-ca", + }, } } @@ -2161,6 +2164,21 @@ func get7RequiredVolumes() []corev1.Volume { EmptyDir: &corev1.EmptyDirVolumeSource{}, }, }, + { + Name: utils.AppRHOKPCACertVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: utils.AgenticRHOKPCASecretName, + DefaultMode: &defaultVolumeMode, + Items: []corev1.KeyToPath{ + { + Key: utils.AgenticRHOKPCASecretDataKey, + Path: utils.AppRHOKPCACertFile, + }, + }, + }, + }, + }, } } diff --git a/internal/controller/appserver/deployment.go b/internal/controller/appserver/deployment.go index b0bdc4130..e102082ee 100644 --- a/internal/controller/appserver/deployment.go +++ b/internal/controller/appserver/deployment.go @@ -20,7 +20,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" - "github.com/openshift/lightspeed-operator/internal/controller/agenticintegration" "github.com/openshift/lightspeed-operator/internal/controller/utils" ) @@ -56,6 +55,11 @@ func appServerEnv(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) []corev1.E utils.AppOtelCollectorCACertDir, utils.AppOtelCollectorCACertFile, ), + }, corev1.EnvVar{ + // Make Python ssl (and httpx) use the merged ols.pem bundle that + // includes extra_ca entries (service-ca CAs for RHOKP, MCP, OTEL, etc.) + Name: "SSL_CERT_FILE", + Value: path.Join(utils.OLSAppCertsMountRoot, utils.CertBundleVolumeName, "ols.pem"), }) if !cr.Spec.OLSConfig.ByokRAGOnly { env = append(env, corev1.EnvVar{ @@ -69,59 +73,6 @@ func appServerEnv(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) []corev1.E return env } -func rhokpHTTPProbeHandler() corev1.ProbeHandler { - return corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: utils.RHOOKPReadinessHTTPPath, - Port: intstr.FromInt32(utils.RHOOKPHTTPPort), - Scheme: corev1.URISchemeHTTP, - }, - } -} - -func rhokpProbeBase() corev1.Probe { - return corev1.Probe{ - ProbeHandler: rhokpHTTPProbeHandler(), - PeriodSeconds: utils.RHOOKPProbePeriodSeconds, - TimeoutSeconds: utils.RHOOKPProbeTimeoutSeconds, - SuccessThreshold: 1, - } -} - -func rhokpStartupProbe() *corev1.Probe { - probe := rhokpProbeBase() - probe.InitialDelaySeconds = utils.RHOOKPStartupProbeInitialDelaySeconds - probe.FailureThreshold = utils.RHOOKPStartupProbeFailureThreshold - probe.PeriodSeconds = utils.RHOOKPStartupProbePeriodSeconds - return &probe -} - -func rhokpReadinessProbe() *corev1.Probe { - probe := rhokpProbeBase() - probe.FailureThreshold = utils.RHOOKPProbeFailureThreshold - return &probe -} - -func rhokpLivenessProbe() *corev1.Probe { - return rhokpReadinessProbe() -} - -func getRHOOKPResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { - // RHOKP recommended sizing: 2 CPU, 2 GiB memory, 75 GiB ephemeral (product docs). - // Defaults follow OpenShift conventions: requests only for CPU/memory (OLS-3397). - return utils.GetResourcesOrDefault( - cr.Spec.OLSConfig.DeploymentConfig.RHOKPContainer.Resources, - &corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("2"), - corev1.ResourceMemory: resource.MustParse("2Gi"), - corev1.ResourceEphemeralStorage: resource.MustParse("75Gi"), - }, - Claims: []corev1.ResourceClaim{}, - }, - ) -} - func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { ctx := context.Background() const OLSConfigVolumeName = "cm-olsconfig" @@ -409,6 +360,24 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( }) } + if !cr.Spec.OLSConfig.ByokRAGOnly { + volumes = append(volumes, corev1.Volume{ + Name: utils.AppRHOKPCACertVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: utils.AgenticRHOKPCASecretName, + DefaultMode: &volumeDefaultMode, + Items: []corev1.KeyToPath{ + { + Key: utils.AgenticRHOKPCASecretDataKey, + Path: utils.AppRHOKPCACertFile, + }, + }, + }, + }, + }) + } + volumes = append(volumes, corev1.Volume{ Name: utils.TmpVolumeName, @@ -438,6 +407,13 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( ReadOnly: true, }) } + if !cr.Spec.OLSConfig.ByokRAGOnly { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: utils.AppRHOKPCACertVolumeName, + MountPath: path.Join(utils.OLSAppCertsMountRoot, utils.AppRHOKPCACertDir), + ReadOnly: true, + }) + } volumeMounts = append(volumeMounts, corev1.VolumeMount{ Name: utils.TmpVolumeName, MountPath: utils.TmpVolumeMountPath, @@ -474,7 +450,6 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( ols_server_resources := getOLSServerResources(cr) data_collector_resources := getOLSDataCollectorResources(cr) - rhokp_resources := getRHOOKPResources(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 @@ -493,13 +468,6 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( utils.OLSConfigMapResourceVersionAnnotation: configMapResourceVersion, utils.ProxyCACertHashAnnotation: proxyCACMResourceVersion, } - if utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { - mcpCAHash, err := GetMCPClientCACertHash(r, ctx, cr) - if err != nil { - return nil, fmt.Errorf("failed to get OpenShift MCP server CA certificate hash: %w", err) - } - annotations[utils.OpenShiftMCPServerCACertHashAnnotation] = mcpCAHash - } deployment := appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ @@ -582,9 +550,8 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( return nil, err } - // Add additional containers in a consistent order: - // 1. Data collector container (if enabled) - // 2. RHOKP Solr sidecar (if Solr hybrid RAG is configured) + // Add the data collector container (if enabled). + // RHOKP now runs as a standalone Deployment, not a sidecar. if dataCollectorEnabled { // Add data exporter container @@ -615,31 +582,6 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, exporterContainer) } - if !cr.Spec.OLSConfig.ByokRAGOnly { - rhokpSidecarContainer := corev1.Container{ - Name: utils.RHOOKPContainerName, - Image: r.GetRHOOKPImage(), - ImagePullPolicy: corev1.PullIfNotPresent, - Command: rhokpContainerCommand(), - Args: rhokpContainerArgs(), - SecurityContext: utils.RHOOKPContainerSecurityContext(), - Env: generateRHOOKPEnv(), - Ports: []corev1.ContainerPort{ - { - ContainerPort: utils.RHOOKPHTTPPort, - Name: "solr-http", - Protocol: corev1.ProtocolTCP, - }, - }, - // portal-rag admin ping; startupProbe tolerates Solr cold start before readiness/liveness run. - StartupProbe: rhokpStartupProbe(), - ReadinessProbe: rhokpReadinessProbe(), - LivenessProbe: rhokpLivenessProbe(), - Resources: *rhokp_resources, - } - deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, rhokpSidecarContainer) - } - return &deployment, nil } @@ -674,21 +616,6 @@ func updateOLSDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1 } } - // Step 4: Check OpenShift MCP CA hash when introspection is enabled - var currentMCPCAHash string - if utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { - currentMCPCAHash, err = GetMCPClientCACertHash(r, ctx, cr) - if err != nil { - return fmt.Errorf("failed to get OpenShift MCP server CA certificate hash: %w", err) - } - if existingDeployment.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation] != currentMCPCAHash { - r.GetLogger().Info("OpenShift MCP server CA certificate content changed, updating deployment") - changed = true - } - } else if _, exists := existingDeployment.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation]; exists { - changed = true - } - // If nothing changed, skip update if !changed { return nil @@ -704,11 +631,6 @@ func updateOLSDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1 existingDeployment.Annotations[utils.OLSConfigMapResourceVersionAnnotation] = desiredDeployment.Annotations[utils.OLSConfigMapResourceVersionAnnotation] existingDeployment.Annotations[utils.ProxyCACertHashAnnotation] = currentProxyCACMHash - if utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { - existingDeployment.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation] = currentMCPCAHash - } else { - delete(existingDeployment.Annotations, utils.OpenShiftMCPServerCACertHashAnnotation) - } r.GetLogger().Info("updating OLS deployment", "name", existingDeployment.Name) @@ -777,9 +699,6 @@ func RestartAppServer(r reconciler.Reconciler, ctx context.Context, deployment . if err := RefreshClientCASecrets(r, ctx, cr); err != nil { return err } - if err := agenticintegration.TouchAgenticConfiguration(r, ctx); err != nil { - return err - } dep := &appsv1.Deployment{} if err := r.Get(ctx, client.ObjectKey{Name: utils.OLSAppServerDeploymentName, Namespace: r.GetNamespace()}, dep); err != nil { diff --git a/internal/controller/appserver/deployment_test.go b/internal/controller/appserver/deployment_test.go index 6f57d3335..bfb6139ca 100644 --- a/internal/controller/appserver/deployment_test.go +++ b/internal/controller/appserver/deployment_test.go @@ -10,7 +10,6 @@ import ( "sigs.k8s.io/yaml" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" @@ -136,7 +135,7 @@ var _ = Describe("App server deployment generation", func() { By("Enabling introspection") cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(true) - Expect(reconcileMCPClientCASecret(testReconcilerInstance, ctx, cr)).To(Succeed()) + Expect(RefreshClientCASecrets(testReconcilerInstance, ctx, cr)).To(Succeed()) dep, err := GenerateOLSDeployment(testReconcilerInstance, cr) Expect(err).NotTo(HaveOccurred()) @@ -165,8 +164,6 @@ var _ = Describe("App server deployment generation", func() { MountPath: path.Join(utils.OLSAppCertsMountRoot, utils.AppOpenShiftMCPServerCACertDir), ReadOnly: true, })) - Expect(dep.Annotations).To(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) - Expect(dep.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation]).NotTo(BeEmpty()) By("Disabling introspection") cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) @@ -175,7 +172,6 @@ var _ = Describe("App server deployment generation", func() { Expect(err).NotTo(HaveOccurred()) Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(deploymentContainerCount(2))) - Expect(dep.Annotations).NotTo(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) for _, v := range dep.Spec.Template.Spec.Volumes { Expect(v.Name).NotTo(Equal(utils.AppOpenShiftMCPServerCACertVolumeName)) } @@ -186,21 +182,9 @@ var _ = Describe("App server deployment generation", func() { dep, err = GenerateOLSDeployment(testReconcilerInstance, cr) Expect(err).NotTo(HaveOccurred()) - Expect(dep.Annotations).To(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) Expect(dep.Spec.Template.Spec.Volumes).To(ContainElement(HaveField("Name", utils.AppOpenShiftMCPServerCACertVolumeName))) }) - It("should not generate app-server deployment when MCP client CA Secret is missing", func() { - utils.DeleteAgenticMCPCASecret(ctx, k8sClient) - defer utils.EnsureAgenticMCPCASecret(ctx, k8sClient, utils.TestCACert) - - cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(true) - - _, err := GenerateOLSDeployment(testReconcilerInstance, cr) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(utils.ErrOpenShiftMCPServerCANotReady)) - }) - It("should mount MCP CA independently of data collection settings", func() { By("introspection enabled, data collection enabled") utils.EnsureOLSCAConfigMap(ctx, k8sClient, "test-mcp-ca") @@ -246,7 +230,6 @@ var _ = Describe("App server deployment generation", func() { Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(deploymentContainerCount(1))) Expect(dep.Spec.Template.Spec.Containers[0].Name).To(Equal(utils.OLSAppServerContainerName)) Expect(dep.Spec.Template.Spec.Volumes).To(ContainElement(HaveField("Name", utils.AppOpenShiftMCPServerCACertVolumeName))) - Expect(dep.Annotations).To(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) }) }) @@ -567,7 +550,7 @@ var _ = Describe("App server deployment generation", func() { olsCm, err := GenerateOLSConfigMap(testReconcilerInstance, ctx, cr) Expect(err).NotTo(HaveOccurred()) - Expect(olsCm.Data[utils.OLSConfigFilename]).To(ContainSubstring("extra_ca:\n - /etc/certs/ols-additional-ca/service-ca.crt\n - /etc/certs/otel-collector-ca/service-ca.crt\n - /etc/certs/ols-user-ca/additional-ca.crt")) + Expect(olsCm.Data[utils.OLSConfigFilename]).To(ContainSubstring("extra_ca:\n - /etc/certs/ols-additional-ca/service-ca.crt\n - /etc/certs/otel-collector-ca/service-ca.crt\n - /etc/certs/rhokp-ca/service-ca.crt\n - /etc/certs/ols-user-ca/additional-ca.crt")) Expect(olsCm.Data[utils.OLSConfigFilename]).To(ContainSubstring("certificate_directory: /etc/certs/cert-bundle")) dep, err = GenerateOLSDeployment(testReconcilerInstance, cr) @@ -714,7 +697,7 @@ var _ = Describe("App server deployment generation", func() { )) }) - It("should add RHOKP sidecar by default", func() { + It("should not include RHOKP sidecar container (standalone replaces sidecar)", func() { cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ FeedbackDisabled: true, @@ -724,52 +707,9 @@ var _ = Describe("App server deployment generation", func() { dep, err := GenerateOLSDeployment(testReconcilerInstance, cr) Expect(err).NotTo(HaveOccurred()) - var rhokpContainer *corev1.Container for i := range dep.Spec.Template.Spec.Containers { - if dep.Spec.Template.Spec.Containers[i].Name == utils.RHOOKPContainerName { - rhokpContainer = &dep.Spec.Template.Spec.Containers[i] - break - } - } - Expect(rhokpContainer).NotTo(BeNil(), "expected RHOKP sidecar container") - Expect(rhokpContainer.Image).To(Equal(testReconcilerInstance.GetRHOOKPImage())) - Expect(rhokpContainer.Ports).To(ContainElement( - MatchFields(IgnoreExtras, Fields{ - "ContainerPort": Equal(int32(utils.RHOOKPHTTPPort)), - "Name": Equal("solr-http"), - "Protocol": Equal(corev1.ProtocolTCP), - }), - )) - Expect(rhokpContainer.Command).To(Equal(rhokpContainerCommand())) - Expect(rhokpContainer.Args).To(Equal(rhokpContainerArgs())) - Expect(rhokpContainer.StartupProbe).To(Equal(rhokpStartupProbe())) - Expect(rhokpContainer.ReadinessProbe).To(Equal(rhokpReadinessProbe())) - Expect(rhokpContainer.LivenessProbe).To(Equal(rhokpLivenessProbe())) - Expect(rhokpContainer.Resources).To(Equal(corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("2"), - corev1.ResourceMemory: resource.MustParse("2Gi"), - corev1.ResourceEphemeralStorage: resource.MustParse("75Gi"), - }, - Claims: []corev1.ResourceClaim{}, - })) - Expect(rhokpContainer.SecurityContext.ReadOnlyRootFilesystem).NotTo(BeNil()) - Expect(*rhokpContainer.SecurityContext.ReadOnlyRootFilesystem).To(BeFalse()) - Expect(rhokpContainer.Env).To(ContainElement( - MatchFields(IgnoreExtras, Fields{ - "Name": Equal("ACCESS_KEY"), - "Value": BeEmpty(), - "ValueFrom": PointTo(MatchFields(IgnoreExtras, Fields{ - "SecretKeyRef": PointTo(MatchFields(IgnoreExtras, Fields{ - "LocalObjectReference": Equal(corev1.LocalObjectReference{Name: utils.RHOOKPAccessKeySecretName}), - "Key": Equal(utils.RHOOKPAccessKeySecretKey), - "Optional": PointTo(BeTrue()), - })), - })), - }), - )) - for _, ic := range dep.Spec.Template.Spec.InitContainers { - Expect(ic.Name).NotTo(HavePrefix("rhokp"), "RHOKP must not use an init container") + Expect(dep.Spec.Template.Spec.Containers[i].Name).NotTo(Equal(utils.RHOOKPContainerName), + "RHOKP sidecar must not be present; it is now a standalone Deployment") } var apiContainer *corev1.Container @@ -786,40 +726,7 @@ var _ = Describe("App server deployment generation", func() { })) }) - It("should apply custom RHOKP resources from the CR", func() { - cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) - cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ - FeedbackDisabled: true, - TranscriptsDisabled: true, - } - cr.Spec.OLSConfig.DeploymentConfig.RHOKPContainer = olsv1alpha1.ContainerConfig{ - Resources: &corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: resource.MustParse("1Gi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("4"), - corev1.ResourceMemory: resource.MustParse("4Gi"), - }, - }, - } - - dep, err := GenerateOLSDeployment(testReconcilerInstance, cr) - Expect(err).NotTo(HaveOccurred()) - - var rhokpContainer *corev1.Container - for i := range dep.Spec.Template.Spec.Containers { - if dep.Spec.Template.Spec.Containers[i].Name == utils.RHOOKPContainerName { - rhokpContainer = &dep.Spec.Template.Spec.Containers[i] - break - } - } - Expect(rhokpContainer).NotTo(BeNil()) - Expect(rhokpContainer.Resources).To(Equal(*cr.Spec.OLSConfig.DeploymentConfig.RHOKPContainer.Resources)) - }) - - It("should not add RHOKP sidecar or OCP_CLUSTER_VERSION when byokRAGOnly is true", func() { + It("should not include RHOKP sidecar even when byokRAGOnly is true", func() { cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ FeedbackDisabled: true, diff --git a/internal/controller/appserver/reconciler.go b/internal/controller/appserver/reconciler.go index 84677b98e..db8232c47 100644 --- a/internal/controller/appserver/reconciler.go +++ b/internal/controller/appserver/reconciler.go @@ -114,12 +114,8 @@ func ReconcileAppServerDeployment(r reconciler.Reconciler, ctx context.Context, tasks := []utils.ReconcileTask{ { - Name: "reconcile OTEL client CA Secret", - Task: reconcileOtelClientCASecret, - }, - { - Name: "reconcile MCP client CA Secret", - Task: reconcileMCPClientCASecret, + Name: "reconcile client CA Secrets", + Task: RefreshClientCASecrets, }, { Name: "reconcile Exporter ConfigMap", diff --git a/internal/controller/appserver/reconciler_test.go b/internal/controller/appserver/reconciler_test.go index 92a00395a..45ce45e45 100644 --- a/internal/controller/appserver/reconciler_test.go +++ b/internal/controller/appserver/reconciler_test.go @@ -219,96 +219,6 @@ var _ = Describe("App server reconciliator", Ordered, func() { Expect(dep.Spec.Template.Spec.Tolerations).To(Equal(olsConfig.Spec.OLSConfig.DeploymentConfig.APIContainer.Tolerations)) }) - It("should track MCP CA hash annotation when introspection is toggled", func() { - By("Ensure MCP CA ConfigMap has injected cert") - utils.EnsureOLSCAConfigMap(ctx, k8sClient, "test-mcp-ca") - utils.EnsureAgenticMCPCASecret(ctx, k8sClient, "test-mcp-ca") - - By("Disable introspection initially") - olsConfig := &olsv1alpha1.OLSConfig{} - err := k8sClient.Get(ctx, crNamespacedName, olsConfig) - Expect(err).NotTo(HaveOccurred()) - olsConfig.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) - - By("Reconcile with introspection disabled") - err = ReconcileAppServer(testReconcilerInstance, ctx, olsConfig) - Expect(err).NotTo(HaveOccurred()) - - By("Get deployment and verify MCP CA hash annotation is absent") - dep := &appsv1.Deployment{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - Expect(dep.Annotations).NotTo(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) - - By("Enable introspection") - err = k8sClient.Get(ctx, crNamespacedName, olsConfig) - Expect(err).NotTo(HaveOccurred()) - olsConfig.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(true) - - By("Reconcile with introspection enabled") - err = ReconcileAppServer(testReconcilerInstance, ctx, olsConfig) - Expect(err).NotTo(HaveOccurred()) - - By("Verify deployment annotation tracks MCP CA hash") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - Expect(dep.Annotations).To(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) - firstHash := dep.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation] - Expect(firstHash).NotTo(BeEmpty()) - - By("Disable introspection again") - err = k8sClient.Get(ctx, crNamespacedName, olsConfig) - Expect(err).NotTo(HaveOccurred()) - olsConfig.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) - - By("Reconcile with introspection disabled again") - err = ReconcileAppServer(testReconcilerInstance, ctx, olsConfig) - Expect(err).NotTo(HaveOccurred()) - - By("Verify MCP CA hash annotation removed") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - Expect(dep.Annotations).NotTo(HaveKey(utils.OpenShiftMCPServerCACertHashAnnotation)) - }) - - It("should trigger rolling update when MCP CA ConfigMap content changes", func() { - By("Ensure MCP CA ConfigMap with initial cert") - utils.EnsureOLSCAConfigMap(ctx, k8sClient, "initial-mcp-ca") - utils.EnsureAgenticMCPCASecret(ctx, k8sClient, "initial-mcp-ca") - - By("Enable introspection") - olsConfig := &olsv1alpha1.OLSConfig{} - err := k8sClient.Get(ctx, crNamespacedName, olsConfig) - Expect(err).NotTo(HaveOccurred()) - olsConfig.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(true) - - By("Reconcile to set MCP CA hash annotation") - err = ReconcileAppServer(testReconcilerInstance, ctx, olsConfig) - Expect(err).NotTo(HaveOccurred()) - - By("Get deployment and capture initial hash") - dep := &appsv1.Deployment{} - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - initialHash := dep.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation] - Expect(initialHash).NotTo(BeEmpty()) - - By("Update MCP CA ConfigMap content") - utils.EnsureOLSCAConfigMap(ctx, k8sClient, "rotated-mcp-ca") - utils.EnsureAgenticMCPCASecret(ctx, k8sClient, "rotated-mcp-ca") - - By("Reconcile again") - err = ReconcileAppServer(testReconcilerInstance, ctx, olsConfig) - Expect(err).NotTo(HaveOccurred()) - - By("Verify deployment annotation updated to new hash") - err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSAppServerDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - newHash := dep.Annotations[utils.OpenShiftMCPServerCACertHashAnnotation] - Expect(newHash).NotTo(Equal(initialHash)) - Expect(dep.Spec.Template.Annotations[utils.ForceReloadAnnotationKey]).NotTo(BeEmpty()) - }) - It("should trigger rolling update of the deployment when updating the nodeselector ", func() { By("Get the deployment") dep := &appsv1.Deployment{} @@ -749,7 +659,7 @@ var _ = Describe("App server reconciliator", Ordered, func() { err = k8sClient.Get(ctx, types.NamespacedName{Name: utils.OLSConfigCmName, Namespace: utils.OLSNamespaceDefault}, cm) Expect(err).NotTo(HaveOccurred()) Expect(cm.Data).To(HaveKey(utils.OLSConfigFilename)) - Expect(cm.Data[utils.OLSConfigFilename]).To(ContainSubstring("extra_ca:\n - /etc/certs/ols-additional-ca/service-ca.crt\n - /etc/certs/otel-collector-ca/service-ca.crt\n - /etc/certs/ols-user-ca/ca-cert-1.crt")) + Expect(cm.Data[utils.OLSConfigFilename]).To(ContainSubstring("extra_ca:\n - /etc/certs/ols-additional-ca/service-ca.crt\n - /etc/certs/otel-collector-ca/service-ca.crt\n - /etc/certs/rhokp-ca/service-ca.crt\n - /etc/certs/ols-user-ca/ca-cert-1.crt")) Expect(cm.Data[utils.OLSConfigFilename]).To(ContainSubstring("certificate_directory: /etc/certs/cert-bundle")) By("Get app deployment and check the volume mount") diff --git a/internal/controller/appserver/rhokp.go b/internal/controller/appserver/rhokp.go deleted file mode 100644 index 949ee84b1..000000000 --- a/internal/controller/appserver/rhokp.go +++ /dev/null @@ -1,52 +0,0 @@ -package appserver - -import ( - "fmt" - - corev1 "k8s.io/api/core/v1" - - "github.com/openshift/lightspeed-operator/internal/controller/utils" -) - -// rhokpStartupScript remaps RHOKP Apache HTTP/HTTPS listen ports before mel start so MCP (8080) -// and the app server (8443) can keep the stock ports in the shared pod network namespace. -// ssl.conf must remain present — mel's httpd pre-init reads it for TLS cert setup. -func rhokpStartupScript() string { - return fmt.Sprintf( - `sed -i 's/^Listen %d/Listen %d/' %s && `+ - `sed -i 's/^Listen 0.0.0.0:%d https/Listen 0.0.0.0:%d https/' %s && `+ - `sed -i 's/_default_:%d/_default_:%d/' %s && `+ - `exec %s %s`, - utils.RHOOKPImageHTTPPort, utils.RHOOKPHTTPPort, utils.RHOOKPHTTPDConfPath, - utils.RHOOKPImageHTTPSPort, utils.RHOOKPHTTPSPort, utils.RHOOKPHTTPDSSLConfPath, - utils.RHOOKPImageHTTPSPort, utils.RHOOKPHTTPSPort, utils.RHOOKPHTTPDSSLConfPath, - utils.RHOOKPContainerEntrypoint, - utils.RHOOKPMainCommand, - ) -} - -func rhokpContainerCommand() []string { - return []string{"/bin/sh", "-c"} -} - -func rhokpContainerArgs() []string { - return []string{rhokpStartupScript()} -} - -func generateRHOOKPEnv() []corev1.EnvVar { - optional := true - return []corev1.EnvVar{ - { - Name: "ACCESS_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.RHOOKPAccessKeySecretName, - }, - Key: utils.RHOOKPAccessKeySecretKey, - Optional: &optional, - }, - }, - }, - } -} diff --git a/internal/controller/appserver/rhokp_test.go b/internal/controller/appserver/rhokp_test.go deleted file mode 100644 index 445df6de8..000000000 --- a/internal/controller/appserver/rhokp_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package appserver - -import ( - "fmt" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/openshift/lightspeed-operator/internal/controller/utils" -) - -var _ = Describe("RHOKP sidecar assets", func() { - It("should remap RHOKP Apache ports before starting the RHOKP entrypoint", func() { - Expect(rhokpContainerCommand()).To(Equal([]string{"/bin/sh", "-c"})) - script := rhokpContainerArgs()[0] - Expect(script).To(ContainSubstring(utils.RHOOKPHTTPDConfPath)) - Expect(script).To(ContainSubstring(utils.RHOOKPHTTPDSSLConfPath)) - Expect(script).To(ContainSubstring(fmt.Sprintf("Listen %d/Listen %d/", utils.RHOOKPImageHTTPPort, utils.RHOOKPHTTPPort))) - Expect(script).To(ContainSubstring(fmt.Sprintf("0.0.0.0:%d https/Listen 0.0.0.0:%d https/", utils.RHOOKPImageHTTPSPort, utils.RHOOKPHTTPSPort))) - Expect(script).To(ContainSubstring(fmt.Sprintf("_default_:%d/_default_:%d/", utils.RHOOKPImageHTTPSPort, utils.RHOOKPHTTPSPort))) - Expect(script).To(ContainSubstring(utils.RHOOKPContainerEntrypoint)) - Expect(script).To(ContainSubstring(utils.RHOOKPMainCommand)) - }) - - It("should wire optional ACCESS_KEY from rhokp-access-key secret", func() { - env := generateRHOOKPEnv() - Expect(env).To(HaveLen(1)) - Expect(env[0].Name).To(Equal("ACCESS_KEY")) - Expect(env[0].ValueFrom.SecretKeyRef.Name).To(Equal(utils.RHOOKPAccessKeySecretName)) - Expect(env[0].ValueFrom.SecretKeyRef.Key).To(Equal(utils.RHOOKPAccessKeySecretKey)) - Expect(*env[0].ValueFrom.SecretKeyRef.Optional).To(BeTrue()) - }) -}) diff --git a/internal/controller/appserver/suite_test.go b/internal/controller/appserver/suite_test.go index 185b9737e..32efb40e4 100644 --- a/internal/controller/appserver/suite_test.go +++ b/internal/controller/appserver/suite_test.go @@ -208,9 +208,6 @@ var _ = BeforeEach(func() { }) func deploymentContainerCount(base int) int { - if !cr.Spec.OLSConfig.ByokRAGOnly { - return base + 1 - } return base } @@ -228,6 +225,10 @@ func expectedAppServerEnv() []corev1.EnvVar { utils.AppOtelCollectorCACertFile, ), }, + { + Name: "SSL_CERT_FILE", + Value: filepath.Join(utils.OLSAppCertsMountRoot, utils.CertBundleVolumeName, "ols.pem"), + }, } if !cr.Spec.OLSConfig.ByokRAGOnly { env = append(env, corev1.EnvVar{ diff --git a/internal/controller/ocpmcp/reconciler.go b/internal/controller/ocpmcp/reconciler.go index 5e66b5555..5851c4fc0 100644 --- a/internal/controller/ocpmcp/reconciler.go +++ b/internal/controller/ocpmcp/reconciler.go @@ -19,11 +19,6 @@ import ( // ReconcileResources reconciles Phase 1 standalone MCP resources. // When introspectionEnabled is false, removes managed MCP resources instead. func ReconcileResources(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - if !utils.BoolDeref(olsconfig.Spec.OLSConfig.IntrospectionEnabled, true) { - r.GetLogger().Info("openshift-mcp-server disabled; removing operand resources") - return Remove(r, ctx) - } - return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileOpenShiftMCPServerResources", []utils.ReconcileTask{ {Name: "reconcile openshift-mcp-server ConfigMap", Task: reconcileConfigMap}, {Name: "reconcile openshift-mcp-server ServiceAccount", Task: reconcileServiceAccount}, @@ -34,10 +29,6 @@ func ReconcileResources(r reconciler.Reconciler, ctx context.Context, olsconfig // ReconcileDeployment reconciles Phase 2: Service, TLS material, and Deployment. func ReconcileDeployment(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - if !utils.BoolDeref(olsconfig.Spec.OLSConfig.IntrospectionEnabled, true) { - return nil - } - return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileOpenShiftMCPServerDeployment", []utils.ReconcileTask{ {Name: "reconcile openshift-mcp-server Service", Task: reconcileService}, {Name: "reconcile openshift-mcp-server TLS Certs", Task: reconcileTLSSecret}, diff --git a/internal/controller/ocpmcp/reconciler_test.go b/internal/controller/ocpmcp/reconciler_test.go index 57b452f63..6551c9d2d 100644 --- a/internal/controller/ocpmcp/reconciler_test.go +++ b/internal/controller/ocpmcp/reconciler_test.go @@ -201,18 +201,12 @@ var _ = Describe("OpenShift MCP Server reconciler", Ordered, func() { Expect(err).NotTo(HaveOccurred()) }) - It("should remove MCP resources when introspection is disabled", func() { + It("should remove all MCP resources via Remove", func() { // Prior tests may have deleted the Deployment; recreate so cleanup is exercised. ensureMCPTLSSecret() Expect(ReconcileDeployment(testReconcilerInstance, ctx, testCR)).To(Succeed()) - disabledCR := testCR.DeepCopy() - disabledCR.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) - - err := ReconcileResources(testReconcilerInstance, ctx, disabledCR) - Expect(err).NotTo(HaveOccurred()) - - err = ReconcileDeployment(testReconcilerInstance, ctx, disabledCR) + err := Remove(testReconcilerInstance, ctx) Expect(err).NotTo(HaveOccurred()) dep := &appsv1.Deployment{} diff --git a/internal/controller/olsconfig_controller.go b/internal/controller/olsconfig_controller.go index 3d2623719..066077356 100644 --- a/internal/controller/olsconfig_controller.go +++ b/internal/controller/olsconfig_controller.go @@ -86,6 +86,7 @@ import ( // OLS-3737: OTEL Collector reconciliation disabled until e2e coverage exists. // "github.com/openshift/lightspeed-operator/internal/controller/otelcollector" "github.com/openshift/lightspeed-operator/internal/controller/postgres" + "github.com/openshift/lightspeed-operator/internal/controller/rhokp" "github.com/openshift/lightspeed-operator/internal/controller/utils" "github.com/openshift/lightspeed-operator/internal/controller/watchers" utiltls "github.com/openshift/lightspeed-operator/internal/tls" @@ -304,6 +305,8 @@ func (r *OLSConfigReconciler) reconcileOperatorResources(ctx context.Context) er // Uses continue-on-error to reconcile as many resources as possible, even if some fail. func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { resourceFailures := make(map[string]error) + + // Always-included operands (unconditionally reconciled). resourceSteps := []utils.ReconcileSteps{ {Name: "console UI resources", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { return console.ReconcileConsoleUIResources(r, ctx, cr) @@ -315,9 +318,35 @@ func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, // {Name: "OTEL Collector resources", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { // return otelcollector.ReconcileOtelCollectorResources(r, ctx, cr) // }}, - {Name: "openshift-mcp-server resources", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { - return ocpmcp.ReconcileResources(r, ctx, cr) - }}, + } + + // Optional operands — gated by CR fields or image flags. + // Each block appends when enabled, or removes resources when transitioning to disabled. + + if utils.BoolDeref(olsconfig.Spec.OLSConfig.IntrospectionEnabled, true) { + resourceSteps = append(resourceSteps, utils.ReconcileSteps{ + Name: "openshift-mcp-server resources", + Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return ocpmcp.ReconcileResources(r, ctx, cr) + }, + }) + } else if wasComponentEnabled(olsconfig, utils.TypeMCPServerReady) { + if err := ocpmcp.Remove(r, ctx); err != nil { + resourceFailures["openshift-mcp-server cleanup"] = fmt.Errorf("%s: %w", utils.ErrRemoveOpenShiftMCPServerResources, err) + } + } + + if !olsconfig.Spec.OLSConfig.ByokRAGOnly { + resourceSteps = append(resourceSteps, utils.ReconcileSteps{ + Name: "RHOKP resources", + Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return rhokp.ReconcileResources(r, ctx, cr) + }, + }) + } else if wasComponentEnabled(olsconfig, utils.TypeRHOKPReady) { + if err := rhokp.Remove(r, ctx); err != nil { + resourceFailures["RHOKP cleanup"] = fmt.Errorf("%s: %w", utils.ErrRemoveRHOKPResources, err) + } } if r.Options.AgenticConsoleUIImage != "" { @@ -329,7 +358,7 @@ func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, }) } else if wasComponentEnabled(olsconfig, utils.TypeAgenticConsolePluginReady) { if err := agenticconsole.RemoveAgenticConsole(r, ctx); err != nil { - resourceFailures["agentic console UI cleanup"] = fmt.Errorf("failed to remove agentic console UI resources: %w", err) + resourceFailures["agentic console UI cleanup"] = fmt.Errorf("%s: %w", utils.ErrRemoveAgenticConsoleUIResources, err) } } @@ -343,7 +372,7 @@ func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, }) } else if wasComponentEnabled(olsconfig, utils.TypeAlertsAdapterReady) { if err := alertsadapter.RemoveAlertsAdapter(r, ctx); err != nil { - resourceFailures["alerts adapter cleanup"] = fmt.Errorf("failed to remove alerts adapter resources: %w", err) + resourceFailures["alerts adapter cleanup"] = fmt.Errorf("%s: %w", utils.ErrRemoveAlertsAdapterResources, err) } } @@ -410,6 +439,7 @@ func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, // These resources depend on Phase 1 resources being available. // Uses a fail-fast pattern and updates status conditions based on deployment health. func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) (ctrl.Result, error) { + // Always-included operands (unconditionally reconciled). deploymentSteps := []utils.ReconcileSteps{ {Name: "console UI deployment", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { return console.ReconcileConsoleUIDeploymentAndPlugin(r, ctx, cr) @@ -423,6 +453,9 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, // }, ConditionType: utils.TypeOtelCollectorReady, Deployment: utils.OtelCollectorDeploymentName}, } + // Optional operands — gated by CR fields or image flags. + // Each block appends when enabled, or sets a "Disabled" status condition when not. + if utils.BoolDeref(olsconfig.Spec.OLSConfig.IntrospectionEnabled, true) { deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ Name: "openshift-mcp-server deployment", @@ -434,6 +467,18 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, }) } + if !olsconfig.Spec.OLSConfig.ByokRAGOnly { + deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ + Name: "RHOKP deployment", + Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return rhokp.ReconcileDeployment(r, ctx, cr) + }, + ConditionType: utils.TypeRHOKPReady, + Deployment: utils.RHOKPDeploymentName, + }) + } + + // App server is always-included but appended last (depends on other operand Services/TLS). deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ Name: "application server deployment", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -463,6 +508,17 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, }) } + if olsconfig.Spec.OLSConfig.ByokRAGOnly { + newStatus.Conditions = append(newStatus.Conditions, metav1.Condition{ + Type: utils.TypeRHOKPReady, + Status: metav1.ConditionFalse, + ObservedGeneration: olsconfig.Generation, + Reason: "Disabled", + Message: "RHOKP is disabled; spec.ols.byokRAGOnly is true", + LastTransitionTime: metav1.Now(), + }) + } + if r.Options.AgenticConsoleUIImage != "" { deploymentSteps = append(deploymentSteps, utils.ReconcileSteps{ Name: "agentic console UI deployment", @@ -497,7 +553,7 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, cleanupFailed := false if wasComponentEnabled(olsconfig, utils.TypeAlertsAdapterReady) { if err := alertsadapter.RemoveAlertsAdapter(r, ctx); err != nil { - failedTasks["alerts adapter cleanup"] = fmt.Errorf("failed to remove alerts adapter resources: %w", err) + failedTasks["alerts adapter cleanup"] = fmt.Errorf("%s: %w", utils.ErrRemoveAlertsAdapterResources, err) cleanupFailed = true } } @@ -743,6 +799,12 @@ func (r *OLSConfigReconciler) finalizeOLSConfig(ctx context.Context, cr *olsv1al r.Logger.V(1).Info("Proceeding with finalization despite openshift-mcp-server removal error") } + r.Logger.V(1).Info("Removing RHOKP operand during finalization") + if err := rhokp.Remove(r, ctx); err != nil { + r.Logger.Error(err, "Failed to remove RHOKP during finalization") + r.Logger.V(1).Info("Proceeding with finalization despite RHOKP removal error") + } + // Step 2: List all owned resources once (avoids duplicate API calls) r.Logger.V(1).Info("Listing owned resources for cleanup") resourceGroups, err := r.listOwnedResources(ctx, cr) diff --git a/internal/controller/olsconfig_helpers.go b/internal/controller/olsconfig_helpers.go index 65539fdb4..2a3359be6 100644 --- a/internal/controller/olsconfig_helpers.go +++ b/internal/controller/olsconfig_helpers.go @@ -411,11 +411,10 @@ func (r *OLSConfigReconciler) annotateExternalResources(ctx context.Context, // Annotate all external secrets err := utils.ForEachExternalSecret(cr, func(name string, source string) error { // TLS secrets affect both console (CA cert) and backend (server cert) - // All other secrets use the default behavior (ACTIVE_BACKEND only) if r.WatcherConfig != nil && source == "tls" { r.WatcherConfig.AnnotatedSecretMapping[name] = []string{ utils.ConsoleUIDeploymentName, - "ACTIVE_BACKEND", + utils.OLSAppServerDeploymentName, } } @@ -453,13 +452,14 @@ func (r *OLSConfigReconciler) annotateExternalResources(ctx context.Context, } r.syncOpenShiftMCPServerTLSWatcher(cr) + r.syncRHOKPTLSWatcher(cr) return nil } // syncOpenShiftMCPServerTLSWatcher enables watching openshift-mcp-server-tls only while // introspectionEnabled is true. The Secret is listed statically in WatcherConfig; this only // toggles OpenShiftMCPServerTLSWatchEnabled so informers never race on SystemResources rewrites. -// When enabled, TLS rotation restarts the MCP Deployment and ACTIVE_BACKEND (app-server). +// When enabled, TLS rotation restarts the MCP Deployment and app-server. func (r *OLSConfigReconciler) syncOpenShiftMCPServerTLSWatcher(cr *olsv1alpha1.OLSConfig) { if r.WatcherConfig == nil { return @@ -469,6 +469,15 @@ func (r *OLSConfigReconciler) syncOpenShiftMCPServerTLSWatcher(cr *olsv1alpha1.O ) } +// syncRHOKPTLSWatcher enables watching lightspeed-rhokp-tls only while +// byokRAGOnly is false. Same pattern as MCP TLS watcher. +func (r *OLSConfigReconciler) syncRHOKPTLSWatcher(cr *olsv1alpha1.OLSConfig) { + if r.WatcherConfig == nil { + return + } + r.WatcherConfig.RHOKPTLSWatchEnabled.Store(!cr.Spec.OLSConfig.ByokRAGOnly) +} + // annotateSecretIfNeeded annotates a secret with the watcher annotation if it doesn't already have it. // Returns nil if the secret doesn't exist (will be picked up on next reconciliation). func (r *OLSConfigReconciler) annotateSecretIfNeeded(ctx context.Context, name, namespace string) error { diff --git a/internal/controller/olsconfig_helpers_test.go b/internal/controller/olsconfig_helpers_test.go index f582340fa..6dc793562 100644 --- a/internal/controller/olsconfig_helpers_test.go +++ b/internal/controller/olsconfig_helpers_test.go @@ -43,7 +43,7 @@ var _ = Describe("Watcher Predicates", func() { Namespace: utils.OLSNamespaceDefault, AffectedDeployments: []string{ utils.OpenShiftMCPServerDeploymentName, - "ACTIVE_BACKEND", + utils.OLSAppServerDeploymentName, }, }, }, @@ -54,7 +54,7 @@ var _ = Describe("Watcher Predicates", func() { Name: "kube-root-ca.crt", Namespace: utils.OLSNamespaceDefault, AffectedDeployments: []string{ - "ACTIVE_BACKEND", + utils.OLSAppServerDeploymentName, }, }, }, @@ -602,7 +602,7 @@ var _ = Describe("Helper Functions", func() { Namespace: testNamespace, AffectedDeployments: []string{ utils.OpenShiftMCPServerDeploymentName, - "ACTIVE_BACKEND", + utils.OLSAppServerDeploymentName, }, }, }, diff --git a/internal/controller/rhokp/assets.go b/internal/controller/rhokp/assets.go new file mode 100644 index 000000000..09a03498a --- /dev/null +++ b/internal/controller/rhokp/assets.go @@ -0,0 +1,97 @@ +package rhokp + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/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/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +func selectorLabels() map[string]string { + return map[string]string{ + "app": utils.RHOKPDeploymentName, + "app.kubernetes.io/component": utils.RHOKPComponentLabel, + "app.kubernetes.io/managed-by": "lightspeed-operator", + "app.kubernetes.io/name": utils.RHOKPDeploymentName, + "app.kubernetes.io/part-of": "openshift-lightspeed", + } +} + +// GenerateService generates the ClusterIP Service on HTTPS port 8443 with a +// service-ca serving-cert annotation. +func GenerateService(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.Service, error) { + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RHOKPServiceName, + Namespace: r.GetNamespace(), + Labels: selectorLabels(), + Annotations: map[string]string{ + utils.ServingCertSecretAnnotationKey: utils.RHOKPCertsSecretName, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: selectorLabels(), + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: utils.RHOOKPImageHTTPSPort, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromString("https"), + }, + }, + }, + } + if err := controllerutil.SetControllerReference(cr, &service, r.GetScheme()); err != nil { + return nil, fmt.Errorf("%s: %w", utils.ErrSetRHOKPServiceOwnerReference, err) + } + return &service, nil +} + +// GenerateNetworkPolicy allows ingress to the RHOKP pods from any pod in the +// operator namespace on HTTPS :8443 (app-server and future sandbox consumers). +func GenerateNetworkPolicy(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*networkingv1.NetworkPolicy, error) { + tcp := corev1.ProtocolTCP + httpsPort := intstr.FromInt32(utils.RHOOKPImageHTTPSPort) + np := networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RHOKPNetworkPolicyName, + Namespace: r.GetNamespace(), + Labels: selectorLabels(), + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: selectorLabels(), + }, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + { + PodSelector: &metav1.LabelSelector{}, + }, + }, + Ports: []networkingv1.NetworkPolicyPort{ + { + Protocol: &tcp, + Port: &httpsPort, + }, + }, + }, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + }, + }, + } + if err := controllerutil.SetControllerReference(cr, &np, r.GetScheme()); err != nil { + return nil, fmt.Errorf("%s: %w", utils.ErrSetRHOKPNetworkPolicyOwnerReference, err) + } + return &np, nil +} diff --git a/internal/controller/rhokp/deployment.go b/internal/controller/rhokp/deployment.go new file mode 100644 index 000000000..add711b05 --- /dev/null +++ b/internal/controller/rhokp/deployment.go @@ -0,0 +1,277 @@ +package rhokp + +import ( + "context" + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "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" + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +func getResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { + return utils.GetResourcesOrDefault( + cr.Spec.OLSConfig.DeploymentConfig.RHOKPContainer.Resources, + &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse(utils.RHOKPSolrDataSizeLimitDefault), + }, + Claims: []corev1.ResourceClaim{}, + }, + ) +} + +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 "", fmt.Errorf("%s: %w", utils.ErrGetRHOKPTLSSecret, err) + } + return secret.ResourceVersion, nil +} + +// GenerateDeployment generates the standalone RHOKP Deployment with service-ca TLS +// mounted for Apache httpd, an EmptyDir for Solr data, and HTTPS probes. +func GenerateDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { + revisionHistoryLimit := int32(1) + runAsNonRoot := true + + tlsSecretResourceVersion, err := getSecretResourceVersion(r, ctx, utils.RHOKPCertsSecretName) + if err != nil { + return nil, err + } + + tlsVolumeDefaultMode := utils.VolumeRestrictedMode + httpsPort := intstr.FromInt32(utils.RHOOKPImageHTTPSPort) + + // Use ephemeral-storage from resolved resources (CRD override or default). + resources := getResources(cr) + solrDataSizeLimit := resource.MustParse(utils.RHOKPSolrDataSizeLimitDefault) + if es, ok := resources.Requests[corev1.ResourceEphemeralStorage]; ok { + solrDataSizeLimit = es + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RHOKPDeploymentName, + Namespace: r.GetNamespace(), + Labels: selectorLabels(), + Annotations: map[string]string{ + utils.RHOKPTLSSecretResourceVersionAnnotation: tlsSecretResourceVersion, + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: selectorLabels(), + }, + RevisionHistoryLimit: &revisionHistoryLimit, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: selectorLabels(), + }, + Spec: corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: &runAsNonRoot, + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + }, + Containers: []corev1.Container{ + { + Name: utils.RHOOKPContainerName, + Image: r.GetRHOOKPImage(), + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: utils.RHOOKPContainerSecurityContext(), + Env: generateRHOOKPEnv(), + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: utils.RHOOKPImageHTTPSPort, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: *resources, + VolumeMounts: []corev1.VolumeMount{ + { + Name: utils.RHOKPTLSVolumeName, + MountPath: utils.RHOKPTLSMountPath, + ReadOnly: true, + }, + { + Name: utils.RHOKPSolrDataVolumeName, + MountPath: utils.RHOKPSolrDataMountPath, + }, + }, + StartupProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: utils.RHOOKPReadinessHTTPPath, + Port: httpsPort, + Scheme: corev1.URISchemeHTTPS, + }, + }, + InitialDelaySeconds: utils.RHOOKPStartupProbeInitialDelaySeconds, + PeriodSeconds: utils.RHOOKPStartupProbePeriodSeconds, + FailureThreshold: utils.RHOOKPStartupProbeFailureThreshold, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: utils.RHOOKPReadinessHTTPPath, + Port: httpsPort, + Scheme: corev1.URISchemeHTTPS, + }, + }, + PeriodSeconds: utils.RHOOKPProbePeriodSeconds, + TimeoutSeconds: utils.RHOOKPProbeTimeoutSeconds, + FailureThreshold: utils.RHOKPReadinessProbeFailureThreshold, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: utils.RHOOKPReadinessHTTPPath, + Port: httpsPort, + Scheme: corev1.URISchemeHTTPS, + }, + }, + PeriodSeconds: utils.RHOOKPProbePeriodSeconds, + TimeoutSeconds: utils.RHOOKPProbeTimeoutSeconds, + FailureThreshold: utils.RHOKPProbeFailureThreshold, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: utils.RHOKPTLSVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: utils.RHOKPCertsSecretName, + DefaultMode: &tlsVolumeDefaultMode, + Items: []corev1.KeyToPath{ + {Key: "tls.crt", Path: "localhost.crt"}, + {Key: "tls.key", Path: "localhost.key"}, + }, + }, + }, + }, + { + Name: utils.RHOKPSolrDataVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + SizeLimit: &solrDataSizeLimit, + }, + }, + }, + }, + }, + }, + }, + } + + utils.ApplyPodDeploymentConfig(deployment, cr.Spec.OLSConfig.DeploymentConfig.RHOKPContainer, false) + + if err := controllerutil.SetControllerReference(cr, deployment, r.GetScheme()); err != nil { + return nil, fmt.Errorf("%s: %w", utils.ErrSetRHOKPDeploymentOwnerReference, err) + } + + return deployment, nil +} + +// UpdateDeployment updates the RHOKP Deployment when the pod spec or TLS Secret changes. +func UpdateDeployment(r reconciler.Reconciler, ctx context.Context, existingDeployment, desiredDeployment *appsv1.Deployment) error { + utils.SetDefaults_Deployment(desiredDeployment) + changed := !utils.DeploymentSpecEqual(&existingDeployment.Spec, &desiredDeployment.Spec, false) + + if existingDeployment.Annotations[utils.RHOKPTLSSecretResourceVersionAnnotation] != + desiredDeployment.Annotations[utils.RHOKPTLSSecretResourceVersionAnnotation] { + changed = true + } + + if !changed { + return nil + } + + existingDeployment.Spec = desiredDeployment.Spec + if existingDeployment.Annotations == nil { + existingDeployment.Annotations = make(map[string]string) + } + existingDeployment.Annotations[utils.RHOKPTLSSecretResourceVersionAnnotation] = + desiredDeployment.Annotations[utils.RHOKPTLSSecretResourceVersionAnnotation] + + if existingDeployment.Spec.Template.Annotations == nil { + existingDeployment.Spec.Template.Annotations = make(map[string]string) + } + existingDeployment.Spec.Template.Annotations[utils.ForceReloadAnnotationKey] = time.Now().Format(time.RFC3339Nano) + + r.GetLogger().Info("updating RHOKP deployment", "name", existingDeployment.Name) + if err := r.Update(ctx, existingDeployment); err != nil { + return fmt.Errorf("%s: %w", utils.ErrUpdateRHOKPDeployment, err) + } + return nil +} + +// Restart triggers a rolling restart of the RHOKP Deployment. +// Re-fetches from the API so callers (TLS watcher) do not depend on a shared +// in-memory Deployment. NotFound is a no-op so races during byokRAGOnly enable +// (Remove deletes the Deployment) do not fail reconciliation. +func Restart(r reconciler.Reconciler, ctx context.Context, deployment ...*appsv1.Deployment) error { + _ = deployment + + dep := &appsv1.Deployment{} + err := r.Get(ctx, client.ObjectKey{Name: utils.RHOKPDeploymentName, Namespace: r.GetNamespace()}, dep) + if err != nil { + if errors.IsNotFound(err) { + r.GetLogger().Info("RHOKP deployment not found, skipping restart", + "deployment", utils.RHOKPDeploymentName) + return nil + } + return fmt.Errorf("%s: %w", utils.ErrUpdateRHOKPDeployment, err) + } + + if dep.Spec.Template.Annotations == nil { + dep.Spec.Template.Annotations = make(map[string]string) + } + dep.Spec.Template.Annotations[utils.ForceReloadAnnotationKey] = time.Now().Format(time.RFC3339Nano) + + r.GetLogger().Info("triggering RHOKP rolling restart", "deployment", dep.Name) + if err := r.Update(ctx, dep); err != nil { + if errors.IsNotFound(err) { + r.GetLogger().Info("RHOKP deployment not found during restart, skipping", + "deployment", dep.Name) + return nil + } + return fmt.Errorf("%s: %w", utils.ErrUpdateRHOKPDeployment, err) + } + return nil +} + +// generateRHOOKPEnv returns environment variables for the RHOKP container. +func generateRHOOKPEnv() []corev1.EnvVar { + optional := true + return []corev1.EnvVar{ + { + Name: "ACCESS_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: utils.RHOOKPAccessKeySecretName, + }, + Key: utils.RHOOKPAccessKeySecretKey, + Optional: &optional, + }, + }, + }, + } +} diff --git a/internal/controller/rhokp/reconciler.go b/internal/controller/rhokp/reconciler.go new file mode 100644 index 000000000..30b6ce746 --- /dev/null +++ b/internal/controller/rhokp/reconciler.go @@ -0,0 +1,143 @@ +package rhokp + +import ( + "context" + "fmt" + "reflect" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +// ReconcileResources reconciles Phase 1 standalone RHOKP resources (NetworkPolicy). +func ReconcileResources(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { + return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileRHOKPResources", []utils.ReconcileTask{ + {Name: "reconcile RHOKP NetworkPolicy", Task: reconcileNetworkPolicy}, + }, true) +} + +// ReconcileDeployment reconciles Phase 2: Service, TLS material, and Deployment. +func ReconcileDeployment(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { + return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileRHOKPDeployment", []utils.ReconcileTask{ + {Name: "reconcile RHOKP Service", Task: reconcileService}, + {Name: "reconcile RHOKP TLS Certs", Task: reconcileTLSSecret}, + {Name: "reconcile RHOKP Deployment", Task: reconcileDeployment}, + }, false) +} + +// Remove deletes all operator-managed standalone RHOKP resources. +func Remove(r reconciler.Reconciler, ctx context.Context) error { + return utils.RunDeleteTasks(r, ctx, "RemoveRHOKP", []utils.DeleteTask{ + {Name: "delete RHOKP deployment", Task: deleteDeployment}, + {Name: "delete RHOKP service", Task: deleteService}, + {Name: "delete RHOKP network policy", Task: deleteNetworkPolicy}, + {Name: "delete RHOKP TLS secret", Task: deleteTLSSecret}, + }) +} + +func reconcileNetworkPolicy(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + np, err := GenerateNetworkPolicy(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateRHOKPNetworkPolicy, err) + } + + foundNP := &networkingv1.NetworkPolicy{} + err = r.Get(ctx, client.ObjectKey{Name: utils.RHOKPNetworkPolicyName, Namespace: r.GetNamespace()}, foundNP) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating RHOKP network policy", "networkpolicy", np.Name) + if err := r.Create(ctx, np); err != nil { + return fmt.Errorf("%s: %w", utils.ErrCreateRHOKPNetworkPolicy, err) + } + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGetRHOKPNetworkPolicy, err) + } + + if utils.NetworkPolicyEqual(np, foundNP) && reflect.DeepEqual(foundNP.Labels, np.Labels) { + r.GetLogger().Info("RHOKP network policy unchanged, reconciliation skipped", "networkpolicy", np.Name) + return nil + } + + foundNP.Labels = np.Labels + foundNP.Spec = np.Spec + if err := r.Update(ctx, foundNP); err != nil { + return fmt.Errorf("%s: %w", utils.ErrUpdateRHOKPNetworkPolicy, err) + } + r.GetLogger().Info("RHOKP network policy reconciled", "networkpolicy", np.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.ErrGenerateRHOKPService, err) + } + return utils.ReconcileConsolePluginService(r, ctx, service) +} + +func reconcileTLSSecret(r reconciler.Reconciler, ctx context.Context, _ *olsv1alpha1.OLSConfig) error { + return utils.WaitForConsolePluginTLSSecret(r, ctx, utils.RHOKPCertsSecretName) +} + +func reconcileDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + desiredDeployment, err := GenerateDeployment(r, ctx, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateRHOKPDeployment, err) + } + + existingDeployment := &appsv1.Deployment{} + err = r.Get(ctx, client.ObjectKey{Name: utils.RHOKPDeploymentName, Namespace: r.GetNamespace()}, existingDeployment) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating RHOKP deployment", "deployment", desiredDeployment.Name) + if err := r.Create(ctx, desiredDeployment); err != nil { + return fmt.Errorf("%s: %w", utils.ErrCreateRHOKPDeployment, err) + } + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGetRHOKPDeployment, err) + } + + if err := UpdateDeployment(r, ctx, existingDeployment, desiredDeployment); err != nil { + return fmt.Errorf("%s: %w", utils.ErrUpdateRHOKPDeployment, err) + } + + r.GetLogger().Info("RHOKP deployment reconciled", "deployment", desiredDeployment.Name) + return nil +} + +func deleteDeployment(r reconciler.Reconciler, ctx context.Context) error { + return deleteNamespacedObject(r, ctx, &appsv1.Deployment{}, utils.RHOKPDeploymentName) +} + +func deleteService(r reconciler.Reconciler, ctx context.Context) error { + return deleteNamespacedObject(r, ctx, &corev1.Service{}, utils.RHOKPServiceName) +} + +func deleteNetworkPolicy(r reconciler.Reconciler, ctx context.Context) error { + return deleteNamespacedObject(r, ctx, &networkingv1.NetworkPolicy{}, utils.RHOKPNetworkPolicyName) +} + +func deleteTLSSecret(r reconciler.Reconciler, ctx context.Context) error { + return deleteNamespacedObject(r, ctx, &corev1.Secret{}, utils.RHOKPCertsSecretName) +} + +func deleteNamespacedObject(r reconciler.Reconciler, ctx context.Context, obj client.Object, name string) error { + err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: r.GetNamespace()}, obj) + if err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + if err := r.Delete(ctx, obj); err != nil && !errors.IsNotFound(err) { + return err + } + return nil +} diff --git a/internal/controller/rhokp/reconciler_test.go b/internal/controller/rhokp/reconciler_test.go new file mode 100644 index 000000000..41c127b15 --- /dev/null +++ b/internal/controller/rhokp/reconciler_test.go @@ -0,0 +1,240 @@ +package rhokp + +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" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func expectOwnedByOLSConfig(obj metav1.Object) { + olsConfig := &olsv1alpha1.OLSConfig{} + Expect(k8sClient.Get(ctx, crNamespacedName, olsConfig)).To(Succeed()) + + var ownerRef *metav1.OwnerReference + for i := range obj.GetOwnerReferences() { + ref := &obj.GetOwnerReferences()[i] + if ref.APIVersion == utils.OLSConfigAPIVersion && + ref.Kind == utils.OLSConfigKind && + ref.Name == olsConfig.Name { + ownerRef = ref + break + } + } + Expect(ownerRef).NotTo(BeNil(), "expected %T %s to be owned by OLSConfig", obj, obj.GetName()) + Expect(ownerRef.Name).To(Equal(olsConfig.Name)) +} + +var _ = Describe("RHOKP reconciler", Ordered, func() { + var testCR *olsv1alpha1.OLSConfig + + BeforeAll(func() { + testCR = cr.DeepCopy() + testCR.Spec.OLSConfig.ByokRAGOnly = false + }) + + Context("Phase 1 resources", func() { + BeforeAll(func() { + err := ReconcileResources(testReconcilerInstance, ctx, testCR) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create the RHOKP NetworkPolicy", func() { + np := &networkingv1.NetworkPolicy{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPNetworkPolicyName, + Namespace: utils.OLSNamespaceDefault, + }, np) + Expect(err).NotTo(HaveOccurred()) + expectOwnedByOLSConfig(np) + }) + + It("should skip NetworkPolicy update when spec is unchanged", func() { + np := &networkingv1.NetworkPolicy{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPNetworkPolicyName, + Namespace: utils.OLSNamespaceDefault, + }, np) + Expect(err).NotTo(HaveOccurred()) + oldRV := np.ResourceVersion + + err = ReconcileResources(testReconcilerInstance, ctx, testCR) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPNetworkPolicyName, + Namespace: utils.OLSNamespaceDefault, + }, np) + Expect(err).NotTo(HaveOccurred()) + Expect(np.ResourceVersion).To(Equal(oldRV)) + }) + }) + + Context("Phase 2 deployment", func() { + BeforeAll(func() { + ensureRHOKPTLSSecret() + err := ReconcileDeployment(testReconcilerInstance, ctx, testCR) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create the RHOKP Service with serving-cert annotation", func() { + svc := &corev1.Service{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPServiceName, + Namespace: utils.OLSNamespaceDefault, + }, svc) + Expect(err).NotTo(HaveOccurred()) + expectOwnedByOLSConfig(svc) + Expect(svc.Annotations[utils.ServingCertSecretAnnotationKey]).To(Equal(utils.RHOKPCertsSecretName)) + }) + + It("should create the RHOKP Deployment", func() { + dep := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(err).NotTo(HaveOccurred()) + expectOwnedByOLSConfig(dep) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal(utils.RHOOKPImageDefault)) + Expect(dep.Annotations).To(HaveKey(utils.RHOKPTLSSecretResourceVersionAnnotation)) + }) + + It("should mount TLS as localhost.crt/localhost.key for Apache httpd", func() { + dep := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(err).NotTo(HaveOccurred()) + + var tlsVolume *corev1.Volume + for i := range dep.Spec.Template.Spec.Volumes { + if dep.Spec.Template.Spec.Volumes[i].Name == utils.RHOKPTLSVolumeName { + tlsVolume = &dep.Spec.Template.Spec.Volumes[i] + break + } + } + Expect(tlsVolume).NotTo(BeNil()) + Expect(tlsVolume.Secret.SecretName).To(Equal(utils.RHOKPCertsSecretName)) + Expect(tlsVolume.Secret.Items).To(ConsistOf( + corev1.KeyToPath{Key: "tls.crt", Path: "localhost.crt"}, + corev1.KeyToPath{Key: "tls.key", Path: "localhost.key"}, + )) + }) + + It("should have an EmptyDir volume for Solr data with 75Gi sizeLimit", func() { + dep := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(err).NotTo(HaveOccurred()) + + var solrVolume *corev1.Volume + for i := range dep.Spec.Template.Spec.Volumes { + if dep.Spec.Template.Spec.Volumes[i].Name == utils.RHOKPSolrDataVolumeName { + solrVolume = &dep.Spec.Template.Spec.Volumes[i] + break + } + } + Expect(solrVolume).NotTo(BeNil()) + Expect(solrVolume.EmptyDir).NotTo(BeNil()) + Expect(solrVolume.EmptyDir.SizeLimit.String()).To(Equal(utils.RHOKPSolrDataSizeLimitDefault)) + }) + + It("should skip Deployment update when spec and versions are unchanged", func() { + dep := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(err).NotTo(HaveOccurred()) + oldRV := dep.ResourceVersion + + err = ReconcileDeployment(testReconcilerInstance, ctx, testCR) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(err).NotTo(HaveOccurred()) + Expect(dep.ResourceVersion).To(Equal(oldRV)) + }) + + It("should trigger a rolling restart via Restart", func() { + dep := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(err).NotTo(HaveOccurred()) + + err = Restart(testReconcilerInstance, ctx, dep) + Expect(err).NotTo(HaveOccurred()) + + updated := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, updated) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Spec.Template.Annotations).To(HaveKey(utils.ForceReloadAnnotationKey)) + }) + + It("should skip Restart when the Deployment is missing", func() { + Expect(k8sClient.Delete(ctx, &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, + })).To(Succeed()) + + err := Restart(testReconcilerInstance, ctx) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should remove all RHOKP resources via Remove", func() { + ensureRHOKPTLSSecret() + Expect(ReconcileDeployment(testReconcilerInstance, ctx, testCR)).To(Succeed()) + + err := Remove(testReconcilerInstance, ctx) + Expect(err).NotTo(HaveOccurred()) + + dep := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPDeploymentName, + Namespace: utils.OLSNamespaceDefault, + }, dep) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "deployment should be deleted") + + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPServiceName, + Namespace: utils.OLSNamespaceDefault, + }, svc) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "service should be deleted") + + np := &networkingv1.NetworkPolicy{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPNetworkPolicyName, + Namespace: utils.OLSNamespaceDefault, + }, np) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "network policy should be deleted") + + tlsSecret := &corev1.Secret{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: utils.RHOKPCertsSecretName, + Namespace: utils.OLSNamespaceDefault, + }, tlsSecret) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "TLS secret should be deleted") + }) + }) +}) diff --git a/internal/controller/rhokp/suite_test.go b/internal/controller/rhokp/suite_test.go new file mode 100644 index 000000000..d4fe50737 --- /dev/null +++ b/internal/controller/rhokp/suite_test.go @@ -0,0 +1,126 @@ +/* +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 rhokp + +import ( + "context" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + configv1 "github.com/openshift/api/config/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" +) + +var ( + ctx context.Context + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + cr *olsv1alpha1.OLSConfig + testReconcilerInstance reconciler.Reconciler + crNamespacedName types.NamespacedName +) + +func TestRHOKP(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "RHOKP Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + filepath.Join("..", "..", "..", ".testcrds"), + }, + CRDInstallOptions: envtest.CRDInstallOptions{ + MaxTime: utils.EnvTestCRDInstallMaxTime, + }, + ErrorIfCRDPathMissing: true, + } + + var err error + 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()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + ctx = context.Background() + + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: utils.OLSNamespaceDefault}}) + Expect(err).NotTo(HaveOccurred()) + + testReconcilerInstance = utils.NewTestReconciler( + k8sClient, + logf.Log.WithName("controller").WithName("OLSConfig"), + scheme.Scheme, + utils.OLSNamespaceDefault, + ) + + cr = &olsv1alpha1.OLSConfig{} + crNamespacedName = types.NamespacedName{Name: utils.OLSConfigName} + 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 { + Expect(err).NotTo(HaveOccurred()) + } + err = k8sClient.Get(ctx, crNamespacedName, cr) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +func ensureRHOKPTLSSecret() { + secret, err := utils.GenerateRandomTLSSecret() + Expect(err).NotTo(HaveOccurred()) + secret.Name = utils.RHOKPCertsSecretName + secret.Namespace = utils.OLSNamespaceDefault + err = k8sClient.Create(ctx, secret) + Expect(client.IgnoreAlreadyExists(err)).NotTo(HaveOccurred()) +} diff --git a/internal/controller/utils/constants.go b/internal/controller/utils/constants.go index 85007dcdb..74977ab2d 100644 --- a/internal/controller/utils/constants.go +++ b/internal/controller/utils/constants.go @@ -278,6 +278,10 @@ const ( AgenticConfigurationMCPEndpointKey = "mcp-endpoint" // AgenticConfigurationMCPCASecretKey is the ConfigMap data key naming the MCP client-CA Secret. AgenticConfigurationMCPCASecretKey = "mcp-ca-secret" // #nosec G101 + // AgenticConfigurationRHOKPEndpointKey is the standalone RHOKP HTTPS endpoint URL. + AgenticConfigurationRHOKPEndpointKey = "rhokp-endpoint" + // AgenticConfigurationRHOKPCASecretKey is the ConfigMap data key naming the RHOKP client-CA Secret. + AgenticConfigurationRHOKPCASecretKey = "rhokp-ca-secret" // #nosec G101 // AgenticConfigurationCertReloadAnnotation is bumped to force ConfigMap RV change // when client CA Secrets rotate so agentic-operator reloads trust material. AgenticConfigurationCertReloadAnnotation = "ols.openshift.io/client-ca-reload" @@ -289,6 +293,10 @@ const ( AgenticMCPCASecretName = "lightspeed-agentic-mcp-ca" // #nosec G101 // AgenticMCPCASecretDataKey is the only data key in AgenticMCPCASecretName. AgenticMCPCASecretDataKey = "mcp-ca.crt" // #nosec G101 + // AgenticRHOKPCASecretName holds the public CA for verifying the standalone RHOKP server. + AgenticRHOKPCASecretName = "lightspeed-agentic-rhokp-ca" // #nosec G101 + // AgenticRHOKPCASecretDataKey is the only data key in AgenticRHOKPCASecretName. + AgenticRHOKPCASecretDataKey = "rhokp-ca.crt" // #nosec G101 // AgenticSandboxContainerName is the container name in the thin sandbox PodSpec. AgenticSandboxContainerName = "lightspeed-agentic-sandbox" // AgenticIntegrationComponentLabel is the app.kubernetes.io/component value for handoff artifacts. @@ -507,6 +515,12 @@ ssl_ca_file = '/etc/certs/cm-olspostgresca/service-ca.crt' AppOpenShiftMCPServerCACertVolumeName = "openshift-mcp-server-ca" // AppOpenShiftMCPServerCACertFile is the projected filename within AppOpenShiftMCPServerCACertDir. AppOpenShiftMCPServerCACertFile = "service-ca.crt" + // AppRHOKPCACertDir is the app-server mount directory for the RHOKP client CA. + AppRHOKPCACertDir = "rhokp-ca" + // AppRHOKPCACertVolumeName is the app-server volume name for the RHOKP client CA Secret. + AppRHOKPCACertVolumeName = "rhokp-ca" + // AppRHOKPCACertFile is the projected filename within AppRHOKPCACertDir. + AppRHOKPCACertFile = "service-ca.crt" // OpenShiftMCPServerTLSVolumeName is the pod volume name for the serving-cert Secret. OpenShiftMCPServerTLSVolumeName = "tls" // OpenShiftMCPServerTLSMountPath is where tls.crt/tls.key are mounted. @@ -615,8 +629,34 @@ ssl_ca_file = '/etc/certs/cm-olspostgresca/service-ca.crt' OpenShiftMCPServerConfigMapResourceVersionAnnotation = "ols.openshift.io/mcp-server-configmap-version" // OpenShiftMCPServerTLSSecretResourceVersionAnnotation tracks the MCP TLS Secret ResourceVersion on the MCP Deployment. OpenShiftMCPServerTLSSecretResourceVersionAnnotation = "ols.openshift.io/mcp-server-tls-secret-version" // #nosec G101 - // OpenShiftMCPServerCACertHashAnnotation tracks the MCP CA ConfigMap content hash on the app-server Deployment. - OpenShiftMCPServerCACertHashAnnotation = "ols.openshift.io/mcp-server-ca-configmap-hash" + + /*** Standalone RHOKP Constants ***/ + // RHOKPDeploymentName is the Deployment name for the standalone RHOKP operand. + RHOKPDeploymentName = "lightspeed-rhokp" + // RHOKPServiceName is the Service name for standalone RHOKP (HTTPS :8443). + RHOKPServiceName = "lightspeed-rhokp" + // RHOKPNetworkPolicyName is the NetworkPolicy name for standalone RHOKP. + RHOKPNetworkPolicyName = "lightspeed-rhokp" + // RHOKPCertsSecretName is the TLS Secret populated by service-ca for the RHOKP Service. + RHOKPCertsSecretName = "lightspeed-rhokp-tls" // #nosec G101 + // RHOKPTLSVolumeName is the volume name for the RHOKP TLS cert/key. + RHOKPTLSVolumeName = "rhokp-tls" + // RHOKPTLSMountPath is where Apache httpd expects the cert/key. + RHOKPTLSMountPath = "/etc/httpd/tls" + // RHOKPSolrDataVolumeName is the EmptyDir volume for Solr indexes. + RHOKPSolrDataVolumeName = "rhokp-solr-data" + // RHOKPSolrDataMountPath is the Solr data directory inside the container. + RHOKPSolrDataMountPath = "/var/solr/data" + // RHOKPSolrDataSizeLimitDefault is the default EmptyDir sizeLimit for Solr data. + RHOKPSolrDataSizeLimitDefault = "75Gi" + // RHOKPComponentLabel is the app.kubernetes.io/component label value. + RHOKPComponentLabel = "rhokp" + // RHOKPTLSSecretResourceVersionAnnotation tracks the TLS Secret ResourceVersion on the RHOKP Deployment. + RHOKPTLSSecretResourceVersionAnnotation = "ols.openshift.io/rhokp-tls-secret-version" // #nosec G101 + // RHOKPProbeFailureThreshold is the liveness probe failure threshold for the standalone RHOKP. + RHOKPProbeFailureThreshold = 6 + // RHOKPReadinessProbeFailureThreshold is the readiness probe failure threshold for the standalone RHOKP. + RHOKPReadinessProbeFailureThreshold = 3 /*** Environment Variable Suffixes ***/ // EnvVarSuffixAPIKey is the environment variable suffix for API key credentials @@ -663,3 +703,8 @@ func imageDefaultOr(name, fallback string) string { func OpenShiftMCPServerServiceURL(namespace string) string { return fmt.Sprintf("https://%s.%s.svc:%d/mcp", OpenShiftMCPServerServiceName, namespace, OpenShiftMCPServerHTTPSPort) } + +// RHOKPServiceURL returns the in-cluster HTTPS RHOKP Solr endpoint for the given namespace. +func RHOKPServiceURL(namespace string) string { + return fmt.Sprintf("https://%s.%s.svc:%d", RHOKPServiceName, namespace, RHOOKPImageHTTPSPort) +} diff --git a/internal/controller/utils/errors.go b/internal/controller/utils/errors.go index 45772ce77..a17b12d40 100644 --- a/internal/controller/utils/errors.go +++ b/internal/controller/utils/errors.go @@ -154,13 +154,21 @@ const ( ErrGetAgenticConfigurationConfigMap = "failed to get agentic configuration configmap" ErrUpdateAgenticConfigurationConfigMap = "failed to update agentic configuration configmap" ErrAgenticConfigurationPrerequisitesNotReady = "agentic configuration prerequisites not ready" - ErrCreateAgenticOtelCASecret = "failed to create agentic OTEL CA secret" // #nosec G101 - ErrGetAgenticOtelCASecret = "failed to get agentic OTEL CA secret" // #nosec G101 - ErrUpdateAgenticOtelCASecret = "failed to update agentic OTEL CA secret" // #nosec G101 - ErrCreateAgenticMCPCASecret = "failed to create agentic MCP CA secret" // #nosec G101 - ErrGetAgenticMCPCASecret = "failed to get agentic MCP CA secret" // #nosec G101 - ErrUpdateAgenticMCPCASecret = "failed to update agentic MCP CA secret" // #nosec G101 - ErrDeleteAgenticMCPCASecret = "failed to delete agentic MCP CA secret" // #nosec G101 + ErrCreateAgenticOtelCASecret = "failed to create agentic OTEL CA secret" // #nosec G101 + ErrGetAgenticOtelCASecret = "failed to get agentic OTEL CA secret" // #nosec G101 + ErrUpdateAgenticOtelCASecret = "failed to update agentic OTEL CA secret" // #nosec G101 + ErrDeleteAgenticOtelCASecret = "failed to delete agentic OTEL CA secret" // #nosec G101 + ErrCreateAgenticMCPCASecret = "failed to create agentic MCP CA secret" // #nosec G101 + ErrGetAgenticMCPCASecret = "failed to get agentic MCP CA secret" // #nosec G101 + ErrUpdateAgenticMCPCASecret = "failed to update agentic MCP CA secret" // #nosec G101 + ErrDeleteAgenticMCPCASecret = "failed to delete agentic MCP CA secret" // #nosec G101 + ErrGenerateAgenticRHOKPCASecret = "failed to generate agentic RHOKP CA secret" // #nosec G101 + ErrSetAgenticRHOKPCASecretOwnerRef = "failed to set agentic RHOKP CA secret owner reference" // #nosec G101 + ErrGetAgenticRHOKPCASourceConfigMap = "failed to get OpenShift service-ca ConfigMap for agentic RHOKP CA" + ErrCreateAgenticRHOKPCASecret = "failed to create agentic RHOKP CA secret" // #nosec G101 + ErrGetAgenticRHOKPCASecret = "failed to get agentic RHOKP CA secret" // #nosec G101 + ErrUpdateAgenticRHOKPCASecret = "failed to update agentic RHOKP CA secret" // #nosec G101 + ErrDeleteAgenticRHOKPCASecret = "failed to delete agentic RHOKP CA secret" // #nosec G101 ErrGetOLSConfigForAgenticIntegrationRefresh = "failed to get OLSConfig for agentic integration refresh" ErrGetOLSConfigForAppServerClientCARefresh = "failed to get OLSConfig for app-server client CA refresh" ErrTouchAgenticConfigurationConfigMap = "failed to touch agentic configuration configmap" @@ -220,5 +228,25 @@ const ( ErrGetOpenShiftMCPServerDeployment = "failed to get openshift-mcp-server deployment" ErrUpdateOpenShiftMCPServerDeployment = "failed to update openshift-mcp-server deployment" ErrGetOpenShiftMCPServerTLSSecret = "failed to get openshift-mcp-server TLS secret" // #nosec G101 - ErrOpenShiftMCPServerCANotReady = "MCP client CA certificate is not ready" + + /*** RHOKP Standalone Errors ***/ + ErrGenerateRHOKPService = "failed to generate RHOKP service" + ErrGenerateRHOKPNetworkPolicy = "failed to generate RHOKP network policy" + ErrGenerateRHOKPDeployment = "failed to generate RHOKP deployment" + ErrSetRHOKPServiceOwnerReference = "failed to set RHOKP service owner reference" + ErrSetRHOKPNetworkPolicyOwnerReference = "failed to set RHOKP network policy owner reference" + ErrSetRHOKPDeploymentOwnerReference = "failed to set RHOKP deployment owner reference" + ErrCreateRHOKPNetworkPolicy = "failed to create RHOKP network policy" + ErrGetRHOKPNetworkPolicy = "failed to get RHOKP network policy" + ErrUpdateRHOKPNetworkPolicy = "failed to update RHOKP network policy" + ErrCreateRHOKPDeployment = "failed to create RHOKP deployment" + ErrGetRHOKPDeployment = "failed to get RHOKP deployment" + ErrUpdateRHOKPDeployment = "failed to update RHOKP deployment" + ErrGetRHOKPTLSSecret = "failed to get RHOKP TLS secret" // #nosec G101 + + // Cleanup error constants for conditional operand removal. + ErrRemoveOpenShiftMCPServerResources = "failed to remove openshift-mcp-server resources" + ErrRemoveRHOKPResources = "failed to remove RHOKP resources" + ErrRemoveAgenticConsoleUIResources = "failed to remove agentic console UI resources" + ErrRemoveAlertsAdapterResources = "failed to remove alerts adapter resources" ) diff --git a/internal/controller/utils/types.go b/internal/controller/utils/types.go index ce59374a6..52ed1a62f 100644 --- a/internal/controller/utils/types.go +++ b/internal/controller/utils/types.go @@ -19,6 +19,7 @@ const ( TypeAlertsAdapterReady = "AlertsAdapterReady" TypeOtelCollectorReady = "OtelCollectorReady" TypeMCPServerReady = "MCPServerReady" + TypeRHOKPReady = "RHOKPReady" TypeCRReconciled = "Reconciled" ) @@ -78,6 +79,9 @@ type WatcherConfig struct { // The Secret stays in Secrets.SystemResources (static); reconcile toggles this flag from // introspectionEnabled so enable/disable does not rewrite SystemResources under the informer. OpenShiftMCPServerTLSWatchEnabled atomic.Bool + // RHOKPTLSWatchEnabled gates informer handling of lightspeed-rhokp-tls. + // Same pattern: static entry, toggled from !byokRAGOnly. + RHOKPTLSWatchEnabled atomic.Bool } // IsSystemSecretWatchEnabled reports whether a SystemResources entry should be active. @@ -89,6 +93,9 @@ func (c *WatcherConfig) IsSystemSecretWatchEnabled(secret SystemSecret) bool { if secret.Name == OpenShiftMCPServerCertsSecretName { return c.OpenShiftMCPServerTLSWatchEnabled.Load() } + if secret.Name == RHOKPCertsSecretName { + return c.RHOKPTLSWatchEnabled.Load() + } return true } diff --git a/internal/controller/watchers/watchers.go b/internal/controller/watchers/watchers.go index 893af904a..9881ed265 100644 --- a/internal/controller/watchers/watchers.go +++ b/internal/controller/watchers/watchers.go @@ -15,6 +15,7 @@ import ( olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" "github.com/openshift/lightspeed-operator/internal/controller/agenticconsole" + "github.com/openshift/lightspeed-operator/internal/controller/agenticintegration" "github.com/openshift/lightspeed-operator/internal/controller/alertsadapter" "github.com/openshift/lightspeed-operator/internal/controller/appserver" "github.com/openshift/lightspeed-operator/internal/controller/console" @@ -22,6 +23,7 @@ import ( "github.com/openshift/lightspeed-operator/internal/controller/otelcollector" "github.com/openshift/lightspeed-operator/internal/controller/postgres" "github.com/openshift/lightspeed-operator/internal/controller/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/rhokp" "github.com/openshift/lightspeed-operator/internal/controller/utils" ) @@ -280,8 +282,8 @@ func SecretWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj clien affectedDeployments, found = watcherConfig.AnnotatedSecretMapping[secretName] } if !found { - // Default: affect only ACTIVE_BACKEND (e.g., LLM provider secrets) - affectedDeployments = []string{"ACTIVE_BACKEND"} + // Default: affect only app-server (e.g., LLM provider secrets) + affectedDeployments = []string{utils.OLSAppServerDeploymentName} } r.GetLogger().Info("Detected annotated secret change", @@ -350,8 +352,8 @@ func ConfigMapWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj cl affectedDeployments, found = watcherConfig.AnnotatedConfigMapMapping[configMapName] } if !found { - // Default: affect only ACTIVE_BACKEND (e.g., CA bundle configmaps) - affectedDeployments = []string{"ACTIVE_BACKEND"} + // Default: affect only app-server (e.g., CA bundle configmaps) + affectedDeployments = []string{utils.OLSAppServerDeploymentName} } r.GetLogger().Info("Detected annotated configmap change", @@ -368,7 +370,7 @@ func ConfigMapWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj cl // RestartFunc is a function that restarts a deployment type RestartFunc func(reconciler.Reconciler, context.Context, ...*appsv1.Deployment) error -// restartFuncs maps deployment names to their restart functions +// restartFuncs maps deployment names (or pseudo-targets) to their restart functions. var restartFuncs = map[string]RestartFunc{ utils.OLSAppServerDeploymentName: appserver.RestartAppServer, utils.PostgresDeploymentName: postgres.RestartPostgres, @@ -377,17 +379,20 @@ var restartFuncs = map[string]RestartFunc{ utils.AlertsAdapterDeploymentName: alertsadapter.RestartAlertsAdapter, utils.OtelCollectorDeploymentName: otelcollector.RestartOtelCollector, utils.OpenShiftMCPServerDeploymentName: ocpmcp.Restart, + utils.RHOKPDeploymentName: rhokp.Restart, + // Pseudo-target: touch the agentic handoff ConfigMap so agentic-operator reloads CA material. + utils.AgenticConfigurationConfigMapName: touchAgenticConfigurationFunc, +} + +// touchAgenticConfigurationFunc adapts TouchAgenticConfiguration to the RestartFunc signature. +func touchAgenticConfigurationFunc(r reconciler.Reconciler, ctx context.Context, _ ...*appsv1.Deployment) error { + return agenticintegration.TouchAgenticConfiguration(r, ctx) } // restart corresponding deployment 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" { - depName = utils.OLSAppServerDeploymentName - } - // Restart the deployment using the appropriate function restartFunc, exists := restartFuncs[depName] if !exists { diff --git a/internal/controller/watchers/watchers_test.go b/internal/controller/watchers/watchers_test.go index 1d492f664..6e291f833 100644 --- a/internal/controller/watchers/watchers_test.go +++ b/internal/controller/watchers/watchers_test.go @@ -50,7 +50,7 @@ func createTestReconciler(objs ...client.Object) reconciler.Reconciler { { Name: utils.DefaultOpenShiftCerts, Namespace: utils.OLSNamespaceDefault, - AffectedDeployments: []string{"ACTIVE_BACKEND"}, + AffectedDeployments: []string{utils.OLSAppServerDeploymentName}, Description: "test openshift CA", }, }, @@ -135,7 +135,7 @@ var _ = Describe("Watchers", func() { Expect(func() { SecretWatcherFilter(r, ctx, sec, false) }).NotTo(Panic()) }) - It("uses default ACTIVE_BACKEND when annotation present but name not in mapping", func() { + It("uses default app-server deployment when annotation present but name not in mapping", func() { r := createTestReconciler() sec := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -362,7 +362,7 @@ var _ = Describe("Watchers", func() { }) Describe("restartDeployment with in-cluster restart", func() { - It("resolves ACTIVE_BACKEND and attempts app server restart", func() { + It("restarts app server deployment", func() { cr := utils.GetDefaultOLSConfigCR() dep := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{