Skip to content

Commit 6c72ce4

Browse files
authored
feat: allow RateLimitRedisSettings.url to be sourced from a Secret (#9143)
* api: add urlRef to RateLimitRedisSettings (#9022) Signed-off-by: Andrey Maltsev <maltsev.andrey@gmail.com>
1 parent a176fb6 commit 6c72ce4

10 files changed

Lines changed: 597 additions & 15 deletions

File tree

api/v1alpha1/envoygateway_types.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package v1alpha1
77

88
import (
9+
corev1 "k8s.io/api/core/v1"
910
"k8s.io/apimachinery/pkg/api/resource"
1011
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1112
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -682,17 +683,42 @@ type RedisTLSSettings struct {
682683
}
683684

684685
// RateLimitRedisSettings defines the configuration for connecting to redis database.
686+
// +kubebuilder:validation:XValidation:rule="has(self.url) != has(self.urlRef)",message="exactly one of url or urlRef must be set"
685687
type RateLimitRedisSettings struct {
686688
// URL of the Redis Database.
687689
// This can reference a single Redis host or a comma delimited list for Sentinel and Cluster deployments of Redis.
688-
URL string `json:"url"`
690+
// Mutually exclusive with URLRef.
691+
//
692+
// +optional
693+
URL string `json:"url,omitempty"`
694+
695+
// URLRef sources the Redis URL from a Kubernetes Secret key. Use this for GitOps
696+
// flows where the Redis endpoint is provisioned by an external controller.
697+
// The referenced Secret must exist in the namespace of the Envoy Gateway rate limit
698+
// deployment. Mutually exclusive with URL.
699+
//
700+
// +optional
701+
URLRef *RedisURLSource `json:"urlRef,omitempty"`
689702

690703
// TLS defines TLS configuration for connecting to redis database.
691704
//
692705
// +optional
693706
TLS *RedisTLSSettings `json:"tls,omitempty"`
694707
}
695708

709+
// RedisURLSource specifies where to source the Redis URL from.
710+
// +kubebuilder:validation:XValidation:rule="!has(self.secretKeyRef.optional) || !self.secretKeyRef.optional",message="urlRef.secretKeyRef.optional must not be true; the Secret is required"
711+
type RedisURLSource struct {
712+
// SecretKeyRef references the Secret and key that hold the Redis URL.
713+
// The Secret must be in the same namespace as the Envoy Gateway rate limit deployment.
714+
// The reference is always required: optional must not be set to true, otherwise
715+
// the rate limit pod could start with an unset REDIS_URL instead of waiting for
716+
// the externally provisioned Secret.
717+
//
718+
// +kubebuilder:validation:Required
719+
SecretKeyRef *corev1.SecretKeySelector `json:"secretKeyRef"`
720+
}
721+
696722
// ExtensionManager defines the configuration for registering an extension manager to
697723
// the Envoy Gateway control plane.
698724
type ExtensionManager struct {

api/v1alpha1/validation/envoygateway_validate.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,42 @@ func validateEnvoyGatewayRateLimit(rateLimit *egv1a1.RateLimit) error {
189189
if rateLimit.Backend.Type != egv1a1.RedisBackendType {
190190
return fmt.Errorf("unsupported ratelimit backend %v", rateLimit.Backend.Type)
191191
}
192-
if rateLimit.Backend.Redis == nil || rateLimit.Backend.Redis.URL == "" {
192+
redis := rateLimit.Backend.Redis
193+
if redis == nil {
193194
return fmt.Errorf("empty ratelimit redis settings")
194195
}
195-
redisHosts := strings.Split(rateLimit.Backend.Redis.URL, ",")
196-
for _, host := range redisHosts {
196+
197+
hasURL := redis.URL != ""
198+
hasURLRef := redis.URLRef != nil
199+
if hasURL == hasURLRef {
200+
return fmt.Errorf("exactly one of ratelimit redis url or urlRef must be set")
201+
}
202+
203+
if hasURLRef {
204+
ref := redis.URLRef.SecretKeyRef
205+
if ref == nil || ref.Name == "" || ref.Key == "" {
206+
return fmt.Errorf("ratelimit redis urlRef.secretKeyRef must set both name and key")
207+
}
208+
// The Secret is required: the rate limit pod must wait for an
209+
// externally provisioned Secret rather than start with an unset
210+
// REDIS_URL. An optional reference would let the container boot
211+
// nonfunctional, so reject optional=true.
212+
if ref.Optional != nil && *ref.Optional {
213+
return fmt.Errorf("ratelimit redis urlRef.secretKeyRef.optional must not be true; the Secret is required")
214+
}
215+
return nil
216+
}
217+
218+
return ValidateRedisURL(redis.URL)
219+
}
220+
221+
// ValidateRedisURL validates a ratelimit Redis URL string, which may be a single
222+
// host or a comma-delimited list of hosts for Sentinel and Cluster deployments.
223+
func ValidateRedisURL(redisURL string) error {
224+
if redisURL == "" {
225+
return fmt.Errorf("ratelimit redis url is empty")
226+
}
227+
for _, host := range strings.Split(redisURL, ",") {
197228
if _, err := url.Parse(host); err != nil {
198229
return fmt.Errorf("unknown ratelimit redis url format: %w", err)
199230
}

api/v1alpha1/validation/envoygateway_validate_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/stretchr/testify/assert"
1212
"github.com/stretchr/testify/require"
13+
corev1 "k8s.io/api/core/v1"
1314
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1415
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
1516

@@ -1462,3 +1463,123 @@ func TestLuaDisabled(t *testing.T) {
14621463
})
14631464
}
14641465
}
1466+
1467+
func TestValidateEnvoyGatewayRateLimitURLRef(t *testing.T) {
1468+
redisBackend := func(redis *egv1a1.RateLimitRedisSettings) *egv1a1.RateLimit {
1469+
return &egv1a1.RateLimit{
1470+
Backend: egv1a1.RateLimitDatabaseBackend{
1471+
Type: egv1a1.RedisBackendType,
1472+
Redis: redis,
1473+
},
1474+
}
1475+
}
1476+
cases := []struct {
1477+
name string
1478+
rateLimit *egv1a1.RateLimit
1479+
expectErr bool
1480+
}{
1481+
{
1482+
name: "nil redis settings",
1483+
rateLimit: redisBackend(nil),
1484+
expectErr: true,
1485+
},
1486+
{
1487+
name: "url only",
1488+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{URL: "redis.redis.svc:6379"}),
1489+
expectErr: false,
1490+
},
1491+
{
1492+
name: "urlRef only",
1493+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{
1494+
URLRef: &egv1a1.RedisURLSource{
1495+
SecretKeyRef: &corev1.SecretKeySelector{
1496+
LocalObjectReference: corev1.LocalObjectReference{Name: "redis-conn"},
1497+
Key: "REDIS_ENDPOINT",
1498+
},
1499+
},
1500+
}),
1501+
expectErr: false,
1502+
},
1503+
{
1504+
name: "both url and urlRef set",
1505+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{
1506+
URL: "redis.redis.svc:6379",
1507+
URLRef: &egv1a1.RedisURLSource{
1508+
SecretKeyRef: &corev1.SecretKeySelector{
1509+
LocalObjectReference: corev1.LocalObjectReference{Name: "redis-conn"},
1510+
Key: "REDIS_ENDPOINT",
1511+
},
1512+
},
1513+
}),
1514+
expectErr: true,
1515+
},
1516+
{
1517+
name: "neither url nor urlRef",
1518+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{}),
1519+
expectErr: true,
1520+
},
1521+
{
1522+
name: "urlRef missing key",
1523+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{
1524+
URLRef: &egv1a1.RedisURLSource{
1525+
SecretKeyRef: &corev1.SecretKeySelector{
1526+
LocalObjectReference: corev1.LocalObjectReference{Name: "redis-conn"},
1527+
},
1528+
},
1529+
}),
1530+
expectErr: true,
1531+
},
1532+
{
1533+
name: "urlRef missing name",
1534+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{
1535+
URLRef: &egv1a1.RedisURLSource{
1536+
SecretKeyRef: &corev1.SecretKeySelector{Key: "REDIS_ENDPOINT"},
1537+
},
1538+
}),
1539+
expectErr: true,
1540+
},
1541+
{
1542+
name: "urlRef optional true",
1543+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{
1544+
URLRef: &egv1a1.RedisURLSource{
1545+
SecretKeyRef: &corev1.SecretKeySelector{
1546+
LocalObjectReference: corev1.LocalObjectReference{Name: "redis-conn"},
1547+
Key: "REDIS_ENDPOINT",
1548+
Optional: new(true),
1549+
},
1550+
},
1551+
}),
1552+
expectErr: true,
1553+
},
1554+
{
1555+
name: "urlRef optional false",
1556+
rateLimit: redisBackend(&egv1a1.RateLimitRedisSettings{
1557+
URLRef: &egv1a1.RedisURLSource{
1558+
SecretKeyRef: &corev1.SecretKeySelector{
1559+
LocalObjectReference: corev1.LocalObjectReference{Name: "redis-conn"},
1560+
Key: "REDIS_ENDPOINT",
1561+
Optional: new(false),
1562+
},
1563+
},
1564+
}),
1565+
expectErr: false,
1566+
},
1567+
}
1568+
for _, tc := range cases {
1569+
t.Run(tc.name, func(t *testing.T) {
1570+
err := validateEnvoyGatewayRateLimit(tc.rateLimit)
1571+
if tc.expectErr {
1572+
require.Error(t, err)
1573+
} else {
1574+
require.NoError(t, err)
1575+
}
1576+
})
1577+
}
1578+
}
1579+
1580+
func TestValidateRedisURL(t *testing.T) {
1581+
require.NoError(t, ValidateRedisURL("redis.redis.svc:6379"))
1582+
require.NoError(t, ValidateRedisURL("a.redis.svc:6379,b.redis.svc:6379"))
1583+
require.ErrorContains(t, ValidateRedisURL(""), "ratelimit redis url is empty")
1584+
require.ErrorContains(t, ValidateRedisURL(":foo"), "unknown ratelimit redis url format")
1585+
}

