Skip to content

Commit 9ba8db7

Browse files
naimadswdnDamian Seredyn
andauthored
feat: Add support for ACL from PVC (#1582)
Add possibility to use ACL files mounted from PVC, not k8s secret. Implement validation webhook to ensure only one ACL storage type is used. Update E2E tests with the example that uses ACL from prepopulated PVC. Signed-off-by: Damian Seredyn <[email protected]> Co-authored-by: Damian Seredyn <[email protected]>
1 parent e2aab87 commit 9ba8db7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1468
-29
lines changed

api/common/v1beta2/common_types.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package v1beta2
22

33
import (
4+
"fmt"
5+
46
appsv1 "k8s.io/api/apps/v1"
57
corev1 "k8s.io/api/core/v1"
68
)
@@ -272,5 +274,27 @@ type InitContainer struct {
272274

273275
// +k8s:deepcopy-gen=true
274276
type ACLConfig struct {
277+
// Secret-based ACL configuration.
278+
// Adapts a Secret into a volume containing ACL rules.
279+
// The contents of the target Secret's Data field will be presented in a volume
280+
// as files using the keys in the Data field as the file names.
281+
// Secret volumes support ownership management and SELinux relabeling.
275282
Secret *corev1.SecretVolumeSource `json:"secret,omitempty"`
283+
// PersistentVolumeClaim-based ACL configuration
284+
// Specify the PVC name to mount ACL file from persistent storage
285+
// The operator will automatically mount /etc/redis/user.acl from the PVC
286+
PersistentVolumeClaim *string `json:"persistentVolumeClaim,omitempty"`
287+
}
288+
289+
// Validate checks that only one ACL source is specified
290+
func (a *ACLConfig) Validate() error {
291+
if a == nil {
292+
return nil
293+
}
294+
295+
if a.Secret != nil && a.PersistentVolumeClaim != nil {
296+
return fmt.Errorf("only one of 'secret' or 'persistentVolumeClaim' can be specified in ACL configuration")
297+
}
298+
299+
return nil
276300
}

api/common/v1beta2/common_types_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
"github.com/stretchr/testify/assert"
7+
corev1 "k8s.io/api/core/v1"
78
"k8s.io/utils/ptr"
89
)
910

@@ -84,3 +85,150 @@ func TestKubernetesConfig_ShouldCreateAdditionalService(t *testing.T) {
8485
})
8586
}
8687
}
88+
89+
func TestACLConfig_PersistentVolumeClaim(t *testing.T) {
90+
tests := []struct {
91+
name string
92+
aclConfig *ACLConfig
93+
expectedPVCName *string
94+
expectedSecretConfigured bool
95+
}{
96+
{
97+
name: "nil ACL config",
98+
aclConfig: nil,
99+
expectedPVCName: nil,
100+
expectedSecretConfigured: false,
101+
},
102+
{
103+
name: "empty ACL config",
104+
aclConfig: &ACLConfig{},
105+
expectedPVCName: nil,
106+
expectedSecretConfigured: false,
107+
},
108+
{
109+
name: "ACL with secret only",
110+
aclConfig: &ACLConfig{
111+
Secret: &corev1.SecretVolumeSource{
112+
SecretName: "redis-acl-secret",
113+
},
114+
},
115+
expectedPVCName: nil,
116+
expectedSecretConfigured: true,
117+
},
118+
{
119+
name: "ACL with PVC only",
120+
aclConfig: &ACLConfig{
121+
PersistentVolumeClaim: ptr.To("redis-acl-pvc"),
122+
},
123+
expectedPVCName: ptr.To("redis-acl-pvc"),
124+
expectedSecretConfigured: false,
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
if tt.aclConfig == nil {
131+
assert.Nil(t, tt.aclConfig)
132+
return
133+
}
134+
135+
// Verify PVC configuration
136+
if tt.expectedPVCName != nil {
137+
assert.NotNil(t, tt.aclConfig.PersistentVolumeClaim)
138+
assert.Equal(t, *tt.expectedPVCName, *tt.aclConfig.PersistentVolumeClaim)
139+
} else {
140+
assert.Nil(t, tt.aclConfig.PersistentVolumeClaim)
141+
}
142+
143+
// Verify Secret configuration
144+
if tt.expectedSecretConfigured {
145+
assert.NotNil(t, tt.aclConfig.Secret)
146+
} else {
147+
assert.Nil(t, tt.aclConfig.Secret)
148+
}
149+
})
150+
}
151+
}
152+
153+
func TestACLConfig_BothSourcesConfigured(t *testing.T) {
154+
// Test scenario where both Secret and PVC are configured
155+
// Validation should fail
156+
aclConfig := &ACLConfig{
157+
Secret: &corev1.SecretVolumeSource{
158+
SecretName: "redis-acl-secret",
159+
},
160+
PersistentVolumeClaim: ptr.To("redis-acl-pvc"),
161+
}
162+
163+
// Both should be settable (struct level)
164+
assert.NotNil(t, aclConfig.Secret)
165+
assert.NotNil(t, aclConfig.PersistentVolumeClaim)
166+
assert.Equal(t, "redis-acl-secret", aclConfig.Secret.SecretName)
167+
assert.Equal(t, "redis-acl-pvc", *aclConfig.PersistentVolumeClaim)
168+
169+
// But validation should fail
170+
err := aclConfig.Validate()
171+
assert.Error(t, err)
172+
assert.Contains(t, err.Error(), "only one of 'secret' or 'persistentVolumeClaim' can be specified")
173+
}
174+
175+
func TestACLConfig_Validate(t *testing.T) {
176+
tests := []struct {
177+
name string
178+
aclConfig *ACLConfig
179+
wantErr bool
180+
errMsg string
181+
}{
182+
{
183+
name: "nil config is valid",
184+
aclConfig: nil,
185+
wantErr: false,
186+
},
187+
{
188+
name: "empty config is valid",
189+
aclConfig: &ACLConfig{},
190+
wantErr: false,
191+
},
192+
{
193+
name: "only secret is valid",
194+
aclConfig: &ACLConfig{
195+
Secret: &corev1.SecretVolumeSource{
196+
SecretName: "redis-acl-secret",
197+
},
198+
},
199+
wantErr: false,
200+
},
201+
{
202+
name: "only PVC is valid",
203+
aclConfig: &ACLConfig{
204+
PersistentVolumeClaim: ptr.To("redis-acl-pvc"),
205+
},
206+
wantErr: false,
207+
},
208+
{
209+
name: "both secret and PVC is invalid",
210+
aclConfig: &ACLConfig{
211+
Secret: &corev1.SecretVolumeSource{
212+
SecretName: "redis-acl-secret",
213+
},
214+
PersistentVolumeClaim: ptr.To("redis-acl-pvc"),
215+
},
216+
wantErr: true,
217+
errMsg: "only one of 'secret' or 'persistentVolumeClaim' can be specified",
218+
},
219+
}
220+
221+
for _, tt := range tests {
222+
t.Run(tt.name, func(t *testing.T) {
223+
err := tt.aclConfig.Validate()
224+
if tt.wantErr {
225+
assert.Error(t, err)
226+
if tt.errMsg != "" {
227+
assert.Contains(t, err.Error(), tt.errMsg)
228+
}
229+
} else {
230+
assert.NoError(t, err)
231+
}
232+
})
233+
}
234+
}

