From d87a664458655e8552a80868c7cf0eb96ba31291 Mon Sep 17 00:00:00 2001 From: Veronika Fisarova Date: Thu, 6 Nov 2025 12:56:48 +0100 Subject: [PATCH] Application Credential support Adds the end-to-end support for consuming Keystone ApplicationCredentials (AC) in the Octavia operator, enabling Octavia API, Housekeeping, Workers and Healthmanager pods to use AC-based authentication when available. Signed-off-by: Veronika Fisarova --- api/go.mod | 2 + controllers/amphoracontroller_controller.go | 63 ++++++++++- controllers/octaviaapi_controller.go | 66 +++++++++++- go.mod | 2 + go.sum | 4 +- .../config/octavia.conf | 14 ++- templates/octaviaapi/config/octavia.conf | 14 ++- .../functional/octaviaapi_controller_test.go | 102 ++++++++++++++++++ 8 files changed, 260 insertions(+), 7 deletions(-) diff --git a/api/go.mod b/api/go.mod index 11ad91ac..fd7b7043 100644 --- a/api/go.mod +++ b/api/go.mod @@ -104,3 +104,5 @@ replace k8s.io/component-base => k8s.io/component-base v0.31.13 //allow-merging replace github.com/rabbitmq/cluster-operator/v2 => github.com/openstack-k8s-operators/rabbitmq-cluster-operator/v2 v2.6.1-0.20250929174222-a0d328fa4dec //allow-merging replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20250627150254-e9823e99808e //allow-merging + +replace github.com/openstack-k8s-operators/keystone-operator/api => github.com/Deydra71/keystone-operator/api v0.0.0-20251105080148-59c1e577e327 diff --git a/controllers/amphoracontroller_controller.go b/controllers/amphoracontroller_controller.go index 89dfa082..5601c563 100644 --- a/controllers/amphoracontroller_controller.go +++ b/controllers/amphoracontroller_controller.go @@ -299,6 +299,19 @@ func (r *OctaviaAmphoraControllerReconciler) reconcileNormal(ctx context.Context // Handle secrets secretsVars := make(map[string]env.Setter) + // Verify Application Credentials if available + ctrlResult, err := keystonev1.VerifyApplicationCredentialsForService( + ctx, + r.Client, + instance.Namespace, + octavia.ServiceName, + &secretsVars, + 10*time.Second, + ) + if (err != nil || ctrlResult != ctrl.Result{}) { + return ctrlResult, err + } + defaultFlavorID, err := amphoracontrollers.EnsureFlavors(ctx, instance, &Log, helper) if err != nil { Log.Info(fmt.Sprintf("Cannot define flavors: %s", err)) @@ -388,7 +401,7 @@ func (r *OctaviaAmphoraControllerReconciler) reconcileNormal(ctx context.Context instance.Status.Conditions.MarkTrue(condition.ServiceConfigReadyCondition, condition.ServiceConfigReadyMessage) // Handle service update - ctrlResult, err := r.reconcileUpdate(ctx) + ctrlResult, err = r.reconcileUpdate(ctx) if err != nil { return ctrlResult, err } else if (ctrlResult != ctrl.Result{}) { @@ -704,6 +717,16 @@ func (r *OctaviaAmphoraControllerReconciler) generateServiceSecrets( templateParameters["Password"] = servicePassword templateParameters["KeystoneInternalURL"] = keystoneInternalURL templateParameters["KeystonePublicURL"] = keystonePublicURL + + // Check for Application Credentials + if acData, err := keystonev1.GetApplicationCredentialFromSecret(ctx, helper.GetClient(), instance.Namespace, octavia.ServiceName); err != nil { + Log.Error(err, "Failed to get ApplicationCredential for service", "service", octavia.ServiceName) + return err + } else if acData != nil { + templateParameters["ApplicationCredentialID"] = acData.ID + templateParameters["ApplicationCredentialSecret"] = acData.Secret + Log.Info("Using ApplicationCredentials auth", "service", octavia.ServiceName) + } templateParameters["ServiceRoleName"] = spec.Role templateParameters["LbMgmtNetworkId"] = templateVars.LbMgmtNetworkID templateParameters["LbSecurityGroupId"] = templateVars.LbSecurityGroupID @@ -857,6 +880,42 @@ func (r *OctaviaAmphoraControllerReconciler) SetupWithManager(mgr ctrl.Manager) return nil } + // Application Credential secret watching function + acSecretFn := func(_ context.Context, o client.Object) []reconcile.Request { + name := o.GetName() + ns := o.GetNamespace() + result := []reconcile.Request{} + + // Only handle Secret objects + if _, isSecret := o.(*corev1.Secret); !isSecret { + return nil + } + + // Check if this is an octavia AC secret by name pattern (ac-octavia-secret) + expectedSecretName := keystonev1.GetACSecretName(octavia.ServiceName) + if name == expectedSecretName { + // get all OctaviaAmphoraController CRs in this namespace + amphoraControllers := &octaviav1.OctaviaAmphoraControllerList{} + listOpts := []client.ListOption{ + client.InNamespace(ns), + } + if err := r.List(context.Background(), amphoraControllers, listOpts...); err != nil { + return nil + } + + // Enqueue reconcile for all amphora controller instances + for _, cr := range amphoraControllers.Items { + objKey := client.ObjectKey{ + Namespace: ns, + Name: cr.Name, + } + result = append(result, reconcile.Request{NamespacedName: objKey}) + } + } + + return result + } + return ctrl.NewControllerManagedBy(mgr). For(&octaviav1.OctaviaAmphoraController{}). Owns(&corev1.Service{}). @@ -871,6 +930,8 @@ func (r *OctaviaAmphoraControllerReconciler) SetupWithManager(mgr ctrl.Manager) handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). + Watches(&corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(acSecretFn)). Watches(&topologyv1.Topology{}, handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), builder.WithPredicates(predicate.GenerationChangedPredicate{})). diff --git a/controllers/octaviaapi_controller.go b/controllers/octaviaapi_controller.go index 3133934c..64595664 100644 --- a/controllers/octaviaapi_controller.go +++ b/controllers/octaviaapi_controller.go @@ -301,6 +301,42 @@ func (r *OctaviaAPIReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Ma return err } + // Application Credential secret watching function + acSecretFn := func(_ context.Context, o client.Object) []reconcile.Request { + name := o.GetName() + ns := o.GetNamespace() + result := []reconcile.Request{} + + // Only handle Secret objects + if _, isSecret := o.(*corev1.Secret); !isSecret { + return nil + } + + // Check if this is an octavia AC secret by name pattern (ac-octavia-secret) + expectedSecretName := keystonev1.GetACSecretName(octavia.ServiceName) + if name == expectedSecretName { + // get all OctaviaAPI CRs in this namespace + octaviaAPIs := &octaviav1.OctaviaAPIList{} + listOpts := []client.ListOption{ + client.InNamespace(ns), + } + if err := r.List(context.Background(), octaviaAPIs, listOpts...); err != nil { + return nil + } + + // Enqueue reconcile for all octavia API instances + for _, cr := range octaviaAPIs.Items { + objKey := client.ObjectKey{ + Namespace: ns, + Name: cr.Name, + } + result = append(result, reconcile.Request{NamespacedName: objKey}) + } + } + + return result + } + return ctrl.NewControllerManagedBy(mgr). For(&octaviav1.OctaviaAPI{}). Owns(&keystonev1.KeystoneService{}). @@ -316,6 +352,8 @@ func (r *OctaviaAPIReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Ma handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). + Watches(&corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(acSecretFn)). Watches(&topologyv1.Topology{}, handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), builder.WithPredicates(predicate.GenerationChangedPredicate{})). @@ -650,6 +688,19 @@ func (r *OctaviaAPIReconciler) reconcileNormal(ctx context.Context, instance *oc // Secrets secretsVars := make(map[string]env.Setter) + // Verify Application Credentials if available + ctrlResult, err := keystonev1.VerifyApplicationCredentialsForService( + ctx, + r.Client, + instance.Namespace, + octavia.ServiceName, + &secretsVars, + 10*time.Second, + ) + if (err != nil || ctrlResult != ctrl.Result{}) { + return ctrlResult, err + } + // // TLS input validation // @@ -722,7 +773,7 @@ func (r *OctaviaAPIReconciler) reconcileNormal(ctx context.Context, instance *oc // - %-scripts secret holding scripts to e.g. bootstrap the service // - %-config secret holding minimal octavia config required to get the service up, user can add additional files to be added to the service // - err := r.generateServiceSecrets(ctx, instance, helper, &secretsVars) + err = r.generateServiceSecrets(ctx, instance, helper, &secretsVars) if err != nil { instance.Status.Conditions.Set(condition.FalseCondition( condition.ServiceConfigReadyCondition, @@ -794,7 +845,7 @@ func (r *OctaviaAPIReconciler) reconcileNormal(ctx context.Context, instance *oc } // Handle service init - ctrlResult, err := r.reconcileInit(ctx, instance, helper, serviceLabels) + ctrlResult, err = r.reconcileInit(ctx, instance, helper, serviceLabels) if err != nil { return ctrlResult, err } else if (ctrlResult != ctrl.Result{}) { @@ -1105,6 +1156,17 @@ func (r *OctaviaAPIReconciler) generateServiceSecrets( templateParameters["TenantDomainName"] = instance.Spec.TenantDomainName templateParameters["KeystoneInternalURL"] = keystoneInternalURL templateParameters["KeystonePublicURL"] = keystonePublicURL + + // Check for Application Credentials + if acData, err := keystonev1.GetApplicationCredentialFromSecret(ctx, h.GetClient(), instance.Namespace, octavia.ServiceName); err != nil { + Log.Error(err, "Failed to get ApplicationCredential for service", "service", octavia.ServiceName) + return err + } else if acData != nil { + templateParameters["ApplicationCredentialID"] = acData.ID + templateParameters["ApplicationCredentialSecret"] = acData.Secret + Log.Info("Using ApplicationCredentials auth", "service", octavia.ServiceName) + } + templateParameters["NBConnection"], err = nbCluster.GetInternalEndpoint() if err != nil { return err diff --git a/go.mod b/go.mod index 299d8178..2e51a8ed 100644 --- a/go.mod +++ b/go.mod @@ -120,3 +120,5 @@ replace k8s.io/component-base => k8s.io/component-base v0.31.13 //allow-merging replace github.com/rabbitmq/cluster-operator/v2 => github.com/openstack-k8s-operators/rabbitmq-cluster-operator/v2 v2.6.1-0.20250929174222-a0d328fa4dec //allow-merging replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20250627150254-e9823e99808e //allow-merging + +replace github.com/openstack-k8s-operators/keystone-operator/api => github.com/Deydra71/keystone-operator/api v0.0.0-20251105080148-59c1e577e327 diff --git a/go.sum b/go.sum index 71f531dd..5251310d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Deydra71/keystone-operator/api v0.0.0-20251105080148-59c1e577e327 h1:Czf2Y2e7S4l6aXChDtdh+b5RKtIA+3HQtG9z9jZ80Lc= +github.com/Deydra71/keystone-operator/api v0.0.0-20251105080148-59c1e577e327/go.mod h1:FMFoO4MjEQ85JpdLtDHxYSZxvJ9KzHua+HdKhpl0KRI= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -100,8 +102,6 @@ github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e h1:E1OdwSpqWuDPCedyU github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e/go.mod h1:Shkl4HanLwDiiBzakv+con/aMGnVE2MAGvoKp5oyYUo= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251030184102-82d2cbaafd35 h1:QFFGu93A+XCvDUxZIgfBE4gB5hEdVQAIw+E8dF1kP/E= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20251030184102-82d2cbaafd35/go.mod h1:qq8BCRxTEmLRriUsQ4HeDUzqltWg32MQPDTMhgbBGK4= -github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20251027074845-ed8154b20ad1 h1:QohvX44nxoV2GwvvOURGXYyDuCn4SCrnwubTKJtzehY= -github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20251027074845-ed8154b20ad1/go.mod h1:FMFoO4MjEQ85JpdLtDHxYSZxvJ9KzHua+HdKhpl0KRI= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251027074416-ab5c045dbe00 h1:Xih6tYYqiDVllo4fDGHqTPL+M2biO5YLOUmbiTqrW/I= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20251027074416-ab5c045dbe00/go.mod h1:PMoNILOdQ1Ij7DyrKgljN6RAiq8pFM2AGsUb6mcxe98= github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20251027074416-ab5c045dbe00 h1:YwkGrTpeeAq9bk09u9Hp96BEZb8X3XgnMfoyxypelVM= diff --git a/templates/octaviaamphoracontroller/config/octavia.conf b/templates/octaviaamphoracontroller/config/octavia.conf index e37b5b2f..44de4e53 100644 --- a/templates/octaviaamphoracontroller/config/octavia.conf +++ b/templates/octaviaamphoracontroller/config/octavia.conf @@ -15,12 +15,18 @@ controller_ip_port_list={{ .ControllerIPList }} [keystone_authtoken] www_authenticate_uri={{ .KeystonePublicURL }} auth_url={{ .KeystoneInternalURL }} +{{ if (index . "ApplicationCredentialID") -}} +auth_type=v3applicationcredential +application_credential_id={{ .ApplicationCredentialID }} +application_credential_secret={{ .ApplicationCredentialSecret }} +{{- else -}} +auth_type=password username={{ .ServiceUser }} password={{ .Password }} +{{- end }} project_name={{ .TenantName }} project_domain_name={{ .TenantDomainName }} user_domain_name=Default -auth_type=password # memcache_use_advanced_pool=True # memcached_servers=FIXMEhost1:11211 # region_name=regionOne @@ -74,9 +80,15 @@ disable_local_log_storage=False project_name={{ .TenantName }} project_domain_name={{ .TenantDomainName }} user_domain_name=Default +{{ if (index . "ApplicationCredentialID") -}} +auth_type=v3applicationcredential +application_credential_id={{ .ApplicationCredentialID }} +application_credential_secret={{ .ApplicationCredentialSecret }} +{{- else -}} password={{ .Password }} username=octavia auth_type=password +{{- end }} auth_url={{ .KeystoneInternalURL }}/v3 region_name=regionOne diff --git a/templates/octaviaapi/config/octavia.conf b/templates/octaviaapi/config/octavia.conf index 890f8371..df841e0e 100644 --- a/templates/octaviaapi/config/octavia.conf +++ b/templates/octaviaapi/config/octavia.conf @@ -19,12 +19,18 @@ stats_update_threads=4 [keystone_authtoken] www_authenticate_uri={{ .KeystonePublicURL }} auth_url={{ .KeystoneInternalURL }} +{{ if (index . "ApplicationCredentialID") -}} +auth_type=v3applicationcredential +application_credential_id={{ .ApplicationCredentialID }} +application_credential_secret={{ .ApplicationCredentialSecret }} +{{- else -}} +auth_type=password username={{ .ServiceUser }} password={{ .Password }} +{{- end }} project_name={{ .TenantName }} project_domain_name={{ .TenantDomainName }} user_domain_name=Default -auth_type=password # memcache_use_advanced_pool=True # memcached_servers=FIXMEhost1:11211 # region_name=regionOne @@ -76,9 +82,15 @@ disable_local_log_storage=False project_name={{ .TenantName }} project_domain_name={{ .TenantDomainName }} user_domain_name=Default +{{ if (index . "ApplicationCredentialID") -}} +auth_type=v3applicationcredential +application_credential_id={{ .ApplicationCredentialID }} +application_credential_secret={{ .ApplicationCredentialSecret }} +{{- else -}} password={{ .Password }} username=octavia auth_type=password +{{- end }} auth_url={{ .KeystoneInternalURL }}/v3 region_name=regionOne diff --git a/tests/functional/octaviaapi_controller_test.go b/tests/functional/octaviaapi_controller_test.go index 8f778cd4..7fe6f3f9 100644 --- a/tests/functional/octaviaapi_controller_test.go +++ b/tests/functional/octaviaapi_controller_test.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + //revive:disable-next-line:dot-imports . "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers" "github.com/openstack-k8s-operators/octavia-operator/pkg/octavia" @@ -334,4 +335,105 @@ var _ = Describe("OctaviaAPI controller", func() { // Keystone Service // Deployment + + When("an ApplicationCredential is created for Octavia", func() { + var keystoneAPIName types.NamespacedName + + BeforeEach(func() { + keystoneAPIName = keystone.CreateKeystoneAPI(namespace) + DeferCleanup(keystone.DeleteKeystoneAPI, keystoneAPIName) + keystoneInternalEndpoint := fmt.Sprintf("http://keystone-for-%s-internal", octaviaAPIName.Name) + keystonePublicEndpoint := fmt.Sprintf("http://keystone-for-%s-public", octaviaAPIName.Name) + SimulateKeystoneReady(keystoneAPIName, keystonePublicEndpoint, keystoneInternalEndpoint) + + DeferCleanup(k8sClient.Delete, ctx, CreateOctaviaSecret(namespace)) + DeferCleanup(k8sClient.Delete, ctx, CreateTransportURLSecret(transportURLSecretName)) + + DeferCleanup(th.DeleteInstance, CreateOctaviaAPI(octaviaAPIName, spec)) + + mariaDBDatabaseName := mariadb.CreateMariaDBDatabase(namespace, octavia.DatabaseCRName, mariadbv1.MariaDBDatabaseSpec{}) + mariaDBDatabase := mariadb.GetMariaDBDatabase(mariaDBDatabaseName) + DeferCleanup(k8sClient.Delete, ctx, mariaDBDatabase) + + octaviaAPI := GetOctaviaAPI(octaviaAPIName) + apiMariaDBAccount, apiMariaDBSecret := mariadb.CreateMariaDBAccountAndSecret( + types.NamespacedName{ + Namespace: namespace, + Name: octaviaAPI.Spec.DatabaseAccount, + }, mariadbv1.MariaDBAccountSpec{}) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBAccount) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBSecret) + + mariaDBDatabaseName = mariadb.CreateMariaDBDatabase(namespace, octavia.PersistenceDatabaseCRName, mariadbv1.MariaDBDatabaseSpec{}) + mariaDBDatabase = mariadb.GetMariaDBDatabase(mariaDBDatabaseName) + DeferCleanup(k8sClient.Delete, ctx, mariaDBDatabase) + + apiMariaDBAccount, apiMariaDBSecret = mariadb.CreateMariaDBAccountAndSecret( + types.NamespacedName{ + Namespace: namespace, + Name: octaviaAPI.Spec.PersistenceDatabaseAccount, + }, mariadbv1.MariaDBAccountSpec{}) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBAccount) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBSecret) + + ovndbCluster := ovn.CreateOVNDBCluster(nil, namespace, + ovnv1.OVNDBClusterSpec{ + OVNDBClusterSpecCore: ovnv1.OVNDBClusterSpecCore{ + DBType: ovnv1.NBDBType, + }}) + ovndb := ovn.GetOVNDBCluster(ovndbCluster) + DeferCleanup(k8sClient.Delete, ctx, ovndb) + Eventually(func(g Gomega) { + ovndb.Status.InternalDBAddress = OVNNBDBEndpoint + g.Expect(k8sClient.Status().Update(ctx, ovndb)).To(Succeed()) + }).Should(Succeed()) + ovn.SimulateOVNDBClusterReady(ovndbCluster) + + ovndbCluster = ovn.CreateOVNDBCluster(nil, namespace, + ovnv1.OVNDBClusterSpec{ + OVNDBClusterSpecCore: ovnv1.OVNDBClusterSpecCore{ + DBType: ovnv1.SBDBType, + }}) + ovndb = ovn.GetOVNDBCluster(ovndbCluster) + DeferCleanup(k8sClient.Delete, ctx, ovndb) + Eventually(func(g Gomega) { + ovndb.Status.InternalDBAddress = OVNSBDBEndpoint + g.Expect(k8sClient.Status().Update(ctx, ovndb)).To(Succeed()) + }).Should(Succeed()) + ovn.SimulateOVNDBClusterReady(ovndbCluster) + + // Create AC secret with test credentials + // The operator looks directly for a secret named "ac-{serviceName}-secret" + acSecretName := fmt.Sprintf("ac-%s-secret", octavia.ServiceName) + acSecret := th.CreateSecret( + types.NamespacedName{Namespace: namespace, Name: acSecretName}, + map[string][]byte{ + "AC_ID": []byte("test-ac-id"), + "AC_SECRET": []byte("test-ac-secret"), + }, + ) + DeferCleanup(th.DeleteInstance, acSecret) + }) + + It("should render ApplicationCredential auth in octavia.conf", func() { + Eventually(func(g Gomega) { + cfgSecret := th.GetSecret(types.NamespacedName{ + Namespace: octaviaAPIName.Namespace, + Name: fmt.Sprintf("%s-config-data", octaviaAPIName.Name)}) + g.Expect(cfgSecret).NotTo(BeNil()) + + conf := string(cfgSecret.Data["octavia.conf"]) + + g.Expect(conf).To(ContainSubstring( + "application_credential_id=test-ac-id"), + ) + g.Expect(conf).To(ContainSubstring( + "application_credential_secret=test-ac-secret"), + ) + g.Expect(conf).To(ContainSubstring( + "auth_type=v3applicationcredential"), + ) + }, timeout, interval).Should(Succeed()) + }) + }) })