api/v1alpha1/zz_generated.deepcopy.go

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

internal/infrastructure/kubernetes/ratelimit/resource.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import (
1313
"strings"
1414

1515
corev1 "k8s.io/api/core/v1"
16+
apierrors "k8s.io/apimachinery/pkg/api/errors"
17+
"k8s.io/apimachinery/pkg/types"
1618
"k8s.io/apimachinery/pkg/util/intstr"
1719
"sigs.k8s.io/controller-runtime/pkg/client"
1820

1921
egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
22+
"github.com/envoyproxy/gateway/api/v1alpha1/validation"
2023
"github.com/envoyproxy/gateway/internal/infrastructure/kubernetes/resource"
2124
"github.com/envoyproxy/gateway/internal/kubernetes"
2225
)
@@ -373,15 +376,20 @@ func expectedRateLimitContainerEnv(rateLimit *egv1a1.RateLimit, rateLimitDeploym
373376
}
374377

375378
if rateLimit.Backend.Redis != nil {
379+
redisURLEnv := corev1.EnvVar{Name: RedisURLEnvVar}
380+
if rateLimit.Backend.Redis.URLRef != nil {
381+
redisURLEnv.ValueFrom = &corev1.EnvVarSource{
382+
SecretKeyRef: rateLimit.Backend.Redis.URLRef.SecretKeyRef,
383+
}
384+
} else {
385+
redisURLEnv.Value = rateLimit.Backend.Redis.URL
386+
}
376387
env = append(env, []corev1.EnvVar{
377388
{
378389
Name: RedisSocketTypeEnvVar,
379390
Value: "tcp",
380391
},
381-
{
382-
Name: RedisURLEnvVar,
383-
Value: rateLimit.Backend.Redis.URL,
384-
},
392+
redisURLEnv,
385393
}...)
386394
}
387395