api/common/v1beta2/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/redis/v1beta2/redis_webhook.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,80 @@ limitations under the License.
1717
package v1beta2
1818

1919
import (
20+
apierrors "k8s.io/apimachinery/pkg/api/errors"
21+
"k8s.io/apimachinery/pkg/runtime"
22+
"k8s.io/apimachinery/pkg/runtime/schema"
23+
"k8s.io/apimachinery/pkg/util/validation/field"
2024
ctrl "sigs.k8s.io/controller-runtime"
25+
logf "sigs.k8s.io/controller-runtime/pkg/log"
26+
"sigs.k8s.io/controller-runtime/pkg/webhook"
27+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2128
)
2229

30+
const (
31+
webhookPath = "/validate-redis-redis-opstreelabs-in-v1beta2-redis"
32+
)
33+
34+
// log is for logging in this package.
35+
var redislog = logf.Log.WithName("redis-v1beta2-validation")
36+
37+
// +kubebuilder:webhook:path=/validate-redis-redis-opstreelabs-in-v1beta2-redis,mutating=false,failurePolicy=fail,sideEffects=None,groups=redis.redis.opstreelabs.in,resources=redis,verbs=create;update,versions=v1beta2,name=validate-redis.redis.opstreelabs.in,admissionReviewVersions=v1
38+
2339
func (r *Redis) SetupWebhookWithManager(mgr ctrl.Manager) error {
2440
return ctrl.NewWebhookManagedBy(mgr).
2541
For(r).
2642
Complete()
2743
}
2844

