Skip to content

Commit ae35cd9

Browse files
Merge pull request #577 from lmiccini/shared-singleton-vhost-user
Use shared singleton vhost/user CRs with per-consumer finalizers
2 parents 73f228e + fcc8556 commit ae35cd9

28 files changed

Lines changed: 1327 additions & 1439 deletions

apis/bases/rabbitmq.openstack.org_rabbitmqpolicies.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ spec:
147147
for this resource
148148
format: int64
149149
type: integer
150+
policyName:
151+
description: PolicyName - actual policy name used in RabbitMQ
152+
type: string
153+
vhost:
154+
description: Vhost - actual vhost name where the policy was last applied
155+
type: string
150156
type: object
151157
type: object
152158
served: true

apis/rabbitmq/v1beta1/conditions.go

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ limitations under the License.
1616
package v1beta1
1717

1818
import (
19+
"crypto/sha256"
20+
"fmt"
21+
1922
condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition"
2023
)
2124

@@ -32,16 +35,52 @@ const (
3235
// TransportURLReadyCondition Status=True condition which indicates if TransportURL is configured and operational
3336
TransportURLReadyCondition condition.Type = "TransportURLReady"
3437

35-
// TransportURLFinalizer - finalizer to add to RabbitMQUsers owned by TransportURL
38+
// TransportURLFinalizer - legacy finalizer for backward compatibility during migration.
39+
// New code should use TransportURLFinalizerFor() instead.
3640
TransportURLFinalizer = "transporturl.rabbitmq.openstack.org/finalizer"
3741

38-
// RabbitMQUserCleanupBlockedFinalizer - temporary finalizer to block automatic cleanup of RabbitMQUsers
39-
// This finalizer prevents TransportURL from automatically deleting users during credential rotation.
40-
// It must be manually removed by an operator/admin to allow cleanup to proceed.
41-
// TODO: Replace with proper safe-to-delete logic, then remove this finalizer from existing users.
42+
// TransportURLFinalizerPrefix - prefix for per-TransportURL finalizers on shared vhost/user CRs.
43+
// Use TransportURLFinalizerFor() to build the full finalizer name safely.
44+
TransportURLFinalizerPrefix = "turl.openstack.org/t-"
45+
46+
// maxFinalizerNameSegment is the Kubernetes limit for the name segment after "/"
47+
maxFinalizerNameSegment = 63
48+
49+
// MaxTransportURLDirectName is the maximum TransportURL name length that
50+
// can be embedded directly (without hashing) into a per-consumer finalizer.
51+
// Names longer than this are truncated+hashed, which breaks the watch-based
52+
// reverse mapping and requires a periodic requeue fallback.
53+
MaxTransportURLDirectName = maxFinalizerNameSegment - len("t-") // 61
54+
55+
// RabbitMQUserCleanupBlockedFinalizer - safety finalizer that blocks automatic deletion of RabbitMQUsers.
56+
// When a shared user CR is orphaned (no active consumers), the user controller will only
57+
// auto-delete it after an admin removes this finalizer, confirming no external services
58+
// depend on the RabbitMQ user.
4259
RabbitMQUserCleanupBlockedFinalizer = "rabbitmq.openstack.org/cleanup-blocked"
60+
61+
// RabbitMQUserOrphanedLabel marks a shared RabbitMQUser CR as having no active consumers.
62+
// The TransportURL controller sets this label instead of deleting the CR directly,
63+
// keeping the CR reclaimable by new consumers. The user controller auto-deletes
64+
// the CR only when this label is present AND the cleanup-blocked finalizer is removed.
65+
RabbitMQUserOrphanedLabel = "rabbitmq.openstack.org/orphaned"
4366
)
4467