@@ -469,13 +477,37 @@ func expectedRateLimitContainerEnv(rateLimit *egv1a1.RateLimit, rateLimitDeploym
469477
return resource.ExpectedContainerEnv(rateLimitDeployment.Container, env)
470478
}
471479

472-
// Validate the ratelimit tls secret validating.
480+
// Validate validates the ratelimit redis url/tls secret references.
473481
func Validate(ctx context.Context, client client.Client, gateway *egv1a1.EnvoyGateway, namespace string) error {
474-
if gateway.RateLimit.Backend.Redis != nil &&
475-
gateway.RateLimit.Backend.Redis.TLS != nil &&
476-
gateway.RateLimit.Backend.Redis.TLS.CertificateRef != nil {
477-
certificateRef := gateway.RateLimit.Backend.Redis.TLS.CertificateRef
478-
_, _, err := kubernetes.ValidateSecretObjectReference(ctx, client, certificateRef, namespace)
482+
redis := gateway.RateLimit.Backend.Redis
483+
if redis == nil {
484+
return nil
485+
}
486+
487+
if redis.URLRef != nil && redis.URLRef.SecretKeyRef != nil {
488+
ref := redis.URLRef.SecretKeyRef
489+
secret := &corev1.Secret{}
490+
err := client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.Name}, secret)
491+
switch {
492+
case apierrors.IsNotFound(err):
493+
// The Secret may be provisioned after the EnvoyGateway config (e.g. by an
494+
// external controller such as Crossplane). The ratelimit Deployment consumes
495+
// the URL via valueFrom.secretKeyRef, so the kubelet starts the container once
496+
// the Secret and key exist. Don't block infra creation on a not-yet-present Secret.
497+
case err != nil:
498+
return fmt.Errorf("failed to get Secret %s in namespace %s: %w", ref.Name, namespace, err)
499+
default:
500+
// Validate the value only when present; a missing key is resolved later by the kubelet.
501+
if value, ok := secret.Data[ref.Key]; ok {
502+
if verr := validation.ValidateRedisURL(string(value)); verr != nil {
503+
return fmt.Errorf("invalid Redis URL in Secret %s/%s key %q: %w", namespace, ref.Name, ref.Key, verr)
504+
}
505+
}
506+
}
507+
}
508+
509+
if redis.TLS != nil && redis.TLS.CertificateRef != nil {
510+
_, _, err := kubernetes.ValidateSecretObjectReference(ctx, client, redis.TLS.CertificateRef, namespace)
479511
return err
480512
}
481513

0 commit comments

Comments
 (0)