From 8cf67447f869705bd20fb68b7b6e812602460223 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 6 May 2026 18:26:02 +0200 Subject: [PATCH 1/6] Fix test Current code fails on `OpenStackLightspeed Controller When reconciling a resource [BeforeEach] should successfully reconcile the resource` with failure `OpenStackLightspeed.lightspeed.openstack.org "test-resource" is invalid: spec.llmEndpointType: Unsupported value: "": supported values: "azure_openai", "bam", "openai", "watsonx", "rhoai_vllm", "rhelai_vllm", "fake_provider"` --- .../controller/openstacklightspeed_controller_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/controller/openstacklightspeed_controller_test.go b/internal/controller/openstacklightspeed_controller_test.go index b90286b0..3bba0b2e 100644 --- a/internal/controller/openstacklightspeed_controller_test.go +++ b/internal/controller/openstacklightspeed_controller_test.go @@ -51,7 +51,13 @@ var _ = Describe("OpenStackLightspeed Controller", func() { Name: resourceName, Namespace: "default", }, - // TODO(user): Specify other spec details if needed. + Spec: apiv1beta1.OpenStackLightspeedSpec{ + OpenStackLightspeedCore: apiv1beta1.OpenStackLightspeedCore{ + LLMEndpoint: "https://example.com/llm", + LLMEndpointType: "openai", + ModelName: "test-model", + }, + }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } From 7aa283ba301527f80da7b85bf37ff7d9b9a8acf1 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 29 Apr 2026 20:45:12 +0200 Subject: [PATCH 2/6] Add clusterversions RBAC rule for lightspeed-stack lightspeed-stack needs to read the cluster version (via the config.openshift.io API) when an admin user interacts with it. Grant list and get on clusterversions to the SAR ClusterRole. Jira: OSPRH-29574 Co-Authored-By: Claude Opus 4.6 --- internal/controller/lcore_reconciler.go | 2 +- .../assert-openstack-lightspeed-instance.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/controller/lcore_reconciler.go b/internal/controller/lcore_reconciler.go index 127d496f..ed3c6fa5 100644 --- a/internal/controller/lcore_reconciler.go +++ b/internal/controller/lcore_reconciler.go @@ -120,7 +120,7 @@ func reconcileSARRole(h *common_helper.Helper, ctx context.Context, instance *ap { APIGroups: []string{"config.openshift.io"}, Resources: []string{"clusterversions"}, - Verbs: []string{"get"}, + Verbs: []string{"list", "get"}, }, { APIGroups: []string{""}, diff --git a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml index bacf80d9..85c3fc26 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml @@ -73,6 +73,7 @@ rules: resources: - clusterversions verbs: + - list - get - apiGroups: - "" From 1b86a117512fd66d67a3982573970ec2667b6ae3 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Thu, 7 May 2026 15:32:53 +0200 Subject: [PATCH 3/6] Add kuttl-test-ocp target for OpenShift internal registry Allows running kuttl tests using the OpenShift internal image registry instead of an external registry like quay.io. Co-Authored-By: Claude Opus 4.6 --- Makefile | 18 +++++++++++ scripts/ocp-catalog-build.sh | 62 ++++++++++++++++++++++++++++++++++++ scripts/ocp-registry-push.sh | 52 ++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 scripts/ocp-catalog-build.sh create mode 100644 scripts/ocp-registry-push.sh diff --git a/Makefile b/Makefile index 122bbcdc..09e511f0 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,10 @@ CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:$(TAG) CATALOG_NAME ?= openstack-lightspeed-catalog CATALOG_CHANNEL ?= alpha +# OpenShift internal registry support for local development/testing. +OCP_REGISTRY_NAMESPACE ?= openstack-lightspeed +OCP_INTERNAL_REGISTRY ?= image-registry.openshift-image-registry.svc:5000 + # BUNDLE_IMG defines the image:tag used for the bundle. # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:$(TAG) @@ -281,6 +285,20 @@ kuttl-test: kuttl ## Run kuttl tests .PHONY: kuttl-test-run kuttl-test-run: kuttl openstack-lightspeed-deploy kuttl-test openstack-lightspeed-undeploy +.PHONY: ocp-registry-push +ocp-registry-push: ## Push images to the OpenShift internal registry. + bash scripts/ocp-registry-push.sh $(CONTAINER_TOOL) $(OCP_REGISTRY_NAMESPACE) $(IMG) $(CATALOG_IMG) + +.PHONY: ocp-catalog-build +ocp-catalog-build: opm ## Build a catalog image for the OpenShift internal registry. + bash scripts/ocp-catalog-build.sh $(CONTAINER_TOOL) $(BUNDLE_IMG) $(CATALOG_IMG) $(OPM) + +.PHONY: kuttl-test-ocp +kuttl-test-ocp: IMG = $(OCP_INTERNAL_REGISTRY)/$(OCP_REGISTRY_NAMESPACE)/operator:latest +kuttl-test-ocp: BUNDLE_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-bundle:$(TAG) +kuttl-test-ocp: CATALOG_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-catalog:$(TAG) +kuttl-test-ocp: docker-build bundle bundle-build ocp-catalog-build ocp-registry-push kuttl-test-run + # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary # $2 - package url which can be installed diff --git a/scripts/ocp-catalog-build.sh b/scripts/ocp-catalog-build.sh new file mode 100644 index 00000000..5c8bc181 --- /dev/null +++ b/scripts/ocp-catalog-build.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Build a catalog image for use with the OpenShift internal registry. +# +# OPM cannot pull bundle images from the internal registry (unreachable from dev machine), +# so this script: +# 1. Pushes the bundle to the internal registry via the external route +# 2. Uses OPM to build the catalog index from the external route (pullable) +# 3. Fixes the bundlepath in the database to use the internal registry address +# 4. Builds the final catalog image +# +# Usage: ocp-catalog-build.sh +set -euo pipefail + +CONTAINER_TOOL="${1:?Usage: $0 }" +BUNDLE_IMG="${2:?Usage: $0 }" +CATALOG_IMG="${3:?Usage: $0 }" +OPM="${4:?Usage: $0 }" + +INTERNAL_PREFIX="image-registry.openshift-image-registry.svc:5000" + +echo "==> Ensuring the OpenShift image registry default route is exposed..." +oc patch configs.imageregistry.operator.openshift.io/cluster \ + --type merge -p '{"spec":{"defaultRoute":true}}' + +ROUTE_HOST="$(oc get route default-route -n openshift-image-registry \ + -o jsonpath='{.spec.host}')" +if [ -z "${ROUTE_HOST}" ]; then + echo "Error: could not obtain the registry route host." + exit 1 +fi + +ROUTE_BUNDLE="${BUNDLE_IMG/${INTERNAL_PREFIX}/${ROUTE_HOST}}" + +echo "==> Logging in to the registry..." +${CONTAINER_TOOL} login "${ROUTE_HOST}" -u "$(oc whoami)" -p "$(oc whoami -t)" --tls-verify=false + +echo "==> Pushing bundle image to internal registry..." +${CONTAINER_TOOL} tag "${BUNDLE_IMG}" "${ROUTE_BUNDLE}" +${CONTAINER_TOOL} push "${ROUTE_BUNDLE}" --tls-verify=false +${CONTAINER_TOOL} rmi "${ROUTE_BUNDLE}" 2>/dev/null || true + +echo "==> Building catalog index from external route..." +WORKDIR=$(mktemp -d) +# shellcheck disable=SC2064 +trap "rm -rf ${WORKDIR}" EXIT + +${OPM} index add \ + --build-tool "${CONTAINER_TOOL}" --pull-tool none --skip-tls-verify \ + --generate --mode semver \ + --out-dockerfile "${WORKDIR}/index.Dockerfile" \ + --bundles "${ROUTE_BUNDLE}" \ + --tag "${CATALOG_IMG}" +mv database "${WORKDIR}/" + +echo "==> Fixing bundle path in catalog database..." +sqlite3 "${WORKDIR}/database/index.db" \ + "UPDATE operatorbundle SET bundlepath = REPLACE(bundlepath, '${ROUTE_HOST}', '${INTERNAL_PREFIX}');" + +echo "==> Building catalog image..." +${CONTAINER_TOOL} build -f "${WORKDIR}/index.Dockerfile" -t "${CATALOG_IMG}" "${WORKDIR}" + +echo "==> Catalog image built successfully: ${CATALOG_IMG}" diff --git a/scripts/ocp-registry-push.sh b/scripts/ocp-registry-push.sh new file mode 100644 index 00000000..d454ada9 --- /dev/null +++ b/scripts/ocp-registry-push.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Push images to the OpenShift internal image registry. +# +# Usage: ocp-registry-push.sh [image2 ...] +# +# Each must use the in-cluster registry address, e.g.: +# image-registry.openshift-image-registry.svc:5000/my-ns/my-image:tag +set -euo pipefail + +CONTAINER_TOOL="${1:?Usage: $0 [image2 ...]}" +NAMESPACE="${2:?Usage: $0 [image2 ...]}" +shift 2 + +if [ $# -eq 0 ]; then + echo "Error: at least one image must be specified" + exit 1 +fi + +INTERNAL_PREFIX="image-registry.openshift-image-registry.svc:5000" + +echo "==> Ensuring the OpenShift image registry default route is exposed..." +oc patch configs.imageregistry.operator.openshift.io/cluster \ + --type merge -p '{"spec":{"defaultRoute":true}}' + +echo "==> Obtaining the external registry route..." +ROUTE_HOST="$(oc get route default-route -n openshift-image-registry \ + -o jsonpath='{.spec.host}')" +if [ -z "${ROUTE_HOST}" ]; then + echo "Error: could not obtain the registry route host." + exit 1 +fi +echo " External route: ${ROUTE_HOST}" + +echo "==> Ensuring namespace '${NAMESPACE}' exists..." +oc create namespace "${NAMESPACE}" --dry-run=client -o yaml | oc apply -f - + +echo "==> Logging in to the registry..." +${CONTAINER_TOOL} login "${ROUTE_HOST}" -u "$(oc whoami)" -p "$(oc whoami -t)" --tls-verify=false + +for IMAGE in "$@"; do + PUSH_IMAGE="${IMAGE/${INTERNAL_PREFIX}/${ROUTE_HOST}}" + if [ "${PUSH_IMAGE}" = "${IMAGE}" ]; then + echo "Warning: image '${IMAGE}' does not start with '${INTERNAL_PREFIX}', pushing as-is." + fi + + echo "==> Pushing ${IMAGE} -> ${PUSH_IMAGE}" + ${CONTAINER_TOOL} tag "${IMAGE}" "${PUSH_IMAGE}" + ${CONTAINER_TOOL} push "${PUSH_IMAGE}" --tls-verify=false + ${CONTAINER_TOOL} rmi "${PUSH_IMAGE}" 2>/dev/null || true +done + +echo "==> All images pushed successfully." From 358df4bec5e8c5526b5da794f96f6d20151c8372 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 29 Apr 2026 15:12:20 +0200 Subject: [PATCH 4/6] Add console plugin deployment Deploy the OpenShift Lightspeed console plugin as part of the operator reconciliation loop. Adds the Deployment, Service, ConsolePlugin CR, nginx ConfigMap, NetworkPolicy, and ServiceAccount using the existing CreateOrPatch pattern. Image used for the console depends on the OCP version. Versions prior to 4.19 use one with PatterFly 5 and for later one with PatternFly 6. Jira: OSPRH-29746 Co-Authored-By: Claude Opus 4.6 --- api/v1beta1/openstacklightspeed_types.go | 12 + ...tspeed-operator.clusterserviceversion.yaml | 21 + cmd/main.go | 6 + config/rbac/role.yaml | 21 + go.mod | 11 +- go.sum | 2 + .../controller/assets/console_nginx.conf.tmpl | 22 + internal/controller/console_deployment.go | 203 +++++++++ internal/controller/console_reconciler.go | 392 ++++++++++++++++++ .../controller/console_reconciler_test.go | 239 +++++++++++ internal/controller/constants.go | 15 + internal/controller/errors.go | 12 + .../openstacklightspeed_controller.go | 8 + internal/controller/suite_test.go | 8 + .../assert-openstack-lightspeed-instance.yaml | 44 ++ .../errors-openstack-lightspeed-instance.yaml | 37 ++ .../08-assert-openstacklightspeed-update.yaml | 37 ++ 17 files changed, 1087 insertions(+), 3 deletions(-) create mode 100644 internal/controller/assets/console_nginx.conf.tmpl create mode 100644 internal/controller/console_deployment.go create mode 100644 internal/controller/console_reconciler.go create mode 100644 internal/controller/console_reconciler_test.go diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index dd43ec7e..4cdf0cd6 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -38,6 +38,12 @@ const ( // PostgresContainerImage is the fall-back container image for PostgreSQL PostgresContainerImage = "registry.redhat.io/rhel9/postgresql-16:latest" + // ConsoleContainerImage is the fall-back container image for the Console Plugin (PatternFly 6, OCP >= 4.19) + ConsoleContainerImage = "registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12" + + // ConsoleContainerImagePF5 is the fall-back console image for PatternFly 5 (OCP < 4.19) + ConsoleContainerImagePF5 = "registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12" + // MaxTokensForResponseDefault is the default maximum number of tokens that should be used for response MaxTokensForResponseDefault = 2048 ) @@ -186,6 +192,8 @@ type OpenStackLightspeedDefaults struct { LCoreImageURL string ExporterImageURL string PostgresImageURL string + ConsoleImageURL string + ConsoleImagePF5URL string MaxTokensForResponse int } @@ -203,6 +211,10 @@ func SetupDefaults() { "RELATED_IMAGE_EXPORTER_IMAGE_URL_DEFAULT", ExporterContainerImage), PostgresImageURL: util.GetEnvVar( "RELATED_IMAGE_POSTGRES_IMAGE_URL_DEFAULT", PostgresContainerImage), + ConsoleImageURL: util.GetEnvVar( + "RELATED_IMAGE_CONSOLE_IMAGE_URL_DEFAULT", ConsoleContainerImage), + ConsoleImagePF5URL: util.GetEnvVar( + "RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT", ConsoleContainerImagePF5), MaxTokensForResponse: MaxTokensForResponseDefault, } diff --git a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml index 0ed32667..1ad8c239 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -162,6 +162,18 @@ spec: - get - list - watch + - apiGroups: + - console.openshift.io + resources: + - consoleplugins + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -188,6 +200,15 @@ spec: - get - patch - update + - apiGroups: + - operator.openshift.io + resources: + - consoles + verbs: + - get + - list + - update + - watch - apiGroups: - operators.coreos.com resources: diff --git a/cmd/main.go b/cmd/main.go index 8c0505d3..60471435 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -38,6 +38,8 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" @@ -56,6 +58,10 @@ func init() { utilruntime.Must(operatorsv1alpha1.AddToScheme(scheme)) utilruntime.Must(apiv1beta1.AddToScheme(scheme)) + + utilruntime.Must(consolev1.AddToScheme(scheme)) + + utilruntime.Must(openshiftv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 0d8e6771..a39fe276 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -20,6 +20,18 @@ rules: - get - list - watch +- apiGroups: + - console.openshift.io + resources: + - consoleplugins + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -46,6 +58,15 @@ rules: - get - patch - update +- apiGroups: + - operator.openshift.io + resources: + - consoles + verbs: + - get + - list + - update + - watch - apiGroups: - operators.coreos.com resources: diff --git a/go.mod b/go.mod index 2877c719..603dd825 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,20 @@ require ( github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 + github.com/openshift/api v3.9.0+incompatible // from lib-common github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 github.com/operator-framework/api v0.37.0 + k8s.io/api v0.34.2 k8s.io/apimachinery v0.34.3 k8s.io/client-go v0.34.2 - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 sigs.k8s.io/controller-runtime v0.22.4 + sigs.k8s.io/yaml v1.6.0 ) +// from https://github.com/openstack-k8s-operators/lib-common/blob/main/modules/common/go.mod +// must be consistent within modules and service operators +replace github.com/openshift/api => github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e + require ( cel.dev/expr v0.24.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -91,15 +97,14 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.2 // indirect k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/apiserver v0.34.2 // indirect k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 264df5b3..2b4c5f4e 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e h1:E1OdwSpqWuDPCedyUt0GEdoAE+r5TXy7YS21yNEo+2U= +github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e/go.mod h1:Shkl4HanLwDiiBzakv+con/aMGnVE2MAGvoKp5oyYUo= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 h1:2TD4hi+MLt67jKxJUs2tuBKFMxibrLJQqKqhsTMsHeQ= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0/go.mod h1:rgpcv2tLD+/vudXx/gpIQSTuRpk4GOxHx84xwfvQalM= github.com/operator-framework/api v0.37.0 h1:2XCMWitBnumtJTqzip6LQKUwpM2pXVlt3gkpdlkbaCE= diff --git a/internal/controller/assets/console_nginx.conf.tmpl b/internal/controller/assets/console_nginx.conf.tmpl new file mode 100644 index 00000000..7fec6ea9 --- /dev/null +++ b/internal/controller/assets/console_nginx.conf.tmpl @@ -0,0 +1,22 @@ + +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; + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + server { + listen %[1]d ssl; + listen [::]:%[1]d ssl; + ssl_certificate /var/cert/tls.crt; + ssl_certificate_key /var/cert/tls.key; + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/internal/controller/console_deployment.go b/internal/controller/console_deployment.go new file mode 100644 index 00000000..185c0a63 --- /dev/null +++ b/internal/controller/console_deployment.go @@ -0,0 +1,203 @@ +/* +Copyright 2026. + +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 controller + +import ( + "fmt" + + consolev1 "github.com/openshift/api/console/v1" + appsv1 "k8s.io/api/apps/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" +) + +// generateConsoleSelectorLabels returns a map of labels used as selectors +// for the console plugin pods. +func generateConsoleSelectorLabels() map[string]string { + return map[string]string{ + "app.kubernetes.io/component": "console-plugin", + "app.kubernetes.io/managed-by": "openstack-lightspeed-operator", + "app.kubernetes.io/name": "lightspeed-console-plugin", + "app.kubernetes.io/part-of": "openstack-lightspeed", + } +} + +// buildConsoleDeploymentSpec builds the Deployment spec for the console plugin. +func buildConsoleDeploymentSpec(consoleImage string) appsv1.DeploymentSpec { + replicas := int32(1) + volumeDefaultMode := VolumeDefaultMode + labels := generateConsoleSelectorLabels() + + return appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: toPtr(true), + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + }, + ServiceAccountName: ConsoleUIServiceAccountName, + Containers: []corev1.Container{ + { + Name: "lightspeed-console-plugin", + Image: consoleImage, + Ports: []corev1.ContainerPort{ + { + ContainerPort: ConsoleUIHTTPSPort, + Name: "https", + Protocol: corev1.ProtocolTCP, + }, + }, + ImagePullPolicy: corev1.PullAlways, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: toPtr(false), + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "lightspeed-console-plugin-cert", + 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: "lightspeed-console-plugin-cert", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: ConsoleUIServiceCertSecretName, + DefaultMode: &volumeDefaultMode, + }, + }, + }, + { + Name: "nginx-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: ConsoleUIConfigMapName, + }, + DefaultMode: &volumeDefaultMode, + }, + }, + }, + { + Name: "nginx-temp", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + }, + }, + }, + } +} + +// buildConsolePluginSpec builds the ConsolePlugin spec with backend and proxy configuration. +func buildConsolePluginSpec(namespace string) consolev1.ConsolePluginSpec { + return consolev1.ConsolePluginSpec{ + Backend: consolev1.ConsolePluginBackend{ + Service: &consolev1.ConsolePluginService{ + Name: ConsoleUIServiceName, + Namespace: namespace, + Port: ConsoleUIHTTPSPort, + BasePath: "/", + }, + Type: consolev1.Service, + }, + DisplayName: "Lightspeed Console Plugin", + I18n: consolev1.ConsolePluginI18n{ + LoadType: consolev1.Preload, + }, + Proxy: []consolev1.ConsolePluginProxy{ + { + Alias: ConsoleProxyAlias, + Authorization: consolev1.UserToken, + Endpoint: consolev1.ConsolePluginProxyEndpoint{ + Service: &consolev1.ConsolePluginProxyServiceConfig{ + Name: OpenStackLightspeedAppServerServiceName, + Namespace: namespace, + Port: OpenStackLightspeedAppServerServicePort, + }, + Type: consolev1.ProxyTypeService, + }, + }, + }, + } +} + +// buildConsoleNginxConfig returns the nginx configuration content for the console plugin. +func buildConsoleNginxConfig() string { + return fmt.Sprintf(consoleNginxConfigTemplate, ConsoleUIHTTPSPort) +} + +// buildConsoleNetworkPolicySpec builds the NetworkPolicy spec for the console plugin. +func buildConsoleNetworkPolicySpec() networkingv1.NetworkPolicySpec { + return networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: generateConsoleSelectorLabels(), + }, + 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: toPtr(corev1.ProtocolTCP), + Port: toPtr(intstr.FromInt32(ConsoleUIHTTPSPort)), + }, + }, + }, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + }, + } +} diff --git a/internal/controller/console_reconciler.go b/internal/controller/console_reconciler.go new file mode 100644 index 00000000..739f9423 --- /dev/null +++ b/internal/controller/console_reconciler.go @@ -0,0 +1,392 @@ +/* +Copyright 2026. + +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 controller + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "time" + + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" + common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" + 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/util/wait" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "k8s.io/apimachinery/pkg/util/intstr" + + appsv1 "k8s.io/api/apps/v1" +) + +// ReconcileConsoleResources reconciles Phase 1 console resources: ConfigMap (nginx), +// NetworkPolicy, and ServiceAccount. Uses a continue-on-error pattern. +func ReconcileConsoleResources(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { + tasks := []ReconcileTask{ + {Name: "ConsoleConfigMap", Task: reconcileConsoleConfigMap}, + {Name: "ConsoleNetworkPolicy", Task: reconcileConsoleNetworkPolicy}, + {Name: "ConsoleServiceAccount", Task: reconcileConsoleServiceAccount}, + } + + return ReconcileTasks(h, ctx, instance, tasks) +} + +// ReconcileConsoleDeployment reconciles Phase 2 console resources: Deployment, Service, +// TLS secret, ConsolePlugin, and console activation. Uses a fail-fast pattern. +func ReconcileConsoleDeployment(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { + tasks := []ReconcileTask{ + {Name: "ConsoleDeployment", Task: reconcileConsoleDeploymentResource}, + {Name: "ConsoleService", Task: reconcileConsoleService}, + {Name: "ConsoleTLSSecret", Task: reconcileConsoleTLSSecret}, + {Name: "ConsolePlugin", Task: reconcileConsolePlugin}, + {Name: "ActivateConsole", Task: activateConsole}, + } + + return ReconcileTasksFailFast(h, ctx, instance, tasks) +} + +// reconcileConsoleConfigMap ensures the console plugin nginx ConfigMap exists. +func reconcileConsoleConfigMap(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConsoleUIConfigMapName, + Namespace: h.GetBeforeObject().GetNamespace(), + }, + } + + result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), cm, func() error { + cm.Data = map[string]string{ + "nginx.conf": buildConsoleNginxConfig(), + } + return controllerutil.SetControllerReference(h.GetBeforeObject(), cm, h.GetScheme()) + }) + + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsoleConfigMap, err) + } + + logger.Info("Console ConfigMap reconciled", "name", cm.Name, "result", result) + return nil +} + +// reconcileConsoleNetworkPolicy ensures the console plugin network policy exists. +func reconcileConsoleNetworkPolicy(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConsoleUINetworkPolicyName, + Namespace: h.GetBeforeObject().GetNamespace(), + }, + } + + result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), np, func() error { + np.Spec = buildConsoleNetworkPolicySpec() + return controllerutil.SetControllerReference(h.GetBeforeObject(), np, h.GetScheme()) + }) + + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsoleNetPolicy, err) + } + + logger.Info("Console NetworkPolicy reconciled", "name", np.Name, "result", result) + return nil +} + +// reconcileConsoleServiceAccount ensures the console plugin service account exists. +func reconcileConsoleServiceAccount(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConsoleUIServiceAccountName, + Namespace: h.GetBeforeObject().GetNamespace(), + }, + } + + result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), sa, func() error { + return controllerutil.SetControllerReference(h.GetBeforeObject(), sa, h.GetScheme()) + }) + + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsoleSA, err) + } + + logger.Info("Console ServiceAccount reconciled", "name", sa.Name, "result", result) + return nil +} + +// consoleImageForVersion selects the console image based on detected OCP version. +// OCP < 4.19 or failed to get cluster version uses PatternFly 5 +// OCP >= 4.19 uses PatternFly 6. +func consoleImageForVersion(version string) string { + if version == "" { + return apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL + } + parts := strings.Split(version, ".") + if len(parts) >= 2 { + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + // OCP < 4 can't run this operator, so major != 4 effectively means OCP 5+ + if err1 == nil && err2 == nil && (major != 4 || minor >= 19) { + return apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImageURL + } + } + return apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL +} + +// resolveConsoleImage selects the console plugin image based on OCP cluster version. +func resolveConsoleImage(ctx context.Context, h *common_helper.Helper) string { + logger := h.GetLogger() + + version, err := DetectOCPVersion(ctx, h) + if err != nil { + logger.Info("Failed to detect OCP version for console image, using default", "error", err) + } + + image := consoleImageForVersion(version) + + if image == apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImageURL { + logger.Info("OCP >= 4.19, using PatternFly 6 console image", "version", version) + } else { + logger.Info("Using PatternFly 5 console image", "version", version) + } + + return image +} + +// reconcileConsoleDeploymentResource ensures the console plugin deployment exists. +func reconcileConsoleDeploymentResource(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + consoleImage := resolveConsoleImage(ctx, h) + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConsoleUIDeploymentName, + Namespace: h.GetBeforeObject().GetNamespace(), + }, + } + + result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), deployment, func() error { + spec := buildConsoleDeploymentSpec(consoleImage) + deployment.Spec.Replicas = spec.Replicas + deployment.Spec.Selector = spec.Selector + deployment.Spec.Template = spec.Template + return controllerutil.SetControllerReference(h.GetBeforeObject(), deployment, h.GetScheme()) + }) + + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsoleDeployment, err) + } + + logger.Info("Console Deployment reconciled", "name", deployment.Name, "result", result) + return nil +} + +// reconcileConsoleService ensures the console plugin service exists. +func reconcileConsoleService(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConsoleUIServiceName, + Namespace: h.GetBeforeObject().GetNamespace(), + }, + } + + result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), svc, func() error { + svc.Spec.Selector = generateConsoleSelectorLabels() + svc.Spec.Ports = []corev1.ServicePort{ + { + Port: ConsoleUIHTTPSPort, + Name: "https", + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromString("https"), + }, + } + svc.Spec.Type = corev1.ServiceTypeClusterIP + + if svc.Annotations == nil { + svc.Annotations = make(map[string]string) + } + svc.Annotations[ServingCertSecretAnnotationKey] = ConsoleUIServiceCertSecretName + + return controllerutil.SetControllerReference(h.GetBeforeObject(), svc, h.GetScheme()) + }) + + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsoleService, err) + } + + logger.Info("Console Service reconciled", "name", svc.Name, "result", result) + return nil +} + +// reconcileConsoleTLSSecret waits for the console TLS secret to be populated by +// the service-ca operator. +func reconcileConsoleTLSSecret(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + logger.Info("waiting for console TLS secret", "name", ConsoleUIServiceCertSecretName) + + secretKey := client.ObjectKey{ + Name: ConsoleUIServiceCertSecretName, + Namespace: h.GetBeforeObject().GetNamespace(), + } + + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, ResourceCreationTimeout, true, func(ctx context.Context) (bool, error) { + secret := &corev1.Secret{} + if err := h.GetClient().Get(ctx, secretKey, secret); err != nil { + if errors.IsNotFound(err) { + return false, nil + } + return false, err + } + _, hasKey := secret.Data["tls.key"] + _, hasCert := secret.Data["tls.crt"] + return hasKey && hasCert, nil + }) + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsoleTLSSecret, err) + } + + logger.Info("Console TLS secret is ready", "name", ConsoleUIServiceCertSecretName) + return nil +} + +// reconcileConsolePlugin ensures the ConsolePlugin CR exists. +func reconcileConsolePlugin(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + namespace := h.GetBeforeObject().GetNamespace() + + plugin := &consolev1.ConsolePlugin{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConsoleUIPluginName, + }, + } + + result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), plugin, func() error { + plugin.Spec = buildConsolePluginSpec(namespace) + // ConsolePlugin is cluster-scoped, no owner reference + return nil + }) + + if err != nil { + return fmt.Errorf("%w: %v", ErrReconcileConsolePlugin, err) + } + + logger.Info("ConsolePlugin reconciled", "name", plugin.Name, "result", result) + return nil +} + +// activateConsole adds the console plugin to the Console CR's plugin list. +func activateConsole(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + console := &openshiftv1.Console{} + err := h.GetClient().Get(ctx, client.ObjectKey{Name: ConsoleCRName}, console) + if err != nil { + if errors.IsNotFound(err) { + logger.Info("Console CR not found, skipping plugin activation") + return nil + } + return fmt.Errorf("failed to get Console CR: %w", err) + } + + if console.Spec.Plugins == nil { + console.Spec.Plugins = []string{ConsoleUIPluginName} + } else if !slices.Contains(console.Spec.Plugins, ConsoleUIPluginName) { + console.Spec.Plugins = append(console.Spec.Plugins, ConsoleUIPluginName) + } else { + return nil + } + + return h.GetClient().Update(ctx, console) + }) + if err != nil { + return fmt.Errorf("%w: %v", ErrActivateConsolePlugin, err) + } + + logger.Info("Console plugin activated") + return nil +} + +// reconcileDeleteConsole deactivates the console plugin and deletes the ConsolePlugin CR. +func reconcileDeleteConsole(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { + logger := h.GetLogger() + + // Deactivate: remove plugin from Console CR + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + console := &openshiftv1.Console{} + err := h.GetClient().Get(ctx, client.ObjectKey{Name: ConsoleCRName}, console) + if err != nil { + if errors.IsNotFound(err) { + logger.Info("Console CR not found, skipping deactivation") + return nil + } + return fmt.Errorf("%w: %v", ErrDeactivateConsolePlugin, err) + } + + if console.Spec.Plugins == nil { + return nil + } + if !slices.Contains(console.Spec.Plugins, ConsoleUIPluginName) { + return nil + } + + console.Spec.Plugins = slices.DeleteFunc(console.Spec.Plugins, func(name string) bool { + return name == ConsoleUIPluginName + }) + + return h.GetClient().Update(ctx, console) + }) + if err != nil { + return fmt.Errorf("%w: %v", ErrDeactivateConsolePlugin, err) + } + logger.Info("Console plugin deactivated") + + // Delete ConsolePlugin CR + plugin := &consolev1.ConsolePlugin{} + err = h.GetClient().Get(ctx, client.ObjectKey{Name: ConsoleUIPluginName}, plugin) + if err != nil { + if errors.IsNotFound(err) { + logger.Info("ConsolePlugin not found, skip deletion") + return nil + } + return fmt.Errorf("%w: %v", ErrDeleteConsolePlugin, err) + } + + err = h.GetClient().Delete(ctx, plugin) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("%w: %v", ErrDeleteConsolePlugin, err) + } + + logger.Info("ConsolePlugin deleted") + return nil +} diff --git a/internal/controller/console_reconciler_test.go b/internal/controller/console_reconciler_test.go new file mode 100644 index 00000000..64c69365 --- /dev/null +++ b/internal/controller/console_reconciler_test.go @@ -0,0 +1,239 @@ +/* +Copyright 2026. + +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 controller + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + consolev1 "github.com/openshift/api/console/v1" + apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" +) + +var _ = Describe("Console Plugin", func() { + + BeforeEach(func() { + // Set up defaults so the builder functions have image URLs + apiv1beta1.SetupDefaults() + }) + + Describe("generateConsoleSelectorLabels", func() { + It("should return the expected labels", func() { + labels := generateConsoleSelectorLabels() + Expect(labels).To(HaveKeyWithValue("app.kubernetes.io/component", "console-plugin")) + Expect(labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "openstack-lightspeed-operator")) + Expect(labels).To(HaveKeyWithValue("app.kubernetes.io/name", "lightspeed-console-plugin")) + Expect(labels).To(HaveKeyWithValue("app.kubernetes.io/part-of", "openstack-lightspeed")) + }) + }) + + Describe("buildConsoleDeploymentSpec", func() { + var spec appsv1.DeploymentSpec + + BeforeEach(func() { + spec = buildConsoleDeploymentSpec(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL) + }) + + It("should have one replica", func() { + Expect(spec.Replicas).NotTo(BeNil()) + Expect(*spec.Replicas).To(Equal(int32(1))) + }) + + It("should have correct selector labels", func() { + Expect(spec.Selector.MatchLabels).To(Equal(generateConsoleSelectorLabels())) + }) + + It("should have one container with the console image", func() { + containers := spec.Template.Spec.Containers + Expect(containers).To(HaveLen(1)) + Expect(containers[0].Name).To(Equal("lightspeed-console-plugin")) + Expect(containers[0].Image).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL)) + }) + + It("should expose HTTPS port 9443", func() { + ports := spec.Template.Spec.Containers[0].Ports + Expect(ports).To(HaveLen(1)) + Expect(ports[0].ContainerPort).To(Equal(ConsoleUIHTTPSPort)) + Expect(ports[0].Name).To(Equal("https")) + Expect(ports[0].Protocol).To(Equal(corev1.ProtocolTCP)) + }) + + It("should have TLS cert, nginx config, and nginx temp volume mounts", func() { + mounts := spec.Template.Spec.Containers[0].VolumeMounts + Expect(mounts).To(HaveLen(3)) + + var names []string + for _, m := range mounts { + names = append(names, m.Name) + } + Expect(names).To(ContainElements("lightspeed-console-plugin-cert", "nginx-config", "nginx-temp")) + }) + + It("should have TLS cert volume from secret", func() { + volumes := spec.Template.Spec.Volumes + var found bool + for _, v := range volumes { + if v.Name == "lightspeed-console-plugin-cert" { + found = true + Expect(v.VolumeSource.Secret).NotTo(BeNil()) + Expect(v.VolumeSource.Secret.SecretName).To(Equal(ConsoleUIServiceCertSecretName)) + } + } + Expect(found).To(BeTrue()) + }) + + It("should have nginx config volume from configmap", func() { + volumes := spec.Template.Spec.Volumes + var found bool + for _, v := range volumes { + if v.Name == "nginx-config" { + found = true + Expect(v.VolumeSource.ConfigMap).NotTo(BeNil()) + Expect(v.VolumeSource.ConfigMap.Name).To(Equal(ConsoleUIConfigMapName)) + } + } + Expect(found).To(BeTrue()) + }) + + It("should have nginx temp emptyDir volume", func() { + volumes := spec.Template.Spec.Volumes + var found bool + for _, v := range volumes { + if v.Name == "nginx-temp" { + found = true + Expect(v.VolumeSource.EmptyDir).NotTo(BeNil()) + } + } + Expect(found).To(BeTrue()) + }) + + It("should use the console service account", func() { + Expect(spec.Template.Spec.ServiceAccountName).To(Equal(ConsoleUIServiceAccountName)) + }) + }) + + Describe("buildConsolePluginSpec", func() { + const testNamespace = "test-ns" + var spec = buildConsolePluginSpec(testNamespace) + + It("should have service backend", func() { + Expect(spec.Backend.Type).To(Equal(consolev1.Service)) + Expect(spec.Backend.Service).NotTo(BeNil()) + Expect(spec.Backend.Service.Name).To(Equal(ConsoleUIServiceName)) + Expect(spec.Backend.Service.Namespace).To(Equal(testNamespace)) + Expect(spec.Backend.Service.Port).To(Equal(ConsoleUIHTTPSPort)) + }) + + It("should have proxy to lightspeed app server", func() { + Expect(spec.Proxy).To(HaveLen(1)) + proxy := spec.Proxy[0] + Expect(proxy.Alias).To(Equal(ConsoleProxyAlias)) + Expect(proxy.Authorization).To(Equal(consolev1.UserToken)) + Expect(proxy.Endpoint.Type).To(Equal(consolev1.ProxyTypeService)) + Expect(proxy.Endpoint.Service).NotTo(BeNil()) + Expect(proxy.Endpoint.Service.Name).To(Equal(OpenStackLightspeedAppServerServiceName)) + Expect(proxy.Endpoint.Service.Namespace).To(Equal(testNamespace)) + Expect(proxy.Endpoint.Service.Port).To(Equal(int32(OpenStackLightspeedAppServerServicePort))) + }) + + It("should have display name and i18n", func() { + Expect(spec.DisplayName).To(Equal("Lightspeed Console Plugin")) + Expect(spec.I18n.LoadType).To(Equal(consolev1.Preload)) + }) + }) + + Describe("buildConsoleNginxConfig", func() { + It("should contain SSL listener on port 9443", func() { + config := buildConsoleNginxConfig() + Expect(config).To(ContainSubstring("listen 9443 ssl")) + Expect(config).To(ContainSubstring("ssl_certificate /var/cert/tls.crt")) + Expect(config).To(ContainSubstring("ssl_certificate_key /var/cert/tls.key")) + }) + }) + + Describe("buildConsoleNetworkPolicySpec", func() { + var spec = buildConsoleNetworkPolicySpec() + + It("should select console plugin pods", func() { + Expect(spec.PodSelector.MatchLabels).To(Equal(generateConsoleSelectorLabels())) + }) + + It("should allow ingress from openshift-console namespace", func() { + Expect(spec.Ingress).To(HaveLen(1)) + Expect(spec.Ingress[0].From).To(HaveLen(1)) + nsSelector := spec.Ingress[0].From[0].NamespaceSelector + Expect(nsSelector).NotTo(BeNil()) + Expect(nsSelector.MatchLabels).To(HaveKeyWithValue("kubernetes.io/metadata.name", "openshift-console")) + }) + + It("should allow ingress on HTTPS port", func() { + Expect(spec.Ingress[0].Ports).To(HaveLen(1)) + Expect(spec.Ingress[0].Ports[0].Port.IntVal).To(Equal(ConsoleUIHTTPSPort)) + }) + + It("should have ingress policy type", func() { + Expect(spec.PolicyTypes).To(ContainElement( + networkingv1.PolicyTypeIngress, + )) + }) + }) + + Describe("consoleImageForVersion", func() { + It("should return PF5 image when version is empty", func() { + result := consoleImageForVersion("") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL)) + }) + + It("should return PF5 image for OCP 4.16", func() { + result := consoleImageForVersion("4.16") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL)) + }) + + It("should return PF5 image for OCP 4.18", func() { + result := consoleImageForVersion("4.18") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL)) + }) + + It("should return PF6 image for OCP 4.19", func() { + result := consoleImageForVersion("4.19") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImageURL)) + }) + + It("should return PF6 image for OCP 4.20", func() { + result := consoleImageForVersion("4.20") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImageURL)) + }) + + It("should return PF6 image for OCP 5.0", func() { + result := consoleImageForVersion("5.0") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImageURL)) + }) + + It("should return PF5 image for non-numeric version parts", func() { + result := consoleImageForVersion("abc.def") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL)) + }) + + It("should return PF5 image for single-part version string", func() { + result := consoleImageForVersion("4") + Expect(result).To(Equal(apiv1beta1.OpenStackLightspeedDefaultValues.ConsoleImagePF5URL)) + }) + }) +}) diff --git a/internal/controller/constants.go b/internal/controller/constants.go index ef7a93bd..d283d347 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -96,6 +96,18 @@ const ( RHOSOLightspeedOwnerIDLabel = "openstack.org/lightspeed-owner-id" ServiceIDRHOSO = "rhos-lightspeed" + // Console Plugin + ConsoleUIConfigMapName = "lightspeed-console-plugin" + ConsoleUIServiceCertSecretName = "lightspeed-console-plugin-cert" + ConsoleUIServiceName = "lightspeed-console-plugin" + ConsoleUIDeploymentName = "lightspeed-console-plugin" + ConsoleUIHTTPSPort = int32(9443) + ConsoleUIPluginName = "lightspeed-console-plugin" + ConsoleUIServiceAccountName = "lightspeed-console-plugin" + ConsoleCRName = "cluster" + ConsoleProxyAlias = "ols" + ConsoleUINetworkPolicyName = "lightspeed-console-plugin" + // Azure AzureOpenAIType = "azure_openai" @@ -209,3 +221,6 @@ var vectorDatabaseCollectScript string // //go:embed assets/vector_database_build.py var vectorDatabaseBuildScript string + +//go:embed assets/console_nginx.conf.tmpl +var consoleNginxConfigTemplate string diff --git a/internal/controller/errors.go b/internal/controller/errors.go index 4b01e11e..9640f76f 100644 --- a/internal/controller/errors.go +++ b/internal/controller/errors.go @@ -37,6 +37,18 @@ var ( ErrGenerateLlamaStackConfigMap = errors.New("failed to generate Llama Stack configmap") ErrCreateExporterConfigMap = errors.New("failed to create exporter configmap") + // Console Plugin Errors + ErrReconcileConsolePlugin = errors.New("failed to reconcile console plugin") + ErrReconcileConsoleDeployment = errors.New("failed to reconcile console deployment") + ErrReconcileConsoleConfigMap = errors.New("failed to reconcile console configmap") + ErrReconcileConsoleService = errors.New("failed to reconcile console service") + ErrReconcileConsoleNetPolicy = errors.New("failed to reconcile console network policy") + ErrReconcileConsoleSA = errors.New("failed to reconcile console service account") + ErrReconcileConsoleTLSSecret = errors.New("failed to reconcile console TLS secret") + ErrActivateConsolePlugin = errors.New("failed to activate console plugin") + ErrDeactivateConsolePlugin = errors.New("failed to deactivate console plugin") + ErrDeleteConsolePlugin = errors.New("failed to delete console plugin") + // Postgres Errors ErrCreatePostgresDeployment = errors.New("failed to create Postgres deployment") ErrCreatePostgresService = errors.New("failed to create Postgres service") diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index 71f897a2..5ae2e8a9 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -21,6 +21,7 @@ import ( "fmt" "github.com/go-logr/logr" + consolev1 "github.com/openshift/api/console/v1" "github.com/openstack-k8s-operators/lib-common/modules/common/condition" common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -70,6 +71,8 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups="",resources=secrets,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update;delete;deletecollection // +kubebuilder:rbac:groups="",resources=services,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update // +kubebuilder:rbac:groups="",resources=serviceaccounts,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch +// +kubebuilder:rbac:groups=console.openshift.io,resources=consoleplugins,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=operator.openshift.io,resources=consoles,verbs=watch;list;get;update func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { Log := r.GetLogger(ctx) @@ -168,6 +171,8 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. {Name: "PostgresDeployment", Task: ReconcilePostgresDeployment}, {Name: "LCoreResources", Task: ReconcileLCoreResources}, {Name: "LCoreDeployment", Task: ReconcileLCoreDeployment}, + {Name: "ConsoleResources", Task: ReconcileConsoleResources}, + {Name: "ConsoleDeployment", Task: ReconcileConsoleDeployment}, } if err := ReconcileTasks(helper, ctx, instance, reconcileTasks); err != nil { @@ -195,6 +200,7 @@ func (r *OpenStackLightspeedReconciler) reconcileDelete( // Delete cluster-scoped resources using fail-fast pattern deletionTasks := []ReconcileTask{ + {Name: "DeleteConsolePlugin", Task: reconcileDeleteConsole}, {Name: "DeleteSARClusterRoleBinding", Task: reconcileDeleteClusterRoleBindingByLabels}, {Name: "DeleteSARClusterRole", Task: reconcileDeleteClusterRoleByLabels}, } @@ -219,6 +225,7 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( deployments := []string{ PostgresDeploymentName, LCoreDeploymentName, + ConsoleUIDeploymentName, } for _, deploymentName := range deployments { deployment, err := getDeployment(ctx, helper, deploymentName, instance.Namespace) @@ -276,6 +283,7 @@ func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). Owns(&corev1.Secret{}). + Owns(&consolev1.ConsolePlugin{}). Watches( clusterVersion, handler.EnqueueRequestsFromMapFunc(r.NotifyAllOpenStackLightspeeds), diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index e03763a8..3b4c42b7 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -25,6 +25,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + consolev1 "github.com/openshift/api/console/v1" + openshiftv1 "github.com/openshift/api/operator/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -75,6 +77,12 @@ var _ = BeforeSuite(func() { err = apiv1beta1.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()) + // +kubebuilder:scaffold:scheme k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) diff --git a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml index 85c3fc26..e519907e 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml @@ -157,6 +157,50 @@ metadata: name: vector-db-scripts namespace: openstack-lightspeed +# Console Plugin resources +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +spec: + template: + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault +--- +apiVersion: v1 +kind: Service +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: lightspeed-console-plugin + # OpenStackLightspeed CR status --- apiVersion: lightspeed.openstack.org/v1beta1 diff --git a/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml index a74ffc6c..a33cf122 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml @@ -42,6 +42,43 @@ metadata: name: lightspeed-stack-config namespace: openstack-lightspeed +# Console Plugin resources should be gone +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Service +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: lightspeed-console-plugin + # Postgres resources should be gone --- apiVersion: apps/v1 diff --git a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml index b9a18ef9..d352493c 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -92,6 +92,43 @@ metadata: name: vector-db-scripts namespace: openstack-lightspeed +# Console Plugin resources +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Service +metadata: + name: lightspeed-console-plugin + namespace: openstack-lightspeed +--- +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: lightspeed-console-plugin + # OpenStackLightspeed CR status --- apiVersion: lightspeed.openstack.org/v1beta1 From fa936218547010fa620ac762eec8bc9fd5f96be7 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 29 Apr 2026 15:13:35 +0200 Subject: [PATCH 5/6] Console: Add OpenShift->OpenStack text replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To avoid having the console saying it's OpenShift Lightspeed we will be doing a replacement of the OpenShift word in the internationalization file. This mechanism is a bit hacky, but it's a patch that's easy to revert if we decide to. Add an init container that rewrites "OpenShift" references to "OpenStack" in the locales JSON file using awk. The awk script splits each line at the key-value boundary and only replaces in values, preserving i18n lookup keys. Using this mechanism should not be a problem for non English locales (at least according to Claude), since the i18n fallback mechanism works like this: 1. This plugin doesn't initialize i18next itself. It relies on the parent OpenShift Console (via ConsoleRemotePlugin in webpack.config.ts) to set up i18next globally. The plugin just uses useTranslation('plugin__lightspeed-console-plugin') to access its namespace. 2. When a non-English locale is requested, i18next looks for locales//plugin__lightspeed-console-plugin.json. Since only locales/en/ exists, i18next falls back to English — this is standard i18next behavior where missing locale files cause a fallback to the default language. 3. Additionally, the translation keys in locales/en/plugin__lightspeed-console-plugin.json are the English strings themselves (e.g., "Close": "Close"). So even if the fallback mechanism somehow failed entirely, i18next's last resort is to display the key as-is — which in this case is already readable English text. As long as OLS console doesn't have a bug in their code that doesn't use the internationalization with a string and that string has OpenShift in the wording we should be fine. Co-Authored-By: Claude Opus 4.6 --- .../assets/console_locales_rewrite.awk | 11 ++++ internal/controller/console_deployment.go | 44 +++++++++++++ .../controller/console_reconciler_test.go | 64 ++++++++++++++++++- internal/controller/constants.go | 6 ++ .../assert-openstack-lightspeed-instance.yaml | 10 +++ .../08-assert-openstacklightspeed-update.yaml | 8 +++ 6 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 internal/controller/assets/console_locales_rewrite.awk diff --git a/internal/controller/assets/console_locales_rewrite.awk b/internal/controller/assets/console_locales_rewrite.awk new file mode 100644 index 00000000..b2a45ad7 --- /dev/null +++ b/internal/controller/assets/console_locales_rewrite.awk @@ -0,0 +1,11 @@ +{ + idx = index($0, "\": ") + if (idx > 0) { + key_part = substr($0, 1, idx + 2) + val_part = substr($0, idx + 3) + gsub(/OpenShift/, "OpenStack", val_part) + gsub(/openshift/, "openstack", val_part) + gsub(/OPENSHIFT/, "OPENSTACK", val_part) + printf "%s%s\n", key_part, val_part + } else { print } +} diff --git a/internal/controller/console_deployment.go b/internal/controller/console_deployment.go index 185c0a63..e4ed6531 100644 --- a/internal/controller/console_deployment.go +++ b/internal/controller/console_deployment.go @@ -38,7 +38,15 @@ func generateConsoleSelectorLabels() map[string]string { } } +// consoleLocalesFilename is the filename of the locales JSON file. +const consoleLocalesFilename = "plugin__lightspeed-console-plugin.json" + +// consoleLocalesPath is the path to the locales JSON file inside the console image. +const consoleLocalesPath = "/usr/share/nginx/html/locales/en/" + consoleLocalesFilename + // buildConsoleDeploymentSpec builds the Deployment spec for the console plugin. +// Includes an init container that rewrites OpenShift references to OpenStack +// in the locales JSON file using an emptyDir volume. func buildConsoleDeploymentSpec(consoleImage string) appsv1.DeploymentSpec { replicas := int32(1) volumeDefaultMode := VolumeDefaultMode @@ -61,6 +69,30 @@ func buildConsoleDeploymentSpec(consoleImage string) appsv1.DeploymentSpec { }, }, ServiceAccountName: ConsoleUIServiceAccountName, + InitContainers: []corev1.Container{ + { + Name: "rewrite-locales", + Image: consoleImage, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: toPtr(false), + ReadOnlyRootFilesystem: toPtr(true), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + }, + Command: []string{ + "sh", "-c", + "awk '" + consoleLocalesRewriteAwk + "' " + + consoleLocalesPath + " > /locales-rewrite/" + consoleLocalesFilename, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "locales-rewrite", + MountPath: "/locales-rewrite", + }, + }, + }, + }, Containers: []corev1.Container{ { Name: "lightspeed-console-plugin", @@ -92,6 +124,12 @@ func buildConsoleDeploymentSpec(consoleImage string) appsv1.DeploymentSpec { Name: "nginx-temp", MountPath: "/tmp/nginx", }, + { + Name: "locales-rewrite", + MountPath: consoleLocalesPath, + SubPath: consoleLocalesFilename, + ReadOnly: true, + }, }, }, }, @@ -122,6 +160,12 @@ func buildConsoleDeploymentSpec(consoleImage string) appsv1.DeploymentSpec { EmptyDir: &corev1.EmptyDirVolumeSource{}, }, }, + { + Name: "locales-rewrite", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, }, }, }, diff --git a/internal/controller/console_reconciler_test.go b/internal/controller/console_reconciler_test.go index 64c69365..9e75c100 100644 --- a/internal/controller/console_reconciler_test.go +++ b/internal/controller/console_reconciler_test.go @@ -75,15 +75,29 @@ var _ = Describe("Console Plugin", func() { Expect(ports[0].Protocol).To(Equal(corev1.ProtocolTCP)) }) - It("should have TLS cert, nginx config, and nginx temp volume mounts", func() { + It("should have TLS cert, nginx config, nginx temp, and locales-rewrite volume mounts", func() { mounts := spec.Template.Spec.Containers[0].VolumeMounts - Expect(mounts).To(HaveLen(3)) + Expect(mounts).To(HaveLen(4)) var names []string for _, m := range mounts { names = append(names, m.Name) } - Expect(names).To(ContainElements("lightspeed-console-plugin-cert", "nginx-config", "nginx-temp")) + Expect(names).To(ContainElements("lightspeed-console-plugin-cert", "nginx-config", "nginx-temp", "locales-rewrite")) + }) + + It("should mount locales-rewrite with SubPath at the locales file path", func() { + mounts := spec.Template.Spec.Containers[0].VolumeMounts + var found bool + for _, m := range mounts { + if m.Name == "locales-rewrite" { + found = true + Expect(m.MountPath).To(Equal(consoleLocalesPath)) + Expect(m.SubPath).To(Equal(consoleLocalesFilename)) + Expect(m.ReadOnly).To(BeTrue()) + } + } + Expect(found).To(BeTrue()) }) It("should have TLS cert volume from secret", func() { @@ -127,6 +141,50 @@ var _ = Describe("Console Plugin", func() { It("should use the console service account", func() { Expect(spec.Template.Spec.ServiceAccountName).To(Equal(ConsoleUIServiceAccountName)) }) + + It("should have a locales-rewrite emptyDir volume", func() { + volumes := spec.Template.Spec.Volumes + var found bool + for _, v := range volumes { + if v.Name == "locales-rewrite" { + found = true + Expect(v.VolumeSource.EmptyDir).NotTo(BeNil()) + } + } + Expect(found).To(BeTrue()) + }) + + It("should have one init container for rewriting locales", func() { + initContainers := spec.Template.Spec.InitContainers + Expect(initContainers).To(HaveLen(1)) + Expect(initContainers[0].Name).To(Equal("rewrite-locales")) + }) + + It("should use the same console image for the init container", func() { + initContainer := spec.Template.Spec.InitContainers[0] + mainContainer := spec.Template.Spec.Containers[0] + Expect(initContainer.Image).To(Equal(mainContainer.Image)) + }) + + It("should have the init container command with awk for text replacement", func() { + initContainer := spec.Template.Spec.InitContainers[0] + Expect(initContainer.Command).To(HaveLen(3)) + Expect(initContainer.Command[0]).To(Equal("sh")) + Expect(initContainer.Command[1]).To(Equal("-c")) + cmd := initContainer.Command[2] + Expect(cmd).To(ContainSubstring("awk")) + Expect(cmd).To(ContainSubstring("OpenShift")) + Expect(cmd).To(ContainSubstring("OpenStack")) + Expect(cmd).To(ContainSubstring(consoleLocalesPath)) + Expect(cmd).To(ContainSubstring("/locales-rewrite/" + consoleLocalesFilename)) + }) + + It("should mount locales-rewrite volume in the init container", func() { + initContainer := spec.Template.Spec.InitContainers[0] + Expect(initContainer.VolumeMounts).To(HaveLen(1)) + Expect(initContainer.VolumeMounts[0].Name).To(Equal("locales-rewrite")) + Expect(initContainer.VolumeMounts[0].MountPath).To(Equal("/locales-rewrite")) + }) }) Describe("buildConsolePluginSpec", func() { diff --git a/internal/controller/constants.go b/internal/controller/constants.go index d283d347..1ee82b48 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -224,3 +224,9 @@ var vectorDatabaseBuildScript string //go:embed assets/console_nginx.conf.tmpl var consoleNginxConfigTemplate string + +// consoleLocalesRewriteAwk is the awk script that performs case-preserving +// OpenShift -> OpenStack replacement only in JSON values (after the first `": `). +// +//go:embed assets/console_locales_rewrite.awk +var consoleLocalesRewriteAwk string diff --git a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml index e519907e..bf2ce836 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml @@ -189,6 +189,16 @@ spec: runAsNonRoot: true seccompProfile: type: RuntimeDefault + initContainers: + - name: rewrite-locales + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: locales-rewrite + mountPath: /locales-rewrite --- apiVersion: v1 kind: Service diff --git a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml index d352493c..f98804db 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -117,6 +117,14 @@ kind: Deployment metadata: name: lightspeed-console-plugin namespace: openstack-lightspeed +spec: + template: + spec: + initContainers: + - name: rewrite-locales + volumeMounts: + - name: locales-rewrite + mountPath: /locales-rewrite --- apiVersion: v1 kind: Service From ba3edf24735fb8de51cf360b9a42a71a8ba7a832 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Tue, 26 May 2026 18:12:47 +0200 Subject: [PATCH 6/6] Update README file with kuttl targets In this patch we update the README.md file to clarify the kuttl related targets, how they are connected and how they can be used. --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5a516d6f..d01f6e90 100644 --- a/README.md +++ b/README.md @@ -226,14 +226,51 @@ pre-commit run --all-files KUTTL (KUbernetes Test TooL) tests validate the operator's behavior in a real OpenShift environment. -Before running the tests ensure that: -- `oc` CLI tool is available in your PATH and you can access an OpenShift cluster -(e.g., deployed with `crc`) with it -- The `openshift-lightspeed` namespace is empty or non-existing to prevent collisions +Kuttl tests are run using the `kuttl-test` make target, which has some +requirements: -Once you are ready you can run the KUTTL tests using: +- `kubectl-kuttl`, `diff` and `oc` binaries exist and are in the `PATH`. +- An OpenShift cluster is up and running (e.g., one deployed with `crc`). +- `oc` CLI tool can access the OpenShift cluster and is logged in. +- The OpenStack Lightspeed operator to be tested is installed and running in the + OpenShift cluster in the `openstack-lightspeed` namespace. + +Using the `kuttl-test` directly is uncommon, as we have 2 helpful targets: + +- `kuttl-test-run`: Given a catalog image location deploys OpenStack Lightspeed + on the OpenShift cluster, runs the tests (using `kuttl-test`), and removes + OpenStack Lightspeed. + +- `kuttl-test-ocp`: Builds the operator, bundle and catalog images, pushes them + to the OpenShift cluster internal registry, and then runs the kuttl tests + (using `kuttl-test-run`). + +In both cases it will check that the `kubectl-kuttl` binary is present in the +system and download it if it's not (target `kuttl`) and both need the +`openstack-lightspeed` namespace to be empty or non-existing to prevent +collisions. + +For the `kuttl-test-run` target the images need to be available in an image +registry accessible by the OpenShift cluster. We can build these images +ourselves or use images built by others, in any case variable `CATALOG_IMG` +must point to the catalog image before running `kuttl-test-run`. + +Using `kuttl-test-ocp` is useful to build and test everything, but it's too +wasteful if we are going to run kuttl tests multiple times, where +`kuttl-test-run` is better as it doesn't rebuild the images on each run. + +A useful option when working on kuttl tests, without changes on the operator +itself, is to use `kuttl-test-ocp` the first time: + +```bash +make kuttl-test-ocp +``` + +And then set `CATALOG_IMG` and use the `kuttl-test-run` target in +consecutive runs: ```bash +export CATALOG_IMG=image-registry.openshift-image-registry.svc:5000/openshift-marketplace/operator-catalog:latest make kuttl-test-run ```