68+
// TransportURLFinalizerFor returns the per-consumer finalizer for a TransportURL.
69+
// If the name fits within Kubernetes' 63-char name segment limit, it is used directly
70+
// (preserving human readability and reverse mapping). For longer names, the suffix
71+
// is truncated and a short hash is appended.
72+
func TransportURLFinalizerFor(transportURLName string) string {
73+
prefix := "t-"
74+
maxNameLen := maxFinalizerNameSegment - len(prefix)
75+
if len(transportURLName) <= maxNameLen {
76+
return TransportURLFinalizerPrefix + transportURLName
77+
}
78+
hash := sha256.Sum256([]byte(transportURLName))
79+
hashHex := fmt.Sprintf("%x", hash[:4])
80+
truncLen := maxNameLen - len(hashHex)
81+
return TransportURLFinalizerPrefix + transportURLName[:truncLen] + hashHex
82+
}
83+
4584
// TransportURL Reasons used by API objects.
4685
const ()
4786

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1beta1
18+
19+
import (
20+
"strings"
21+
"testing"
22+
)
23+
24+
func TestTransportURLFinalizerFor(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
transportURL string
28+
wantPrefix bool
29+
wantMaxLen int
30+
wantExact string
31+
}{
32+
{
33+
name: "short name used directly",
34+
transportURL: "nova-api-transport",
35+
wantPrefix: true,
36+
wantExact: TransportURLFinalizerPrefix + "nova-api-transport",
37+
},
38+
{
39+
name: "61-char name fits exactly",
40+
transportURL: strings.Repeat("a", 61),
41+
wantPrefix: true,
42+
wantExact: TransportURLFinalizerPrefix + strings.Repeat("a", 61),
43+
},
44+
{
45+
name: "62-char name gets truncated and hashed",
46+
transportURL: strings.Repeat("b", 62),
47+
wantPrefix: true,
48+
wantMaxLen: maxFinalizerNameSegment + len("turl.openstack.org/"),
49+
},
50+
{
51+
name: "253-char name gets truncated and hashed",
52+
transportURL: strings.Repeat("c", 253),
53+
wantPrefix: true,
54+
wantMaxLen: maxFinalizerNameSegment + len("turl.openstack.org/"),
55+
},
56+
{
57+
name: "different long names produce different finalizers",
58+
transportURL: strings.Repeat("d", 100),
59+
wantPrefix: true,
60+
wantMaxLen: maxFinalizerNameSegment + len("turl.openstack.org/"),
61+
},
62+
}
63+
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
got := TransportURLFinalizerFor(tt.transportURL)
67+
68+
if !strings.HasPrefix(got, TransportURLFinalizerPrefix) {
69+
t.Errorf("finalizer %q does not start with prefix %q", got, TransportURLFinalizerPrefix)
70+
}
71+
72+
nameSegment := strings.SplitN(got, "/", 2)[1]
73+
if len(nameSegment) > maxFinalizerNameSegment {
74+
t.Errorf("name segment %q is %d chars, exceeds max %d", nameSegment, len(nameSegment), maxFinalizerNameSegment)
75+
}
76+
77+
if tt.wantExact != "" && got != tt.wantExact {
78+
t.Errorf("got %q, want %q", got, tt.wantExact)
79+
}
80+
})
81+
}
82+
83+
// Verify two different long names produce different finalizers
84+
fin1 := TransportURLFinalizerFor(strings.Repeat("x", 100))
85+
fin2 := TransportURLFinalizerFor(strings.Repeat("y", 100))
86+
if fin1 == fin2 {
87+
t.Errorf("different long names produced the same finalizer: %q", fin1)
88+
}
89+
}

apis/rabbitmq/v1beta1/rabbitmqpolicy_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ type RabbitMQPolicyStatus struct {
6565

6666
// ObservedGeneration - the most recent generation observed for this resource
6767
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
68+
69+
// Vhost - actual vhost name where the policy was last applied
70+
Vhost string `json:"vhost,omitempty"`
71+
72+
// PolicyName - actual policy name used in RabbitMQ
73+
PolicyName string `json:"policyName,omitempty"`
6874
}
6975

7076
//+kubebuilder:object:root=true

apis/rabbitmq/v1beta1/rabbitmqpolicy_webhook.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package v1beta1
1818

