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/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 ``` 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_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/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..e4ed6531 --- /dev/null +++ b/internal/controller/console_deployment.go @@ -0,0 +1,247 @@ +/* +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", + } +} + +// 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 + 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, + 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", + 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", + }, + { + Name: "locales-rewrite", + MountPath: consoleLocalesPath, + SubPath: consoleLocalesFilename, + ReadOnly: true, + }, + }, + }, + }, + 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{}, + }, + }, + { + Name: "locales-rewrite", + 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..9e75c100 --- /dev/null +++ b/internal/controller/console_reconciler_test.go @@ -0,0 +1,297 @@ +/* +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, nginx temp, and locales-rewrite volume mounts", func() { + mounts := spec.Template.Spec.Containers[0].VolumeMounts + 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", "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() { + 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)) + }) + + 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() { + 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..1ee82b48 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,12 @@ var vectorDatabaseCollectScript string // //go:embed assets/vector_database_build.py 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/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/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/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/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()) } 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/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." 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..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 @@ -73,6 +73,7 @@ rules: resources: - clusterversions verbs: + - list - get - apiGroups: - "" @@ -156,6 +157,60 @@ 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 + initContainers: + - name: rewrite-locales + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: locales-rewrite + mountPath: /locales-rewrite +--- +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..f98804db 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,51 @@ 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: + initContainers: + - name: rewrite-locales + volumeMounts: + - name: locales-rewrite + mountPath: /locales-rewrite +--- +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