From 7219225524b5efc623a4298bb8ffc144bb7e85e6 Mon Sep 17 00:00:00 2001 From: blublinsky Date: Thu, 25 Jun 2026 13:13:31 +0100 Subject: [PATCH] OLS-3236: Reconcile agentic console plugin in lightspeed-operator --- .ai/spec/README.md | 4 +- .ai/spec/how/project-structure.md | 60 +- .ai/spec/how/reconciliation.md | 8 +- .ai/spec/what/agentic-console-ui.md | 79 ++ .ai/spec/what/console-ui.md | 2 +- AGENTS.md | 25 +- ARCHITECTURE.md | 47 +- Makefile | 4 +- api/v1alpha1/olsconfig_types.go | 5 +- api/v1alpha1/zz_generated.deepcopy.go | 33 +- cmd/main.go | 18 +- .../bases/ols.openshift.io_olsconfigs.yaml | 1235 ++++++++++++++++- internal/controller/agenticconsole/assets.go | 120 ++ .../controller/agenticconsole/assets_test.go | 88 ++ .../controller/agenticconsole/deployment.go | 35 + .../controller/agenticconsole/reconciler.go | 117 ++ .../agenticconsole/reconciler_test.go | 118 ++ .../controller/agenticconsole/suite_test.go | 132 ++ internal/controller/appserver/reconciler.go | 8 + internal/controller/console/assets.go | 63 +- internal/controller/console/assets_test.go | 31 - internal/controller/console/deployment.go | 146 +- internal/controller/console/reconciler.go | 409 +----- .../controller/console/reconciler_test.go | 52 +- internal/controller/olsconfig_controller.go | 14 + internal/controller/olsconfig_helpers.go | 4 + internal/controller/reconciler/interface.go | 3 + .../utils/console_plugin_reconciler.go | 371 +++++ .../controller/utils/console_plugin_test.go | 325 +++++ internal/controller/utils/constants.go | 25 + .../utils/resource_defaults_test.go | 1 + internal/controller/utils/suite_test.go | 14 +- internal/controller/utils/testing.go | 6 + internal/controller/utils/types.go | 10 +- internal/controller/utils/utils.go | 231 +++ internal/controller/watchers/watchers.go | 8 +- 36 files changed, 3139 insertions(+), 712 deletions(-) create mode 100644 .ai/spec/what/agentic-console-ui.md create mode 100644 internal/controller/agenticconsole/assets.go create mode 100644 internal/controller/agenticconsole/assets_test.go create mode 100644 internal/controller/agenticconsole/deployment.go create mode 100644 internal/controller/agenticconsole/reconciler.go create mode 100644 internal/controller/agenticconsole/reconciler_test.go create mode 100644 internal/controller/agenticconsole/suite_test.go create mode 100644 internal/controller/utils/console_plugin_reconciler.go create mode 100644 internal/controller/utils/console_plugin_test.go diff --git a/.ai/spec/README.md b/.ai/spec/README.md index c32e7e775..af69777be 100644 --- a/.ai/spec/README.md +++ b/.ai/spec/README.md @@ -35,6 +35,7 @@ AI agents (Claude). Content is optimized for precision and machine consumption o | Add a new managed component | `what/system-overview.md` + `how/project-structure.md` | | Understand the CRD | `what/crd-api.md` | | Navigate the codebase | `how/project-structure.md` | +| Understand console plugins | `what/console-ui.md` (chat), `what/agentic-console-ui.md` (agentic) | | Understand TLS configuration | `what/tls.md` | | Understand security constraints | `what/security.md` | | Debug external resource watching | `what/resource-lifecycle.md` + `how/reconciliation.md` | @@ -48,7 +49,8 @@ When what/ and how/ file names don't match 1:1, this table maps behavioral specs | what/ | how/ | |---|---| | `reconciliation.md` | `how/reconciliation.md` -- implementation patterns, code locations, task registration | -| `app-server.md`, `postgres.md`, `console-ui.md` | `how/deployment-generation.md` -- how deployments/services/configmaps are generated | +| `app-server.md`, `postgres.md` | `how/deployment-generation.md` -- how deployments/services/configmaps are generated | +| `console-ui.md`, `agentic-console-ui.md` | `how/deployment-generation.md` -- deployment/service/configmap generation; `how/reconciliation.md` -- ConsolePlugin lifecycle, activation, and cleanup | | `crd-api.md` | `how/config-generation.md` -- how CRD fields map to generated configuration | | `system-overview.md` | `how/project-structure.md` -- codebase layout, package responsibilities | diff --git a/.ai/spec/how/project-structure.md b/.ai/spec/how/project-structure.md index 805cf531b..33d2011b4 100644 --- a/.ai/spec/how/project-structure.md +++ b/.ai/spec/how/project-structure.md @@ -9,7 +9,7 @@ | `api/v1alpha1/zz_generated.deepcopy.go` | Generated `DeepCopyObject()` methods | Auto-generated deep copy | | `cmd/main.go` | `main()`, `overrideImages()` | Operator entry point, flag parsing, manager setup | | `internal/controller/olsconfig_controller.go` | `OLSConfigReconciler`, `Reconcile()`, `SetupWithManager()` | Main reconciler, orchestration, watcher registration | -| `internal/controller/olsconfig_helpers.go` | `UpdateStatusCondition()`, `checkDeploymentStatus()`, `annotateExternalResources()`, `shouldWatchSecret()` | Status management, diagnostics, annotation, watcher predicates | +| `internal/controller/olsconfig_helpers.go` | `UpdateStatusCondition()`, `checkDeploymentStatus()`, `annotateExternalResources()`, `shouldWatchSecret()`, `GetAgenticConsoleImage()` | Status management, diagnostics, annotation, watcher predicates, image getter for agentic console | | `internal/controller/operator_assets.go` | `ReconcileServiceMonitorForOperator()`, `ReconcileNetworkPolicyForOperator()` | Operator-level resources | | `internal/controller/appserver/reconciler.go` | `ReconcileAppServerResources()`, `ReconcileAppServerDeployment()` | AppServer Phase 1 + Phase 2 orchestration | | `internal/controller/appserver/deployment.go` | `GenerateOLSDeployment()`, `updateOLSDeployment()` | AppServer deployment generation, update detection | @@ -18,9 +18,13 @@ | `internal/controller/postgres/reconciler.go` | `ReconcilePostgresResources()`, `ReconcilePostgresDeployment()` | PostgreSQL Phase 1 + Phase 2 | | `internal/controller/postgres/deployment.go` | `GeneratePostgresDeployment()` | PostgreSQL deployment generation | | `internal/controller/postgres/assets.go` | `GeneratePostgresConfigMap()`, `GeneratePostgresBootstrapSecret()`, `GeneratePostgresSecret()` | PostgreSQL config, bootstrap script, credentials | -| `internal/controller/console/reconciler.go` | `ReconcileConsoleUIResources()`, `ReconcileConsoleUIDeploymentAndPlugin()`, `RemoveConsoleUI()` | Console UI Phase 1 + Phase 2 + cleanup | -| `internal/controller/console/deployment.go` | `GenerateConsoleUIDeployment()` | Console UI deployment generation | -| `internal/controller/console/assets.go` | ConsolePlugin CR generator, nginx config, service, network policy | Console UI resource generation | +| `internal/controller/console/reconciler.go` | `ReconcileConsoleUIResources()`, `ReconcileConsoleUIDeploymentAndPlugin()`, `RemoveConsoleUI()` | Chat console plugin Phase 1 + Phase 2 + cleanup | +| `internal/controller/console/deployment.go` | `GenerateConsoleUIDeployment()` | Chat console plugin deployment generation | +| `internal/controller/console/assets.go` | ConsolePlugin CR generator, nginx config, service, network policy | Chat console plugin resource generation | +| `internal/controller/agenticconsole/reconciler.go` | `ReconcileAgenticConsoleUIResources()`, `ReconcileAgenticConsoleUIDeploymentAndPlugin()`, `RemoveAgenticConsole()` | Agentic console plugin Phase 1 + Phase 2 + cleanup | +| `internal/controller/agenticconsole/deployment.go` | `GenerateAgenticConsoleUIDeployment()` | Agentic console plugin deployment generation | +| `internal/controller/agenticconsole/assets.go` | ConsolePlugin CR generator, nginx config, service, network policy | Agentic console plugin resource generation | +| `internal/controller/utils/console_plugin_reconciler.go` | Shared ConsolePlugin reconcile helpers | Used by `console/` and `agenticconsole/` | | `internal/controller/reconciler/interface.go` | `Reconciler` interface | Dependency injection interface for component packages | | `internal/controller/utils/constants.go` | ~200 constants | Resource names, ports, paths, annotation keys, defaults | | `internal/controller/utils/errors.go` | ~80 error message constants | Structured error messages for all operations | @@ -67,10 +71,12 @@ OLSConfigReconciler.Reconcile() 4. annotateExternalResources() -- Mark external secrets/configmaps for watching 5. reconcileIndependentResources() -- Phase 1: ConfigMaps, Secrets, ServiceAccounts, RBAC, NetworkPolicies +-- console.ReconcileConsoleUIResources() + +-- agenticconsole.ReconcileAgenticConsoleUIResources() +-- postgres.ReconcilePostgresResources() +-- appserver.ReconcileAppServerResources() 6. reconcileDeploymentsAndStatus() -- Phase 2: Deployments, Services, TLS certs, status +-- console.ReconcileConsoleUIDeploymentAndPlugin() + +-- agenticconsole.ReconcileAgenticConsoleUIDeploymentAndPlugin() +-- postgres.ReconcilePostgresDeployment() +-- appserver.ReconcileAppServerDeployment() +-- checkDeploymentStatus() per deployment -> build newStatus @@ -90,25 +96,31 @@ External secret/configmap changes -> Match against SystemResources list (by name+namespace) -> OR match against WatcherAnnotationKey annotation -> Resolve "ACTIVE_BACKEND" to appserver deployment name - -> Call RestartAppServer() / RestartPostgres() / RestartConsoleUI() + -> Call RestartAppServer() / RestartPostgres() / RestartConsoleUI() / RestartAgenticConsoleUI() -> Set force-reload annotation with current timestamp ``` ## Key Abstractions ### Image Management -Default images are stored in a `defaultImages` map in `cmd/main.go` keyed by logical name (e.g., `"lightspeed-service"`, `"postgres-image"`, `"console-plugin"`). Default values come from `internal/relatedimages/` which reads `related_images.json` at build time. Command-line flags override individual images. The map is passed to the reconciler via `OLSConfigReconcilerOptions` as individual named fields (e.g., `LightspeedServiceImage`, `ConsoleUIImage`). +Default images are stored in a `defaultImages` map in `cmd/main.go` keyed by logical name (e.g., `"lightspeed-service"`, `"postgres-image"`, `"console-plugin"`, `"agentic-console-plugin"`). Default values come from `internal/relatedimages/` which reads `related_images.json` at build time. Command-line flags override individual images (`--console-image`, `--agentic-console-image`, etc.). The map is passed to the reconciler via `OLSConfigReconcilerOptions` as individual named fields (e.g., `LightspeedServiceImage`, `ConsoleUIImage`, `AgenticConsoleUIImage`). ### WatcherConfig -Declarative configuration for external resource watching. Contains: -- `Secrets.SystemResources`: Fixed list of system secrets with affected deployment names (telemetry pull secret, console TLS cert, postgres TLS cert) +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`) + - `lightspeed-console-plugin-cert` → chat console deployment + - `lightspeed-agentic-console-plugin-cert` → agentic console deployment (`AgenticConsoleUIDeploymentName`) + - Postgres TLS cert → postgres + app server - `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`). +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`). + ### Component Package Pattern -Each component (appserver, postgres, console) follows the same package structure: +Each component (appserver, postgres, console, agenticconsole) follows the same package structure: - `reconciler.go`: Phase 1 (resources) and Phase 2 (deployment) entry points - `deployment.go`: Deployment spec generation and update detection - `assets.go` and/or `config.go`: Resource and config generation @@ -117,17 +129,20 @@ The packages receive `reconciler.Reconciler` interface, never import the control ### Reconciler Interface (`internal/controller/reconciler/interface.go`) Embeds `client.Client` and adds getter methods for: - `GetScheme()`, `GetLogger()`, `GetNamespace()` -- Image getters: `GetAppServerImage()`, `GetPostgresImage()`, `GetConsoleUIImage()`, `GetOpenShiftMCPServerImage()`, `GetDataverseExporterImage()` +- Image getters: `GetAppServerImage()`, `GetPostgresImage()`, `GetConsoleUIImage()`, `GetAgenticConsoleImage()`, `GetOpenShiftMCPServerImage()`, `GetDataverseExporterImage()` - Version getters: `GetOpenShiftMajor()`, `GetOpenshiftMinor()` - Config getters: `IsPrometheusAvailable()`, `GetWatcherConfig()` +`OLSConfigReconciler` implements the interface in `olsconfig_helpers.go`. Component packages call `r.GetAgenticConsoleImage()` when generating the agentic console deployment; the value comes from `OLSConfigReconcilerOptions.AgenticConsoleUIImage`, set in `cmd/main.go` from `--agentic-console-image` (with default from `defaultImages["agentic-console-plugin"]`). + ### Finalizer Pattern The OLSConfig CR uses finalizer `ols.openshift.io/finalizer` (defined in `utils.OLSConfigFinalizer`). On deletion: -1. Remove Console UI (deactivate plugin, delete ConsolePlugin CR) -2. List all owned resources via owner references -3. Explicitly delete owned resources -4. Wait up to 3 minutes for deletion (poll every 5 seconds) -5. Remove finalizer (proceeds even if cleanup times out) +1. Remove chat console UI (deactivate plugin, delete ConsolePlugin CR) +2. Remove agentic console UI (deactivate plugin, delete ConsolePlugin CR) +3. List all owned resources via owner references +4. Explicitly delete owned resources +5. Wait up to 3 minutes for deletion (poll every 5 seconds) +6. Remove finalizer (proceeds even if cleanup times out) ## Integration Points @@ -210,6 +225,20 @@ E2E tests live in `test/e2e/` and run against a real OpenShift cluster with the | `CONDITION_TIMEOUT` | No | Custom timeout in seconds for condition checks | | `ARTIFACT_DIR` | No | Directory for must-gather diagnostics output | +## Local Development + +`make run` sets `LOCAL_DEV_MODE=true` and runs the operator on the host against the cluster kubeconfig. + +| Behavior | When `LOCAL_DEV_MODE=true` | +|---|---| +| Operator ServiceMonitor | Skipped in `reconcileOperatorResources()` | +| App-server metrics reader secret | Skipped in `appserver.reconcileMetricsReaderSecret()` | +| App-server ServiceMonitor / PrometheusRule | Still reconciled if Prometheus Operator CRDs exist | + +Skipping metrics reader secret reconciliation avoids a local reconcile loop: creating the token secret triggers `Owns(Secret)` and immediate requeue. + +`make run` also runs `dev-setup` (namespace, metrics RBAC, user-access). Image overrides: `--console-image`, `--agentic-console-image`, and other flags in `cmd/main.go`. + ## Implementation Notes - The operator uses kubebuilder v3 markers for CRD generation and RBAC. @@ -218,4 +247,3 @@ E2E tests live in `test/e2e/` and run against a real OpenShift cluster with the - The OLSConfig CRD is cluster-scoped and validated to require `.metadata.name == "cluster"`. - `SetupWithManager()` registers `Owns()` watches for: Deployment, ServiceAccount, ClusterRole, ClusterRoleBinding, Service, ConfigMap, Secret, PersistentVolumeClaim, ConsolePlugin, ServiceMonitor, PrometheusRule, ImageStream. - Controller-runtime handles retry with exponential backoff; the operator does not use periodic reconciliation. -- `LOCAL_DEV_MODE=true` env var skips ServiceMonitor creation for local development with `make run-local`. diff --git a/.ai/spec/how/reconciliation.md b/.ai/spec/how/reconciliation.md index 088ce7dec..1aee7132b 100644 --- a/.ai/spec/how/reconciliation.md +++ b/.ai/spec/how/reconciliation.md @@ -18,12 +18,14 @@ Reconcile(ctx, req) -> handleFinalizer() # Add/remove finalizer, run cleanup -> reconcileOperatorResources() # ServiceMonitor, NetworkPolicy (operator-level) -> annotateExternalResources() # Validate secrets, annotate for watching - -> reconcileIndependentResources() # Phase 1: console, postgres, backend resources + -> reconcileIndependentResources() # Phase 1: console, agentic console, postgres, backend resources | |-- console.ReconcileConsoleUIResources() + | |-- agenticconsole.ReconcileAgenticConsoleUIResources() | |-- postgres.ReconcilePostgresResources() | +-- appserver.ReconcileAppServerResources() -> reconcileDeploymentsAndStatus() # Phase 2: deployments + status update |-- console.ReconcileConsoleUIDeploymentAndPlugin() + |-- agenticconsole.ReconcileAgenticConsoleUIDeploymentAndPlugin() |-- postgres.ReconcilePostgresDeployment() |-- appserver.ReconcileAppServerDeployment() |-- checkDeploymentStatus() for each # Collect diagnostics @@ -33,7 +35,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, console) 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, console, agenticconsole) 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. @@ -77,5 +79,5 @@ The `finalizeOLSConfig()` method uses `listOwnedResources()` which queries every - `SetupWithManager()` registers Owns() for 12 resource types and Watches() for Secrets and ConfigMaps with custom predicates. - Secret watch predicates: Create events allowed for all secrets in operator namespace (handles recreated secrets); Update events filtered by watcher annotation; Delete events ignored. - ConfigMap watch predicates: Same pattern as secrets. -- The `LOCAL_DEV_MODE` environment variable skips ServiceMonitor creation when running locally. +- The `LOCAL_DEV_MODE` environment variable skips operator ServiceMonitor creation and app-server metrics reader secret reconciliation when running locally (`make run`). - Phase 1 failures update status with `ResourceReconciliation` condition type (not the component-specific types used in Phase 2). diff --git a/.ai/spec/what/agentic-console-ui.md b/.ai/spec/what/agentic-console-ui.md new file mode 100644 index 000000000..479e4e8eb --- /dev/null +++ b/.ai/spec/what/agentic-console-ui.md @@ -0,0 +1,79 @@ +# Agentic Console UI + +The operator deploys the OpenShift Lightspeed **agentic** console plugin, which integrates the AI Hub (proposals and configuration UI) into the OpenShift web console. This spec covers only the agentic console plugin. The chat console plugin is documented in `console-ui.md`. + +Implementation package: `internal/controller/agenticconsole/`. Shared ConsolePlugin reconcile helpers live in `internal/controller/utils/console_plugin_reconciler.go`. + +## Behavioral Rules + +### Deployment +1. The agentic console plugin always runs as a single replica (operator forces 1 regardless of `spec.ols.deployment.agenticConsole.replicas`). +2. The operator always reconciles this operand when `OLSConfig` exists; there is no CR toggle to disable it. +3. The container image is configured at operator startup via the `--agentic-console-image` flag (default from `AgenticConsoleUIImageDefault` in `constants.go`). +4. The container serves static plugin assets via nginx, listening on HTTPS port 9443. +5. TLS certificates for the Service are generated by the OpenShift service-ca operator into secret `lightspeed-agentic-console-plugin-cert`. +6. The nginx configuration is generated by the operator as ConfigMap `lightspeed-agentic-console-plugin`. + +### Console Integration +7. The operator creates a cluster-scoped `ConsolePlugin` CR named `lightspeed-agentic-console-plugin`. +8. The `ConsolePlugin` CR registers a **Service** backend only. Unlike the chat console plugin, there is **no** `spec.proxy` entry to the app-server. +9. The operator waits for the serving-cert TLS secret before registering the plugin. +10. The operator activates the plugin by adding `lightspeed-agentic-console-plugin` to the Console CR `spec.plugins` array. +11. Activation uses retry-on-conflict to handle concurrent Console CR modifications. + +### Cleanup +12. On `OLSConfig` deletion, the operator deactivates the plugin (removes from Console CR `spec.plugins`), then deletes the `ConsolePlugin` CR. +13. Console CR modification errors during cleanup are logged but do not block finalizer completion. + +### Networking +14. NetworkPolicy `lightspeed-agentic-console-plugin` allows ingress only from OpenShift Console pods (`app=console` in `openshift-console` namespace). + +### Status +15. Deployment health is reported via condition type `AgenticConsolePluginReady`. + +### Reconciliation Phases +16. **Phase 1** (`ReconcileAgenticConsoleUIResources`): ConfigMap, NetworkPolicy, ServiceAccount. +17. **Phase 2** (`ReconcileAgenticConsoleUIDeploymentAndPlugin`): Deployment, Service, TLS wait, ConsolePlugin CR, Console CR activation. + +## Resource Names + +| Resource | Name | +|---|---| +| Deployment | `lightspeed-agentic-console-plugin` | +| Service | `lightspeed-agentic-console-plugin` | +| ConfigMap (nginx) | `lightspeed-agentic-console-plugin` | +| ServiceAccount | `lightspeed-agentic-console-plugin` | +| NetworkPolicy | `lightspeed-agentic-console-plugin` | +| TLS secret (service-ca) | `lightspeed-agentic-console-plugin-cert` | +| ConsolePlugin CR | `lightspeed-agentic-console-plugin` | +| Container | `console` | +| HTTPS port | `9443` | +| Display name | `OpenShift Lightspeed Agentic Console Plugin` | + +Resource names match the prior `lightspeed-agentic-operator` deployment for upgrade adoption. + +## Configuration Surface + +| Field path | Description | +|---|---| +| `spec.ols.deployment.agenticConsole.replicas` | Ignored; always 1 | +| `spec.ols.deployment.agenticConsole.resources` | Container resource requirements | +| `spec.ols.deployment.agenticConsole.tolerations` | Pod tolerations | +| `spec.ols.deployment.agenticConsole.nodeSelector` | Node selector constraints | +| `spec.ols.deployment.agenticConsole.affinity` | Pod affinity | +| `spec.ols.deployment.agenticConsole.topologySpreadConstraints` | Topology spread constraints | +| `--agentic-console-image` (operator flag) | Container image override at operator startup | + +## Constraints + +1. Replicas are always 1 regardless of configuration. +2. The `ConsolePlugin` CR is cluster-scoped; cleanup requires explicit deactivation and delete (not only owner-reference cascade). +3. The plugin frontend image must be compatible with the cluster OpenShift Console version (the `:main` image targets Console plugin API 4.22+). +4. The plugin name in the Console CR must exactly match the `ConsolePlugin` CR name for activation. +5. UI routes are defined in the plugin bundle (`console-extensions.json`), e.g. `/lightspeed/proposals` and `/lightspeed/configuration`. + +## Planned Changes + +| Ticket | Summary | +|---|---| +| OLS-3236 | [PLANNED] OLM bundle/CSV: add `--agentic-console-image` to operator deployment and `related_images.json` entry | diff --git a/.ai/spec/what/console-ui.md b/.ai/spec/what/console-ui.md index 98aa81197..4356a4b4b 100644 --- a/.ai/spec/what/console-ui.md +++ b/.ai/spec/what/console-ui.md @@ -1,6 +1,6 @@ # Console UI -The operator deploys the OpenShift Lightspeed **chat** console plugin, which integrates the Lightspeed chat interface into the OpenShift web console. This spec covers only the Lightspeed chat console plugin. The agentic console plugin is a separate plugin deployed by the lightspeed-agentic-operator controller (see `bundle-composition.md`). +The operator deploys the OpenShift Lightspeed **chat** console plugin, which integrates the Lightspeed chat interface into the OpenShift web console. This spec covers only the Lightspeed chat console plugin. The agentic console plugin is documented in `agentic-console-ui.md`. ## Behavioral Rules diff --git a/AGENTS.md b/AGENTS.md index 5db4639d3..bcebc4274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,17 @@ When updating the operator version for a release, you **MUST** update version nu OLSConfigReconciler.Reconcile() → ├── [Operator-level resources: ServiceMonitor, NetworkPolicy] ├── [Finalizer logic: handle CR deletion if DeletionTimestamp set] -├── reconcileLLMSecrets() -├── reconcileConsoleUI() -├── reconcilePostgresServer() -└── reconcileAppServer() (application server via `appserver` package) - └── [12+ sub-tasks via ReconcileTask pattern] +├── [Annotate external resources + validate LLM/TLS secrets] +├── Phase 1 (independent resources): +│ ├── console UI (`console/`) +│ ├── agentic console UI (`agenticconsole/`) +│ ├── postgres (`postgres/`) +│ └── app server (`appserver/`) +└── Phase 2 (deployments + status): + ├── console UI → ConsolePluginReady + ├── agentic console UI → AgenticConsolePluginReady + ├── postgres → CacheReady + └── app server → ApiReady ``` ## Code Conventions @@ -75,10 +81,12 @@ make test-e2e # E2E tests (requires cluster) - `internal/controller/olsconfig_controller.go` - Main reconciler with finalizer logic - `internal/controller/appserver/` - App server - `internal/controller/postgres/` - PostgreSQL -- `internal/controller/console/` - Console UI +- `internal/controller/console/` - Chat console plugin (Lightspeed assistant UI) +- `internal/controller/agenticconsole/` - Agentic console plugin (AI Hub / proposals UI) - `internal/controller/watchers/` - External resource watching - `internal/controller/utils/` - Shared utilities, constants - `constants.go` - Includes `OLSConfigFinalizer` constant + - `console_plugin_reconciler.go` - Shared ConsolePlugin reconcile helpers (used by `console/` and `agenticconsole/`) ### Tests - `*_test.go` - Unit tests (co-located) @@ -99,10 +107,15 @@ make test-e2e # E2E tests (requires cluster) ### Adding New Reconciliation Step - **App Server**: Add to `ReconcileTask` slice in `internal/controller/appserver/reconciler.go` +- **Console plugin operand**: Add package under `internal/controller//`, reuse `utils/console_plugin_reconciler.go` where possible, wire Phase 1/2 in `olsconfig_controller.go` - **Top-Level**: Create package under `internal/controller//`, add to `olsconfig_controller.go` - Add error constants to `internal/controller/utils/utils.go` - Write unit tests in co-located `*_test.go` files +### Local Development (`make run`) +- Sets `LOCAL_DEV_MODE=true`, which skips operator ServiceMonitor reconciliation and app-server metrics reader secret reconciliation (no local metrics scraping loop). +- Image overrides: `--console-image`, `--agentic-console-image`, and other flags in `cmd/main.go`. + ## AI Assistant Skills Available skills for code review: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1d8564578..e8257bfbb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ This document describes the internal architecture of the OpenShift Lightspeed Op ## Overview -The operator follows a modular, component-based architecture where each major component (application server, PostgreSQL, Console UI) is managed by its own dedicated package with independent reconciliation logic. +The operator follows a modular, component-based architecture where each major component (application server, PostgreSQL, chat console plugin, agentic console plugin) is managed by its own dedicated package with independent reconciliation logic. ## Key Design Decisions @@ -58,7 +58,7 @@ The operator follows a modular, component-based architecture where each major co - Detect OpenShift version and select appropriate images - Start controller and handle graceful shutdown -**Key Flags:** Image URLs, `--controller-namespace`, reconcile interval, and related runtime options. See `cmd/main.go` for the complete list. +**Key Flags:** Image URLs (`--service-image`, `--console-image`, `--agentic-console-image`, etc.), `--namespace`, and related runtime options. See `cmd/main.go` for the complete list. `make run` sets `LOCAL_DEV_MODE=true` to skip operator metrics resources during local development. ### Reconciler Interface (`internal/controller/reconciler`) @@ -82,9 +82,21 @@ Provides clean contract between main controller and component packages: ### Console UI Package (`internal/controller/console`) -**Purpose:** Manages OpenShift Console plugin for web UI integration +**Purpose:** Manages the chat OpenShift Console plugin (Lightspeed assistant UI). -**Entry Points:** `ReconcileConsoleUI()` (setup), `RemoveConsoleUI()` (cleanup when disabled) +**Entry Points:** `ReconcileConsoleUIResources`, `ReconcileConsoleUIDeploymentAndPlugin`, `RemoveConsoleUI()`. + +**Notes:** Includes a `ConsolePlugin` proxy to the app-server for API calls. Shared reconcile logic lives in `utils/console_plugin_reconciler.go`. + +### Agentic Console UI Package (`internal/controller/agenticconsole`) + +**Purpose:** Manages the agentic OpenShift Console plugin (AI Hub: proposals and configuration UI). + +**Entry Points:** `ReconcileAgenticConsoleUIResources`, `ReconcileAgenticConsoleUIDeploymentAndPlugin`, `RemoveAgenticConsole()`. + +**Notes:** No app-server proxy on the `ConsolePlugin` CR. Reuses `utils/console_plugin_reconciler.go`. Status condition: `AgenticConsolePluginReady`. CR tuning: `spec.ols.deployment.agenticConsole`. + +**Planned:** Alerts adapter operand (`AlertsAdapterReady`) is specified in `.ai/spec/` but not yet implemented in this operator. ### Utilities Package (`internal/controller/utils`) @@ -92,6 +104,7 @@ Provides clean contract between main controller and component packages: **Contains:** - Constants (resource names, labels, annotations, error messages) +- Console plugin shared reconcilers (`console_plugin_reconciler.go`) - Helper functions (hash computation, resource comparison, equality checks) - Status utilities (condition management) - Validation (certificates, version detection) @@ -121,12 +134,17 @@ High-level reconciliation sequence: 2. Check if CR is being deleted → run finalizer cleanup if needed 3. Add finalizer if not present 4. Validate OLSConfig CR exists -5. Reconcile LLM Secrets (validate credentials) -6. Reconcile Components: - - Console UI (if enabled) - - PostgreSQL (if conversation cache enabled) - - Application server (`appserver` package) -7. Update Status Conditions based on deployment readiness +5. Annotate external resources; validate LLM credentials and TLS secrets +6. Phase 1 — independent resources (continue on error): + - Chat console UI (`console/`) + - Agentic console UI (`agenticconsole/`) + - PostgreSQL (`postgres/`) + - Application server (`appserver/`) +7. Phase 2 — deployments and status (fail-fast on pod failures): + - Chat console UI → ConsolePluginReady + - Agentic console UI → AgenticConsolePluginReady + - PostgreSQL → CacheReady + - Application server → ApiReady ``` ### Finalizer Pattern @@ -134,7 +152,7 @@ High-level reconciliation sequence: The operator uses a finalizer (`ols.openshift.io/finalizer`) to ensure proper cleanup when `OLSConfig` CR is deleted. **Why Needed:** -- **Console UI cleanup**: ConsolePlugin is cluster-scoped and not cascade-deleted by owner references +- **Console plugin cleanup**: `ConsolePlugin` is cluster-scoped and not cascade-deleted by owner references; chat and agentic plugins must be deactivated in the Console CR - **PVC cleanup**: PersistentVolumeClaims can block deletion if not properly released - **Race condition prevention**: Ensures complete cleanup before CR can be recreated (important for tests and sequential deployments) @@ -160,9 +178,10 @@ if !olsconfig.DeletionTimestamp.IsZero() { ``` **Cleanup Sequence** (`finalizeOLSConfig`): -1. **Remove Console UI**: Deactivate plugin from Console CR, delete ConsolePlugin CR -2. **Wait for owned resources**: Poll for up to 3 minutes until deployments, services, PVCs are deleted (cascade deletion) -3. **Remove finalizer**: Allows Kubernetes to remove CR from etcd +1. **Remove chat console UI**: Deactivate plugin from Console CR, delete ConsolePlugin CR +2. **Remove agentic console UI**: Deactivate plugin from Console CR, delete ConsolePlugin CR +3. **Wait for owned resources**: Poll for up to 3 minutes until deployments, services, PVCs are deleted (cascade deletion) +4. **Remove finalizer**: Allows Kubernetes to remove CR from etcd **Error Handling:** - Cleanup errors are logged but don't block finalizer removal diff --git a/Makefile b/Makefile index 7943debc2..431c9957a 100644 --- a/Makefile +++ b/Makefile @@ -252,9 +252,9 @@ dev-teardown: uninstall ## Teardown local development environment (removes RBAC, @echo "✅ Development environment cleaned up." .PHONY: run -run: dev-setup manifests generate fmt vet ## Run a controller from your host (auto-setup RBAC if needed). +run: dev-setup manifests generate fmt vet ## Run a controller from your host (auto-setup RBAC if needed). Optional: make run ARGS="--agentic-console-image=..." @echo "🔧 Running controller locally - using default images from constants" - LOCAL_DEV_MODE=true go run ./cmd/main.go + LOCAL_DEV_MODE=true go run ./cmd/main.go $(ARGS) # If you wish built the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64 ). However, you must enable docker buildKit for it. diff --git a/api/v1alpha1/olsconfig_types.go b/api/v1alpha1/olsconfig_types.go index 936b27434..c22bc6099 100644 --- a/api/v1alpha1/olsconfig_types.go +++ b/api/v1alpha1/olsconfig_types.go @@ -345,6 +345,9 @@ type DeploymentConfig struct { // Console container settings. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Console Deployment" ConsoleContainer Config `json:"console,omitempty"` + // Agentic console plugin container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Agentic Console Deployment" + AgenticConsoleContainer Config `json:"agenticConsole,omitempty"` // Database container settings. // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Database Deployment" DatabaseContainer Config `json:"database,omitempty"` @@ -353,7 +356,7 @@ type DeploymentConfig struct { // Config defines pod configuration using standard Kubernetes types type Config struct { // Defines the number of desired OLS pods. Default: "1" - // Note: Replicas can only be changed for APIContainer. For PostgreSQL and Console containers, + // Note: Replicas can only be changed for APIContainer. For PostgreSQL, Console, and Agentic Console containers, // the number of replicas will always be set to 1. // +kubebuilder:default=1 // +kubebuilder:validation:Minimum=0 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a49d08d50..aa559ed7b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + configv1 "github.com/openshift/api/config/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -36,7 +37,8 @@ func (in *Config) DeepCopyInto(out *Config) { } if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = (*in).DeepCopy() + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations @@ -54,7 +56,8 @@ func (in *Config) DeepCopyInto(out *Config) { } if in.Affinity != nil { in, out := &in.Affinity, &out.Affinity - *out = (*in).DeepCopy() + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) } if in.TopologySpreadConstraints != nil { in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints @@ -80,7 +83,8 @@ func (in *ContainerConfig) DeepCopyInto(out *ContainerConfig) { *out = *in if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = (*in).DeepCopy() + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) } } @@ -117,6 +121,7 @@ func (in *DeploymentConfig) DeepCopyInto(out *DeploymentConfig) { in.DataCollectorContainer.DeepCopyInto(&out.DataCollectorContainer) in.MCPServerContainer.DeepCopyInto(&out.MCPServerContainer) in.ConsoleContainer.DeepCopyInto(&out.ConsoleContainer) + in.AgenticConsoleContainer.DeepCopyInto(&out.AgenticConsoleContainer) in.DatabaseContainer.DeepCopyInto(&out.DatabaseContainer) } @@ -188,7 +193,8 @@ func (in *MCPHeaderValueSource) DeepCopyInto(out *MCPHeaderValueSource) { *out = *in if in.SecretRef != nil { in, out := &in.SecretRef, &out.SecretRef - *out = (*in).DeepCopy() + *out = new(corev1.LocalObjectReference) + **out = **in } } @@ -421,11 +427,13 @@ func (in *OLSSpec) DeepCopyInto(out *OLSSpec) { } if in.AdditionalCAConfigMapRef != nil { in, out := &in.AdditionalCAConfigMapRef, &out.AdditionalCAConfigMapRef - *out = (*in).DeepCopy() + *out = new(corev1.LocalObjectReference) + **out = **in } if in.TLSSecurityProfile != nil { in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile - *out = (*in).DeepCopy() + *out = new(configv1.TLSSecurityProfile) + (*in).DeepCopyInto(*out) } if in.IntrospectionEnabled != nil { in, out := &in.IntrospectionEnabled, &out.IntrospectionEnabled @@ -460,9 +468,7 @@ func (in *OLSSpec) DeepCopyInto(out *OLSSpec) { if in.ImagePullSecrets != nil { in, out := &in.ImagePullSecrets, &out.ImagePullSecrets *out = make([]corev1.LocalObjectReference, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } + copy(*out, *in) } if in.ToolFilteringConfig != nil { in, out := &in.ToolFilteringConfig, &out.ToolFilteringConfig @@ -525,7 +531,7 @@ func (in *PostgresSpec) DeepCopy() *PostgresSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { *out = *in - in.CredentialsSecretRef.DeepCopyInto(&out.CredentialsSecretRef) + out.CredentialsSecretRef = in.CredentialsSecretRef if in.Models != nil { in, out := &in.Models, &out.Models *out = make([]ModelSpec, len(*in)) @@ -543,7 +549,8 @@ func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { } if in.TLSSecurityProfile != nil { in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile - *out = (*in).DeepCopy() + *out = new(configv1.TLSSecurityProfile) + (*in).DeepCopyInto(*out) } } @@ -560,7 +567,7 @@ func (in *ProviderSpec) DeepCopy() *ProviderSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProxyCACertConfigMapRef) DeepCopyInto(out *ProxyCACertConfigMapRef) { *out = *in - in.LocalObjectReference.DeepCopyInto(&out.LocalObjectReference) + out.LocalObjectReference = in.LocalObjectReference } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyCACertConfigMapRef. @@ -662,7 +669,7 @@ func (in *Storage) DeepCopy() *Storage { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { *out = *in - in.KeyCertSecretRef.DeepCopyInto(&out.KeyCertSecretRef) + out.KeyCertSecretRef = in.KeyCertSecretRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. diff --git a/cmd/main.go b/cmd/main.go index 994589c55..2a91ed941 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -34,6 +34,7 @@ limitations under the License. // - secure-metrics-server: Enable mTLS for metrics server // - service-image: Override default lightspeed-service image // - console-image: Override default console plugin image +// - agentic-console-image: Override default agentic console plugin image // - postgres-image: Override default PostgreSQL image // - openshift-mcp-server-image: Override default MCP server image // - namespace: Operator namespace (defaults to WATCH_NAMESPACE env var or "openshift-lightspeed") @@ -95,6 +96,7 @@ var ( "lightspeed-service": utils.OLSAppServerImageDefault, "postgres-image": utils.PostgresServerImageDefault, "console-plugin": utils.ConsoleUIImageDefault, + "agentic-console-plugin": utils.AgenticConsoleUIImageDefault, "openshift-mcp-server-image": utils.OpenShiftMCPServerImageDefault, "dataverse-exporter-image": utils.DataverseExporterImageDefault, "ocp-rag-image": utils.OcpRagImageDefault, @@ -115,7 +117,7 @@ func init() { // overrideImages overrides the default images with the images provided by the user. // If an image is not provided, the default is used. -func overrideImages(serviceImage string, consoleImage string, postgresImage string, openshiftMCPServerImage string, dataverseExporterImage string, ocpRagImage string) map[string]string { +func overrideImages(serviceImage string, consoleImage string, agenticConsoleImage string, postgresImage string, openshiftMCPServerImage string, dataverseExporterImage string, ocpRagImage string) map[string]string { res := defaultImages if serviceImage != "" { res["lightspeed-service"] = serviceImage @@ -123,6 +125,9 @@ func overrideImages(serviceImage string, consoleImage string, postgresImage stri if consoleImage != "" { res["console-plugin"] = consoleImage } + if agenticConsoleImage != "" { + res["agentic-console-plugin"] = agenticConsoleImage + } if postgresImage != "" { res["postgres-image"] = postgresImage } @@ -162,6 +167,7 @@ func main() { var metricsClientCA string var serviceImage string var consoleImage string + var agenticConsoleImage string var namespace string var postgresImage string var openshiftMCPServerImage string @@ -179,6 +185,7 @@ func main() { flag.StringVar(&caCertPath, "ca-cert", utils.OperatorCACertPathDefault, "The path to the CA certificate file.") flag.StringVar(&serviceImage, "service-image", utils.OLSAppServerImageDefault, "The image of the lightspeed-service container.") flag.StringVar(&consoleImage, "console-image", utils.ConsoleUIImageDefault, "The image of the console-plugin container.") + flag.StringVar(&agenticConsoleImage, "agentic-console-image", utils.AgenticConsoleUIImageDefault, "The image of the agentic console-plugin container.") flag.StringVar(&namespace, "namespace", "", "The namespace where the operator is deployed.") flag.StringVar(&postgresImage, "postgres-image", utils.PostgresServerImageDefault, "The image of the PostgreSQL server.") flag.StringVar(&openshiftMCPServerImage, "openshift-mcp-server-image", utils.OpenShiftMCPServerImageDefault, "The image of the OpenShift MCP server container.") @@ -196,7 +203,7 @@ func main() { namespace = getWatchNamespace() } - imagesMap := overrideImages(serviceImage, consoleImage, postgresImage, openshiftMCPServerImage, dataverseExporterImage, ocpRagImage) + imagesMap := overrideImages(serviceImage, consoleImage, agenticConsoleImage, postgresImage, openshiftMCPServerImage, dataverseExporterImage, ocpRagImage) setupLog.Info("Images setting loaded", "images", listImages()) setupLog.Info("Starting the operator", "metricsAddr", metricsAddr, "probeAddr", probeAddr, "certDir", certDir, "certName", certName, "keyName", keyName, "namespace", namespace) @@ -342,6 +349,12 @@ func main() { Description: "Console UI TLS certificate", AffectedDeployments: []string{utils.ConsoleUIDeploymentName}, }, + { + Name: utils.AgenticConsoleUIServiceCertSecretName, + Namespace: namespace, + Description: "Agentic Console UI TLS certificate", + AffectedDeployments: []string{utils.AgenticConsoleUIDeploymentName}, + }, { Name: utils.PostgresCertsSecretName, Namespace: namespace, @@ -389,6 +402,7 @@ func main() { OpenShiftMajor: major, OpenshiftMinor: minor, ConsoleUIImage: imagesMap["console-plugin"], + AgenticConsoleUIImage: imagesMap["agentic-console-plugin"], LightspeedServiceImage: imagesMap["lightspeed-service"], LightspeedServicePostgresImage: imagesMap["postgres-image"], OpenShiftMCPServerImage: imagesMap["openshift-mcp-server-image"], diff --git a/config/crd/bases/ols.openshift.io_olsconfigs.yaml b/config/crd/bases/ols.openshift.io_olsconfigs.yaml index addd1fcf9..2e3a67eeb 100644 --- a/config/crd/bases/ols.openshift.io_olsconfigs.yaml +++ b/config/crd/bases/ols.openshift.io_olsconfigs.yaml @@ -477,6 +477,1235 @@ spec: deployment: description: OLS deployment settings properties: + agenticConsole: + description: Agentic console plugin container settings. + properties: + affinity: + description: |- + Affinity rules (can be added without API version bump) + Uses standard corev1.Affinity + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in + the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules + (e.g. co-locate this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling + rules (e.g. avoid putting this pod in the same node, + zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + nodeSelector: + additionalProperties: + type: string + description: Node selector constraints + type: object + replicas: + default: 1 + description: |- + Defines the number of desired OLS pods. Default: "1" + Note: Replicas can only be changed for APIContainer. For PostgreSQL, Console, and Agentic Console containers, + the number of replicas will always be set to 1. + format: int32 + minimum: 0 + type: integer + resources: + description: |- + Resource requirements (CPU, memory) + Uses standard corev1.ResourceRequirements + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + 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 + topologySpreadConstraints: + description: |- + Topology spread constraints (can be added without API version bump) + Uses standard corev1.TopologySpreadConstraint + items: + description: TopologySpreadConstraint specifies how + to spread matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + type: object api: description: API container settings. properties: @@ -1419,7 +2648,7 @@ spec: default: 1 description: |- Defines the number of desired OLS pods. Default: "1" - Note: Replicas can only be changed for APIContainer. For PostgreSQL and Console containers, + Note: Replicas can only be changed for APIContainer. For PostgreSQL, Console, and Agentic Console containers, the number of replicas will always be set to 1. format: int32 minimum: 0 @@ -2648,7 +3877,7 @@ spec: default: 1 description: |- Defines the number of desired OLS pods. Default: "1" - Note: Replicas can only be changed for APIContainer. For PostgreSQL and Console containers, + Note: Replicas can only be changed for APIContainer. For PostgreSQL, Console, and Agentic Console containers, the number of replicas will always be set to 1. format: int32 minimum: 0 @@ -3943,7 +5172,7 @@ spec: default: 1 description: |- Defines the number of desired OLS pods. Default: "1" - Note: Replicas can only be changed for APIContainer. For PostgreSQL and Console containers, + Note: Replicas can only be changed for APIContainer. For PostgreSQL, Console, and Agentic Console containers, the number of replicas will always be set to 1. format: int32 minimum: 0 diff --git a/internal/controller/agenticconsole/assets.go b/internal/controller/agenticconsole/assets.go new file mode 100644 index 000000000..561f00153 --- /dev/null +++ b/internal/controller/agenticconsole/assets.go @@ -0,0 +1,120 @@ +package agenticconsole + +import ( + consolev1 "github.com/openshift/api/console/v1" + 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 GenerateAgenticConsoleUILabels() map[string]string { + return map[string]string{ + "app.kubernetes.io/name": utils.AgenticConsoleUIPluginName, + "app.kubernetes.io/component": "console", + "app.kubernetes.io/managed-by": "lightspeed-operator", + } +} + +func GenerateAgenticConsoleUIConfigMap(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.ConfigMap, error) { + nginxConfig := `pid /tmp/nginx/nginx.pid; +error_log /dev/stdout info; +events {} +http { + client_body_temp_path /tmp/nginx/client_body; + proxy_temp_path /tmp/nginx/proxy; + fastcgi_temp_path /tmp/nginx/fastcgi; + uwsgi_temp_path /tmp/nginx/uwsgi; + scgi_temp_path /tmp/nginx/scgi; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + server { + listen 9443 ssl; + listen [::]:9443 ssl; + ssl_certificate /var/cert/tls.crt; + ssl_certificate_key /var/cert/tls.key; + root /usr/share/nginx/html; + access_log /dev/stdout; + } +} +` + + return utils.GenerateConsolePluginNginxConfigMap(r, cr, utils.AgenticConsoleUIConfigMapName, GenerateAgenticConsoleUILabels(), nginxConfig) +} + +func GenerateAgenticConsoleUIService(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.Service, error) { + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.AgenticConsoleUIServiceName, + Namespace: r.GetNamespace(), + Labels: GenerateAgenticConsoleUILabels(), + Annotations: map[string]string{ + utils.ServingCertSecretAnnotationKey: utils.AgenticConsoleUIServiceCertSecretName, + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: utils.AgenticConsoleUIHTTPSPort, + Name: "https", + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt32(utils.AgenticConsoleUIHTTPSPort), + }, + }, + Selector: map[string]string{ + "app.kubernetes.io/name": utils.AgenticConsoleUIPluginName, + }, + }, + } + + if err := controllerutil.SetControllerReference(cr, &service, r.GetScheme()); err != nil { + return nil, err + } + + return &service, nil +} + +func GenerateAgenticConsoleUIPlugin(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*consolev1.ConsolePlugin, error) { + plugin := &consolev1.ConsolePlugin{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.AgenticConsoleUIPluginName, + Labels: GenerateAgenticConsoleUILabels(), + }, + Spec: consolev1.ConsolePluginSpec{ + Backend: consolev1.ConsolePluginBackend{ + Service: &consolev1.ConsolePluginService{ + Name: utils.AgenticConsoleUIServiceName, + Namespace: r.GetNamespace(), + Port: utils.AgenticConsoleUIHTTPSPort, + BasePath: "/", + }, + Type: consolev1.Service, + }, + DisplayName: utils.AgenticConsoleUIPluginDisplayName, + I18n: consolev1.ConsolePluginI18n{ + LoadType: consolev1.Preload, + }, + }, + } + + if err := controllerutil.SetControllerReference(cr, plugin, r.GetScheme()); err != nil { + return nil, err + } + + return plugin, nil +} + +func GenerateAgenticConsoleUINetworkPolicy(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*networkingv1.NetworkPolicy, error) { + labels := GenerateAgenticConsoleUILabels() + return utils.GenerateConsolePluginNetworkPolicy(r, cr, utils.AgenticConsoleUINetworkPolicyName, labels, utils.AgenticConsoleUIHTTPSPort) +} + +func GenerateAgenticConsoleUIServiceAccount(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.ServiceAccount, error) { + return utils.GenerateServiceAccount(r, cr, utils.AgenticConsoleUIServiceAccountName) +} diff --git a/internal/controller/agenticconsole/assets_test.go b/internal/controller/agenticconsole/assets_test.go new file mode 100644 index 000000000..485bad0a6 --- /dev/null +++ b/internal/controller/agenticconsole/assets_test.go @@ -0,0 +1,88 @@ +package agenticconsole + +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" +) + +var _ = Describe("Agentic Console UI assets", func() { + var cr *olsv1alpha1.OLSConfig + labels := map[string]string{ + "app.kubernetes.io/name": utils.AgenticConsoleUIPluginName, + "app.kubernetes.io/component": "console", + "app.kubernetes.io/managed-by": "lightspeed-operator", + } + + Context("complete custom resource", func() { + BeforeEach(func() { + cr = utils.GetDefaultOLSConfigCR() + }) + + It("should generate the nginx config map", func() { + cm, err := GenerateAgenticConsoleUIConfigMap(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Name).To(Equal(utils.AgenticConsoleUIConfigMapName)) + Expect(cm.Namespace).To(Equal(utils.OLSNamespaceDefault)) + Expect(cm.Labels).To(Equal(labels)) + Expect(cm.Data["nginx.conf"]).To(ContainSubstring("listen 9443 ssl")) + }) + + It("should generate the agentic console UI service", func() { + svc, err := GenerateAgenticConsoleUIService(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(svc.Name).To(Equal(utils.AgenticConsoleUIServiceName)) + Expect(svc.Labels).To(Equal(labels)) + Expect(svc.Annotations[utils.ServingCertSecretAnnotationKey]).To(Equal(utils.AgenticConsoleUIServiceCertSecretName)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(utils.AgenticConsoleUIHTTPSPort))) + Expect(svc.Spec.Ports[0].TargetPort.IntVal).To(Equal(int32(utils.AgenticConsoleUIHTTPSPort))) + Expect(svc.Spec.Selector).To(Equal(map[string]string{ + "app.kubernetes.io/name": utils.AgenticConsoleUIPluginName, + })) + }) + + It("should generate the agentic console UI deployment", func() { + dep, err := GenerateAgenticConsoleUIDeployment(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(dep.Name).To(Equal(utils.AgenticConsoleUIDeploymentName)) + Expect(dep.Labels).To(Equal(labels)) + Expect(dep.Spec.Selector.MatchLabels).To(Equal(map[string]string{ + "app.kubernetes.io/name": utils.AgenticConsoleUIPluginName, + })) + Expect(dep.Spec.Template.Spec.Containers[0].Name).To(Equal(utils.AgenticConsoleUIContainerName)) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal(utils.AgenticConsoleUIImageDefault)) + Expect(dep.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort).To(Equal(int32(utils.AgenticConsoleUIHTTPSPort))) + Expect(dep.Spec.Template.Spec.Containers[0].Ports[0].Name).To(BeEmpty()) + Expect(dep.Spec.Template.Spec.Containers[0].Resources).To(Equal(*utils.DefaultConsolePluginResourceRequirements())) + Expect(dep.Spec.Template.Spec.Containers[0].Env).To(BeNil()) + Expect(*dep.Spec.Replicas).To(Equal(int32(1))) + }) + + It("should generate the agentic console UI plugin without proxy", func() { + plugin, err := GenerateAgenticConsoleUIPlugin(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(plugin.Name).To(Equal(utils.AgenticConsoleUIPluginName)) + Expect(plugin.Labels).To(Equal(labels)) + Expect(plugin.Spec.DisplayName).To(Equal(utils.AgenticConsoleUIPluginDisplayName)) + Expect(plugin.Spec.Backend.Service.Name).To(Equal(utils.AgenticConsoleUIServiceName)) + Expect(plugin.Spec.Backend.Service.Port).To(Equal(int32(utils.AgenticConsoleUIHTTPSPort))) + Expect(plugin.Spec.Proxy).To(BeNil()) + }) + + It("should generate the agentic console UI plugin NetworkPolicy", func() { + np, err := GenerateAgenticConsoleUINetworkPolicy(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(np.Name).To(Equal(utils.AgenticConsoleUINetworkPolicyName)) + Expect(np.Labels).To(Equal(labels)) + }) + + It("should generate the agentic console UI service account", func() { + sa, err := GenerateAgenticConsoleUIServiceAccount(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + Expect(sa.Name).To(Equal(utils.AgenticConsoleUIServiceAccountName)) + Expect(sa.Namespace).To(Equal(utils.OLSNamespaceDefault)) + }) + }) +}) diff --git a/internal/controller/agenticconsole/deployment.go b/internal/controller/agenticconsole/deployment.go new file mode 100644 index 000000000..21cd39fd9 --- /dev/null +++ b/internal/controller/agenticconsole/deployment.go @@ -0,0 +1,35 @@ +package agenticconsole + +import ( + appsv1 "k8s.io/api/apps/v1" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +// GenerateAgenticConsoleUIDeployment generates the agentic console UI deployment object. +func GenerateAgenticConsoleUIDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { + labels := GenerateAgenticConsoleUILabels() + resources := utils.GetResourcesOrDefault( + cr.Spec.OLSConfig.DeploymentConfig.AgenticConsoleContainer.Resources, + utils.DefaultConsolePluginResourceRequirements(), + ) + + return utils.GenerateConsolePluginDeployment(r, cr, utils.ConsolePluginDeploymentOptions{ + Name: utils.AgenticConsoleUIDeploymentName, + Labels: labels, + SelectorLabels: map[string]string{"app.kubernetes.io/name": utils.AgenticConsoleUIPluginName}, + ServiceAccountName: utils.AgenticConsoleUIServiceAccountName, + ContainerName: utils.AgenticConsoleUIContainerName, + Image: r.GetAgenticConsoleImage(), + Port: utils.AgenticConsoleUIHTTPSPort, + CertVolumeName: "cert", + CertSecretName: utils.AgenticConsoleUIServiceCertSecretName, + NginxVolumeName: "nginx-conf", + NginxConfigMapName: utils.AgenticConsoleUIConfigMapName, + NginxTempVolumeName: "nginx-tmp", + Resources: resources, + DeploymentConfig: cr.Spec.OLSConfig.DeploymentConfig.AgenticConsoleContainer, + }) +} diff --git a/internal/controller/agenticconsole/reconciler.go b/internal/controller/agenticconsole/reconciler.go new file mode 100644 index 000000000..a022f2174 --- /dev/null +++ b/internal/controller/agenticconsole/reconciler.go @@ -0,0 +1,117 @@ +// Package agenticconsole provides reconciliation logic for the OpenShift Lightspeed +// agentic console plugin. +package agenticconsole + +import ( + "context" + "fmt" + + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" + "github.com/openshift/lightspeed-operator/internal/controller/utils" + + appsv1 "k8s.io/api/apps/v1" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" +) + +// ReconcileAgenticConsoleUIResources reconciles all resources except the deployment (Phase 1). +func ReconcileAgenticConsoleUIResources(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { + return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileAgenticConsoleUIResources", []utils.ReconcileTask{ + {Name: "reconcile Agentic Console Plugin ConfigMap", Task: reconcileAgenticConsoleUIConfigMap}, + {Name: "reconcile Agentic Console Plugin NetworkPolicy", Task: reconcileAgenticConsoleNetworkPolicy}, + {Name: "reconcile Agentic Console Plugin Service Account", Task: reconcileAgenticConsoleUIServiceAccount}, + }, true) +} + +// ReconcileAgenticConsoleUIDeploymentAndPlugin reconciles the deployment and related resources (Phase 2). +func ReconcileAgenticConsoleUIDeploymentAndPlugin(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { + return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileAgenticConsoleUIDeploymentAndPlugin", []utils.ReconcileTask{ + {Name: "reconcile Agentic Console Plugin Deployment", Task: ReconcileAgenticConsoleUIDeployment}, + {Name: "reconcile Agentic Console Plugin Service", Task: reconcileAgenticConsoleUIService}, + {Name: "reconcile Agentic Console Plugin TLS Certs", Task: reconcileAgenticConsoleTLSSecret}, + {Name: "reconcile Agentic Console Plugin", Task: reconcileAgenticConsoleUIPlugin}, + {Name: "activate Agentic Console Plugin", Task: activateAgenticConsoleUI}, + }, false) +} + +func reconcileAgenticConsoleUIConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + cm, err := GenerateAgenticConsoleUIConfigMap(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginConfigMap, err) + } + return utils.ReconcileConsolePluginConfigMap(r, ctx, cm) +} + +func reconcileAgenticConsoleUIService(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + service, err := GenerateAgenticConsoleUIService(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginService, err) + } + return utils.ReconcileConsolePluginService(r, ctx, service) +} + +// ReconcileAgenticConsoleUIDeployment reconciles the agentic console UI deployment. +func ReconcileAgenticConsoleUIDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + deployment, err := GenerateAgenticConsoleUIDeployment(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginDeployment, err) + } + return utils.ReconcileConsolePluginDeployment(r, ctx, deployment, RestartAgenticConsoleUI) +} + +func reconcileAgenticConsoleUIPlugin(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + plugin, err := GenerateAgenticConsoleUIPlugin(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePlugin, err) + } + return utils.ReconcileConsolePluginCR(r, ctx, plugin) +} + +func activateAgenticConsoleUI(r reconciler.Reconciler, ctx context.Context, _ *olsv1alpha1.OLSConfig) error { + return utils.ActivateConsolePlugin(r, ctx, utils.AgenticConsoleUIPluginName) +} + +// RemoveAgenticConsole deactivates and deletes the agentic console plugin. +func RemoveAgenticConsole(r reconciler.Reconciler, ctx context.Context) error { + return utils.RemoveConsolePlugin(r, ctx, utils.AgenticConsoleUIPluginName) +} + +func reconcileAgenticConsoleTLSSecret(r reconciler.Reconciler, ctx context.Context, _ *olsv1alpha1.OLSConfig) error { + return utils.WaitForConsolePluginTLSSecret(r, ctx, utils.AgenticConsoleUIServiceCertSecretName) +} + +func reconcileAgenticConsoleNetworkPolicy(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + np, err := GenerateAgenticConsoleUINetworkPolicy(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginNetworkPolicy, err) + } + return utils.ReconcileConsolePluginNetworkPolicy(r, ctx, np) +} + +func reconcileAgenticConsoleUIServiceAccount(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + sa, err := GenerateAgenticConsoleUIServiceAccount(r, cr) + if err != nil { + return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginServiceAccount, err) + } + return utils.ReconcileConsolePluginServiceAccount(r, ctx, sa) +} + +// RestartAgenticConsoleUI triggers a rolling restart of the agentic console UI deployment. +func RestartAgenticConsoleUI(r reconciler.Reconciler, ctx context.Context, deployment ...*appsv1.Deployment) error { + return utils.RestartConsolePluginDeployment(r, ctx, utils.AgenticConsoleUIDeploymentName, deployment...) +} + +// ReconcileAgenticConsoleUI reconciles all agentic console UI resources (test helper). +func ReconcileAgenticConsoleUI(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { + r.GetLogger().Info("reconcileAgenticConsoleUI starts") + + if err := ReconcileAgenticConsoleUIResources(r, ctx, olsconfig); err != nil { + return err + } + if err := ReconcileAgenticConsoleUIDeploymentAndPlugin(r, ctx, olsconfig); err != nil { + return err + } + + r.GetLogger().Info("reconcileAgenticConsoleUI completed") + return nil +} diff --git a/internal/controller/agenticconsole/reconciler_test.go b/internal/controller/agenticconsole/reconciler_test.go new file mode 100644 index 000000000..869a2bcce --- /dev/null +++ b/internal/controller/agenticconsole/reconciler_test.go @@ -0,0 +1,118 @@ +package agenticconsole + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" + 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" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("Agentic Console UI reconciler", Ordered, func() { + Context("Creation logic", Ordered, func() { + var tlsSecret *corev1.Secret + + BeforeAll(func() { + console := openshiftv1.Console{ + ObjectMeta: metav1.ObjectMeta{Name: utils.ConsoleCRName}, + Spec: openshiftv1.ConsoleSpec{ + Plugins: []string{"monitoring-plugin"}, + OperatorSpec: openshiftv1.OperatorSpec{ + ManagementState: openshiftv1.Managed, + }, + }, + } + Expect(k8sClient.Create(ctx, &console)).To(Succeed()) + + tlsSecret, _ = utils.GenerateRandomTLSSecret() + tlsSecret.Name = utils.AgenticConsoleUIServiceCertSecretName + Expect(testReconcilerInstance.Create(ctx, tlsSecret)).To(Succeed()) + + Expect(k8sClient.Get(ctx, crNamespacedName, cr)).To(Succeed()) + crDefault := utils.GetDefaultOLSConfigCR() + cr.Spec = crDefault.Spec + }) + + AfterAll(func() { + console := openshiftv1.Console{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleCRName}, &console) + if err == nil { + Expect(k8sClient.Delete(ctx, &console)).To(Succeed()) + } + _ = testReconcilerInstance.Delete(ctx, tlsSecret) + }) + + It("should reconcile and create plugin resources", func() { + Expect(ReconcileAgenticConsoleUI(testReconcilerInstance, ctx, cr)).To(Succeed()) + + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIServiceName, Namespace: utils.OLSNamespaceDefault}, &corev1.Service{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIConfigMapName, Namespace: utils.OLSNamespaceDefault}, &corev1.ConfigMap{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIDeploymentName, Namespace: utils.OLSNamespaceDefault}, &appsv1.Deployment{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIPluginName}, &consolev1.ConsolePlugin{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUINetworkPolicyName, Namespace: utils.OLSNamespaceDefault}, &networkingv1.NetworkPolicy{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIServiceAccountName, Namespace: utils.OLSNamespaceDefault}, &corev1.ServiceAccount{})).To(Succeed()) + }) + + It("should activate the agentic console plugin", func() { + console := &openshiftv1.Console{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleCRName}, console)).To(Succeed()) + Expect(console.Spec.Plugins).To(ContainElement(utils.AgenticConsoleUIPluginName)) + }) + }) + + Context("Deleting logic", Ordered, func() { + BeforeAll(func() { + console := openshiftv1.Console{ + ObjectMeta: metav1.ObjectMeta{Name: utils.ConsoleCRName}, + Spec: openshiftv1.ConsoleSpec{ + Plugins: []string{"monitoring-plugin", utils.AgenticConsoleUIPluginName}, + OperatorSpec: openshiftv1.OperatorSpec{ + ManagementState: openshiftv1.Managed, + }, + }, + } + Expect(k8sClient.Create(ctx, &console)).To(Succeed()) + }) + + AfterAll(func() { + console := openshiftv1.Console{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleCRName}, &console) + if err == nil { + Expect(k8sClient.Delete(ctx, &console)).To(Succeed()) + } + }) + + It("should remove the agentic console plugin", func() { + Expect(RemoveAgenticConsole(testReconcilerInstance, ctx)).To(Succeed()) + + plugin := &consolev1.ConsolePlugin{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIPluginName}, plugin) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + console := &openshiftv1.Console{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleCRName}, console)).To(Succeed()) + Expect(console.Spec.Plugins).NotTo(ContainElement(utils.AgenticConsoleUIPluginName)) + }) + }) + + It("should apply agentic console deployment overrides from the CR", func() { + olsConfig := &olsv1alpha1.OLSConfig{} + Expect(k8sClient.Get(ctx, crNamespacedName, olsConfig)).To(Succeed()) + olsConfig.Spec.OLSConfig.DeploymentConfig.AgenticConsoleContainer.NodeSelector = map[string]string{ + "agentic": "node", + } + + Expect(ReconcileAgenticConsoleUIDeployment(testReconcilerInstance, ctx, olsConfig)).To(Succeed()) + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.AgenticConsoleUIDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep)).To(Succeed()) + Expect(dep.Spec.Template.Spec.NodeSelector).To(Equal(olsConfig.Spec.OLSConfig.DeploymentConfig.AgenticConsoleContainer.NodeSelector)) + }) +}) diff --git a/internal/controller/agenticconsole/suite_test.go b/internal/controller/agenticconsole/suite_test.go new file mode 100644 index 000000000..211959c4c --- /dev/null +++ b/internal/controller/agenticconsole/suite_test.go @@ -0,0 +1,132 @@ +/* +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 agenticconsole + +import ( + "context" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + configv1 "github.com/openshift/api/config/v1" + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/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 TestAgenticConsole(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Agentic Console Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + filepath.Join("..", "..", "..", ".testcrds"), + }, + ErrorIfCRDPathMissing: true, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = olsv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = consolev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = openshiftv1.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() + + clusterVersion := &configv1.ClusterVersion{ + ObjectMeta: metav1.ObjectMeta{Name: "version"}, + Spec: configv1.ClusterVersionSpec{ClusterID: "foobar"}, + } + Expect(k8sClient.Create(ctx, clusterVersion)).To(Succeed()) + clusterVersion.Status = configv1.ClusterVersionStatus{ + Desired: configv1.Release{Version: "123.456.789"}, + } + Expect(k8sClient.Status().Update(ctx, clusterVersion)).To(Succeed()) + + for _, ns := range []string{utils.OLSNamespaceDefault, utils.TelemetryPullSecretNamespace} { + Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}})).To(Succeed()) + } + + testReconcilerInstance = utils.NewTestReconciler( + k8sClient, + logf.Log.WithName("controller").WithName("OLSConfig"), + scheme.Scheme, + utils.OLSNamespaceDefault, + ) + + cr = &olsv1alpha1.OLSConfig{} + crNamespacedName = types.NamespacedName{Name: "cluster"} + + err = k8sClient.Get(ctx, crNamespacedName, cr) + if err != nil && errors.IsNotFound(err) { + cr = utils.GetDefaultOLSConfigCR() + Expect(k8sClient.Create(ctx, cr)).To(Succeed()) + } else if err == nil { + cr = utils.GetDefaultOLSConfigCR() + Expect(k8sClient.Update(ctx, cr)).To(Succeed()) + } else { + Fail("Failed to create or update the OLSConfig custom resource") + } + + Expect(k8sClient.Get(ctx, crNamespacedName, cr)).To(Succeed()) +}) + +var _ = AfterSuite(func() { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: utils.OLSNamespaceDefault}}) + Expect(testEnv.Stop()).To(Succeed()) +}) diff --git a/internal/controller/appserver/reconciler.go b/internal/controller/appserver/reconciler.go index 8161f799a..903af75e9 100644 --- a/internal/controller/appserver/reconciler.go +++ b/internal/controller/appserver/reconciler.go @@ -17,6 +17,7 @@ package appserver import ( "context" "fmt" + "os" "time" "github.com/openshift/lightspeed-operator/internal/controller/reconciler" @@ -373,6 +374,13 @@ func reconcileService(r reconciler.Reconciler, ctx context.Context, cr *olsv1alp } func reconcileMetricsReaderSecret(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + // Skip in local development mode (make run sets LOCAL_DEV_MODE=true); same rationale as + // skipping the operator ServiceMonitor in olsconfig_controller.reconcileOperatorResources. + if os.Getenv("LOCAL_DEV_MODE") == "true" { + r.GetLogger().Info("Skipping metrics reader secret reconciliation in LOCAL_DEV_MODE") + return nil + } + secret, err := GenerateMetricsReaderSecret(r, cr) if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateMetricsReaderSecret, err) diff --git a/internal/controller/console/assets.go b/internal/controller/console/assets.go index 876f0d4e8..52e6965a5 100644 --- a/internal/controller/console/assets.go +++ b/internal/controller/console/assets.go @@ -49,21 +49,7 @@ func GenerateConsoleUIConfigMap(r reconciler.Reconciler, cr *olsv1alpha1.OLSConf } }` - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.ConsoleUIConfigMapName, - Namespace: r.GetNamespace(), - Labels: GenerateConsoleUILabels(), - }, - Data: map[string]string{ - "nginx.conf": nginxConfig, - }, - } - if err := controllerutil.SetControllerReference(cr, cm, r.GetScheme()); err != nil { - return nil, err - } - - return cm, nil + return utils.GenerateConsolePluginNginxConfigMap(r, cr, utils.ConsoleUIConfigMapName, GenerateConsoleUILabels(), nginxConfig) } func GenerateConsoleUIService(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.Service, error) { @@ -154,51 +140,8 @@ func GenerateConsoleUIPlugin(r reconciler.Reconciler, ctx context.Context, cr *o } func GenerateConsoleUINetworkPolicy(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*networkingv1.NetworkPolicy, error) { - np := networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.ConsoleUINetworkPolicyName, - Namespace: r.GetNamespace(), - Labels: GenerateConsoleUILabels(), - }, - Spec: networkingv1.NetworkPolicySpec{ - Ingress: []networkingv1.NetworkPolicyIngressRule{ - { - From: []networkingv1.NetworkPolicyPeer{ - { - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "kubernetes.io/metadata.name": "openshift-console", - }, - }, - PodSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "console", - }, - }, - }, - }, - Ports: []networkingv1.NetworkPolicyPort{ - { - Protocol: &[]corev1.Protocol{corev1.ProtocolTCP}[0], - Port: &[]intstr.IntOrString{intstr.FromInt(utils.ConsoleUIHTTPSPort)}[0], - }, - }, - }, - }, - PodSelector: metav1.LabelSelector{ - MatchLabels: GenerateConsoleUILabels(), - }, - PolicyTypes: []networkingv1.PolicyType{ - networkingv1.PolicyTypeIngress, - }, - }, - } - - if err := controllerutil.SetControllerReference(cr, &np, r.GetScheme()); err != nil { - return nil, err - } - return &np, nil - + labels := GenerateConsoleUILabels() + return utils.GenerateConsolePluginNetworkPolicy(r, cr, utils.ConsoleUINetworkPolicyName, labels, utils.ConsoleUIHTTPSPort) } func GenerateConsoleUIServiceAccount(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*corev1.ServiceAccount, error) { diff --git a/internal/controller/console/assets_test.go b/internal/controller/console/assets_test.go index 740cf3a17..d64ce5fad 100644 --- a/internal/controller/console/assets_test.go +++ b/internal/controller/console/assets_test.go @@ -4,10 +4,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" consolev1 "github.com/openshift/api/console/v1" olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" @@ -102,34 +99,6 @@ var _ = Describe("Console UI assets", func() { Expect(np.Name).To(Equal(utils.ConsoleUINetworkPolicyName)) Expect(np.Namespace).To(Equal(utils.OLSNamespaceDefault)) Expect(np.Labels).To(Equal(labels)) - Expect(np.Spec.PolicyTypes).To(Equal([]networkingv1.PolicyType{networkingv1.PolicyTypeIngress})) - Expect(np.Spec.Ingress).To(HaveLen(1)) - Expect(np.Spec.Ingress).To(ConsistOf([]networkingv1.NetworkPolicyIngressRule{ - { - From: []networkingv1.NetworkPolicyPeer{ - { - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "kubernetes.io/metadata.name": "openshift-console", - }, - }, - PodSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "console", - }, - }, - }, - }, - Ports: []networkingv1.NetworkPolicyPort{ - { - Protocol: &[]corev1.Protocol{corev1.ProtocolTCP}[0], - Port: &[]intstr.IntOrString{intstr.FromInt(utils.ConsoleUIHTTPSPort)}[0], - }, - }, - }, - })) - Expect(np.Spec.PodSelector.MatchLabels).To(Equal(labels)) - }) It("should generate the console UI service account", func() { diff --git a/internal/controller/console/deployment.go b/internal/controller/console/deployment.go index a18149a88..d8d312f3a 100644 --- a/internal/controller/console/deployment.go +++ b/internal/controller/console/deployment.go @@ -3,133 +3,39 @@ package console import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "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" ) -// getConsoleUIResources returns the resource requirements for the console UI container. -func getConsoleUIResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequirements { - return utils.GetResourcesOrDefault( - cr.Spec.OLSConfig.DeploymentConfig.ConsoleContainer.Resources, - &corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("10m"), corev1.ResourceMemory: resource.MustParse("50Mi")}, - Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("100Mi")}, - Claims: []corev1.ResourceClaim{}, - }, - ) -} - // GenerateConsoleUIDeployment generates the Console UI deployment object. func GenerateConsoleUIDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { - const certVolumeName = "lightspeed-console-plugin-cert" - val_true := true - volumeDefaultMode := utils.VolumeDefaultMode - resources := getConsoleUIResources(cr) - replicas := int32(1) // Console always runs 1 replica - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.ConsoleUIDeploymentName, - Namespace: r.GetNamespace(), - Labels: GenerateConsoleUILabels(), - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: GenerateConsoleUILabels(), - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: GenerateConsoleUILabels(), - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "lightspeed-console-plugin", - Image: r.GetConsoleUIImage(), - Ports: []corev1.ContainerPort{ - { - ContainerPort: utils.ConsoleUIHTTPSPort, - Name: "https", - Protocol: corev1.ProtocolTCP, - }, - }, - SecurityContext: utils.RestrictedContainerSecurityContext(), - ImagePullPolicy: corev1.PullAlways, - Env: append(utils.GetProxyEnvVars(), corev1.EnvVar{ - Name: "OCP_VERSION", - Value: r.GetOpenShiftMajor() + "." + r.GetOpenshiftMinor(), - }), - Resources: *resources, - VolumeMounts: []corev1.VolumeMount{ - { - Name: certVolumeName, - MountPath: "/var/cert", - ReadOnly: true, - }, - { - Name: "nginx-config", - MountPath: "/etc/nginx/nginx.conf", - SubPath: "nginx.conf", - ReadOnly: true, - }, - { - Name: "nginx-temp", - MountPath: "/tmp/nginx", - }, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: certVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: utils.ConsoleUIServiceCertSecretName, - DefaultMode: &volumeDefaultMode, - }, - }, - }, - { - Name: "nginx-config", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: utils.ConsoleUIConfigMapName, - }, - DefaultMode: &volumeDefaultMode, - }, - }, - }, - { - Name: "nginx-temp", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, - }, - SecurityContext: &corev1.PodSecurityContext{ - RunAsNonRoot: &val_true, - SeccompProfile: &corev1.SeccompProfile{ - Type: "RuntimeDefault", - }, - }, - ServiceAccountName: utils.ConsoleUIServiceAccountName, - }, - }, - }, - } - - // Apply pod-level scheduling constraints (replicas not configurable for console) - utils.ApplyPodDeploymentConfig(deployment, cr.Spec.OLSConfig.DeploymentConfig.ConsoleContainer, false) - - if err := controllerutil.SetControllerReference(cr, deployment, r.GetScheme()); err != nil { - return nil, err - } + labels := GenerateConsoleUILabels() + resources := utils.GetResourcesOrDefault( + cr.Spec.OLSConfig.DeploymentConfig.ConsoleContainer.Resources, + utils.DefaultConsolePluginResourceRequirements(), + ) - return deployment, nil + return utils.GenerateConsolePluginDeployment(r, cr, utils.ConsolePluginDeploymentOptions{ + Name: utils.ConsoleUIDeploymentName, + Labels: labels, + SelectorLabels: labels, + ServiceAccountName: utils.ConsoleUIServiceAccountName, + ContainerName: utils.ConsoleUIContainerName, + Image: r.GetConsoleUIImage(), + Port: utils.ConsoleUIHTTPSPort, + PortName: "https", + CertVolumeName: "lightspeed-console-plugin-cert", + CertSecretName: utils.ConsoleUIServiceCertSecretName, + NginxVolumeName: "nginx-config", + NginxConfigMapName: utils.ConsoleUIConfigMapName, + NginxTempVolumeName: "nginx-temp", + Resources: resources, + Env: append(utils.GetProxyEnvVars(), corev1.EnvVar{ + Name: "OCP_VERSION", + Value: r.GetOpenShiftMajor() + "." + r.GetOpenshiftMinor(), + }), + DeploymentConfig: cr.Spec.OLSConfig.DeploymentConfig.ConsoleContainer, + }) } diff --git a/internal/controller/console/reconciler.go b/internal/controller/console/reconciler.go index 21bc92e3e..b30309015 100644 --- a/internal/controller/console/reconciler.go +++ b/internal/controller/console/reconciler.go @@ -18,23 +18,11 @@ package console import ( "context" "fmt" - "slices" - "time" "github.com/openshift/lightspeed-operator/internal/controller/reconciler" "github.com/openshift/lightspeed-operator/internal/controller/utils" - consolev1 "github.com/openshift/api/console/v1" - openshiftv1 "github.com/openshift/api/operator/v1" appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/util/retry" - - "sigs.k8s.io/controller-runtime/pkg/client" olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" ) @@ -42,82 +30,22 @@ import ( // ReconcileConsoleUIResources reconciles all resources except the deployment (Phase 1) // Uses continue-on-error pattern since these resources are independent func ReconcileConsoleUIResources(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - r.GetLogger().Info("reconcileConsoleUIResources starts") - tasks := []utils.ReconcileTask{ - { - Name: "reconcile Console Plugin ConfigMap", - Task: reconcileConsoleUIConfigMap, - }, - { - Name: "reconcile Console Plugin NetworkPolicy", - Task: reconcileConsoleNetworkPolicy, - }, - { - Name: "reconcile Console Plugin Service Account", - Task: reconcileConsoleUIServiceAccount, - }, - } - - failedTasks := make(map[string]error) - - for _, task := range tasks { - err := task.Task(r, ctx, olsconfig) - if err != nil { - r.GetLogger().Error(err, "reconcileConsoleUIResources error", "task", task.Name) - failedTasks[task.Name] = err - } - } - - if len(failedTasks) > 0 { - taskNames := make([]string, 0, len(failedTasks)) - for taskName, err := range failedTasks { - taskNames = append(taskNames, taskName) - r.GetLogger().Error(err, "Task failed in reconcileConsoleUIResources", "task", taskName) - } - return fmt.Errorf("failed tasks: %v", taskNames) - } - - r.GetLogger().Info("reconcileConsoleUIResources completes") - return nil + return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileConsoleUIResources", []utils.ReconcileTask{ + {Name: "reconcile Console Plugin ConfigMap", Task: reconcileConsoleUIConfigMap}, + {Name: "reconcile Console Plugin NetworkPolicy", Task: reconcileConsoleNetworkPolicy}, + {Name: "reconcile Console Plugin Service Account", Task: reconcileConsoleUIServiceAccount}, + }, true) } // ReconcileConsoleUIDeploymentAndPlugin reconciles the deployment and related resources (Phase 2) func ReconcileConsoleUIDeploymentAndPlugin(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { - r.GetLogger().Info("reconcileConsoleUIDeploymentAndPlugin starts") - - tasks := []utils.ReconcileTask{ - { - Name: "reconcile Console Plugin Deployment", - Task: ReconcileConsoleUIDeployment, - }, - { - Name: "reconcile Console Plugin Service", - Task: reconcileConsoleUIService, - }, - { - Name: "reconcile Console Plugin TLS Certs", - Task: reconcileConsoleTLSSecret, - }, - { - Name: "reconcile Console Plugin", - Task: reconcileConsoleUIPlugin, - }, - { - Name: "activate Console Plugin", - Task: activateConsoleUI, - }, - } - - for _, task := range tasks { - err := task.Task(r, ctx, olsconfig) - if err != nil { - r.GetLogger().Error(err, "reconcileConsoleUIDeploymentAndPlugin error", "task", task.Name) - return fmt.Errorf("failed to %s: %w", task.Name, err) - } - } - - r.GetLogger().Info("reconcileConsoleUIDeploymentAndPlugin completes") - return nil + return utils.RunReconcileTasks(r, ctx, olsconfig, "reconcileConsoleUIDeploymentAndPlugin", []utils.ReconcileTask{ + {Name: "reconcile Console Plugin Deployment", Task: ReconcileConsoleUIDeployment}, + {Name: "reconcile Console Plugin Service", Task: reconcileConsoleUIService}, + {Name: "reconcile Console Plugin TLS Certs", Task: reconcileConsoleTLSSecret}, + {Name: "reconcile Console Plugin", Task: reconcileConsoleUIPlugin}, + {Name: "activate Console Plugin", Task: activateConsoleUI}, + }, false) } func reconcileConsoleUIConfigMap(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -125,32 +53,7 @@ func reconcileConsoleUIConfigMap(r reconciler.Reconciler, ctx context.Context, c if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginConfigMap, err) } - foundCm := &corev1.ConfigMap{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIConfigMapName, Namespace: r.GetNamespace()}, foundCm) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating Console UI configmap", "configmap", cm.Name) - err = r.Create(ctx, cm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateConsolePluginConfigMap, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsolePluginConfigMap, err) - } - - if apiequality.Semantic.DeepEqual(foundCm.Data, cm.Data) { - r.GetLogger().Info("Console UI configmap unchanged, reconciliation skipped", "configmap", cm.Name) - return nil - } - // Update the existing ConfigMap with desired data (preserving ResourceVersion) - foundCm.Data = cm.Data - err = r.Update(ctx, foundCm) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsolePluginConfigMap, err) - } - r.GetLogger().Info("Console configmap reconciled", "configmap", foundCm.Name) - - return nil + return utils.ReconcileConsolePluginConfigMap(r, ctx, cm) } func reconcileConsoleUIService(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -158,39 +61,7 @@ func reconcileConsoleUIService(r reconciler.Reconciler, ctx context.Context, cr if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginService, err) } - foundService := &corev1.Service{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIServiceName, Namespace: r.GetNamespace()}, foundService) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating Console UI service", "service", service.Name) - err = r.Create(ctx, service) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateConsolePluginService, err) - } - r.GetLogger().Info("Console UI service created", "service", service.Name) - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsolePluginService, err) - } - - if utils.ServiceEqual(foundService, service) && - foundService.Annotations != nil && - foundService.Annotations[utils.ServingCertSecretAnnotationKey] == service.Annotations[utils.ServingCertSecretAnnotationKey] { - r.GetLogger().Info("Console UI service unchanged, reconciliation skipped", "service", service.Name) - return nil - } - - // Update the existing Service with desired spec (preserving ResourceVersion) - foundService.Spec = service.Spec - foundService.Annotations = service.Annotations - foundService.Labels = service.Labels - err = r.Update(ctx, foundService) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsolePluginService, err) - } - - r.GetLogger().Info("Console UI service reconciled", "service", foundService.Name) - - return nil + return utils.ReconcileConsolePluginService(r, ctx, service) } func ReconcileConsoleUIDeployment(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -198,40 +69,7 @@ func ReconcileConsoleUIDeployment(r reconciler.Reconciler, ctx context.Context, if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginDeployment, err) } - foundDeployment := &appsv1.Deployment{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIDeploymentName, Namespace: r.GetNamespace()}, foundDeployment) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating Console UI deployment", "deployment", deployment.Name) - err = r.Create(ctx, deployment) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateConsolePluginDeployment, err) - } - r.GetLogger().Info("Console UI deployment created", "deployment", deployment.Name) - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsolePluginDeployment, err) - } - - // fill in the default values for the deployment for comparison - utils.SetDefaults_Deployment(deployment) - - // Check if deployment spec has changed - // Note: TLS secret changes are handled by watchers, not here - if utils.DeploymentSpecEqual(&foundDeployment.Spec, &deployment.Spec, true) { - return nil - } - - // Apply the desired spec to the existing deployment - foundDeployment.Spec = deployment.Spec - - r.GetLogger().Info("Updating Console UI deployment", "deployment", foundDeployment.Name) - err = RestartConsoleUI(r, ctx, foundDeployment) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsolePluginDeployment, err) - } - r.GetLogger().Info("Console UI deployment reconciled", "deployment", deployment.Name) - - return nil + return utils.ReconcileConsolePluginDeployment(r, ctx, deployment, RestartConsoleUI) } func reconcileConsoleUIPlugin(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -239,152 +77,19 @@ func reconcileConsoleUIPlugin(r reconciler.Reconciler, ctx context.Context, cr * if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePlugin, err) } - foundPlugin := &consolev1.ConsolePlugin{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIPluginName}, foundPlugin) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating Console Plugin", "plugin", plugin.Name) - err = r.Create(ctx, plugin) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateConsolePlugin, err) - } - r.GetLogger().Info("Console Plugin created", "plugin", plugin.Name) - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsolePlugin, err) - } - - if apiequality.Semantic.DeepEqual(foundPlugin.Spec, plugin.Spec) { - r.GetLogger().Info("Console Plugin unchanged, reconciliation skipped", "plugin", plugin.Name) - return nil - } - - foundPlugin.Spec = plugin.Spec - err = r.Update(ctx, foundPlugin) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsolePlugin, err) - } - r.GetLogger().Info("Console Plugin reconciled", "plugin", plugin.Name) - - return nil + return utils.ReconcileConsolePluginCR(r, ctx, plugin) } func activateConsoleUI(r reconciler.Reconciler, ctx context.Context, _ *olsv1alpha1.OLSConfig) error { - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - console := &openshiftv1.Console{} - err := r.Get(ctx, client.ObjectKey{Name: utils.ConsoleCRName}, console) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsole, err) - } - if console.Spec.Plugins == nil { - console.Spec.Plugins = []string{utils.ConsoleUIPluginName} - } else if !slices.Contains(console.Spec.Plugins, utils.ConsoleUIPluginName) { - console.Spec.Plugins = append(console.Spec.Plugins, utils.ConsoleUIPluginName) - } else { - return nil - } - - return r.Update(ctx, console) - }) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsole, err) - } - r.GetLogger().Info("Console UI plugin activated") - return nil + return utils.ActivateConsolePlugin(r, ctx, utils.ConsoleUIPluginName) } func RemoveConsoleUI(r reconciler.Reconciler, ctx context.Context) error { - tasks := []utils.DeleteTask{ - { - Name: "deactivate Console Plugin", - Task: deactivateConsoleUI, - }, - { - Name: "delete Console Plugin", - Task: deleteConsoleUIPlugin, - }, - } - - for _, task := range tasks { - err := task.Task(r, ctx) - if err != nil { - r.GetLogger().Error(err, "DeleteConsoleUIPlugin error", "task", task.Name) - return fmt.Errorf("failed to %s: %w", task.Name, err) - } - } - - r.GetLogger().Info("DeleteConsoleUIPlugin completed") - - return nil -} - -func deleteConsoleUIPlugin(r reconciler.Reconciler, ctx context.Context) error { - plugin := &consolev1.ConsolePlugin{} - err := r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIPluginName}, plugin) - if err != nil { - if errors.IsNotFound(err) { - r.GetLogger().Info("Console Plugin not found, skip deletion") - return nil - } - return fmt.Errorf("%s: %w", utils.ErrGetConsolePlugin, err) - } - err = r.Delete(ctx, plugin) - if err != nil { - if errors.IsNotFound(err) { - r.GetLogger().Info("Console Plugin not found, consider deletion successful") - return nil - } - return fmt.Errorf("%s: %w", utils.ErrDeleteConsolePlugin, err) - } - r.GetLogger().Info("Console Plugin deleted") - return nil -} - -func deactivateConsoleUI(r reconciler.Reconciler, ctx context.Context) error { - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - console := &openshiftv1.Console{} - err := r.Get(ctx, client.ObjectKey{Name: utils.ConsoleCRName}, console) - if err != nil { - // If Console CR doesn't exist, there's nothing to deactivate - // This can happen in non-OpenShift environments or test scenarios - if errors.IsNotFound(err) { - r.GetLogger().Info("Console CR not found, skipping plugin deactivation") - return nil - } - return fmt.Errorf("%s: %w", utils.ErrGetConsole, err) - } - if console.Spec.Plugins == nil { - return nil - } - if slices.Contains(console.Spec.Plugins, utils.ConsoleUIPluginName) { - console.Spec.Plugins = slices.DeleteFunc(console.Spec.Plugins, func(name string) bool { return name == utils.ConsoleUIPluginName }) - } else { - return nil - } - return r.Update(ctx, console) - }) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsole, err) - } - r.GetLogger().Info("Console UI plugin deactivated") - return nil + return utils.RemoveConsolePlugin(r, ctx, utils.ConsoleUIPluginName) } func reconcileConsoleTLSSecret(r reconciler.Reconciler, ctx context.Context, _ *olsv1alpha1.OLSConfig) error { - foundSecret := &corev1.Secret{} - var err, lastErr error - err = wait.PollUntilContextTimeout(ctx, 1*time.Second, utils.ResourceCreationTimeout, true, func(ctx context.Context) (bool, error) { - _, err = utils.GetSecretContent(r, ctx, utils.ConsoleUIServiceCertSecretName, r.GetNamespace(), []string{"tls.key", "tls.crt"}, foundSecret) - if err != nil { - lastErr = fmt.Errorf("secret: %s does not have expected tls.key or tls.crt. error: %w", utils.ConsoleUIServiceCertSecretName, err) - return false, nil - } - return true, nil - }) - if err != nil { - return fmt.Errorf("failed to get TLS key and cert - wait err %w; last error: %w", err, lastErr) - } - r.GetLogger().Info("OLS console tls secret reconciled") - return nil + return utils.WaitForConsolePluginTLSSecret(r, ctx, utils.ConsoleUIServiceCertSecretName) } func reconcileConsoleNetworkPolicy(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -392,32 +97,7 @@ func reconcileConsoleNetworkPolicy(r reconciler.Reconciler, ctx context.Context, if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginNetworkPolicy, err) } - foundNp := &networkingv1.NetworkPolicy{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUINetworkPolicyName, Namespace: r.GetNamespace()}, foundNp) - if err != nil && errors.IsNotFound(err) { - r.GetLogger().Info("creating Console NetworkPolicy", "networkpolicy", utils.ConsoleUINetworkPolicyName) - err = r.Create(ctx, np) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateConsolePluginNetworkPolicy, err) - } - return nil - } - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsolePluginNetworkPolicy, err) - } - if utils.NetworkPolicyEqual(np, foundNp) { - r.GetLogger().Info("Console NetworkPolicy unchanged, reconciliation skipped", "networkpolicy", utils.ConsoleUINetworkPolicyName) - return nil - } - // Update the existing NetworkPolicy with desired spec (preserving ResourceVersion) - foundNp.Spec = np.Spec - err = r.Update(ctx, foundNp) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrUpdateConsolePluginNetworkPolicy, err) - } - r.GetLogger().Info("Console NetworkPolicy reconciled", "networkpolicy", utils.ConsoleUINetworkPolicyName) - return nil - + return utils.ReconcileConsolePluginNetworkPolicy(r, ctx, np) } func reconcileConsoleUIServiceAccount(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha1.OLSConfig) error { @@ -425,55 +105,13 @@ func reconcileConsoleUIServiceAccount(r reconciler.Reconciler, ctx context.Conte if err != nil { return fmt.Errorf("%s: %w", utils.ErrGenerateConsolePluginServiceAccount, err) } - foundSa := &corev1.ServiceAccount{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIServiceAccountName, Namespace: r.GetNamespace()}, foundSa) - if err != nil && errors.IsNotFound(err) { - err = r.Create(ctx, sa) - if err != nil { - return fmt.Errorf("%s: %w", utils.ErrCreateConsolePluginServiceAccount, err) - } - return nil - } else if err != nil { - return fmt.Errorf("%s: %w", utils.ErrGetConsolePluginServiceAccount, err) - } - - return nil + return utils.ReconcileConsolePluginServiceAccount(r, ctx, sa) } // RestartConsoleUI triggers a rolling restart of the Console UI deployment by updating its pod template annotation. // This is useful when configuration changes require a pod restart (e.g., ConfigMap or Secret updates). func RestartConsoleUI(r reconciler.Reconciler, ctx context.Context, deployment ...*appsv1.Deployment) error { - var dep *appsv1.Deployment - var err error - - // If deployment is provided, use it; otherwise fetch it - if len(deployment) > 0 && deployment[0] != nil { - dep = deployment[0] - } else { - // Get the Console UI deployment - dep = &appsv1.Deployment{} - err = r.Get(ctx, client.ObjectKey{Name: utils.ConsoleUIDeploymentName, Namespace: r.GetNamespace()}, dep) - if err != nil { - return fmt.Errorf("failed to get deployment %s: %w", utils.ConsoleUIDeploymentName, err) - } - } - - // Initialize annotations map if empty - if dep.Spec.Template.Annotations == nil { - dep.Spec.Template.Annotations = make(map[string]string) - } - - // Bump the annotation to trigger a rolling update (new template hash) - dep.Spec.Template.Annotations[utils.ForceReloadAnnotationKey] = time.Now().Format(time.RFC3339Nano) - - // Update the deployment - r.GetLogger().Info("triggering Console UI rolling restart", "deployment", dep.Name) - err = r.Update(ctx, dep) - if err != nil { - return fmt.Errorf("failed to update deployment %s: %w", dep.Name, err) - } - - return nil + return utils.RestartConsolePluginDeployment(r, ctx, utils.ConsoleUIDeploymentName, deployment...) } // ============================================================================= @@ -488,12 +126,9 @@ func RestartConsoleUI(r reconciler.Reconciler, ctx context.Context, deployment . func ReconcileConsoleUI(r reconciler.Reconciler, ctx context.Context, olsconfig *olsv1alpha1.OLSConfig) error { r.GetLogger().Info("reconcileConsoleUI starts") - // Call Resources phase if err := ReconcileConsoleUIResources(r, ctx, olsconfig); err != nil { return err } - - // Call Deployment phase if err := ReconcileConsoleUIDeploymentAndPlugin(r, ctx, olsconfig); err != nil { return err } diff --git a/internal/controller/console/reconciler_test.go b/internal/controller/console/reconciler_test.go index bbd3a67a6..4b765a636 100644 --- a/internal/controller/console/reconciler_test.go +++ b/internal/controller/console/reconciler_test.go @@ -70,54 +70,18 @@ var _ = Describe("Console UI reconciliator", Ordered, func() { Expect(secretDeletionErr).NotTo(HaveOccurred()) }) - It("should reconcile from OLSConfig custom resource", func() { + It("should reconcile from OLSConfig custom resource and create plugin resources", func() { By("Reconcile the OLSConfig custom resource") err := ReconcileConsoleUI(testReconcilerInstance, ctx, cr) Expect(err).NotTo(HaveOccurred()) - // Note: Status conditions are managed by the main OLSConfigReconciler, - // not by the component-specific reconcilers - }) - - It("should create a service lightspeed-console-plugin", func() { - By("Get the service") - svc := &corev1.Service{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIServiceName, Namespace: utils.OLSNamespaceDefault}, svc) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should create a config map lightspeed-console-plugin", func() { - By("Get the config map") - cm := &corev1.ConfigMap{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIConfigMapName, Namespace: utils.OLSNamespaceDefault}, cm) - Expect(err).NotTo(HaveOccurred()) - }) - It("should create a deployment lightspeed-console-plugin", func() { - By("Get the deployment") - dep := &appsv1.Deployment{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIDeploymentName, Namespace: utils.OLSNamespaceDefault}, dep) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should create a console plugin lightspeed-console-plugin", func() { - By("Get the console plugin") - plugin := &consolev1.ConsolePlugin{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIPluginName}, plugin) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should create a network policy lightspeed-console-plugin", func() { - By("Get the network policy") - np := &networkingv1.NetworkPolicy{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUINetworkPolicyName, Namespace: utils.OLSNamespaceDefault}, np) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should create a service account lightspeed-console-plugin", func() { - By("Get the service account") - sa := &corev1.ServiceAccount{} - err := k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIServiceAccountName, Namespace: utils.OLSNamespaceDefault}, sa) - Expect(err).NotTo(HaveOccurred()) + By("Verify reconciled resources exist") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIServiceName, Namespace: utils.OLSNamespaceDefault}, &corev1.Service{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIConfigMapName, Namespace: utils.OLSNamespaceDefault}, &corev1.ConfigMap{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIDeploymentName, Namespace: utils.OLSNamespaceDefault}, &appsv1.Deployment{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIPluginName}, &consolev1.ConsolePlugin{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUINetworkPolicyName, Namespace: utils.OLSNamespaceDefault}, &networkingv1.NetworkPolicy{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: utils.ConsoleUIServiceAccountName, Namespace: utils.OLSNamespaceDefault}, &corev1.ServiceAccount{})).To(Succeed()) }) It("should activate the console plugin", func() { diff --git a/internal/controller/olsconfig_controller.go b/internal/controller/olsconfig_controller.go index d40be7c54..fb1316bfd 100644 --- a/internal/controller/olsconfig_controller.go +++ b/internal/controller/olsconfig_controller.go @@ -77,6 +77,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/agenticconsole" "github.com/openshift/lightspeed-operator/internal/controller/appserver" "github.com/openshift/lightspeed-operator/internal/controller/console" "github.com/openshift/lightspeed-operator/internal/controller/postgres" @@ -296,6 +297,9 @@ func (r *OLSConfigReconciler) reconcileIndependentResources(ctx context.Context, {Name: "console UI resources", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { return console.ReconcileConsoleUIResources(r, ctx, cr) }}, + {Name: "agentic console UI resources", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return agenticconsole.ReconcileAgenticConsoleUIResources(r, ctx, cr) + }}, {Name: "postgres resources", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { return postgres.ReconcilePostgresResources(r, ctx, cr) }}, @@ -369,6 +373,9 @@ func (r *OLSConfigReconciler) reconcileDeploymentsAndStatus(ctx context.Context, {Name: "console UI deployment", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { return console.ReconcileConsoleUIDeploymentAndPlugin(r, ctx, cr) }, ConditionType: utils.TypeConsolePluginReady, Deployment: utils.ConsoleUIDeploymentName}, + {Name: "agentic console UI deployment", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { + return agenticconsole.ReconcileAgenticConsoleUIDeploymentAndPlugin(r, ctx, cr) + }, ConditionType: utils.TypeAgenticConsolePluginReady, Deployment: utils.AgenticConsoleUIDeploymentName}, {Name: "postgres deployment", Fn: func(ctx context.Context, cr *olsv1alpha1.OLSConfig) error { return postgres.ReconcilePostgresDeployment(r, ctx, cr) }, ConditionType: utils.TypeCacheReady, Deployment: utils.PostgresDeploymentName}, @@ -572,6 +579,13 @@ func (r *OLSConfigReconciler) finalizeOLSConfig(ctx context.Context, cr *olsv1al r.Logger.V(1).Info("Proceeding with finalization despite Console UI removal error") } + // Step 1b: Remove agentic Console UI (deactivate plugin, delete ConsolePlugin CR) + r.Logger.V(1).Info("Removing Agentic Console UI during finalization") + if err := agenticconsole.RemoveAgenticConsole(r, ctx); err != nil { + r.Logger.Error(err, "Failed to remove Agentic Console UI during finalization") + r.Logger.V(1).Info("Proceeding with finalization despite Agentic Console UI 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 07f50c085..3474b8780 100644 --- a/internal/controller/olsconfig_helpers.go +++ b/internal/controller/olsconfig_helpers.go @@ -49,6 +49,10 @@ func (r *OLSConfigReconciler) GetConsoleUIImage() string { return r.Options.ConsoleUIImage } +func (r *OLSConfigReconciler) GetAgenticConsoleImage() string { + return r.Options.AgenticConsoleUIImage +} + func (r *OLSConfigReconciler) GetOpenShiftMajor() string { return r.Options.OpenShiftMajor } diff --git a/internal/controller/reconciler/interface.go b/internal/controller/reconciler/interface.go index c525b1e84..58a0d7607 100644 --- a/internal/controller/reconciler/interface.go +++ b/internal/controller/reconciler/interface.go @@ -49,6 +49,9 @@ type Reconciler interface { // GetConsoleUIImage returns the console UI image to use GetConsoleUIImage() string + // GetAgenticConsoleImage returns the agentic console UI image to use + GetAgenticConsoleImage() string + // GetOpenShiftMajor returns the OpenShift major version GetOpenShiftMajor() string diff --git a/internal/controller/utils/console_plugin_reconciler.go b/internal/controller/utils/console_plugin_reconciler.go new file mode 100644 index 000000000..f98dad329 --- /dev/null +++ b/internal/controller/utils/console_plugin_reconciler.go @@ -0,0 +1,371 @@ +package utils + +import ( + "context" + stderrors "errors" + "fmt" + "slices" + "time" + + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" +) + +// RunReconcileTasks runs reconcile tasks. When continueOnError is true, all tasks run and failures are aggregated. +func RunReconcileTasks( + r reconciler.Reconciler, + ctx context.Context, + cr *olsv1alpha1.OLSConfig, + phase string, + tasks []ReconcileTask, + continueOnError bool, +) error { + r.GetLogger().Info(phase + " starts") + + if continueOnError { + var failedErrs []error + for _, task := range tasks { + if err := task.Task(r, ctx, cr); err != nil { + r.GetLogger().Error(err, phase+" error", "task", task.Name) + failedErrs = append(failedErrs, fmt.Errorf("%s: %w", task.Name, err)) + } + } + if len(failedErrs) > 0 { + return fmt.Errorf("%s: %w", phase, stderrors.Join(failedErrs...)) + } + r.GetLogger().Info(phase + " completes") + return nil + } + + for _, task := range tasks { + if err := task.Task(r, ctx, cr); err != nil { + r.GetLogger().Error(err, phase+" error", "task", task.Name) + return fmt.Errorf("failed to %s: %w", task.Name, err) + } + } + + r.GetLogger().Info(phase + " completes") + return nil +} + +// RunDeleteTasks runs delete tasks and fails fast on the first error. +func RunDeleteTasks(r reconciler.Reconciler, ctx context.Context, phase string, tasks []DeleteTask) error { + for _, task := range tasks { + if err := task.Task(r, ctx); err != nil { + r.GetLogger().Error(err, phase+" error", "task", task.Name) + return fmt.Errorf("failed to %s: %w", task.Name, err) + } + } + r.GetLogger().Info(phase + " completed") + return nil +} + +// ReconcileConsolePluginConfigMap creates or updates a console plugin nginx ConfigMap. +func ReconcileConsolePluginConfigMap(r reconciler.Reconciler, ctx context.Context, desired *corev1.ConfigMap) error { + foundCm := &corev1.ConfigMap{} + err := r.Get(ctx, client.ObjectKey{Name: desired.Name, Namespace: r.GetNamespace()}, foundCm) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating Console Plugin configmap", "configmap", desired.Name) + if err = r.Create(ctx, desired); err != nil { + return fmt.Errorf("%s: %w", ErrCreateConsolePluginConfigMap, err) + } + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", ErrGetConsolePluginConfigMap, err) + } + + if apiequality.Semantic.DeepEqual(foundCm.Data, desired.Data) { + r.GetLogger().Info("Console Plugin configmap unchanged, reconciliation skipped", "configmap", desired.Name) + return nil + } + + foundCm.Data = desired.Data + if err = r.Update(ctx, foundCm); err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsolePluginConfigMap, err) + } + r.GetLogger().Info("Console Plugin configmap reconciled", "configmap", foundCm.Name) + return nil +} + +// ReconcileConsolePluginService creates or updates a console plugin Service. +func ReconcileConsolePluginService(r reconciler.Reconciler, ctx context.Context, desired *corev1.Service) error { + foundService := &corev1.Service{} + err := r.Get(ctx, client.ObjectKey{Name: desired.Name, Namespace: r.GetNamespace()}, foundService) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating Console Plugin service", "service", desired.Name) + if err = r.Create(ctx, desired); err != nil { + return fmt.Errorf("%s: %w", ErrCreateConsolePluginService, err) + } + r.GetLogger().Info("Console Plugin service created", "service", desired.Name) + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", ErrGetConsolePluginService, err) + } + + if ServiceEqual(foundService, desired) && + foundService.Annotations != nil && + foundService.Annotations[ServingCertSecretAnnotationKey] == desired.Annotations[ServingCertSecretAnnotationKey] { + r.GetLogger().Info("Console Plugin service unchanged, reconciliation skipped", "service", desired.Name) + return nil + } + + foundService.Spec = desired.Spec + foundService.Annotations = desired.Annotations + foundService.Labels = desired.Labels + if err = r.Update(ctx, foundService); err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsolePluginService, err) + } + r.GetLogger().Info("Console Plugin service reconciled", "service", foundService.Name) + return nil +} + +// ReconcileConsolePluginDeployment creates or updates a console plugin Deployment. +func ReconcileConsolePluginDeployment( + r reconciler.Reconciler, + ctx context.Context, + desired *appsv1.Deployment, + restart func(reconciler.Reconciler, context.Context, ...*appsv1.Deployment) error, +) error { + foundDeployment := &appsv1.Deployment{} + err := r.Get(ctx, client.ObjectKey{Name: desired.Name, Namespace: r.GetNamespace()}, foundDeployment) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating Console Plugin deployment", "deployment", desired.Name) + if err = r.Create(ctx, desired); err != nil { + return fmt.Errorf("%s: %w", ErrCreateConsolePluginDeployment, err) + } + r.GetLogger().Info("Console Plugin deployment created", "deployment", desired.Name) + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", ErrGetConsolePluginDeployment, err) + } + + SetDefaults_Deployment(desired) + if DeploymentSpecEqual(&foundDeployment.Spec, &desired.Spec, true) { + return nil + } + + foundDeployment.Spec = desired.Spec + r.GetLogger().Info("Updating Console Plugin deployment", "deployment", foundDeployment.Name) + if err = restart(r, ctx, foundDeployment); err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsolePluginDeployment, err) + } + r.GetLogger().Info("Console Plugin deployment reconciled", "deployment", desired.Name) + return nil +} + +// ReconcileConsolePluginCR creates or updates a ConsolePlugin CR. +func ReconcileConsolePluginCR(r reconciler.Reconciler, ctx context.Context, desired *consolev1.ConsolePlugin) error { + foundPlugin := &consolev1.ConsolePlugin{} + err := r.Get(ctx, client.ObjectKey{Name: desired.Name}, foundPlugin) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating Console Plugin", "plugin", desired.Name) + if err = r.Create(ctx, desired); err != nil { + return fmt.Errorf("%s: %w", ErrCreateConsolePlugin, err) + } + r.GetLogger().Info("Console Plugin created", "plugin", desired.Name) + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", ErrGetConsolePlugin, err) + } + + if apiequality.Semantic.DeepEqual(foundPlugin.Spec, desired.Spec) { + r.GetLogger().Info("Console Plugin unchanged, reconciliation skipped", "plugin", desired.Name) + return nil + } + + foundPlugin.Spec = desired.Spec + if err = r.Update(ctx, foundPlugin); err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsolePlugin, err) + } + r.GetLogger().Info("Console Plugin reconciled", "plugin", desired.Name) + return nil +} + +// ActivateConsolePlugin adds a plugin name to the cluster Console CR. +func ActivateConsolePlugin(r reconciler.Reconciler, ctx context.Context, pluginName string) error { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + console := &openshiftv1.Console{} + if err := r.Get(ctx, client.ObjectKey{Name: ConsoleCRName}, console); err != nil { + return fmt.Errorf("%s: %w", ErrGetConsole, err) + } + if console.Spec.Plugins == nil { + console.Spec.Plugins = []string{pluginName} + } else if !slices.Contains(console.Spec.Plugins, pluginName) { + console.Spec.Plugins = append(console.Spec.Plugins, pluginName) + } else { + return nil + } + return r.Update(ctx, console) + }) + if err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsole, err) + } + r.GetLogger().Info("Console Plugin activated", "plugin", pluginName) + return nil +} + +// DeactivateConsolePlugin removes a plugin name from the cluster Console CR. +func DeactivateConsolePlugin(r reconciler.Reconciler, ctx context.Context, pluginName string) error { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + console := &openshiftv1.Console{} + if err := r.Get(ctx, client.ObjectKey{Name: ConsoleCRName}, console); err != nil { + if errors.IsNotFound(err) { + r.GetLogger().Info("Console CR not found, skipping plugin deactivation") + return nil + } + return fmt.Errorf("%s: %w", ErrGetConsole, err) + } + if console.Spec.Plugins == nil { + return nil + } + if slices.Contains(console.Spec.Plugins, pluginName) { + console.Spec.Plugins = slices.DeleteFunc(console.Spec.Plugins, func(name string) bool { return name == pluginName }) + } else { + return nil + } + return r.Update(ctx, console) + }) + if err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsole, err) + } + r.GetLogger().Info("Console Plugin deactivated", "plugin", pluginName) + return nil +} + +// DeleteConsolePluginCR deletes a ConsolePlugin CR. +func DeleteConsolePluginCR(r reconciler.Reconciler, ctx context.Context, pluginName string) error { + plugin := &consolev1.ConsolePlugin{} + err := r.Get(ctx, client.ObjectKey{Name: pluginName}, plugin) + if err != nil { + if errors.IsNotFound(err) { + r.GetLogger().Info("Console Plugin not found, skip deletion", "plugin", pluginName) + return nil + } + return fmt.Errorf("%s: %w", ErrGetConsolePlugin, err) + } + if err = r.Delete(ctx, plugin); err != nil { + if errors.IsNotFound(err) { + r.GetLogger().Info("Console Plugin not found, consider deletion successful", "plugin", pluginName) + return nil + } + return fmt.Errorf("%s: %w", ErrDeleteConsolePlugin, err) + } + r.GetLogger().Info("Console Plugin deleted", "plugin", pluginName) + return nil +} + +// WaitForConsolePluginTLSSecret waits for the service-ca TLS secret for a console plugin. +func WaitForConsolePluginTLSSecret(r reconciler.Reconciler, ctx context.Context, secretName string) error { + foundSecret := &corev1.Secret{} + var lastErr error + err := wait.PollUntilContextTimeout(ctx, 1*time.Second, ResourceCreationTimeout, true, func(ctx context.Context) (bool, error) { + _, err := GetSecretContent(r, ctx, secretName, r.GetNamespace(), []string{"tls.key", "tls.crt"}, foundSecret) + if err != nil { + lastErr = fmt.Errorf("secret: %s does not have expected tls.key or tls.crt. error: %w", secretName, err) + return false, nil + } + return true, nil + }) + if err != nil { + return fmt.Errorf("failed to get TLS key and cert - wait err %w; last error: %w", err, lastErr) + } + r.GetLogger().Info("Console Plugin tls secret reconciled", "secret", secretName) + return nil +} + +// ReconcileConsolePluginNetworkPolicy creates or updates a console plugin NetworkPolicy. +func ReconcileConsolePluginNetworkPolicy(r reconciler.Reconciler, ctx context.Context, desired *networkingv1.NetworkPolicy) error { + foundNp := &networkingv1.NetworkPolicy{} + err := r.Get(ctx, client.ObjectKey{Name: desired.Name, Namespace: r.GetNamespace()}, foundNp) + if err != nil && errors.IsNotFound(err) { + r.GetLogger().Info("creating Console Plugin NetworkPolicy", "networkpolicy", desired.Name) + if err = r.Create(ctx, desired); err != nil { + return fmt.Errorf("%s: %w", ErrCreateConsolePluginNetworkPolicy, err) + } + return nil + } + if err != nil { + return fmt.Errorf("%s: %w", ErrGetConsolePluginNetworkPolicy, err) + } + if NetworkPolicyEqual(desired, foundNp) { + r.GetLogger().Info("Console Plugin NetworkPolicy unchanged, reconciliation skipped", "networkpolicy", desired.Name) + return nil + } + foundNp.Spec = desired.Spec + if err = r.Update(ctx, foundNp); err != nil { + return fmt.Errorf("%s: %w", ErrUpdateConsolePluginNetworkPolicy, err) + } + r.GetLogger().Info("Console Plugin NetworkPolicy reconciled", "networkpolicy", desired.Name) + return nil +} + +// ReconcileConsolePluginServiceAccount creates a console plugin ServiceAccount if missing. +func ReconcileConsolePluginServiceAccount(r reconciler.Reconciler, ctx context.Context, desired *corev1.ServiceAccount) error { + foundSa := &corev1.ServiceAccount{} + err := r.Get(ctx, client.ObjectKey{Name: desired.Name, Namespace: r.GetNamespace()}, foundSa) + if err != nil && errors.IsNotFound(err) { + if err = r.Create(ctx, desired); err != nil { + return fmt.Errorf("%s: %w", ErrCreateConsolePluginServiceAccount, err) + } + return nil + } else if err != nil { + return fmt.Errorf("%s: %w", ErrGetConsolePluginServiceAccount, err) + } + return nil +} + +// RestartConsolePluginDeployment triggers a rolling restart of a console plugin deployment. +func RestartConsolePluginDeployment(r reconciler.Reconciler, ctx context.Context, deploymentName string, deployment ...*appsv1.Deployment) error { + var dep *appsv1.Deployment + var err error + + if len(deployment) > 0 && deployment[0] != nil { + dep = deployment[0] + } else { + dep = &appsv1.Deployment{} + err = r.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: r.GetNamespace()}, dep) + if err != nil { + return fmt.Errorf("failed to get deployment %s: %w", deploymentName, err) + } + } + + if dep.Spec.Template.Annotations == nil { + dep.Spec.Template.Annotations = make(map[string]string) + } + dep.Spec.Template.Annotations[ForceReloadAnnotationKey] = time.Now().Format(time.RFC3339Nano) + + r.GetLogger().Info("triggering Console Plugin rolling restart", "deployment", dep.Name) + if err = r.Update(ctx, dep); err != nil { + return fmt.Errorf("failed to update deployment %s: %w", dep.Name, err) + } + return nil +} + +// RemoveConsolePlugin deactivates and deletes a console plugin from the OpenShift Console. +func RemoveConsolePlugin(r reconciler.Reconciler, ctx context.Context, pluginName string) error { + return RunDeleteTasks(r, ctx, "RemoveConsolePlugin", []DeleteTask{ + { + Name: "deactivate Console Plugin", + Task: func(r reconciler.Reconciler, ctx context.Context) error { + return DeactivateConsolePlugin(r, ctx, pluginName) + }, + }, + { + Name: "delete Console Plugin", + Task: func(r reconciler.Reconciler, ctx context.Context) error { + return DeleteConsolePluginCR(r, ctx, pluginName) + }, + }, + }) +} diff --git a/internal/controller/utils/console_plugin_test.go b/internal/controller/utils/console_plugin_test.go new file mode 100644 index 000000000..01946bc7d --- /dev/null +++ b/internal/controller/utils/console_plugin_test.go @@ -0,0 +1,325 @@ +package utils + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/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/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/reconciler" +) + +const testConsolePluginName = "test-console-plugin" + +var testConsolePluginLabels = map[string]string{ + "app.kubernetes.io/name": testConsolePluginName, + "app.kubernetes.io/managed-by": "lightspeed-operator", +} + +var _ = Describe("Console plugin shared utilities", func() { + var ( + testReconciler *TestReconciler + testCr *olsv1alpha1.OLSConfig + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + testReconciler = NewTestReconciler( + k8sClient, + logf.Log.WithName("test"), + k8sClient.Scheme(), + OLSNamespaceDefault, + ) + testCr = GetDefaultOLSConfigCR() + }) + + Describe("DefaultConsolePluginResourceRequirements", func() { + It("returns expected defaults", func() { + resources := DefaultConsolePluginResourceRequirements() + Expect(resources.Requests[corev1.ResourceCPU]).To(Equal(resource.MustParse("10m"))) + Expect(resources.Requests[corev1.ResourceMemory]).To(Equal(resource.MustParse("50Mi"))) + Expect(resources.Limits[corev1.ResourceMemory]).To(Equal(resource.MustParse("100Mi"))) + }) + }) + + Describe("GenerateConsolePluginNginxConfigMap", func() { + It("creates a configmap with nginx.conf and owner reference", func() { + cm, err := GenerateConsolePluginNginxConfigMap( + testReconciler, testCr, testConsolePluginName, testConsolePluginLabels, "events {}", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Name).To(Equal(testConsolePluginName)) + Expect(cm.Data["nginx.conf"]).To(Equal("events {}")) + Expect(cm.OwnerReferences).To(HaveLen(1)) + Expect(cm.OwnerReferences[0].Kind).To(Equal("OLSConfig")) + }) + }) + + Describe("GenerateConsolePluginNetworkPolicy", func() { + It("allows ingress from openshift-console pods on the plugin port", func() { + np, err := GenerateConsolePluginNetworkPolicy( + testReconciler, testCr, testConsolePluginName, testConsolePluginLabels, ConsoleUIHTTPSPort, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(np.Name).To(Equal(testConsolePluginName)) + Expect(np.Spec.PolicyTypes).To(Equal([]networkingv1.PolicyType{networkingv1.PolicyTypeIngress})) + Expect(np.Spec.Ingress).To(HaveLen(1)) + Expect(np.Spec.Ingress[0].From[0].NamespaceSelector.MatchLabels).To(HaveKeyWithValue( + "kubernetes.io/metadata.name", "openshift-console", + )) + Expect(np.Spec.Ingress[0].From[0].PodSelector.MatchLabels).To(HaveKeyWithValue("app", "console")) + Expect(*np.Spec.Ingress[0].Ports[0].Port).To(Equal(intstr.FromInt32(ConsoleUIHTTPSPort))) + Expect(np.Spec.PodSelector.MatchLabels).To(Equal(testConsolePluginLabels)) + }) + }) + + Describe("GenerateConsolePluginDeployment", func() { + It("builds an nginx console plugin deployment from options", func() { + resources := DefaultConsolePluginResourceRequirements() + dep, err := GenerateConsolePluginDeployment(testReconciler, testCr, ConsolePluginDeploymentOptions{ + Name: testConsolePluginName, + Labels: testConsolePluginLabels, + SelectorLabels: testConsolePluginLabels, + ServiceAccountName: testConsolePluginName, + ContainerName: "console", + Image: "example/console:latest", + Port: ConsoleUIHTTPSPort, + PortName: "https", + CertVolumeName: "cert", + CertSecretName: testConsolePluginName + "-cert", + NginxVolumeName: "nginx-conf", + NginxConfigMapName: testConsolePluginName, + NginxTempVolumeName: "nginx-tmp", + Resources: resources, + DeploymentConfig: olsv1alpha1.Config{}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(*dep.Spec.Replicas).To(Equal(int32(1))) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal("example/console:latest")) + Expect(dep.Spec.Template.Spec.Containers[0].Ports[0].Name).To(Equal("https")) + Expect(dep.Spec.Template.Spec.Volumes).To(HaveLen(3)) + }) + }) + + Describe("ReconcileConsolePluginConfigMap", func() { + It("creates and updates a configmap", func() { + cm, err := GenerateConsolePluginNginxConfigMap( + testReconciler, testCr, testConsolePluginName+"-cm", testConsolePluginLabels, "events {}", + ) + Expect(err).NotTo(HaveOccurred()) + + err = ReconcileConsolePluginConfigMap(testReconciler, ctx, cm) + Expect(err).NotTo(HaveOccurred()) + + cm.Data["nginx.conf"] = "events { worker_connections 1024; }" + err = ReconcileConsolePluginConfigMap(testReconciler, ctx, cm) + Expect(err).NotTo(HaveOccurred()) + + found := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: cm.Name, Namespace: OLSNamespaceDefault}, found) + Expect(err).NotTo(HaveOccurred()) + Expect(found.Data["nginx.conf"]).To(ContainSubstring("worker_connections")) + }) + }) + + Describe("ActivateConsolePlugin and DeactivateConsolePlugin", func() { + BeforeEach(func() { + console := &openshiftv1.Console{ + ObjectMeta: metav1.ObjectMeta{Name: ConsoleCRName}, + Spec: openshiftv1.ConsoleSpec{ + Plugins: []string{"monitoring-plugin"}, + OperatorSpec: openshiftv1.OperatorSpec{ + ManagementState: openshiftv1.Managed, + }, + }, + } + Expect(k8sClient.Create(ctx, console)).To(Succeed()) + }) + + AfterEach(func() { + console := &openshiftv1.Console{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: ConsoleCRName}, console) + if err == nil { + Expect(k8sClient.Delete(ctx, console)).To(Succeed()) + } + }) + + It("adds and removes a plugin from the Console CR", func() { + err := ActivateConsolePlugin(testReconciler, ctx, testConsolePluginName) + Expect(err).NotTo(HaveOccurred()) + + console := &openshiftv1.Console{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: ConsoleCRName}, console) + Expect(err).NotTo(HaveOccurred()) + Expect(console.Spec.Plugins).To(ContainElement(testConsolePluginName)) + + err = DeactivateConsolePlugin(testReconciler, ctx, testConsolePluginName) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{Name: ConsoleCRName}, console) + Expect(err).NotTo(HaveOccurred()) + Expect(console.Spec.Plugins).NotTo(ContainElement(testConsolePluginName)) + }) + }) + + Describe("RemoveConsolePlugin", func() { + BeforeEach(func() { + console := &openshiftv1.Console{ + ObjectMeta: metav1.ObjectMeta{Name: ConsoleCRName}, + Spec: openshiftv1.ConsoleSpec{ + Plugins: []string{testConsolePluginName}, + OperatorSpec: openshiftv1.OperatorSpec{ + ManagementState: openshiftv1.Managed, + }, + }, + } + Expect(k8sClient.Create(ctx, console)).To(Succeed()) + + plugin := &consolev1.ConsolePlugin{ + ObjectMeta: metav1.ObjectMeta{Name: testConsolePluginName}, + Spec: consolev1.ConsolePluginSpec{ + DisplayName: "Test Plugin", + Backend: consolev1.ConsolePluginBackend{ + Type: consolev1.Service, + Service: &consolev1.ConsolePluginService{ + Name: testConsolePluginName, + Namespace: OLSNamespaceDefault, + Port: ConsoleUIHTTPSPort, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, plugin)).To(Succeed()) + }) + + AfterEach(func() { + plugin := &consolev1.ConsolePlugin{} + _ = k8sClient.Get(ctx, types.NamespacedName{Name: testConsolePluginName}, plugin) + if plugin.Name != "" { + _ = k8sClient.Delete(ctx, plugin) + } + console := &openshiftv1.Console{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: ConsoleCRName}, console) + if err == nil { + _ = k8sClient.Delete(ctx, console) + } + }) + + It("deactivates and deletes the ConsolePlugin CR", func() { + err := RemoveConsolePlugin(testReconciler, ctx, testConsolePluginName) + Expect(err).NotTo(HaveOccurred()) + + plugin := &consolev1.ConsolePlugin{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: testConsolePluginName}, plugin) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + console := &openshiftv1.Console{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: ConsoleCRName}, console) + Expect(err).NotTo(HaveOccurred()) + Expect(console.Spec.Plugins).NotTo(ContainElement(testConsolePluginName)) + }) + }) + + Describe("WaitForConsolePluginTLSSecret", func() { + It("waits until tls.key and tls.crt are present", func() { + secretName := testConsolePluginName + "-tls" + secret, err := GenerateRandomTLSSecret() + Expect(err).NotTo(HaveOccurred()) + secret.Name = secretName + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, secret) + }() + + err = WaitForConsolePluginTLSSecret(testReconciler, ctx, secretName) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("RunReconcileTasks", func() { + It("aggregates errors when continueOnError is true", func() { + tasks := []ReconcileTask{ + { + Name: "failing task", + Task: func(_ reconciler.Reconciler, _ context.Context, _ *olsv1alpha1.OLSConfig) error { + return fmt.Errorf("boom") + }, + }, + } + err := RunReconcileTasks(testReconciler, ctx, testCr, "test phase", tasks, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failing task")) + }) + + It("stops on first error when continueOnError is false", func() { + called := false + tasks := []ReconcileTask{ + { + Name: "failing task", + Task: func(_ reconciler.Reconciler, _ context.Context, _ *olsv1alpha1.OLSConfig) error { + return fmt.Errorf("boom") + }, + }, + { + Name: "second task", + Task: func(_ reconciler.Reconciler, _ context.Context, _ *olsv1alpha1.OLSConfig) error { + called = true + return nil + }, + }, + } + err := RunReconcileTasks(testReconciler, ctx, testCr, "test phase", tasks, false) + Expect(err).To(HaveOccurred()) + Expect(called).To(BeFalse()) + }) + }) + + Describe("RestartConsolePluginDeployment", func() { + It("sets the force-reload annotation on the deployment pod template", func() { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: testConsolePluginName + "-dep", + Namespace: OLSNamespaceDefault, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: testConsolePluginLabels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: testConsolePluginLabels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "console", Image: "example:latest"}}, + }, + }, + }, + } + Expect(controllerutil.SetControllerReference(testCr, dep, testReconciler.GetScheme())).To(Succeed()) + Expect(k8sClient.Create(ctx, dep)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, dep) + }() + + err := RestartConsolePluginDeployment(testReconciler, ctx, dep.Name, dep) + Expect(err).NotTo(HaveOccurred()) + + found := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: dep.Name, Namespace: OLSNamespaceDefault}, found) + Expect(err).NotTo(HaveOccurred()) + Expect(found.Spec.Template.Annotations).To(HaveKey(ForceReloadAnnotationKey)) + }) + }) +}) diff --git a/internal/controller/utils/constants.go b/internal/controller/utils/constants.go index e388d02a9..11e5320bb 100644 --- a/internal/controller/utils/constants.go +++ b/internal/controller/utils/constants.go @@ -170,6 +170,31 @@ const ( // ConsoleUINetworkPolicyName is the name of the network policy for the console UI plugin ConsoleUINetworkPolicyName = "lightspeed-console-plugin" + /*** agentic console UI plugin ***/ + // AgenticConsoleUIConfigMapName is the name of the agentic console UI nginx configmap + AgenticConsoleUIConfigMapName = "lightspeed-agentic-console-plugin" + // AgenticConsoleUIServiceCertSecretName is the name of the agentic console UI service certificate secret + AgenticConsoleUIServiceCertSecretName = "lightspeed-agentic-console-plugin-cert" + // AgenticConsoleUIServiceName is the name of the agentic console UI service + AgenticConsoleUIServiceName = "lightspeed-agentic-console-plugin" + // AgenticConsoleUIDeploymentName is the name of the agentic console UI deployment + AgenticConsoleUIDeploymentName = "lightspeed-agentic-console-plugin" + // AgenticConsoleUIHTTPSPort is the port number of the agentic console UI service + AgenticConsoleUIHTTPSPort = 9443 + // AgenticConsoleUIPluginName is the name of the agentic console UI plugin + AgenticConsoleUIPluginName = "lightspeed-agentic-console-plugin" + // AgenticConsoleUIPluginDisplayName is the display name of the agentic console UI plugin + AgenticConsoleUIPluginDisplayName = "OpenShift Lightspeed Agentic Console Plugin" + // AgenticConsoleUIServiceAccountName is the name of the service account for the agentic console UI plugin + AgenticConsoleUIServiceAccountName = "lightspeed-agentic-console-plugin" + // AgenticConsoleUINetworkPolicyName is the name of the network policy for the agentic console UI plugin + AgenticConsoleUINetworkPolicyName = "lightspeed-agentic-console-plugin" + // AgenticConsoleUIContainerName is the name of the agentic console UI container + AgenticConsoleUIContainerName = "console" + // AgenticConsoleUIImageDefault is the default image for the agentic console UI plugin. + // Konflux-built image until a productized openshift-lightspeed image is published. + AgenticConsoleUIImageDefault = "quay.io/redhat-user-workloads/crt-nshift-lightspeed-tenant/lightspeed-agentic-console:main" + /*** watchers ***/ // Watcher Annotation key WatcherAnnotationKey = "ols.openshift.io/watcher" diff --git a/internal/controller/utils/resource_defaults_test.go b/internal/controller/utils/resource_defaults_test.go index 98c0b004d..18280d61a 100644 --- a/internal/controller/utils/resource_defaults_test.go +++ b/internal/controller/utils/resource_defaults_test.go @@ -56,6 +56,7 @@ var _ = Describe("Resource defaults and test reconciler", func() { Expect(r.GetNamespace()).To(Equal("test-ns")) Expect(r.GetPostgresImage()).To(Equal(PostgresServerImageDefault)) Expect(r.GetConsoleUIImage()).To(Equal(ConsoleUIImageDefault)) + Expect(r.GetAgenticConsoleImage()).To(Equal(AgenticConsoleUIImageDefault)) Expect(r.GetOpenShiftMajor()).To(Equal("123")) Expect(r.GetOpenshiftMinor()).To(Equal("456")) Expect(r.GetAppServerImage()).To(Equal(OLSAppServerImageDefault)) diff --git a/internal/controller/utils/suite_test.go b/internal/controller/utils/suite_test.go index a67787f61..6409ab679 100644 --- a/internal/controller/utils/suite_test.go +++ b/internal/controller/utils/suite_test.go @@ -7,6 +7,9 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" @@ -39,7 +42,10 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + filepath.Join("..", "..", "..", ".testcrds"), + }, ErrorIfCRDPathMissing: true, } @@ -51,6 +57,12 @@ var _ = BeforeSuite(func() { err = olsv1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = consolev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = openshiftv1.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()) diff --git a/internal/controller/utils/testing.go b/internal/controller/utils/testing.go index 37136fdb3..88a467c86 100644 --- a/internal/controller/utils/testing.go +++ b/internal/controller/utils/testing.go @@ -18,6 +18,7 @@ type TestReconciler struct { namespace string PostgresImage string ConsoleImage string + AgenticConsoleImage string AppServerImage string McpServerImage string DataverseExporter string @@ -47,6 +48,10 @@ func (r *TestReconciler) GetConsoleUIImage() string { return r.ConsoleImage } +func (r *TestReconciler) GetAgenticConsoleImage() string { + return r.AgenticConsoleImage +} + func (r *TestReconciler) GetOpenShiftMajor() string { return r.openShiftMajor } @@ -93,6 +98,7 @@ func NewTestReconciler( namespace: namespace, PostgresImage: PostgresServerImageDefault, ConsoleImage: ConsoleUIImageDefault, + AgenticConsoleImage: AgenticConsoleUIImageDefault, AppServerImage: OLSAppServerImageDefault, McpServerImage: OLSAppServerImageDefault, DataverseExporter: DataverseExporterImageDefault, diff --git a/internal/controller/utils/types.go b/internal/controller/utils/types.go index 791993931..b2335e36f 100644 --- a/internal/controller/utils/types.go +++ b/internal/controller/utils/types.go @@ -9,10 +9,11 @@ import ( // Definitions to manage status conditions const ( - TypeApiReady = "ApiReady" - TypeCacheReady = "CacheReady" - TypeConsolePluginReady = "ConsolePluginReady" - TypeCRReconciled = "Reconciled" + TypeApiReady = "ApiReady" + TypeCacheReady = "CacheReady" + TypeConsolePluginReady = "ConsolePluginReady" + TypeAgenticConsolePluginReady = "AgenticConsolePluginReady" + TypeCRReconciled = "Reconciled" ) type OLSConfigReconcilerOptions struct { @@ -21,6 +22,7 @@ type OLSConfigReconcilerOptions struct { LightspeedServiceImage string LightspeedServicePostgresImage string ConsoleUIImage string + AgenticConsoleUIImage string DataverseExporterImage string OpenShiftMCPServerImage string Namespace string diff --git a/internal/controller/utils/utils.go b/internal/controller/utils/utils.go index 43ed0a08a..b5a65d6a4 100644 --- a/internal/controller/utils/utils.go +++ b/internal/controller/utils/utils.go @@ -35,6 +35,7 @@ import ( networkingv1 "k8s.io/api/networking/v1" apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -842,6 +843,236 @@ func ImageStreamNameFor(image string) string { return fmt.Sprintf("%s-%s", slug, sfx) } +// GenerateConsolePluginNginxConfigMap generates a ConfigMap containing nginx.conf for a console plugin. +func GenerateConsolePluginNginxConfigMap( + r reconciler.Reconciler, + cr *olsv1alpha1.OLSConfig, + name string, + labels map[string]string, + nginxConfig string, +) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: r.GetNamespace(), + Labels: labels, + }, + Data: map[string]string{ + "nginx.conf": nginxConfig, + }, + } + if err := controllerutil.SetControllerReference(cr, cm, r.GetScheme()); err != nil { + return nil, err + } + return cm, nil +} + +// GenerateConsolePluginNetworkPolicy generates a network policy allowing ingress from OpenShift Console pods. +func GenerateConsolePluginNetworkPolicy( + r reconciler.Reconciler, + cr *olsv1alpha1.OLSConfig, + name string, + labels map[string]string, + port int32, +) (*networkingv1.NetworkPolicy, error) { + protocolTCP := corev1.ProtocolTCP + servicePort := intstr.FromInt32(port) + np := networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: r.GetNamespace(), + Labels: labels, + }, + Spec: networkingv1.NetworkPolicySpec{ + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": "openshift-console", + }, + }, + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "console", + }, + }, + }, + }, + Ports: []networkingv1.NetworkPolicyPort{ + { + Protocol: &protocolTCP, + Port: &servicePort, + }, + }, + }, + }, + PodSelector: metav1.LabelSelector{ + MatchLabels: labels, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + }, + }, + } + + if err := controllerutil.SetControllerReference(cr, &np, r.GetScheme()); err != nil { + return nil, err + } + return &np, nil +} + +// DefaultConsolePluginResourceRequirements returns default resource requirements for console plugin containers. +func DefaultConsolePluginResourceRequirements() *corev1.ResourceRequirements { + return &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("50Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("100Mi"), + }, + Claims: []corev1.ResourceClaim{}, + } +} + +// ConsolePluginDeploymentOptions configures nginx-based console plugin Deployments. +type ConsolePluginDeploymentOptions struct { + Name string + Labels map[string]string + SelectorLabels map[string]string + ServiceAccountName string + ContainerName string + Image string + Port int32 + PortName string + CertVolumeName string + CertSecretName string + NginxVolumeName string + NginxConfigMapName string + NginxTempVolumeName string + Resources *corev1.ResourceRequirements + Env []corev1.EnvVar + DeploymentConfig olsv1alpha1.Config +} + +// GenerateConsolePluginDeployment generates a Deployment for an nginx-served console plugin. +func GenerateConsolePluginDeployment( + r reconciler.Reconciler, + cr *olsv1alpha1.OLSConfig, + opts ConsolePluginDeploymentOptions, +) (*appsv1.Deployment, error) { + runAsNonRoot := true + volumeDefaultMode := VolumeDefaultMode + replicas := int32(1) + + containerPort := corev1.ContainerPort{ + ContainerPort: opts.Port, + Protocol: corev1.ProtocolTCP, + } + if opts.PortName != "" { + containerPort.Name = opts.PortName + } + + resources := opts.Resources + if resources == nil { + resources = DefaultConsolePluginResourceRequirements() + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.Name, + Namespace: r.GetNamespace(), + Labels: opts.Labels, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: opts.SelectorLabels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: opts.Labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: opts.ContainerName, + Image: opts.Image, + Ports: []corev1.ContainerPort{containerPort}, + SecurityContext: RestrictedContainerSecurityContext(), + ImagePullPolicy: corev1.PullAlways, + Env: opts.Env, + Resources: *resources, + VolumeMounts: []corev1.VolumeMount{ + { + Name: opts.CertVolumeName, + MountPath: "/var/cert", + ReadOnly: true, + }, + { + Name: opts.NginxVolumeName, + MountPath: "/etc/nginx/nginx.conf", + SubPath: "nginx.conf", + ReadOnly: true, + }, + { + Name: opts.NginxTempVolumeName, + MountPath: "/tmp/nginx", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: opts.CertVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: opts.CertSecretName, + DefaultMode: &volumeDefaultMode, + }, + }, + }, + { + Name: opts.NginxVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: opts.NginxConfigMapName, + }, + DefaultMode: &volumeDefaultMode, + }, + }, + }, + { + Name: opts.NginxTempVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + }, + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: &runAsNonRoot, + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + }, + ServiceAccountName: opts.ServiceAccountName, + }, + }, + }, + } + + ApplyPodDeploymentConfig(deployment, opts.DeploymentConfig, false) + + if err := controllerutil.SetControllerReference(cr, deployment, r.GetScheme()); err != nil { + return nil, err + } + + return deployment, nil +} + // GenerateServiceAccount generates a service account with the given name in the operator namespace func GenerateServiceAccount(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig, name string) (*corev1.ServiceAccount, error) { sa := corev1.ServiceAccount{ diff --git a/internal/controller/watchers/watchers.go b/internal/controller/watchers/watchers.go index e91455fff..80052d01e 100644 --- a/internal/controller/watchers/watchers.go +++ b/internal/controller/watchers/watchers.go @@ -14,6 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" olsv1alpha1 "github.com/openshift/lightspeed-operator/api/v1alpha1" + "github.com/openshift/lightspeed-operator/internal/controller/agenticconsole" "github.com/openshift/lightspeed-operator/internal/controller/appserver" "github.com/openshift/lightspeed-operator/internal/controller/console" "github.com/openshift/lightspeed-operator/internal/controller/postgres" @@ -363,9 +364,10 @@ type RestartFunc func(reconciler.Reconciler, context.Context, ...*appsv1.Deploym // restartFuncs maps deployment names to their restart functions var restartFuncs = map[string]RestartFunc{ - utils.OLSAppServerDeploymentName: appserver.RestartAppServer, - utils.PostgresDeploymentName: postgres.RestartPostgres, - utils.ConsoleUIDeploymentName: console.RestartConsoleUI, + utils.OLSAppServerDeploymentName: appserver.RestartAppServer, + utils.PostgresDeploymentName: postgres.RestartPostgres, + utils.ConsoleUIDeploymentName: console.RestartConsoleUI, + utils.AgenticConsoleUIDeploymentName: agenticconsole.RestartAgenticConsoleUI, } // restart corresponding deployment