diff --git a/api/bases/horizon.openstack.org_horizons.yaml b/api/bases/horizon.openstack.org_horizons.yaml index 6fdf6bb5..db918de1 100644 --- a/api/bases/horizon.openstack.org_horizons.yaml +++ b/api/bases/horizon.openstack.org_horizons.yaml @@ -1134,6 +1134,20 @@ spec: - extraVol type: object type: array + httpdCustomization: + description: HttpdCustomization - customize the httpd service + properties: + customConfigSecret: + description: |- + CustomConfigSecret - customize the httpd vhost config using this parameter to specify + a secret that contains service config data. The content of each provided snippet gets + rendered as a go template and placed into /etc/httpd/conf/httpd_custom_ . + In the default httpd template at the end of the vhost those custom configs get + included using `Include conf/httpd_custom__*`. + For information on how sections in httpd configuration get merged, check section + "How the sections are merged" in https://httpd.apache.org/docs/current/sections.html#merging + type: string + type: object memcachedInstance: default: memcached description: Memcached instance name. diff --git a/api/v1beta1/horizon_types.go b/api/v1beta1/horizon_types.go index 4f6674eb..036bb781 100644 --- a/api/v1beta1/horizon_types.go +++ b/api/v1beta1/horizon_types.go @@ -118,6 +118,10 @@ type HorizonSpecCore struct { // TopologyRef to apply the Topology defined by the associated CR referenced // by name TopologyRef *topologyv1.TopoRef `json:"topologyRef,omitempty"` + + // +kubebuilder:validation:Optional + // HttpdCustomization - customize the httpd service + HttpdCustomization HttpdCustomization `json:"httpdCustomization,omitempty"` } // HorizionOverrideSpec to override the generated manifest of several child resources. @@ -126,6 +130,19 @@ type HorizionOverrideSpec struct { Service *service.RoutedOverrideSpec `json:"service,omitempty"` } +// HttpdCustomization - customize the httpd service +type HttpdCustomization struct { + // +kubebuilder:validation:Optional + // CustomConfigSecret - customize the httpd vhost config using this parameter to specify + // a secret that contains service config data. The content of each provided snippet gets + // rendered as a go template and placed into /etc/httpd/conf/httpd_custom_ . + // In the default httpd template at the end of the vhost those custom configs get + // included using `Include conf/httpd_custom__*`. + // For information on how sections in httpd configuration get merged, check section + // "How the sections are merged" in https://httpd.apache.org/docs/current/sections.html#merging + CustomConfigSecret *string `json:"customConfigSecret,omitempty"` +} + // HorizonStatus defines the observed state of Horizon type HorizonStatus struct { // Map of hashes to track e.g. job status diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index c1b29ff8..0efec30f 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -206,6 +206,7 @@ func (in *HorizonSpecCore) DeepCopyInto(out *HorizonSpecCore) { *out = new(topologyv1beta1.TopoRef) **out = **in } + in.HttpdCustomization.DeepCopyInto(&out.HttpdCustomization) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HorizonSpecCore. @@ -267,3 +268,23 @@ func (in *HorizonStatus) DeepCopy() *HorizonStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HttpdCustomization) DeepCopyInto(out *HttpdCustomization) { + *out = *in + if in.CustomConfigSecret != nil { + in, out := &in.CustomConfigSecret, &out.CustomConfigSecret + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HttpdCustomization. +func (in *HttpdCustomization) DeepCopy() *HttpdCustomization { + if in == nil { + return nil + } + out := new(HttpdCustomization) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/horizon.openstack.org_horizons.yaml b/config/crd/bases/horizon.openstack.org_horizons.yaml index 6fdf6bb5..db918de1 100644 --- a/config/crd/bases/horizon.openstack.org_horizons.yaml +++ b/config/crd/bases/horizon.openstack.org_horizons.yaml @@ -1134,6 +1134,20 @@ spec: - extraVol type: object type: array + httpdCustomization: + description: HttpdCustomization - customize the httpd service + properties: + customConfigSecret: + description: |- + CustomConfigSecret - customize the httpd vhost config using this parameter to specify + a secret that contains service config data. The content of each provided snippet gets + rendered as a go template and placed into /etc/httpd/conf/httpd_custom_ . + In the default httpd template at the end of the vhost those custom configs get + included using `Include conf/httpd_custom__*`. + For information on how sections in httpd configuration get merged, check section + "How the sections are merged" in https://httpd.apache.org/docs/current/sections.html#merging + type: string + type: object memcachedInstance: default: memcached description: Memcached instance name. diff --git a/controllers/horizon_controller.go b/controllers/horizon_controller.go index 9106c3af..bd466d84 100644 --- a/controllers/horizon_controller.go +++ b/controllers/horizon_controller.go @@ -44,6 +44,7 @@ import ( "github.com/openstack-k8s-operators/lib-common/modules/common/service" "github.com/openstack-k8s-operators/lib-common/modules/common/tls" util "github.com/openstack-k8s-operators/lib-common/modules/common/util" + "gopkg.in/yaml.v2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -222,10 +223,11 @@ func (r *HorizonReconciler) Reconcile(ctx context.Context, req ctrl.Request) (re // fields to index to reconcile when change const ( - passwordSecretField = ".spec.secret" - tlsField = ".spec.tls.secretName" - caBundleSecretNameField = ".spec.tls.caBundleSecretName" - topologyField = ".spec.topologyRef.Name" + passwordSecretField = ".spec.secret" + tlsField = ".spec.tls.secretName" + caBundleSecretNameField = ".spec.tls.caBundleSecretName" + topologyField = ".spec.topologyRef.Name" + httpdCustomServiceConfigSecretField = ".spec.httpdCustomization.customServiceConfigSecret" ) var allWatchFields = []string{ @@ -233,6 +235,7 @@ var allWatchFields = []string{ caBundleSecretNameField, tlsField, topologyField, + httpdCustomServiceConfigSecretField, } var keystoneServicesWatch = []string{ @@ -291,6 +294,18 @@ func (r *HorizonReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } + // index httpdOverrideSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &horizonv1beta1.Horizon{}, httpdCustomServiceConfigSecretField, func(rawObj client.Object) []string { + // Extract the secret name from the spec, if one is provided + cr := rawObj.(*horizonv1beta1.Horizon) + if cr.Spec.HttpdCustomization.CustomConfigSecret == nil { + return nil + } + return []string{*cr.Spec.HttpdCustomization.CustomConfigSecret} + }); err != nil { + return err + } + memcachedFn := func(_ context.Context, o client.Object) []reconcile.Request { result := []reconcile.Request{} @@ -1077,17 +1092,25 @@ func (r *HorizonReconciler) generateServiceConfigMaps( return err } + httpdOverrideSecret := &corev1.Secret{} + if instance.Spec.HttpdCustomization.CustomConfigSecret != nil && *instance.Spec.HttpdCustomization.CustomConfigSecret != "" { + httpdOverrideSecret, _, err = oko_secret.GetSecret(ctx, h, *instance.Spec.HttpdCustomization.CustomConfigSecret, instance.Namespace) + if err != nil { + return err + } + } + templateParameters := map[string]interface{}{ - "keystoneURL": authURL, - "horizonEndpoint": instance.Status.Endpoint, - "horizonEndpointHost": url.Host, - "memcachedServers": mc.GetMemcachedServerListQuotedString(), - "memcachedTLS": mc.GetMemcachedTLSSupport(), - "ServerName": fmt.Sprintf("%s.%s.svc", horizon.ServiceName, instance.Namespace), - "Port": horizon.HorizonPort, - "TLS": false, - "isPublicHTTPS": url.Scheme == "https", - "LogFile": horizon.LogFile, + "KeystoneEndpointInternal": authURL, + "HorizonEndpoint": instance.Status.Endpoint, + "HorizonEndpointHost": url.Host, + "MemcachedServers": mc.GetMemcachedServerListQuotedString(), + "MemcachedTLS": mc.GetMemcachedTLSSupport(), + "ServerName": fmt.Sprintf("%s.%s.svc", horizon.ServiceName, instance.Namespace), + "Port": horizon.HorizonPort, + "TLS": false, + "IsPublicHTTPS": url.Scheme == "https", + "LogFile": horizon.LogFile, } // create httpd tls template parameters @@ -1100,22 +1123,42 @@ func (r *HorizonReconciler) generateServiceConfigMaps( // Set Memcached MTLS parameters if required if mc.GetMemcachedMTLSSecret() != "" { - templateParameters["memcachedMTLS"] = true - templateParameters["memcachedAuthCa"] = fmt.Sprint(memcachedv1.CaMountPath()) - templateParameters["memcachedAuthCert"] = fmt.Sprint(memcachedv1.CertMountPath()) - templateParameters["memcachedAuthKey"] = fmt.Sprint(memcachedv1.KeyMountPath()) + templateParameters["MemcachedMTLS"] = true + templateParameters["MemcachedAuthCert"] = fmt.Sprint(memcachedv1.CertMountPath()) + templateParameters["MemcachedAuthKey"] = fmt.Sprint(memcachedv1.KeyMountPath()) + templateParameters["MemcachedAuthCa"] = fmt.Sprint(memcachedv1.CaMountPath()) + } + + // httpd overrides + customTemplates := map[string]string{} + templateParameters["Override"] = false + if len(httpdOverrideSecret.Data) > 0 { + templateParameters["Override"] = true + for key, data := range httpdOverrideSecret.Data { + if len(data) > 0 { + customTemplates["httpd_custom_"+key] = string(data) + } + } + } + + // Marshal the templateParameters map to YAML + yamlData, err := yaml.Marshal(templateParameters) + if err != nil { + return fmt.Errorf("Error marshalling to YAML: %w", err) } + customData[common.TemplateParameters] = string(yamlData) cms := []util.Template{ // ConfigMap { - Name: fmt.Sprintf("%s-config-data", instance.Name), - Namespace: instance.Namespace, - Type: util.TemplateTypeConfig, - InstanceType: instance.Kind, - CustomData: customData, - ConfigOptions: templateParameters, - Labels: cmLabels, + Name: fmt.Sprintf("%s-config-data", instance.Name), + Namespace: instance.Namespace, + Type: util.TemplateTypeConfig, + InstanceType: instance.Kind, + CustomData: customData, + ConfigOptions: templateParameters, + StringTemplate: customTemplates, + Labels: cmLabels, }, } // Append scripts diff --git a/go.mod b/go.mod index 58d2f01e..66ffc723 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/openstack-k8s-operators/lib-common/modules/storage v0.6.1-0.20250605082218-a58074898dd7 github.com/openstack-k8s-operators/lib-common/modules/test v0.6.1-0.20250605082218-a58074898dd7 github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.29.15 k8s.io/apimachinery v0.29.15 k8s.io/client-go v0.29.15 @@ -76,7 +77,6 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.29.15 // indirect k8s.io/component-base v0.29.15 // indirect diff --git a/templates/horizon/config/horizon.json b/templates/horizon/config/horizon.json index fb66730e..97ed38e2 100644 --- a/templates/horizon/config/horizon.json +++ b/templates/horizon/config/horizon.json @@ -51,20 +51,35 @@ "merge": true }, { - "source": "/var/lib/config-data/mtls/certs/*", - "dest": "/etc/pki/tls/certs/", - "owner": "apache:apache", - "perm": "0640", - "optional": true, - "merge": true + "source": "/var/lib/config-data/mtls/certs/*", + "dest": "/etc/pki/tls/certs/", + "owner": "apache:apache", + "perm": "0640", + "optional": true, + "merge": true }, { - "source": "/var/lib/config-data/mtls/private/*", - "dest": "/etc/pki/tls/private/", - "owner": "apache:apache", - "perm": "0600", - "optional": true, - "merge": true + "source": "/var/lib/config-data/mtls/private/*", + "dest": "/etc/pki/tls/private/", + "owner": "apache:apache", + "perm": "0600", + "optional": true, + "merge": true + }, + { + "source": "/var/lib/config-data/tls/private/*", + "dest": "/etc/pki/tls/private/", + "owner": "apache:apache", + "perm": "0600", + "optional": true, + "merge": true + }, + { + "source": "/var/lib/config-data/default/httpd_custom_*", + "dest": "/etc/httpd/conf/", + "owner": "apache", + "perm": "0444", + "optional": true } ], "permissions": [ diff --git a/templates/horizon/config/httpd.conf b/templates/horizon/config/httpd.conf index 9d668e4b..4872fa51 100644 --- a/templates/horizon/config/httpd.conf +++ b/templates/horizon/config/httpd.conf @@ -57,11 +57,15 @@ LogLevel debug CustomLog {{ .LogFile }} "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" env=forwarded ## RedirectMatch rules - RedirectMatch permanent ^/$ "{{ .horizonEndpoint }}/dashboard" + RedirectMatch permanent ^/$ "{{ .HorizonEndpoint }}/dashboard" ## WSGI configuration WSGIApplicationGroup %{GLOBAL} WSGIDaemonProcess apache display-name=horizon group=apache processes=4 threads=1 user=apache WSGIProcessGroup apache WSGIScriptAlias /dashboard "/usr/share/openstack-dashboard/openstack_dashboard/wsgi.py" + +{{- if .Override }} + Include conf/httpd_custom_* +{{- end }} diff --git a/templates/horizon/config/local_settings.py b/templates/horizon/config/local_settings.py index a0ae6cde..20edbc3c 100644 --- a/templates/horizon/config/local_settings.py +++ b/templates/horizon/config/local_settings.py @@ -65,8 +65,8 @@ def get_pod_ip(): import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) hostport = ( - "{{ .horizonEndpointHost }}", - {{- if .isPublicHTTPS }} + "{{ .HorizonEndpointHost }}", + {{- if .IsPublicHTTPS }} 443 {{- else }} 80 @@ -83,11 +83,11 @@ def get_pod_ip(): finally: s.close() -ALLOWED_HOSTS = [get_pod_ip(), "{{ .horizonEndpointHost }}"] +ALLOWED_HOSTS = [get_pod_ip(), "{{ .HorizonEndpointHost }}"] USE_X_FORWARDED_HOST = True -{{- if .isPublicHTTPS }} +{{- if .IsPublicHTTPS }} SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_SSL_REDIRECT = True CSRF_COOKIE_SECURE = True @@ -130,7 +130,7 @@ def mtls_context(): CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', - 'LOCATION': [ {{.memcachedServers}} ], + 'LOCATION': [ {{.MemcachedServers}} ], # To drop the cached sessions when config changes 'KEY_PREFIX': os.environ['CONFIG_HASH'], 'OPTIONS': { @@ -138,8 +138,8 @@ def mtls_context(): "ignore_exc": True, "max_pool_size": 4, "use_pooling": True, -{{- if .memcachedTLS }} -{{- if (index . "memcachedAuthCert") }} +{{- if .MemcachedTLS }} +{{- if (index . "MemcachedAuthCert") }} "tls_context": mtls_context() {{- else }} "tls_context": ssl.create_default_context() @@ -170,7 +170,7 @@ def mtls_context(): OPENSTACK_HOST = "127.0.0.1" #OPENSTACK_KEYSTONE_URL = "http://%s/identity/v3" % OPENSTACK_HOST -OPENSTACK_KEYSTONE_URL = "{{ .keystoneURL }}/v3" +OPENSTACK_KEYSTONE_URL = "{{ .KeystoneEndpointInternal }}/v3" # The timezone of the server. This should correspond with the timezone # of your entire OpenStack installation, and hopefully be in UTC. diff --git a/tests/functional/horizon_controller_test.go b/tests/functional/horizon_controller_test.go index f028c84a..93cd622a 100644 --- a/tests/functional/horizon_controller_test.go +++ b/tests/functional/horizon_controller_test.go @@ -17,6 +17,7 @@ import ( "github.com/openstack-k8s-operators/horizon-operator/pkg/horizon" memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" + "github.com/openstack-k8s-operators/lib-common/modules/common" condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" ) @@ -904,4 +905,59 @@ var _ = Describe("Horizon controller", func() { Expect(nad).To(Equal(horizon.Status.NetworkAttachments)) }) }) + + When("A Horizon is created with HttpdCustomization.CustomConfigSecret", func() { + BeforeEach(func() { + customServiceConfigSecretName := types.NamespacedName{Name: "foo", Namespace: namespace} + customConfig := []byte(`CustomParam "foo" +CustomKeystoneEndpointInternal "{{ .KeystoneEndpointInternal }}"`) + th.CreateSecret( + customServiceConfigSecretName, + map[string][]byte{ + "bar.conf": customConfig, + }, + ) + + rawSpec := map[string]interface{}{ + "secret": SecretName, + "memcachedInstance": "memcached", + "httpdCustomization": map[string]interface{}{ + "customConfigSecret": customServiceConfigSecretName.Name, + }, + } + DeferCleanup(th.DeleteInstance, CreateHorizon(horizonName, rawSpec)) + DeferCleanup( + k8sClient.Delete, ctx, CreateHorizonSecret(namespace, SecretName)) + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(namespace, "memcached", memcachedSpec)) + infra.SimulateMemcachedReady(types.NamespacedName{ + Name: "memcached", + Namespace: namespace, + }) + keystoneAPI := keystone.CreateKeystoneAPI(namespace) + DeferCleanup(keystone.DeleteKeystoneAPI, keystoneAPI) + th.SimulateDeploymentReadyWithPods( + horizonName, + map[string][]string{}, + ) + + }) + It("it renders the custom template and adds it to the config-data secret", func() { + cm := th.GetConfigMap(types.NamespacedName{ + Namespace: horizonName.Namespace, + Name: horizonName.Name + "-config-data", + }) + + Expect(cm).ShouldNot(BeNil()) + Expect(cm.Data).Should(HaveKey(common.TemplateParameters)) + configData := string(cm.Data[common.TemplateParameters]) + keystoneInternalURL := "http://keystone-internal.openstack.svc:5000" + Expect(configData).Should(ContainSubstring(fmt.Sprintf("KeystoneEndpointInternal: %s", keystoneInternalURL))) + + Expect(cm.Data).Should(HaveKey("httpd_custom_bar.conf")) + configData = string(cm.Data["httpd_custom_bar.conf"]) + Expect(configData).Should(ContainSubstring("CustomParam \"foo\"")) + Expect(configData).Should(ContainSubstring(fmt.Sprintf("CustomKeystoneEndpointInternal \"%s\"", keystoneInternalURL))) + + }) + }) })