Skip to content
Merged
4 changes: 2 additions & 2 deletions api/v1alpha1/envoygateway_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ func (e *EnvoyGateway) GetEnvoyGatewayAdminAddress() string {
return ""
}

// NamespaceMode returns if uses namespace mode.
func (e *EnvoyGateway) NamespaceMode() bool {
// WatchesNamespaces returns true when Envoy Gateway is configured to watch specific Kubernetes namespaces.
func (e *EnvoyGateway) WatchesNamespaces() bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the exported NamespaceMode helper

This renames the exported EnvoyGateway.NamespaceMode() method in the public api/v1alpha1 package even though the behavior is unchanged. Any downstream Go code that imports Envoy Gateway API types and calls NamespaceMode() will fail to compile on upgrade; keep NamespaceMode() as a wrapper around WatchesNamespaces() if the clearer name is needed internally.

Useful? React with 👍 / 👎.

@zhaohuabing zhaohuabing Jun 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a helper method, and it should not be in the API directory.
The original NamespaceMode method name could be easily get confused with GatewayNamespaceMode.

return e.Provider != nil &&
e.Provider.Kubernetes != nil &&
e.Provider.Kubernetes.Watch != nil &&
Expand Down
4 changes: 4 additions & 0 deletions api/v1alpha1/envoygateway_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,12 +439,16 @@ type KubernetesWatchMode struct {

// Namespaces holds the list of namespaces that Envoy Gateway will watch for namespaced scoped
// resources such as Gateway, HTTPRoute and Service.
// The namespace where Envoy Gateway runs is always included so Envoy Gateway can reconcile its
// own managed infrastructure resources.
// Note that Envoy Gateway will continue to reconcile relevant cluster scoped resources such as
// GatewayClass that it is linked to. Precisely one of Namespaces and NamespaceSelector must be set.
Namespaces []string `json:"namespaces,omitempty"`

// NamespaceSelector holds the label selector used to dynamically select namespaces.
// Envoy Gateway will watch for namespaces matching the specified label selector.
// The namespace where Envoy Gateway runs is always included so Envoy Gateway can reconcile its
// own managed infrastructure resources.
// Precisely one of Namespaces and NamespaceSelector must be set.
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
}
Expand Down
6 changes: 0 additions & 6 deletions internal/infrastructure/kubernetes/infra_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,6 @@ func (cli *InfraClient) DeleteAllExcept(ctx context.Context, objList client.Obje
v := reflect.ValueOf(objList).Elem()
items := v.FieldByName("Items")

// If there is only one item, we don't need to delete it,
// because it normally means custom resource name is not enabled.
if items.Len() <= 1 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not safe since now we use the cached kube client.

return nil
}

for i := range items.Len() {
item := items.Index(i)
name := item.FieldByName("Name")
Expand Down
4 changes: 3 additions & 1 deletion internal/provider/kubernetes/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ func newGatewayAPIController(ctx context.Context, mgr manager.Manager, cfg *conf

if byNamespaceSelectorEnabled(cfg.EnvoyGateway) {
r.namespaceLabel = cfg.EnvoyGateway.Provider.Kubernetes.Watch.NamespaceSelector
r.client = newNamespaceSelectorClient(r.client, r.namespaceLabel)
// Always allow controller-namespace infrastructure resources to bypass
// user namespace selectors.
r.client = newNamespaceSelectorClient(r.client, r.namespaceLabel, cfg.ControllerNamespace)
}

// controller-runtime doesn't allow run controller with same name for more than once
Expand Down
77 changes: 74 additions & 3 deletions internal/provider/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,81 @@ func newProvider(ctx context.Context, restCfg *rest.Config, svrCfg *ec.Server,

mgrOpts.Cache.DefaultTransform = cache.TransformStripManagedFields()

if svrCfg.EnvoyGateway.NamespaceMode() {
mgrOpts.Cache.DefaultNamespaces = make(map[string]cache.Config)
// When configured with an explicit namespace watch list, scope the default
// cache to those namespaces and add type-specific controller namespace
// exceptions below.
if svrCfg.EnvoyGateway.WatchesNamespaces() {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the above if svrCfg.EnvoyGateway.GatewayNamespaceMode() { code block are starting to get a bit messy. For easier review, I keep that block untouched in this PR, and we can clean it up in a follow-up PR.

watchedNamespaces := map[string]cache.Config{}
for _, watchNS := range svrCfg.EnvoyGateway.Provider.Kubernetes.Watch.Namespaces {
mgrOpts.Cache.DefaultNamespaces[watchNS] = cache.Config{}
watchedNamespaces[watchNS] = cache.Config{}
}
Comment thread
zhaohuabing marked this conversation as resolved.

watchedAndControllerNamespaces := make(map[string]cache.Config, len(watchedNamespaces)+1)
for ns, cfg := range watchedNamespaces {
watchedAndControllerNamespaces[ns] = cfg
}
watchedAndControllerNamespaces[svrCfg.ControllerNamespace] = cache.Config{}

// DefaultNamespaces applies to every namespaced informer without a
// ByObject namespace override, including Gateway API informers
// registered later. Since Gateway API resources do not get controller
// namespace overrides below, they only watch these configured namespaces.
mgrOpts.Cache.DefaultNamespaces = watchedNamespaces

// ConfigMaps and Services must cover both scopes: watched namespaces for
// user refs such as policy/filter ConfigMaps and Route backend Services,
// and the controller namespace for EG-owned proxy/ratelimit infra
// ConfigMaps and Services.
mgrOpts.Cache.ByObject[&corev1.ConfigMap{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Transform: composeTransforms(cache.TransformStripManagedFields(), transformConfigMapData),
Namespaces: watchedAndControllerNamespaces,
}
mgrOpts.Cache.ByObject[&corev1.Service{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: watchedAndControllerNamespaces,
}
mgrOpts.Cache.ByObject[&discoveryv1.EndpointSlice{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: watchedAndControllerNamespaces,
}
if svrCfg.EnvoyGateway.GatewayNamespaceMode() {
// GatewayNamespaceMode still needs controller namespace access for
// EG controller resources and the xDS CA Secret.
mgrOpts.Cache.ByObject[&corev1.ServiceAccount{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: watchedAndControllerNamespaces,
}
mgrOpts.Cache.ByObject[&appsv1.Deployment{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: watchedAndControllerNamespaces,
}
mgrOpts.Cache.ByObject[&corev1.Secret{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: watchedAndControllerNamespaces,
}
} else {
// In normal mode, ServiceAccounts and Deployments are controller
// namespace infra, while Secrets cover watched namespaces for user
// refs and the controller namespace for EG-managed infra Secrets,
// including the OIDC HMAC Secret and Envoy's TLS Secret for
// connections to EG-managed control-plane services.
mgrOpts.Cache.ByObject[&corev1.ServiceAccount{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: map[string]cache.Config{
svrCfg.ControllerNamespace: {},
},
}
mgrOpts.Cache.ByObject[&appsv1.Deployment{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: map[string]cache.Config{
svrCfg.ControllerNamespace: {},
},
}
mgrOpts.Cache.ByObject[&corev1.Secret{}] = cache.ByObject{
UnsafeDisableDeepCopy: new(true),
Namespaces: watchedAndControllerNamespaces,
}
Comment thread
zhaohuabing marked this conversation as resolved.
}
}
if svrCfg.EnvoyGateway.Provider.Kubernetes.TopologyInjector == nil || !ptr.Deref(svrCfg.EnvoyGateway.Provider.Kubernetes.TopologyInjector.Disable, false) {
Expand Down
32 changes: 20 additions & 12 deletions internal/provider/kubernetes/namespace_selector_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ import (
// List operations.
type namespaceSelectorClient struct {
client.Client
namespaceSelector *metav1.LabelSelector
namespaceSelector *metav1.LabelSelector
controllerNamespace string
}

// newNamespaceSelectorClient creates a new namespace-filtered client wrapper.
// If namespaceSelector is nil, the wrapper passes through all operations unchanged.
func newNamespaceSelectorClient(c client.Client, namespaceSelector *metav1.LabelSelector) client.Client {
func newNamespaceSelectorClient(c client.Client, namespaceSelector *metav1.LabelSelector, controllerNamespace string) client.Client {
if namespaceSelector == nil {
return c
}
return &namespaceSelectorClient{
Client: c,
namespaceSelector: namespaceSelector,
Client: c,
namespaceSelector: namespaceSelector,
controllerNamespace: controllerNamespace,
}
}

Expand Down Expand Up @@ -86,15 +88,21 @@ func (c *namespaceSelectorClient) filterByNamespaceLabels(ctx context.Context, l
}

ns := obj.GetNamespace()
matches, cached := namespaceMatches[ns]
if !cached {
var err error
matches, err = checkObjectNamespaceLabels(ctx, c.Client, c.namespaceSelector, obj)
if err != nil {
return fmt.Errorf("failed to check namespace labels for object %s/%s: %w",
ns, obj.GetName(), err)
// Keep controller-namespace infrastructure resources visible even when
// the controller namespace does not match the user selector.
matches := ns == c.controllerNamespace && isNamespaceSelectorBypassInfrastructureResource(item)
if !matches {
cachedMatches, cached := namespaceMatches[ns]
matches = cachedMatches
if !cached {
var err error
matches, err = checkObjectNamespaceLabels(ctx, c.Client, c.namespaceSelector, obj)
if err != nil {
return fmt.Errorf("failed to check namespace labels for object %s/%s: %w",
ns, obj.GetName(), err)
}
namespaceMatches[ns] = matches
}
namespaceMatches[ns] = matches
}

if matches {
Expand Down
64 changes: 54 additions & 10 deletions internal/provider/kubernetes/namespace_selector_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,30 @@ func TestNamespaceSelectorClient(t *testing.T) {
GatewayClassName: "test-gc",
},
}
svcInMatchingNs := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "svc-matching",
Namespace: "matching-ns",
},
}
svcInNonMatchingNs := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "svc-non-matching",
Namespace: "non-matching-ns",
},
}

// Get scheme with all required types
scheme := envoygateway.GetScheme()

testCases := []struct {
name string
namespaceSelector *metav1.LabelSelector
objects []runtime.Object
expectCTPCount int
expectGWCount int
name string
namespaceSelector *metav1.LabelSelector
controllerNamespace string
objects []runtime.Object
expectCTPCount int
expectGWCount int
expectSvcCount int
}{
{
name: "nil selector returns all resources",
Expand All @@ -93,9 +107,11 @@ func TestNamespaceSelectorClient(t *testing.T) {
nsMatching, nsNonMatching,
ctpInMatchingNs, ctpInNonMatchingNs,
gwInMatchingNs, gwInNonMatchingNs,
svcInMatchingNs, svcInNonMatchingNs,
},
expectCTPCount: 2,
expectGWCount: 2,
expectSvcCount: 2,
},
{
name: "selector filters resources by namespace labels",
Expand All @@ -108,9 +124,11 @@ func TestNamespaceSelectorClient(t *testing.T) {
nsMatching, nsNonMatching,
ctpInMatchingNs, ctpInNonMatchingNs,
gwInMatchingNs, gwInNonMatchingNs,
svcInMatchingNs, svcInNonMatchingNs,
},
expectCTPCount: 1,
expectGWCount: 1,
expectSvcCount: 1,
},
{
name: "selector with no matching namespaces returns empty",
Expand All @@ -123,9 +141,29 @@ func TestNamespaceSelectorClient(t *testing.T) {
nsMatching, nsNonMatching,
ctpInMatchingNs, ctpInNonMatchingNs,
gwInMatchingNs, gwInNonMatchingNs,
svcInMatchingNs, svcInNonMatchingNs,
},
expectCTPCount: 0,
expectGWCount: 0,
expectSvcCount: 0,
},
{
name: "controller namespace only bypasses selector for infrastructure resources",
namespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"env": "development",
},
},
controllerNamespace: "non-matching-ns",
objects: []runtime.Object{
nsMatching, nsNonMatching,
ctpInMatchingNs, ctpInNonMatchingNs,
gwInMatchingNs, gwInNonMatchingNs,
svcInMatchingNs, svcInNonMatchingNs,
},
expectCTPCount: 0,
expectGWCount: 0,
expectSvcCount: 1,
},
}

Expand All @@ -138,7 +176,7 @@ func TestNamespaceSelectorClient(t *testing.T) {
Build()

// Wrap with namespace selector client
wrappedClient := newNamespaceSelectorClient(fakeClient, tc.namespaceSelector)
wrappedClient := newNamespaceSelectorClient(fakeClient, tc.namespaceSelector, tc.controllerNamespace)

ctx := context.Background()

Expand All @@ -153,6 +191,12 @@ func TestNamespaceSelectorClient(t *testing.T) {
err = wrappedClient.List(ctx, gwList)
require.NoError(t, err)
require.Len(t, gwList.Items, tc.expectGWCount, "Gateway count mismatch")

// Test Service list filtering
svcList := &corev1.ServiceList{}
err = wrappedClient.List(ctx, svcList)
require.NoError(t, err)
require.Len(t, svcList.Items, tc.expectSvcCount, "Service count mismatch")
})
}
}
Expand Down Expand Up @@ -182,7 +226,7 @@ func TestNamespaceSelectorClientClusterScopedResources(t *testing.T) {
"env": "production",
},
}
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector)
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector, "")

ctx := context.Background()

Expand Down Expand Up @@ -230,7 +274,7 @@ func TestNamespaceSelectorClientNamespaceGetError(t *testing.T) {
"env": "production",
},
}
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector)
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector, "")

ctx := context.Background()

Expand All @@ -254,7 +298,7 @@ func TestNamespaceSelectorClientEmptyList(t *testing.T) {
"env": "production",
},
}
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector)
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector, "")

ctx := context.Background()

Expand Down Expand Up @@ -285,7 +329,7 @@ func TestNamespaceSelectorClientUnderlyingListError(t *testing.T) {
"env": "production",
},
}
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector)
wrappedClient := newNamespaceSelectorClient(fakeClient, namespaceSelector, "")

