diff --git a/doc/overviews/development.md b/doc/overviews/development.md index 9e91751c2..3c0f670f4 100644 --- a/doc/overviews/development.md +++ b/doc/overviews/development.md @@ -201,11 +201,38 @@ The `make bundle` target accepts the following variables: | `CHANNELS` | Bundle channels used in the bundle, comma separated | `alpha` | | | `DEFAULT_CHANNEL` | The default channel used in the bundle | `alpha` | | -*Note:* The console plugin image is configured via two `RELATED_IMAGE` environment variables based on OpenShift version: -- `RELATED_IMAGE_CONSOLE_PLUGIN_LATEST`: Used for OpenShift versions >= 4.20 (PatternFly 6 compatible) +*Note:* The console plugin image is configured via three `RELATED_IMAGE` environment variables based on OpenShift version: + +- `RELATED_IMAGE_CONSOLE_PLUGIN_LATEST`: Used for OpenShift versions >= 4.22 +- `RELATED_IMAGE_CONSOLE_PLUGIN_SDK1`: Used for OpenShift versions 4.20–4.21 - `RELATED_IMAGE_CONSOLE_PLUGIN_PF5`: Used for OpenShift versions < 4.20 (PatternFly 5 compatible) The operator automatically selects the appropriate image based on the detected OpenShift cluster version. +`CONSOLE_PLUGIN_IMAGE_OVERRIDE` explicitly selects a development image, uses +`IfNotPresent`, and permits deployment without a ClusterVersion resource when +the ConsolePlugin API is installed. + +### Console plugin network access + +The operator reconciles a `kuadrant-console-plugin` NetworkPolicy in its namespace +before creating the plugin Deployment. It selects only plugin pods and permits +TCP 9443 from pods labelled `app: console` in `openshift-console` (both selectors +must match). This covers plugin assets and the MCP Inspector backend proxy even +when the operator namespace has default-deny ingress. + +The policy is owned by the topology ConfigMap and follows the plugin lifecycle. +The operator restores its spec, labels and ownership after edits, and recreates +it after deletion. Add separately named NetworkPolicies for additional ingress; +do not edit the managed policy. Kubernetes NetworkPolicies are additive, so a +broader policy selecting these pods can also grant access. + +This policy does not select egress or grant unrestricted outbound access. If +your environment denies egress, permit DNS, HTTPS to the Kubernetes API, and the +configured MCP Gateway listener on its actual destination port. Also permit +Console egress to the plugin and, if restricted, Gateway ingress from the plugin. +The Inspector contacts the Gateway listener, not the broker's internal ports. +The broker's managed policy remains the MCP gateway controller's responsibility; +see [MCP gateway PR #1429](https://github.com/Kuadrant/mcp-gateway/pull/1429). * Build the bundle manifests diff --git a/internal/controller/consoleplugin_reconciler.go b/internal/controller/consoleplugin_reconciler.go index 6af035568..6ad84af88 100644 --- a/internal/controller/consoleplugin_reconciler.go +++ b/internal/controller/consoleplugin_reconciler.go @@ -12,8 +12,10 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" "k8s.io/utils/ptr" ctrlruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/kuadrant/kuadrant-operator/internal/openshift" "github.com/kuadrant/kuadrant-operator/internal/openshift/consoleplugin" @@ -23,21 +25,24 @@ import ( //+kubebuilder:rbac:groups=console.openshift.io,resources=consoleplugins,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=config.openshift.io,resources=clusterversions,verbs=get;list;watch +//+kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete type ConsolePluginReconciler struct { *reconcilers.BaseReconciler - namespace string + namespace string + imageOverride string } -func NewConsolePluginReconciler(mgr ctrlruntime.Manager, namespace string) *ConsolePluginReconciler { +func NewConsolePluginReconciler(mgr ctrlruntime.Manager, namespace, imageOverride string) *ConsolePluginReconciler { return &ConsolePluginReconciler{ BaseReconciler: reconcilers.NewBaseReconciler( mgr.GetClient(), mgr.GetScheme(), mgr.GetAPIReader(), ), - namespace: namespace, + namespace: namespace, + imageOverride: imageOverride, } } @@ -58,6 +63,11 @@ func (r *ConsolePluginReconciler) Subscription() *controller.Subscription { ObjectName: TopologyConfigMapName, EventType: ptr.To(controller.DeleteEvent), }, + { + Kind: ptr.To(networkingv1.SchemeGroupVersion.WithKind("NetworkPolicy").GroupKind()), + ObjectNamespace: r.namespace, + ObjectName: consoleplugin.KuadrantConsoleName, + }, }, } } @@ -79,13 +89,32 @@ func (r *ConsolePluginReconciler) Run(eventCtx context.Context, _ []controller.R }) clusterVersionExists := len(clusterVersions) > 0 + consolePluginSupported := clusterVersionExists || r.imageOverride != "" + + // Apply ingress protection before starting the backend. The topology + // ConfigMap anchors the plugin's lifecycle, including garbage collection. + networkPolicy := consoleplugin.NetworkPolicy(r.namespace) + if !topologyExists || !consolePluginSupported { + utils.TagObjectToDelete(networkPolicy) + } else { + owner := existingTopologyConfigMaps[0].(*controller.RuntimeObject).Object + if err := controllerutil.SetOwnerReference(owner, networkPolicy, r.Scheme()); err != nil { + return err + } + } + _, err := r.ReconcileResource(ctx, &networkingv1.NetworkPolicy{}, networkPolicy, + reconcilers.Mutator[*networkingv1.NetworkPolicy](consoleplugin.NetworkPolicyMutator)) + if err != nil { + logger.Error(err, "reconciling network policy") + return err + } // Service service := consoleplugin.Service(r.namespace) - if !topologyExists || !clusterVersionExists { + if !topologyExists || !consolePluginSupported { utils.TagObjectToDelete(service) } - _, err := r.ReconcileResource(ctx, &corev1.Service{}, service, reconcilers.CreateOnlyMutator) + _, err = r.ReconcileResource(ctx, &corev1.Service{}, service, reconcilers.CreateOnlyMutator) if err != nil { logger.Error(err, "reconciling service") return err @@ -93,7 +122,9 @@ func (r *ConsolePluginReconciler) Run(eventCtx context.Context, _ []controller.R // Deployment var consolePluginImageURL string - if topologyExists && clusterVersionExists { + if topologyExists && r.imageOverride != "" { + consolePluginImageURL = r.imageOverride + } else if topologyExists && clusterVersionExists { clusterVersion := clusterVersions[0].(*controller.RuntimeObject).Object.(*configv1.ClusterVersion) consolePluginImageURL, err = openshift.GetConsolePluginImageForVersion(clusterVersion) @@ -104,9 +135,13 @@ func (r *ConsolePluginReconciler) Run(eventCtx context.Context, _ []controller.R } deployment := consoleplugin.Deployment(r.namespace, consolePluginImageURL, TopologyConfigMapName) - deploymentMutators := make([]reconcilers.DeploymentMutateFn, 0, 1) + if r.imageOverride != "" { + deployment.Spec.Template.Spec.Containers[0].ImagePullPolicy = corev1.PullIfNotPresent + } + deploymentMutators := make([]reconcilers.DeploymentMutateFn, 0, 2) deploymentMutators = append(deploymentMutators, reconcilers.DeploymentImageMutator) - if !topologyExists || !clusterVersionExists { + deploymentMutators = append(deploymentMutators, consoleplugin.DeploymentConfigMutator) + if !topologyExists || !consolePluginSupported { utils.TagObjectToDelete(deployment) } _, err = r.ReconcileResource(ctx, &appsv1.Deployment{}, deployment, reconcilers.DeploymentMutator(deploymentMutators...)) @@ -115,23 +150,22 @@ func (r *ConsolePluginReconciler) Run(eventCtx context.Context, _ []controller.R return err } - // Nginx ConfigMap - nginxConfigMap := consoleplugin.NginxConfigMap(r.namespace) - if !topologyExists || !clusterVersionExists { - utils.TagObjectToDelete(nginxConfigMap) - } - _, err = r.ReconcileResource(ctx, &corev1.ConfigMap{}, nginxConfigMap, reconcilers.CreateOnlyMutator) + // Remove the nginx configuration left behind by older Console plugin + // deployments. The combined asset server/backend no longer mounts it. + legacyNginxConfigMap := consoleplugin.LegacyNginxConfigMap(r.namespace) + utils.TagObjectToDelete(legacyNginxConfigMap) + _, err = r.ReconcileResource(ctx, &corev1.ConfigMap{}, legacyNginxConfigMap, reconcilers.CreateOnlyMutator) if err != nil { - logger.Error(err, "reconciling nginx configmap") + logger.Error(err, "deleting legacy nginx configmap") return err } // ConsolePlugin consolePlugin := consoleplugin.ConsolePlugin(r.namespace) - if !topologyExists || !clusterVersionExists { + if !topologyExists || !consolePluginSupported { utils.TagObjectToDelete(consolePlugin) } - consolePluginMutator := reconcilers.Mutator[*consolev1.ConsolePlugin](consoleplugin.ServiceMutator) + consolePluginMutator := reconcilers.Mutator[*consolev1.ConsolePlugin](consoleplugin.SpecMutator) _, err = r.ReconcileResource(ctx, &consolev1.ConsolePlugin{}, consolePlugin, consolePluginMutator) if err != nil { logger.Error(err, "reconciling consoleplugin") diff --git a/internal/controller/consoleplugin_reconciler_test.go b/internal/controller/consoleplugin_reconciler_test.go index 62fa17362..e4cf1bec2 100644 --- a/internal/controller/consoleplugin_reconciler_test.go +++ b/internal/controller/consoleplugin_reconciler_test.go @@ -14,6 +14,7 @@ import ( is "gotest.tools/assert/cmp" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -83,6 +84,7 @@ func TestConsolePluginReconciler(t *testing.T) { scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) + _ = networkingv1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = gatewayapiv1.AddToScheme(scheme) _ = consolev1.AddToScheme(scheme) @@ -106,14 +108,14 @@ func TestConsolePluginReconciler(t *testing.T) { WithScheme(scheme). Build() - reconciler := NewConsolePluginReconciler(manager, TestNamespace) + reconciler := NewConsolePluginReconciler(manager, TestNamespace, "") assert.Assert(t, reconciler != nil) t.Run("Subscription", func(subT *testing.T) { subscription := reconciler.Subscription() assert.Assert(subT, subscription != nil) events := subscription.Events - assert.Assert(subT, is.Len(events, 3)) + assert.Assert(subT, is.Len(events, 4)) assert.DeepEqual(subT, events[0].Kind, ptr.To(openshift.ConsolePluginGVK.GroupKind())) assert.DeepEqual(subT, events[1].Kind, ptr.To(ConfigMapGroupKind)) assert.DeepEqual(subT, events[1].ObjectName, TopologyConfigMapName) @@ -123,6 +125,39 @@ func TestConsolePluginReconciler(t *testing.T) { assert.DeepEqual(subT, events[2].ObjectName, TopologyConfigMapName) assert.DeepEqual(subT, events[2].ObjectNamespace, TestNamespace) assert.DeepEqual(subT, events[2].EventType, ptr.To(controller.DeleteEvent)) + assert.DeepEqual(subT, events[3].Kind, ptr.To(networkingv1.SchemeGroupVersion.WithKind("NetworkPolicy").GroupKind())) + assert.Equal(subT, events[3].ObjectName, consoleplugin.KuadrantConsoleName) + assert.Equal(subT, events[3].ObjectNamespace, TestNamespace) + assert.Assert(subT, events[3].EventType == nil) + }) + + t.Run("Create, repair, recreate and delete network policy", func(t *testing.T) { + topology := buildTopologyWithClusterVersion(t) + assert.NilError(t, reconciler.Run(context.TODO(), nil, topology, nil, nil)) + policy := &networkingv1.NetworkPolicy{} + key := client.ObjectKey{Name: consoleplugin.KuadrantConsoleName, Namespace: TestNamespace} + assert.NilError(t, manager.GetClient().Get(context.TODO(), key, policy)) + expected := policy.DeepCopy() + assert.DeepEqual(t, policy.Spec, consoleplugin.NetworkPolicy(TestNamespace).Spec) + assert.Assert(t, is.Len(policy.OwnerReferences, 1)) + assert.Equal(t, policy.OwnerReferences[0].Name, TopologyConfigMapName) + policy.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{}} + policy.Labels = nil + policy.OwnerReferences = nil + assert.NilError(t, manager.GetClient().Update(context.TODO(), policy)) + assert.NilError(t, reconciler.Run(context.TODO(), nil, topology, nil, nil)) + assert.NilError(t, manager.GetClient().Get(context.TODO(), key, policy)) + assert.DeepEqual(t, policy.Spec, expected.Spec) + assert.DeepEqual(t, policy.Labels, expected.Labels) + assert.DeepEqual(t, policy.OwnerReferences, expected.OwnerReferences) + assert.Assert(t, !consoleplugin.NetworkPolicyMutator(expected, policy)) + assert.NilError(t, manager.GetClient().Delete(context.TODO(), policy)) + assert.NilError(t, reconciler.Run(context.TODO(), nil, topology, nil, nil)) + assert.NilError(t, manager.GetClient().Get(context.TODO(), key, policy)) + empty, err := machinery.NewTopology() + assert.NilError(t, err) + assert.NilError(t, reconciler.Run(context.TODO(), nil, empty, nil, nil)) + assert.Assert(t, apierrors.IsNotFound(manager.GetClient().Get(context.TODO(), key, policy))) }) t.Run("Create service", func(subT *testing.T) { @@ -163,6 +198,9 @@ func TestConsolePluginReconciler(t *testing.T) { assert.DeepEqual(subT, deployment.Spec.Strategy, consoleplugin.DeploymentStrategy()) assert.Assert(subT, is.Len(deployment.Spec.Template.Spec.Containers, 1)) assert.Assert(subT, deployment.Spec.Template.Spec.Containers[0].Image == ConsolePluginImageURL) + assert.Equal(subT, deployment.Spec.Template.Spec.Containers[0].ImagePullPolicy, corev1.PullAlways) + assert.Assert(subT, is.Len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts, 1)) + assert.Assert(subT, is.Len(deployment.Spec.Template.Spec.Volumes, 1)) }) t.Run("Delete deployment", func(subT *testing.T) { @@ -175,27 +213,6 @@ func TestConsolePluginReconciler(t *testing.T) { assert.Assert(subT, apierrors.IsNotFound(err)) }) - t.Run("Create nginx configmap", func(subT *testing.T) { - topology := buildTopologyWithClusterVersion(subT) - assert.NilError(subT, reconciler.Run(context.TODO(), nil, topology, nil, nil)) - configMap := &corev1.ConfigMap{} - cmKey := client.ObjectKey{Name: consoleplugin.NginxConfigMapName(), Namespace: TestNamespace} - assert.NilError(subT, manager.GetClient().Get(context.TODO(), cmKey, configMap)) - assert.DeepEqual(subT, configMap.GetLabels(), consoleplugin.CommonLabels()) - _, ok := configMap.Data["nginx.conf"] - assert.Assert(subT, ok) - }) - - t.Run("Delete nginx configmap", func(subT *testing.T) { - topology, err := machinery.NewTopology() - assert.Assert(subT, err == nil) - assert.NilError(subT, reconciler.Run(context.TODO(), nil, topology, nil, nil)) - configMap := &corev1.ConfigMap{} - cmKey := client.ObjectKey{Name: consoleplugin.NginxConfigMapName(), Namespace: TestNamespace} - err = manager.GetClient().Get(context.TODO(), cmKey, configMap) - assert.Assert(subT, apierrors.IsNotFound(err)) - }) - t.Run("Create consoleplugin", func(subT *testing.T) { topology := buildTopologyWithClusterVersion(subT) assert.NilError(subT, reconciler.Run(context.TODO(), nil, topology, nil, nil)) @@ -206,6 +223,11 @@ func TestConsolePluginReconciler(t *testing.T) { assert.Assert(subT, consolePlugin.Spec.Backend.Service != nil) assert.Assert(subT, consolePlugin.Spec.Backend.Service.Name == consoleplugin.ServiceName()) assert.Assert(subT, consolePlugin.Spec.Backend.Service.Namespace == TestNamespace) + assert.Assert(subT, is.Len(consolePlugin.Spec.Proxy, 1)) + assert.Assert(subT, consolePlugin.Spec.Proxy[0].Alias == "backend") + assert.Assert(subT, consolePlugin.Spec.Proxy[0].Authorization == consolev1.UserToken) + assert.Assert(subT, consolePlugin.Spec.Proxy[0].Endpoint.Service != nil) + assert.Assert(subT, consolePlugin.Spec.Proxy[0].Endpoint.Service.Name == consoleplugin.ServiceName()) }) t.Run("Delete consoleplugin", func(subT *testing.T) { @@ -218,3 +240,51 @@ func TestConsolePluginReconciler(t *testing.T) { assert.Assert(subT, apierrors.IsNotFound(err)) }) } + +func TestConsolePluginReconcilerWithDevelopmentImageOverride(t *testing.T) { + const imageOverride = "localhost/kuadrant/console-plugin:dev" + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = networkingv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = consolev1.AddToScheme(scheme) + _ = configv1.AddToScheme(scheme) + + legacyConfigMap := consoleplugin.LegacyNginxConfigMap(TestNamespace) + legacyConfigMap.Data = map[string]string{"nginx.conf": "legacy"} + manager := controllersfake. + NewManagerBuilder(). + WithClient(fake.NewClientBuilder().WithScheme(scheme).WithObjects(legacyConfigMap).Build()). + WithScheme(scheme). + Build() + reconciler := NewConsolePluginReconciler(manager, TestNamespace, imageOverride) + + topologyConfigMap := &controller.RuntimeObject{ + Object: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: ConfigMapGroupKind.Kind, APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: TopologyConfigMapName, + Namespace: TestNamespace, + Labels: map[string]string{kuadrant.TopologyLabel: "true"}, + }, + }, + } + topology, err := machinery.NewTopology(machinery.WithObjects(topologyConfigMap)) + assert.NilError(t, err) + assert.NilError(t, reconciler.Run(context.TODO(), nil, topology, nil, nil)) + + deployment := &appsv1.Deployment{} + deploymentKey := client.ObjectKey{Name: consoleplugin.DeploymentName(), Namespace: TestNamespace} + assert.NilError(t, manager.GetClient().Get(context.TODO(), deploymentKey, deployment)) + assert.Equal(t, deployment.Spec.Template.Spec.Containers[0].Image, imageOverride) + assert.Equal(t, deployment.Spec.Template.Spec.Containers[0].ImagePullPolicy, corev1.PullIfNotPresent) + + consolePlugin := &consolev1.ConsolePlugin{} + assert.NilError(t, manager.GetClient().Get(context.TODO(), client.ObjectKey{Name: consoleplugin.Name()}, consolePlugin)) + err = manager.GetClient().Get(context.TODO(), client.ObjectKeyFromObject(legacyConfigMap), &corev1.ConfigMap{}) + assert.Assert(t, apierrors.IsNotFound(err)) + policy := &networkingv1.NetworkPolicy{} + assert.NilError(t, manager.GetClient().Get(context.TODO(), client.ObjectKey{Name: consoleplugin.KuadrantConsoleName, Namespace: TestNamespace}, policy)) + assert.DeepEqual(t, policy.Spec, consoleplugin.NetworkPolicy(TestNamespace).Spec) +} diff --git a/internal/controller/state_of_the_world.go b/internal/controller/state_of_the_world.go index 269f07c11..5f4aa2751 100644 --- a/internal/controller/state_of_the_world.go +++ b/internal/controller/state_of_the_world.go @@ -275,6 +275,7 @@ type BootOptionsBuilder struct { isCertManagerInstalled bool isConsolePluginInstalled bool isClusterVersionInstalled bool + consolePluginImageOverride string isDNSOperatorInstalled bool isLimitadorOperatorInstalled bool isAuthorinoOperatorInstalled bool @@ -535,7 +536,9 @@ func (b *BootOptionsBuilder) getConsolePluginOptions() ([]controller.ControllerO return nil, err } - if !b.isConsolePluginInstalled || !b.isClusterVersionInstalled { + b.consolePluginImageOverride = env.GetString(openshift.ConsolePluginImageOverrideEnvVar, "") + + if !b.isConsolePluginInstalled || (!b.isClusterVersionInstalled && b.consolePluginImageOverride == "") { b.logger.Info("console plugin or openshift cluster version is not installed, skipping related watches and reconcilers") return opts, nil } @@ -544,13 +547,20 @@ func (b *BootOptionsBuilder) getConsolePluginOptions() ([]controller.ControllerO controller.WithRunnable("consoleplugin watcher", controller.Watch( &consolev1.ConsolePlugin{}, openshift.ConsolePluginsResource, metav1.NamespaceAll, controller.FilterResourcesByLabel[*consolev1.ConsolePlugin](fmt.Sprintf("%s=%s", consoleplugin.AppLabelKey, consoleplugin.AppLabelValue)))), - controller.WithRunnable("cluster version watcher", controller.Watch( + controller.WithObjectKinds(openshift.ConsolePluginGVK.GroupKind()), + // Filter by name, not labels, so removing a managed label still triggers repair. + controller.WithRunnable("consoleplugin networkpolicy watcher", controller.Watch( + &networkingv1.NetworkPolicy{}, networkingv1.SchemeGroupVersion.WithResource("networkpolicies"), operatorNamespace, + controller.FilterResourcesByField[*networkingv1.NetworkPolicy]("metadata.name="+consoleplugin.KuadrantConsoleName))), + controller.WithObjectKinds(networkingv1.SchemeGroupVersion.WithKind("NetworkPolicy").GroupKind()), + ) + if b.isClusterVersionInstalled { + opts = append(opts, controller.WithRunnable("cluster version watcher", controller.Watch( &configv1.ClusterVersion{}, openshift.ClusterVersionResource, metav1.NamespaceAll, - )), - controller.WithObjectKinds(openshift.ConsolePluginGVK.GroupKind(), openshift.ClusterVersionGroupKind.GroupKind()), - ) + )), controller.WithObjectKinds(openshift.ClusterVersionGroupKind.GroupKind())) + } return opts, nil } @@ -867,9 +877,9 @@ func (b *BootOptionsBuilder) Reconciler() controller.ReconcileFunc { Postcondition: traceReconcileFunc("workflow.finalize", b.finalStepsWorkflow().Run), } - if b.isConsolePluginInstalled && b.isClusterVersionInstalled { + if b.isConsolePluginInstalled && (b.isClusterVersionInstalled || b.consolePluginImageOverride != "") { mainWorkflow.Tasks = append(mainWorkflow.Tasks, - traceReconcileFunc("workflow.console_plugin", NewConsolePluginReconciler(b.manager, operatorNamespace).Subscription().Reconcile), + traceReconcileFunc("workflow.console_plugin", NewConsolePluginReconciler(b.manager, operatorNamespace, b.consolePluginImageOverride).Subscription().Reconcile), ) } diff --git a/internal/openshift/consoleplugin/consoleplugin.go b/internal/openshift/consoleplugin/consoleplugin.go index dd47239d1..e6da1e8ee 100644 --- a/internal/openshift/consoleplugin/consoleplugin.go +++ b/internal/openshift/consoleplugin/consoleplugin.go @@ -30,6 +30,20 @@ func ConsolePlugin(ns string) *consolev1.ConsolePlugin { BasePath: "/", }, }, + Proxy: []consolev1.ConsolePluginProxy{ + { + Alias: "backend", + Authorization: consolev1.UserToken, + Endpoint: consolev1.ConsolePluginProxyEndpoint{ + Type: consolev1.ProxyTypeService, + Service: &consolev1.ConsolePluginProxyServiceConfig{ + Name: ServiceName(), + Namespace: ns, + Port: 9443, + }, + }, + }, + }, }, } } diff --git a/internal/openshift/consoleplugin/consoleplugin_mutator.go b/internal/openshift/consoleplugin/consoleplugin_mutator.go index 5e45b332e..69e0eda6b 100644 --- a/internal/openshift/consoleplugin/consoleplugin_mutator.go +++ b/internal/openshift/consoleplugin/consoleplugin_mutator.go @@ -6,7 +6,7 @@ import ( consolev1 "github.com/openshift/api/console/v1" ) -func ServiceMutator(desired, existing *consolev1.ConsolePlugin) bool { +func SpecMutator(desired, existing *consolev1.ConsolePlugin) bool { if desired.Spec.Backend.Service == nil { panic("coded ConsolePlugin does not specify service") } @@ -17,6 +17,10 @@ func ServiceMutator(desired, existing *consolev1.ConsolePlugin) bool { existing.Spec.Backend.Service = desired.Spec.Backend.Service update = true } + if !reflect.DeepEqual(existing.Spec.Proxy, desired.Spec.Proxy) { + existing.Spec.Proxy = desired.Spec.Proxy + update = true + } return update } diff --git a/internal/openshift/consoleplugin/deployment.go b/internal/openshift/consoleplugin/deployment.go index 39360c7ec..ca6a27f81 100644 --- a/internal/openshift/consoleplugin/deployment.go +++ b/internal/openshift/consoleplugin/deployment.go @@ -36,12 +36,6 @@ func DeploymentVolumeMounts() []corev1.VolumeMount { ReadOnly: true, MountPath: "/var/serving-cert", }, - { - Name: "nginx-conf", - ReadOnly: true, - MountPath: "/etc/nginx/nginx.conf", - SubPath: "nginx.conf", - }, } } @@ -56,17 +50,6 @@ func DeploymentVolumes() []corev1.Volume { }, }, }, - { - Name: "nginx-conf", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: NginxConfigMapName(), - }, - DefaultMode: ptr.To(int32(420)), - }, - }, - }, } } @@ -102,6 +85,7 @@ func Deployment(ns, image, topologyName string) *appsv1.Deployment { Image: image, Ports: []corev1.ContainerPort{ { + Name: "https", ContainerPort: 9443, Protocol: corev1.ProtocolTCP, }, @@ -111,6 +95,8 @@ func Deployment(ns, image, topologyName string) *appsv1.Deployment { Env: []corev1.EnvVar{ {Name: "TOPOLOGY_CONFIGMAP_NAME", Value: topologyName}, {Name: "TOPOLOGY_CONFIGMAP_NAMESPACE", Value: ns}, + {Name: "TLS_CERTIFICATE_FILE", Value: "/var/serving-cert/tls.crt"}, + {Name: "TLS_KEY_FILE", Value: "/var/serving-cert/tls.key"}, }, }, }, diff --git a/internal/openshift/consoleplugin/deployment_mutator.go b/internal/openshift/consoleplugin/deployment_mutator.go new file mode 100644 index 000000000..df3573aae --- /dev/null +++ b/internal/openshift/consoleplugin/deployment_mutator.go @@ -0,0 +1,67 @@ +package consoleplugin + +import ( + "reflect" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// DeploymentConfigMutator reconciles the parts of the plugin pod that changed +// when its image became both the asset server and the backend. Environment +// variables not owned by the operator are retained so development overrides +// can still be injected without being removed on every reconcile. +func DeploymentConfigMutator(desired, existing *appsv1.Deployment) bool { + if len(desired.Spec.Template.Spec.Containers) == 0 || len(existing.Spec.Template.Spec.Containers) == 0 { + return false + } + + updated := false + desiredContainer := desired.Spec.Template.Spec.Containers[0] + existingContainer := &existing.Spec.Template.Spec.Containers[0] + + if !reflect.DeepEqual(existingContainer.Ports, desiredContainer.Ports) { + existingContainer.Ports = desiredContainer.Ports + updated = true + } + if existingContainer.ImagePullPolicy != desiredContainer.ImagePullPolicy { + existingContainer.ImagePullPolicy = desiredContainer.ImagePullPolicy + updated = true + } + if !reflect.DeepEqual(existingContainer.VolumeMounts, desiredContainer.VolumeMounts) { + existingContainer.VolumeMounts = desiredContainer.VolumeMounts + updated = true + } + if mergeOwnedEnvironment(existingContainer, desiredContainer.Env) { + updated = true + } + if !reflect.DeepEqual(existing.Spec.Template.Spec.Volumes, desired.Spec.Template.Spec.Volumes) { + existing.Spec.Template.Spec.Volumes = desired.Spec.Template.Spec.Volumes + updated = true + } + + return updated +} + +func mergeOwnedEnvironment(container *corev1.Container, desired []corev1.EnvVar) bool { + updated := false + for _, desiredVariable := range desired { + found := false + for index := range container.Env { + if container.Env[index].Name != desiredVariable.Name { + continue + } + found = true + if !reflect.DeepEqual(container.Env[index], desiredVariable) { + container.Env[index] = desiredVariable + updated = true + } + break + } + if !found { + container.Env = append(container.Env, desiredVariable) + updated = true + } + } + return updated +} diff --git a/internal/openshift/consoleplugin/deployment_mutator_test.go b/internal/openshift/consoleplugin/deployment_mutator_test.go new file mode 100644 index 000000000..3031cba28 --- /dev/null +++ b/internal/openshift/consoleplugin/deployment_mutator_test.go @@ -0,0 +1,42 @@ +//go:build unit + +package consoleplugin + +import ( + "testing" + + "gotest.tools/assert" + "gotest.tools/assert/cmp" + corev1 "k8s.io/api/core/v1" +) + +func TestDeploymentConfigMutatorUpgradesNginxDeployment(t *testing.T) { + desired := Deployment("test-namespace", "example.test/plugin:new", "topology") + desired.Spec.Template.Spec.Containers[0].ImagePullPolicy = corev1.PullIfNotPresent + existing := desired.DeepCopy() + existing.Spec.Template.Spec.Containers[0].Ports[0].Name = "" + existing.Spec.Template.Spec.Containers[0].ImagePullPolicy = corev1.PullAlways + existing.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + {Name: "TOPOLOGY_CONFIGMAP_NAME", Value: "old-topology"}, + {Name: "MCP_PROXY_DIAL_ADDRESS", Value: "gateway.example:80"}, + } + existing.Spec.Template.Spec.Containers[0].VolumeMounts = append( + existing.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{Name: "nginx-conf", MountPath: "/etc/nginx/nginx.conf"}, + ) + existing.Spec.Template.Spec.Volumes = append( + existing.Spec.Template.Spec.Volumes, + corev1.Volume{Name: "nginx-conf"}, + ) + + assert.Assert(t, DeploymentConfigMutator(desired, existing)) + container := existing.Spec.Template.Spec.Containers[0] + assert.DeepEqual(t, container.Ports, desired.Spec.Template.Spec.Containers[0].Ports) + assert.Equal(t, container.ImagePullPolicy, corev1.PullIfNotPresent) + assert.DeepEqual(t, container.VolumeMounts, desired.Spec.Template.Spec.Containers[0].VolumeMounts) + assert.DeepEqual(t, existing.Spec.Template.Spec.Volumes, desired.Spec.Template.Spec.Volumes) + assert.Assert(t, cmp.Contains(container.Env, corev1.EnvVar{Name: "TLS_CERTIFICATE_FILE", Value: "/var/serving-cert/tls.crt"})) + assert.Assert(t, cmp.Contains(container.Env, corev1.EnvVar{Name: "TLS_KEY_FILE", Value: "/var/serving-cert/tls.key"})) + assert.Assert(t, cmp.Contains(container.Env, corev1.EnvVar{Name: "MCP_PROXY_DIAL_ADDRESS", Value: "gateway.example:80"})) + assert.Assert(t, !DeploymentConfigMutator(desired, existing)) +} diff --git a/internal/openshift/consoleplugin/legacy_nginx_configmap.go b/internal/openshift/consoleplugin/legacy_nginx_configmap.go new file mode 100644 index 000000000..35853e987 --- /dev/null +++ b/internal/openshift/consoleplugin/legacy_nginx_configmap.go @@ -0,0 +1,16 @@ +package consoleplugin + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const legacyNginxConfigMapName = "kuadrant-console-nginx-conf" + +// LegacyNginxConfigMap identifies the ConfigMap used by plugin releases whose +// runtime image was served by nginx. The backend image no longer consumes it. +func LegacyNginxConfigMap(namespace string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: legacyNginxConfigMapName, Namespace: namespace}, + } +} diff --git a/internal/openshift/consoleplugin/networkpolicy.go b/internal/openshift/consoleplugin/networkpolicy.go new file mode 100644 index 000000000..aa7eb6153 --- /dev/null +++ b/internal/openshift/consoleplugin/networkpolicy.go @@ -0,0 +1,42 @@ +package consoleplugin + +import ( + "maps" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" +) + +func NetworkPolicy(namespace string) *networkingv1.NetworkPolicy { + return &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: KuadrantConsoleName, Namespace: namespace, Labels: CommonLabels()}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: *DeploymentSelector(), + // Do not override administrator-managed egress restrictions. The backend + // needs DNS, the Kubernetes API and the selected Gateway listener. + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + 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: ptr.To(corev1.ProtocolTCP), Port: ptr.To(intstr.FromInt32(9443))}}, + }}, + }, + } +} + +func NetworkPolicyMutator(desired, existing *networkingv1.NetworkPolicy) bool { + if equality.Semantic.DeepEqual(desired.Spec, existing.Spec) && maps.Equal(desired.Labels, existing.Labels) && + equality.Semantic.DeepEqual(desired.OwnerReferences, existing.OwnerReferences) { + return false + } + existing.Spec = *desired.Spec.DeepCopy() + existing.Labels = maps.Clone(desired.Labels) + existing.OwnerReferences = append([]metav1.OwnerReference(nil), desired.OwnerReferences...) + return true +} diff --git a/internal/openshift/consoleplugin/networkpolicy_test.go b/internal/openshift/consoleplugin/networkpolicy_test.go new file mode 100644 index 000000000..57c5897b3 --- /dev/null +++ b/internal/openshift/consoleplugin/networkpolicy_test.go @@ -0,0 +1,30 @@ +//go:build unit + +package consoleplugin + +import ( + "testing" + + "gotest.tools/assert" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNetworkPolicy(t *testing.T) { + policy := NetworkPolicy("custom-kuadrant-namespace") + assert.Equal(t, policy.Namespace, "custom-kuadrant-namespace") + assert.DeepEqual(t, policy.Spec.PodSelector, *DeploymentSelector()) + assert.DeepEqual(t, policy.Spec.PolicyTypes, []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}) + assert.Assert(t, len(policy.Spec.Egress) == 0) + assert.Assert(t, len(policy.Spec.Ingress) == 1) + rule := policy.Spec.Ingress[0] + assert.Assert(t, len(rule.From) == 1) + assert.DeepEqual(t, rule.From[0], networkingv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "openshift-console"}}, + PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "console"}}, + }) + assert.Assert(t, len(rule.Ports) == 1) + assert.Equal(t, *rule.Ports[0].Protocol, corev1.ProtocolTCP) + assert.Equal(t, rule.Ports[0].Port.IntValue(), 9443) +} diff --git a/internal/openshift/consoleplugin/nginx_configmap.go b/internal/openshift/consoleplugin/nginx_configmap.go deleted file mode 100644 index 90e0b3b8f..000000000 --- a/internal/openshift/consoleplugin/nginx_configmap.go +++ /dev/null @@ -1,44 +0,0 @@ -package consoleplugin - -import ( - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func NginxConfigMapName() string { - return "kuadrant-console-nginx-conf" -} - -func NginxConfigMap(ns string) *corev1.ConfigMap { - return &corev1.ConfigMap{ - TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: NginxConfigMapName(), - Namespace: ns, - Labels: CommonLabels(), - }, - Data: map[string]string{ - "nginx.conf": `error_log /dev/stdout; -events {} -http { - access_log /dev/stdout; - include /etc/nginx/mime.types; - default_type application/octet-stream; - keepalive_timeout 65; - server { - listen 9443 ssl; - listen [::]:9443 ssl; - ssl_certificate /var/serving-cert/tls.crt; - ssl_certificate_key /var/serving-cert/tls.key; - location / { - root /usr/share/nginx/html; - } - location /config.js { - root /tmp; - } - } -} -`, - }, - } -} diff --git a/internal/openshift/utils.go b/internal/openshift/utils.go index 177785663..529ff3390 100644 --- a/internal/openshift/utils.go +++ b/internal/openshift/utils.go @@ -17,6 +17,9 @@ const ( RelatedImageConsolePluginLatestEnvVar = "RELATED_IMAGE_CONSOLE_PLUGIN_LATEST" RelatedImageConsolePluginSDK1EnvVar = "RELATED_IMAGE_CONSOLE_PLUGIN_SDK1" RelatedImageConsolePluginPF5EnvVar = "RELATED_IMAGE_CONSOLE_PLUGIN_PF5" + // ConsolePluginImageOverrideEnvVar allows development clusters without a + // ClusterVersion API to opt in to the Console plugin. + ConsolePluginImageOverrideEnvVar = "CONSOLE_PLUGIN_IMAGE_OVERRIDE" ) type consolePluginImageRule struct {