29-
// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
45+
var _ webhook.Validator = &Redis{}
46+
47+
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
48+
func (r *Redis) ValidateCreate() (admission.Warnings, error) {
49+
redislog.Info("validate create", "name", r.Name)
50+
51+
return r.validate(nil)
52+
}
53+
54+
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
55+
func (r *Redis) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
56+
redislog.Info("validate update", "name", r.Name)
57+
58+
return r.validate(old.(*Redis))
59+
}
60+
61+
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
62+
func (r *Redis) ValidateDelete() (admission.Warnings, error) {
63+
redislog.Info("validate delete", "name", r.Name)
64+
65+
return nil, nil
66+
}
67+
68+
// validate validates the Redis CR
69+
func (r *Redis) validate(_ *Redis) (admission.Warnings, error) {
70+
var errors field.ErrorList
71+
72+
// Validate ACL configuration
73+
if r.Spec.ACL != nil {
74+
if err := r.Spec.ACL.Validate(); err != nil {
75+
errors = append(errors, field.Invalid(
76+
field.NewPath("spec").Child("acl"),
77+
r.Spec.ACL,
78+
err.Error(),
79+
))
80+
}
81+
}
82+
83+
if len(errors) == 0 {
84+
return nil, nil
85+
}
86+
87+
return nil, apierrors.NewInvalid(
88+
schema.GroupKind{Group: "redis.redis.opstreelabs.in", Kind: "Redis"},
89+
r.Name,
90+
errors,
91+
)
92+
}
93+
94+
func (r *Redis) WebhookPath() string {
95+
return webhookPath
96+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package v1beta2_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
common "github.com/OT-CONTAINER-KIT/redis-operator/api/common/v1beta2"
9+
v1beta2 "github.com/OT-CONTAINER-KIT/redis-operator/api/redis/v1beta2"
10+
"github.com/OT-CONTAINER-KIT/redis-operator/internal/testutil/webhook"
11+
"github.com/stretchr/testify/require"
12+
admissionv1beta1 "k8s.io/api/admission/v1beta1"
13+
corev1 "k8s.io/api/core/v1"
14+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15+
"k8s.io/apimachinery/pkg/types"
16+
"k8s.io/utils/ptr"
17+
)
18+
19+
func TestRedisWebhook(t *testing.T) {
20+
cases := []webhook.ValidationWebhookTestCase{
21+
{
22+
Name: "failed-create-v1beta2-redis-acl-both-sources",
23+
Operation: admissionv1beta1.Create,
24+
Object: func(t *testing.T, uid string) []byte {
25+
t.Helper()
26+
redis := mkRedis(uid)
27+
redis.Spec.ACL = mkACLWithBothSources()
28+
return marshal(t, redis)
29+
},
30+
Check: webhook.ValidationWebhookFailed("only one of 'secret' or 'persistentVolumeClaim' can be specified"),
31+
},
32+
}
33+
34+
gvk := metav1.GroupVersionKind{
35+
Group: "redis.redis.opstreelabs.in",
36+
Version: "v1beta2",
37+
Kind: "Redis",
38+
}
39+
40+
redis := &v1beta2.Redis{}
41+
webhook.RunValidationWebhookTests(t, gvk, redis, cases...)
42+
}
43+
44+
func mkRedis(uid string) *v1beta2.Redis {
45+
return &v1beta2.Redis{
46+
ObjectMeta: metav1.ObjectMeta{
47+
Name: fmt.Sprintf("test-%s", uid),
48+
UID: types.UID(fmt.Sprintf("test-%s", uid)),
49+
},
50+
Spec: v1beta2.RedisSpec{},
51+
}
52+
}
53+
54+
func marshal(t *testing.T, obj interface{}) []byte {
55+
t.Helper()
56+
bytes, err := json.Marshal(obj)
57+
require.NoError(t, err)
58+
return bytes
59+
}
60+
61+
func mkACLWithBothSources() *common.ACLConfig {
62+
return &common.ACLConfig{
63+
Secret: &corev1.SecretVolumeSource{
64+
SecretName: "test-secret",
65+
},
66+
PersistentVolumeClaim: ptr.To("test-pvc"),
67+
}
68+
}

api/redis/v1beta2/zz_generated.deepcopy.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/rediscluster/v1beta2/rediscluster_webhook.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ func (r *RedisCluster) validate(_ *RedisCluster) (admission.Warnings, error) {
8484
))
8585
}
8686

87+
// Validate ACL configuration
88+
if r.Spec.ACL != nil {
89+
if err := r.Spec.ACL.Validate(); err != nil {
90+
errors = append(errors, field.Invalid(
91+
field.NewPath("spec").Child("acl"),
92+
r.Spec.ACL,
93+
err.Error(),
94+
))
95+
}
96+
}
97+
8798
if len(errors) == 0 {
8899
return nil, nil
89100
}

0 commit comments

Comments
 (0)