1919
import (
2020
"fmt"
21+
"regexp"
2122

2223
apierrors "k8s.io/apimachinery/pkg/api/errors"
2324
"k8s.io/apimachinery/pkg/runtime"
@@ -30,8 +31,6 @@ import (
3031

3132
var rabbitmqpolicylog = logf.Log.WithName("rabbitmqpolicy-resource")
3233

33-
//+kubebuilder:webhook:path=/mutate-rabbitmq-openstack-org-v1beta1-rabbitmqpolicy,mutating=true,failurePolicy=fail,sideEffects=None,groups=rabbitmq.openstack.org,resources=rabbitmqpolicies,verbs=create;update,versions=v1beta1,name=mrabbitmqpolicy.kb.io,admissionReviewVersions=v1
34-
3534
// Default implements defaulting for RabbitMQPolicy
3635
func (r *RabbitMQPolicy) Default(_ client.Client) {
3736
rabbitmqpolicylog.Info("default", "name", r.Name)
@@ -42,8 +41,6 @@ func (r *RabbitMQPolicy) Default(_ client.Client) {
4241
}
4342
}
4443

45-
//+kubebuilder:webhook:path=/validate-rabbitmq-openstack-org-v1beta1-rabbitmqpolicy,mutating=false,failurePolicy=fail,sideEffects=None,groups=rabbitmq.openstack.org,resources=rabbitmqpolicies,verbs=create;update,versions=v1beta1,name=vrabbitmqpolicy.kb.io,admissionReviewVersions=v1
46-
4744
// ValidateCreate validates the RabbitMQPolicy on creation
4845
func (r *RabbitMQPolicy) ValidateCreate(_ client.Client) (admission.Warnings, error) {
4946
rabbitmqpolicylog.Info("validate create", "name", r.Name)
@@ -56,6 +53,10 @@ func (r *RabbitMQPolicy) ValidateCreate(_ client.Client) (admission.Warnings, er
5653
)
5754
}
5855

56+
if err := r.validatePattern(); err != nil {
57+
return nil, err
58+
}
59+
5960
return nil, nil
6061
}
6162

@@ -68,6 +69,20 @@ func (r *RabbitMQPolicy) ValidateUpdate(_ client.Client, old runtime.Object) (ad
6869
return nil, fmt.Errorf("expected RabbitMQPolicy but got %T", old)
6970
}
7071

72+
// Prevent changing the cluster after creation
73+
if r.Spec.RabbitmqClusterName != oldPolicy.Spec.RabbitmqClusterName {
74+
return nil, apierrors.NewInvalid(
75+
schema.GroupKind{Group: "rabbitmq.openstack.org", Kind: "RabbitMQPolicy"},
76+
r.Name,
77+
field.ErrorList{
78+
field.Forbidden(
79+
field.NewPath("spec", "rabbitmqClusterName"),
80+
"rabbitmqClusterName cannot be changed after creation",
81+
),
82+
},
83+
)
84+
}
85+
7186
// Prevent changing the policy name after creation
7287
if r.Spec.Name != oldPolicy.Spec.Name {
7388
return nil, apierrors.NewInvalid(
@@ -82,10 +97,29 @@ func (r *RabbitMQPolicy) ValidateUpdate(_ client.Client, old runtime.Object) (ad
8297
)
8398
}
8499

100+
if err := r.validatePattern(); err != nil {
101+
return nil, err
102+
}
103+
85104
return nil, nil
86105
}
87106

88107
// ValidateDelete validates the RabbitMQPolicy on deletion
89108
func (r *RabbitMQPolicy) ValidateDelete(_ client.Client) (admission.Warnings, error) {
90109
return nil, nil
91110
}
111+
112+
// validatePattern validates that the Pattern field is a valid regex
113+
func (r *RabbitMQPolicy) validatePattern() error {
114+
if _, err := regexp.Compile(r.Spec.Pattern); err != nil {
115+
return apierrors.NewInvalid(
116+
schema.GroupKind{Group: "rabbitmq.openstack.org", Kind: "RabbitMQPolicy"},
117+
r.Name,
118+
field.ErrorList{
119+
field.Invalid(field.NewPath("spec", "pattern"), r.Spec.Pattern,
120+
fmt.Sprintf("invalid regex pattern: %v", err)),
121+
},
122+
)
123+
}
124+
return nil
125+
}

apis/rabbitmq/v1beta1/rabbitmquser_types.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"fmt"
21+
"strings"
22+
2023
condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition"
2124
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2225
)
@@ -168,14 +171,28 @@ const (
168171
// RabbitMQUserReadyErrorMessage is the message format for the RabbitMQUserReady condition when an error occurs
169172
RabbitMQUserReadyErrorMessage = "RabbitMQ user error occurred %s"
170173

174+
// RabbitMQUserOrphanedMessage is the message when a user CR is orphaned and awaiting admin approval
175+
RabbitMQUserOrphanedMessage = "User has no active consumers. Remove finalizer %s to approve deletion"
176+
171177
// Internal controller finalizer (from rabbitmquser_controller.go)
172178
userControllerFinalizer = "rabbitmquser.openstack.org/finalizer"
173179
)
174180

175181
// IsInternalFinalizer returns true if the finalizer is managed by RabbitMQ controllers
176-
// (as opposed to external controllers like dataplane)
182+
// (as opposed to external controllers like dataplane).
183+
// Note: RabbitMQUserCleanupBlockedFinalizer is intentionally excluded — it must block
184+
// user deletion as an external-like finalizer requiring manual admin removal.
177185
func IsInternalFinalizer(finalizer string) bool {
178186
return finalizer == UserFinalizer ||
179187
finalizer == TransportURLFinalizer ||
180-
finalizer == userControllerFinalizer
188+
finalizer == userControllerFinalizer ||
189+
strings.HasPrefix(finalizer, TransportURLFinalizerPrefix)
190+
}
191+
192+
// CanonicalUserName returns the deterministic CR name for a shared user singleton.
193+
func CanonicalUserName(clusterName, vhostName, username string) string {
194+
if vhostName == "/" || vhostName == "" {
195+
return fmt.Sprintf("%s-user-%s", clusterName, username)
196+
}
197+
return fmt.Sprintf("%s-%s-user-%s", clusterName, vhostName, username)
181198
}

0 commit comments

Comments
 (0)