diff --git a/api/bases/nova.openstack.org_nova.yaml b/api/bases/nova.openstack.org_nova.yaml index 322c6ee19..f2c1e485e 100644 --- a/api/bases/nova.openstack.org_nova.yaml +++ b/api/bases/nova.openstack.org_nova.yaml @@ -1914,6 +1914,13 @@ spec: from nova-api format: int32 type: integer + applicationCredentialSecret: + description: |- + ApplicationCredentialSecret - the AC secret nova is currently + consuming and protecting with the openstack.org/nova-ac-consumer + finalizer. Tracked so the controller can remove its finalizer from the + old secret when the openstack-operator rotates the reference. + type: string conditions: description: Conditions items: diff --git a/api/go.mod b/api/go.mod index 38c3236b7..a62c326ba 100644 --- a/api/go.mod +++ b/api/go.mod @@ -93,3 +93,5 @@ replace k8s.io/component-base => k8s.io/component-base v0.31.14 //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-20260424093804-00a0ccdc9d20 diff --git a/api/nova/v1beta1/nova_types.go b/api/nova/v1beta1/nova_types.go index 39d4a5c11..4c21caf83 100644 --- a/api/nova/v1beta1/nova_types.go +++ b/api/nova/v1beta1/nova_types.go @@ -185,6 +185,12 @@ type NovaStatus struct { //ObservedGeneration - the most recent generation observed for this service. If the observed generation is less than the spec generation, then the controller has not processed the latest changes. ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // ApplicationCredentialSecret - the AC secret nova is currently + // consuming and protecting with the openstack.org/nova-ac-consumer + // finalizer. Tracked so the controller can remove its finalizer from the + // old secret when the openstack-operator rotates the reference. + ApplicationCredentialSecret string `json:"applicationCredentialSecret,omitempty"` } //+kubebuilder:object:root=true diff --git a/config/crd/bases/nova.openstack.org_nova.yaml b/config/crd/bases/nova.openstack.org_nova.yaml index 322c6ee19..f2c1e485e 100644 --- a/config/crd/bases/nova.openstack.org_nova.yaml +++ b/config/crd/bases/nova.openstack.org_nova.yaml @@ -1914,6 +1914,13 @@ spec: from nova-api format: int32 type: integer + applicationCredentialSecret: + description: |- + ApplicationCredentialSecret - the AC secret nova is currently + consuming and protecting with the openstack.org/nova-ac-consumer + finalizer. Tracked so the controller can remove its finalizer from the + old secret when the openstack-operator rotates the reference. + type: string conditions: description: Conditions items: diff --git a/go.mod b/go.mod index e623513e9..be26c53a1 100644 --- a/go.mod +++ b/go.mod @@ -143,3 +143,5 @@ replace k8s.io/component-base => k8s.io/component-base v0.31.14 //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-20260424093804-00a0ccdc9d20 diff --git a/go.sum b/go.sum index 7cf957d9d..c82b729ff 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Deydra71/keystone-operator/api v0.0.0-20260424093804-00a0ccdc9d20 h1:iyxfh2SDvQrOrsHItYAE3A3+8Ku9UnzWAq9jnLJDLjg= +github.com/Deydra71/keystone-operator/api v0.0.0-20260424093804-00a0ccdc9d20/go.mod h1:SpO4CL7c5/1HG+61fP6kWhL2+3aqR+5SNatdZueKrz8= 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/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= @@ -120,8 +122,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.20260416122644-5476763a36b6 h1:117Gu9HCSu2tAp579WnCJ9QtnslH2qnPB8UFvn8ZpqE= github.com/openstack-k8s-operators/infra-operator/apis v0.6.1-0.20260416122644-5476763a36b6/go.mod h1:i7l8cihvFktd/LSuyvL2z6OcwauarQGoVhDMePL4VyI= -github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20260420052838-77f94aef5af2 h1:h7pTz90cHqX6nTYjYDphuitIfD4UpM9yGnI3AbLdHrY= -github.com/openstack-k8s-operators/keystone-operator/api v0.6.1-0.20260420052838-77f94aef5af2/go.mod h1:SpO4CL7c5/1HG+61fP6kWhL2+3aqR+5SNatdZueKrz8= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20260417092244-81c71b39e981 h1:v1viH0gmNb+AXMg/0GxDcj8VUTdjVLotfOIGrNyMxHk= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.1-0.20260417092244-81c71b39e981/go.mod h1:I/VBXZLdjk8DUGsEbB+Ha72JBFYYntP7Pm2FpEto9K8= github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20260417092244-81c71b39e981 h1:jN3Kvt+RYUTaL9EXeeeIqRXVjqeNF74SuLTDXmi4X2Y= diff --git a/internal/controller/nova/nova_controller.go b/internal/controller/nova/nova_controller.go index 1960f2fd7..f88f405d9 100644 --- a/internal/controller/nova/nova_controller.go +++ b/internal/controller/nova/nova_controller.go @@ -56,7 +56,7 @@ import ( novav1 "github.com/openstack-k8s-operators/nova-operator/api/nova/v1beta1" "github.com/openstack-k8s-operators/nova-operator/internal/nova" - "github.com/openstack-k8s-operators/nova-operator/internal/nova/api" + novaapi "github.com/openstack-k8s-operators/nova-operator/internal/nova/api" memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" rabbitmqv1 "github.com/openstack-k8s-operators/infra-operator/apis/rabbitmq/v1beta1" @@ -303,6 +303,22 @@ func (r *NovaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (resul } } + if instance.Spec.Auth.ApplicationCredentialSecret != "" || instance.Status.ApplicationCredentialSecret != "" { + if err := keystonev1.ManageACSecretFinalizer(ctx, h, instance.Namespace, + instance.Spec.Auth.ApplicationCredentialSecret, + instance.Status.ApplicationCredentialSecret, + nova.ACConsumerFinalizer); err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.ServiceConfigReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.ServiceConfigReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } + } + instance.Status.ApplicationCredentialSecret = instance.Spec.Auth.ApplicationCredentialSecret + instance.Status.Conditions.MarkTrue(condition.InputReadyCondition, condition.InputReadyMessage) err = r.ensureKeystoneServiceUser(ctx, h, instance) @@ -1768,6 +1784,17 @@ func (r *NovaReconciler) reconcileDelete( return err } + // Remove consumer finalizer from AC secrets nova was consuming. + for _, secretName := range []string{ + instance.Status.ApplicationCredentialSecret, + instance.Spec.Auth.ApplicationCredentialSecret, + } { + if err := keystonev1.RemoveACSecretConsumerFinalizer(ctx, h, instance.Namespace, + secretName, nova.ACConsumerFinalizer); err != nil { + return err + } + } + // Successfully cleaned up everything. So as the final step let's remove the // finalizer from ourselves to allow the deletion of Nova CR itself updated := controllerutil.RemoveFinalizer(instance, h.GetFinalizer()) diff --git a/internal/nova/common.go b/internal/nova/common.go index c4afe775e..900ef31a7 100644 --- a/internal/nova/common.go +++ b/internal/nova/common.go @@ -30,6 +30,11 @@ const ( NovaUserID int64 = 42436 ) +const ( + // ACConsumerFinalizer is added to AC secrets that nova is actively consuming + ACConsumerFinalizer = "openstack.org/nova-ac-consumer" +) + // GetScriptSecretName returns the name of the Secret used for the // db sync scripts func GetScriptSecretName(crName string) string { diff --git a/test/functional/nova/nova_controller_test.go b/test/functional/nova/nova_controller_test.go index 0882f1aff..b36bd057d 100644 --- a/test/functional/nova/nova_controller_test.go +++ b/test/functional/nova/nova_controller_test.go @@ -37,6 +37,7 @@ import ( "github.com/openstack-k8s-operators/lib-common/modules/common/util" novav1 "github.com/openstack-k8s-operators/nova-operator/api/nova/v1beta1" controllers "github.com/openstack-k8s-operators/nova-operator/internal/controller/nova" + nova "github.com/openstack-k8s-operators/nova-operator/internal/nova" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -2157,4 +2158,164 @@ var _ = Describe("application credentials", func() { }, timeout, interval).Should(Succeed()) }) }) + + When("ApplicationCredential consumer finalizer is managed", func() { + var acSecretName string + + BeforeEach(func() { + acSecretName = "ac-nova-a1b2c-secret" //nolint:gosec // G101 + ac := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: novaNames.NovaName.Namespace, + Name: acSecretName, + }, + Data: map[string][]byte{ + keystonev1.ACIDSecretKey: []byte("a1b2ctest-ac-id"), + keystonev1.ACSecretSecretKey: []byte("test-ac-secret"), + }, + } + DeferCleanup(k8sClient.Delete, ctx, ac) + Expect(k8sClient.Create(ctx, ac)).To(Succeed()) + + DeferCleanup(k8sClient.Delete, ctx, CreateNovaSecret(novaNames.NovaName.Namespace, SecretName)) + DeferCleanup(k8sClient.Delete, ctx, CreateNovaMessageBusSecret(cell0)) + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + novaNames.NovaName.Namespace, + "openstack", + corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Port: 3306}}, + }, + ), + ) + memcachedSpec := infra.GetDefaultMemcachedSpec() + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(novaNames.NovaName.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(novaNames.MemcachedNamespace) + + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(novaNames.NovaName.Namespace)) + + rawNova := map[string]any{ + "apiVersion": "nova.openstack.org/v1beta1", + "kind": "Nova", + "metadata": map[string]any{ + "name": novaNames.NovaName.Name, + "namespace": novaNames.NovaName.Namespace, + }, + "spec": map[string]any{ + "secret": SecretName, + "apiDatabaseAccount": novaNames.APIMariaDBDatabaseAccount.Name, + "cellTemplates": map[string]any{ + "cell0": map[string]any{ + "cellDatabaseAccount": cell0.MariaDBAccountName.Name, + "apiDatabaseAccount": novaNames.APIMariaDBDatabaseAccount.Name, + "hasAPIAccess": true, + "dbPurge": map[string]any{ + "schedule": "1 0 * * *", + }, + }, + }, + "messagingBus": map[string]any{ + "cluster": cell0.TransportURLName.Name, + }, + "auth": map[string]any{ + "applicationCredentialSecret": acSecretName, + }, + }, + } + DeferCleanup(th.DeleteInstance, th.CreateUnstructured(rawNova)) + }) + + It("should add the consumer finalizer to the AC secret", func() { + Eventually(func(g Gomega) { + secret := th.GetSecret(types.NamespacedName{ + Namespace: novaNames.NovaName.Namespace, + Name: acSecretName, + }) + g.Expect(secret.Finalizers).To( + ContainElement(nova.ACConsumerFinalizer)) + }, timeout, interval).Should(Succeed()) + }) + + It("should track the consumed AC secret in status", func() { + Eventually(func(g Gomega) { + n := GetNova(novaNames.NovaName) + g.Expect(n.Status.ApplicationCredentialSecret).To(Equal(acSecretName)) + }, timeout, interval).Should(Succeed()) + }) + + It("should move the finalizer from the old to the new secret on rotation", func() { + Eventually(func(g Gomega) { + secret := th.GetSecret(types.NamespacedName{ + Namespace: novaNames.NovaName.Namespace, + Name: acSecretName, + }) + g.Expect(secret.Finalizers).To( + ContainElement(nova.ACConsumerFinalizer)) + }, timeout, interval).Should(Succeed()) + + newACSecretName := "ac-nova-x9y8z-secret" //nolint:gosec // G101 + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: novaNames.NovaName.Namespace, + Name: newACSecretName, + }, + Data: map[string][]byte{ + keystonev1.ACIDSecretKey: []byte("x9y8zrotated-ac-id"), + keystonev1.ACSecretSecretKey: []byte("rotated-ac-secret"), + }, + } + DeferCleanup(k8sClient.Delete, ctx, newSecret) + Expect(k8sClient.Create(ctx, newSecret)).To(Succeed()) + + Eventually(func(g Gomega) { + n := GetNova(novaNames.NovaName) + n.Spec.Auth.ApplicationCredentialSecret = newACSecretName + g.Expect(k8sClient.Update(ctx, n)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + secret := th.GetSecret(types.NamespacedName{ + Namespace: novaNames.NovaName.Namespace, + Name: newACSecretName, + }) + g.Expect(secret.Finalizers).To( + ContainElement(nova.ACConsumerFinalizer)) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + secret := th.GetSecret(types.NamespacedName{ + Namespace: novaNames.NovaName.Namespace, + Name: acSecretName, + }) + g.Expect(secret.Finalizers).NotTo( + ContainElement(nova.ACConsumerFinalizer)) + }, timeout, interval).Should(Succeed()) + + Eventually(func(g Gomega) { + n := GetNova(novaNames.NovaName) + g.Expect(n.Status.ApplicationCredentialSecret).To(Equal(newACSecretName)) + }, timeout, interval).Should(Succeed()) + }) + + It("should remove the consumer finalizer from AC secret on CR deletion", func() { + Eventually(func(g Gomega) { + secret := th.GetSecret(types.NamespacedName{ + Namespace: novaNames.NovaName.Namespace, + Name: acSecretName, + }) + g.Expect(secret.Finalizers).To( + ContainElement(nova.ACConsumerFinalizer)) + }, timeout, interval).Should(Succeed()) + + th.DeleteInstance(GetNova(novaNames.NovaName)) + + secret := th.GetSecret(types.NamespacedName{ + Namespace: novaNames.NovaName.Namespace, + Name: acSecretName, + }) + Expect(secret.Finalizers).NotTo( + ContainElement(nova.ACConsumerFinalizer)) + }) + }) })