ctx := context.Background()

Expand Down
20 changes: 20 additions & 0 deletions internal/provider/kubernetes/predicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func (r *gatewayAPIReconciler) hasMatchingController(gc *gwapiv1.GatewayClass) b
// hasMatchingNamespaceLabels returns true if the namespace of provided object has
// the provided labels or false otherwise.
func (r *gatewayAPIReconciler) hasMatchingNamespaceLabels(obj client.Object) bool {
// Keep controller-namespace infrastructure events visible even when the
// controller namespace does not match the user selector.
if obj.GetNamespace() == r.namespace && isNamespaceSelectorBypassInfrastructureResource(obj) {
return true
}
ok, err := checkObjectNamespaceLabels(context.Background(), r.client, r.namespaceLabel, obj)
if err != nil {
r.log.Error(
Expand All @@ -63,6 +68,21 @@ func (r *gatewayAPIReconciler) hasMatchingNamespaceLabels(obj client.Object) boo
return ok
}

func isNamespaceSelectorBypassInfrastructureResource(obj any) bool {
switch obj.(type) {
case *appsv1.Deployment, appsv1.Deployment,
*appsv1.DaemonSet, appsv1.DaemonSet,
*corev1.ConfigMap, corev1.ConfigMap,
*corev1.Secret, corev1.Secret,
*corev1.Service, corev1.Service,
*corev1.ServiceAccount, corev1.ServiceAccount,
*discoveryv1.EndpointSlice, discoveryv1.EndpointSlice:
return true
default:
return false
}
}

type NamespaceGetter interface {
GetNamespace() string
}
Expand Down
Loading