Skip to content

Commit 8a18f27

Browse files
authored
feat: support namespace_selector to limit synced namespaces (#487)
1 parent 66cd506 commit 8a18f27

24 files changed

Lines changed: 740 additions & 8 deletions

‎cmd/root/root.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535

3636
// +kubebuilder:scaffold:imports
3737

38+
"github.com/apache/apisix-ingress-controller/internal/controller"
3839
"github.com/apache/apisix-ingress-controller/internal/controller/config"
3940
"github.com/apache/apisix-ingress-controller/internal/manager"
4041
"github.com/apache/apisix-ingress-controller/internal/version"
@@ -108,6 +109,9 @@ func newAPISIXIngressController() *cobra.Command {
108109
if err := cfg.Validate(); err != nil {
109110
return err
110111
}
112+
if err := controller.SetNamespaceSelector(cfg.NamespaceSelector); err != nil {
113+
return err
114+
}
111115

112116
logLevel, err := zapcore.ParseLevel(cfg.LogLevel)
113117
if err != nil {

‎config/samples/config.yaml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ listener_port_match_mode: "off" # Mode for injecting server_port route v
4747
# "auto"/"explicit" when those two coincide, otherwise routes bound to a
4848
# listener via sectionName/port will never match.
4949

50+
namespace_selector: [] # Label selectors of the namespaces whose resources are handled by the controller.
51+
# A namespace is selected when its labels match all entries. Equality and "in"
52+
# requirements on the same key are merged, so the example below selects namespaces
53+
# labeled team=a or team=b that are also labeled env=prod:
54+
# namespace_selector:
55+
# - "team=a"
56+
# - "team=b"
57+
# - "env=prod"
58+
# Only separate entries are merged. Within one entry, comma-separated requirements
59+
# follow the Kubernetes label selector syntax, so "team=a,team=b" matches nothing.
60+
# It applies to Ingress and apisix.apache.org/v2 resources. Gateway API resources are
61+
# not filtered, use the allowedRoutes of the Gateway listeners instead. Resources they
62+
# reference, such as Services, Secrets and GatewayProxies, are read from any namespace.
63+
# When a namespace stops matching, the configuration of its resources is removed
64+
# from the data plane.
65+
# The default value is empty, which selects all namespaces. Empty entries are ignored.
66+
5067
provider:
5168
type: "api7ee"
5269

‎docs/en/latest/reference/configuration-file.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ secure_metrics: false # The secure metrics configuration.
6565
exec_adc_timeout: 15s # The timeout for the ADC to execute.
6666
# The default value is 15 seconds.
6767

68+
namespace_selector: [] # Label selectors of the namespaces whose resources are handled by the controller.
69+
# A namespace is selected when its labels match all entries. Equality and "in"
70+
# requirements on the same key are merged, so the example below selects namespaces
71+
# labeled team=a or team=b that are also labeled env=prod:
72+
# namespace_selector:
73+
# - "team=a"
74+
# - "team=b"
75+
# - "env=prod"
76+
# Only separate entries are merged. Within one entry, comma-separated requirements
77+
# follow the Kubernetes label selector syntax, so "team=a,team=b" matches nothing.
78+
# It applies to Ingress and apisix.apache.org/v2 resources. Gateway API resources are
79+
# not filtered, use the allowedRoutes of the Gateway listeners instead. Resources they
80+
# reference, such as Services, Secrets and GatewayProxies, are read from any namespace.
81+
# When a namespace stops matching, the configuration of its resources is removed
82+
# from the data plane.
83+
# The default value is empty, which selects all namespaces. Empty entries are ignored.
84+
6885
provider:
6986
type: "api7ee" # Provider type.
7087

‎docs/en/latest/upgrade-guide.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ Because the Admin API fills in default values, the submitted content may differ
9090
| `apisix.*` | Static Admin API configuration |
9191
| `etcdserver.*` | Configuration for mock-etcd (deprecated) |
9292

93+
#### Namespace Selector
94+
95+
`kubernetes.namespace_selector` is replaced by the top-level `namespace_selector`. Entries written for 1.x keep their meaning: every entry must match, and the values given for the same key are ORed. Each entry also accepts the full Kubernetes label selector syntax, such as `env in (prod,staging)` or `!legacy`. The command line flag `--namespace-selector` is not available, set the option in the configuration file.
96+
97+
It behaves differently from 1.x in the following ways:
98+
99+
- When a namespace stops matching, 2.x removes the configuration of its resources from the data plane, while 1.x left the synced routes in place. Before upgrading, check for namespaces that were unlabeled in 1.x but still have routes in service, since those routes disappear after the upgrade.
100+
- Only Ingress and `apisix.apache.org/v2` resources are filtered. Gateway API resources, which 1.x also filtered, are not; use the `allowedRoutes` of the Gateway listeners to limit their namespaces.
101+
93102
#### Example: Legacy Configuration Removed in 2.0.0
94103

95104
```yaml

‎internal/controller/apisixconsumer_controller.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func (r *ApisixConsumerReconciler) SetupWithManager(mgr ctrl.Manager) error {
132132
icWatch = &networkingv1.IngressClass{}
133133
}
134134

135-
return ctrl.NewControllerManagedBy(mgr).
135+
bdr := ctrl.NewControllerManagedBy(mgr).
136136
For(&apiv2.ApisixConsumer{},
137137
builder.WithPredicates(
138138
MatchesIngressClassPredicate(r.Client, r.Log, r.ICGV.String()),
@@ -142,6 +142,7 @@ func (r *ApisixConsumerReconciler) SetupWithManager(mgr ctrl.Manager) error {
142142
predicate.GenerationChangedPredicate{},
143143
predicate.AnnotationChangedPredicate{},
144144
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
145+
predicate.NewPredicateFuncs(TypePredicate[*corev1.Namespace]()),
145146
),
146147
).
147148
Watches(
@@ -156,7 +157,8 @@ func (r *ApisixConsumerReconciler) SetupWithManager(mgr ctrl.Manager) error {
156157
).
157158
Watches(&corev1.Secret{},
158159
handler.EnqueueRequestsFromMapFunc(r.listApisixConsumerForSecret),
159-
).
160+
)
161+
return watchNamespaceSelector(bdr, r.Client, r.Log, func() client.ObjectList { return &apiv2.ApisixConsumerList{} }).
160162
Named("apisixconsumer").
161163
Complete(r)
162164
}

‎internal/controller/apisixglobalrule_controller.go‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ func (r *ApisixGlobalRuleReconciler) Reconcile(ctx context.Context, req ctrl.Req
9595
r.Log.V(1).Info("no matching IngressClass available",
9696
"ingressClassName", globalRule.Spec.IngressClassName,
9797
"error", err.Error())
98+
if !isIngressClassSelectionAbsent(err) {
99+
return ctrl.Result{}, err
100+
}
98101
if err := r.Provider.Delete(ctx, &globalRule); err != nil {
99102
r.Log.Error(err, "failed to delete global rule from provider")
100103
return ctrl.Result{}, err
@@ -159,7 +162,7 @@ func (r *ApisixGlobalRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
159162
} else {
160163
icWatch = &networkingv1.IngressClass{}
161164
}
162-
return ctrl.NewControllerManagedBy(mgr).
165+
bdr := ctrl.NewControllerManagedBy(mgr).
163166
For(&apiv2.ApisixGlobalRule{},
164167
builder.WithPredicates(
165168
MatchesIngressClassPredicate(r.Client, r.Log, r.ICGV.String()),
@@ -170,6 +173,7 @@ func (r *ApisixGlobalRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
170173
predicate.GenerationChangedPredicate{},
171174
predicate.AnnotationChangedPredicate{},
172175
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
176+
predicate.NewPredicateFuncs(TypePredicate[*corev1.Namespace]()),
173177
),
174178
).
175179
Watches(
@@ -184,7 +188,8 @@ func (r *ApisixGlobalRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
184188
).
185189
Watches(&corev1.Secret{},
186190
handler.EnqueueRequestsFromMapFunc(r.listGlobalRulesForSecret),
187-
).
191+
)
192+
return watchNamespaceSelector(bdr, r.Client, r.Log, func() client.ObjectList { return &apiv2.ApisixGlobalRuleList{} }).
188193
Named("apisixglobalrule").
189194
Complete(r)
190195
}

‎internal/controller/apisixroute_controller.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func (r *ApisixRouteReconciler) SetupWithManager(mgr ctrl.Manager) error {
8888
predicate.GenerationChangedPredicate{},
8989
predicate.AnnotationChangedPredicate{},
9090
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
91+
predicate.NewPredicateFuncs(TypePredicate[*corev1.Namespace]()),
9192
}
9293

9394
if !r.supportsEndpointSlice {
@@ -117,6 +118,7 @@ func (r *ApisixRouteReconciler) SetupWithManager(mgr ctrl.Manager) error {
117118
r.listApisixRoutesForService,
118119
r.listApisixRoutesForEndpoints,
119120
r.Log)
121+
bdr = watchNamespaceSelector(bdr, r.Client, r.Log, func() client.ObjectList { return &apiv2.ApisixRouteList{} })
120122

121123
return bdr.
122124
Watches(&corev1.Secret{},
@@ -163,6 +165,9 @@ func (r *ApisixRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request)
163165
r.Log.V(1).Info("no matching IngressClass available",
164166
"ingressClassName", ar.Spec.IngressClassName,
165167
"error", err.Error())
168+
if !isIngressClassSelectionAbsent(err) {
169+
return ctrl.Result{}, err
170+
}
166171
if err := r.Provider.Delete(ctx, &ar); err != nil {
167172
r.Log.Error(err, "failed to delete apisixroute", "apisixroute", utils.NamespacedName(&ar))
168173
return ctrl.Result{}, err

‎internal/controller/apisixtls_controller.go‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package controller
1919

2020
import (
2121
"context"
22+
"errors"
2223
"fmt"
2324

2425
"github.com/go-logr/logr"
@@ -67,7 +68,7 @@ func (r *ApisixTlsReconciler) SetupWithManager(mgr ctrl.Manager) error {
6768
default:
6869
icWatch = &networkingv1.IngressClass{}
6970
}
70-
return ctrl.NewControllerManagedBy(mgr).
71+
bdr := ctrl.NewControllerManagedBy(mgr).
7172
For(&apiv2.ApisixTls{},
7273
builder.WithPredicates(
7374
MatchesIngressClassPredicate(r.Client, r.Log, r.ICGV.String()),
@@ -78,6 +79,7 @@ func (r *ApisixTlsReconciler) SetupWithManager(mgr ctrl.Manager) error {
7879
predicate.GenerationChangedPredicate{},
7980
predicate.AnnotationChangedPredicate{},
8081
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
82+
predicate.NewPredicateFuncs(TypePredicate[*corev1.Namespace]()),
8183
),
8284
).
8385
Watches(
@@ -92,7 +94,8 @@ func (r *ApisixTlsReconciler) SetupWithManager(mgr ctrl.Manager) error {
9294
).
9395
Watches(&corev1.Secret{},
9496
handler.EnqueueRequestsFromMapFunc(r.listApisixTlsForSecret),
95-
).
97+
)
98+
return watchNamespaceSelector(bdr, r.Client, r.Log, func() client.ObjectList { return &apiv2.ApisixTlsList{} }).
9699
Complete(r)
97100
}
98101

@@ -131,6 +134,13 @@ func (r *ApisixTlsReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
131134
r.Log.V(1).Info("no matching IngressClass available, skip processing",
132135
"ingressClassName", tls.Spec.IngressClassName,
133136
"error", err.Error())
137+
// Retract what was synced before the namespace stopped being watched.
138+
if errors.Is(err, ErrNamespaceNotWatched) {
139+
if err := r.Provider.Delete(ctx, &tls); err != nil {
140+
r.Log.Error(err, "failed to delete TLS from provider")
141+
return ctrl.Result{}, err
142+
}
143+
}
134144
return ctrl.Result{}, nil
135145
}
136146

‎internal/controller/config/config.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
"time"
2828

2929
"gopkg.in/yaml.v3"
30+
"k8s.io/apimachinery/pkg/labels"
31+
"k8s.io/apimachinery/pkg/selection"
3032

3133
"github.com/apache/apisix-ingress-controller/internal/types"
3234
)
@@ -132,6 +134,10 @@ func (c *Config) Validate() error {
132134
}
133135
}
134136

137+
if _, err := ParseNamespaceSelector(c.NamespaceSelector); err != nil {
138+
return err
139+
}
140+
135141
if err := validateProvider(c.ProviderConfig); err != nil {
136142
return err
137143
}
@@ -155,6 +161,52 @@ func validateProvider(config ProviderConfig) error {
155161
}
156162
}
157163

164+
// ParseNamespaceSelector combines the namespace_selector entries into one
165+
// selector, keeping the semantics of 1.x: every entry must match, and entries
166+
// holding a single equality or "in" requirement on the same key are merged, so
167+
// ["team=a", "team=b"] selects "team in (a,b)". An entry with several
168+
// requirements keeps the standard label selector semantics, so "team=a,team=b"
169+
// matches nothing. Empty entries are ignored, as 1.x used [""] to disable the
170+
// selector. It returns nil when no entry is left.
171+
func ParseNamespaceSelector(entries []string) (labels.Selector, error) {
172+
var (
173+
selector labels.Selector
174+
keys []string
175+
values = map[string][]string{}
176+
)
177+
for _, entry := range entries {
178+
if strings.TrimSpace(entry) == "" {
179+
continue
180+
}
181+
reqs, err := labels.ParseToRequirements(entry)
182+
if err != nil {
183+
return nil, fmt.Errorf("invalid namespace_selector %q: %w", entry, err)
184+
}
185+
if selector == nil {
186+
selector = labels.NewSelector()
187+
}
188+
if len(reqs) == 1 {
189+
switch req := reqs[0]; req.Operator() {
190+
case selection.Equals, selection.DoubleEquals, selection.In:
191+
if _, ok := values[req.Key()]; !ok {
192+
keys = append(keys, req.Key())
193+
}
194+
values[req.Key()] = append(values[req.Key()], req.ValuesUnsorted()...)
195+
continue
196+
}
197+
}
198+
selector = selector.Add(reqs...)
199+
}
200+
for _, key := range keys {
201+
req, err := labels.NewRequirement(key, selection.In, values[key])
202+
if err != nil {
203+
return nil, fmt.Errorf("invalid namespace_selector on key %q: %w", key, err)
204+
}
205+
selector = selector.Add(*req)
206+
}
207+
return selector, nil
208+
}
209+
158210
func GetControllerName() string {
159211
return ControllerConfig.ControllerName
160212
}

‎internal/controller/config/config_test.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"testing"
66

77
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"k8s.io/apimachinery/pkg/labels"
10+
"k8s.io/utils/ptr"
811
)
912

1013
func TestNewDefaultConfig(t *testing.T) {
@@ -65,12 +68,85 @@ func TestConfigValidateListenerPortMatchMode(t *testing.T) {
6568
}
6669
}
6770

71+
func TestConfigValidateNamespaceSelector(t *testing.T) {
72+
tests := []struct {
73+
name string
74+
selector []string
75+
expectErr bool
76+
}{
77+
{name: "unset", selector: nil},
78+
{name: "1.x default", selector: []string{""}},
79+
{name: "equality", selector: []string{"team=a"}},
80+
{name: "set based", selector: []string{"env in (prod,staging),!legacy", "team=a"}},
81+
{name: "invalid", selector: []string{"team in a"}, expectErr: true},
82+
}
83+
84+
for _, tt := range tests {
85+
t.Run(tt.name, func(t *testing.T) {
86+
cfg := NewDefaultConfig()
87+
cfg.NamespaceSelector = tt.selector
88+
89+
err := cfg.Validate()
90+
if tt.expectErr {
91+
assert.ErrorContains(t, err, "invalid namespace_selector")
92+
} else {
93+
assert.NoError(t, err)
94+
}
95+
})
96+
}
97+
}
98+
99+
func TestParseNamespaceSelector(t *testing.T) {
100+
nsLabels := labels.Set{"version": "v1", "env": "prod"}
101+
102+
tests := []struct {
103+
name string
104+
entries []string
105+
// nil means the selector is disabled.
106+
matches *bool
107+
}{
108+
// Cases ported from TestMultiValueLabelsIsSubsetOf of 1.x.
109+
{name: "no entry", entries: nil},
110+
{name: "1.x default", entries: []string{""}},
111+
{name: "single value", entries: []string{"env=prod"}, matches: ptr.To(true)},
112+
{name: "values on one key are ORed", entries: []string{"env=qa", "env=prod"}, matches: ptr.To(true)},
113+
{name: "value mismatch", entries: []string{"env=qa"}, matches: ptr.To(false)},
114+
{name: "missing key", entries: []string{"env3=not"}, matches: ptr.To(false)},
115+
// Entries on different keys are ANDed.
116+
{name: "all keys match", entries: []string{"env=prod", "version=v1"}, matches: ptr.To(true)},
117+
{name: "one key mismatches", entries: []string{"env=prod", "version=v2"}, matches: ptr.To(false)},
118+
{name: "empty entry is ignored", entries: []string{"env=qa", ""}, matches: ptr.To(false)},
119+
// Full selector syntax on top of 1.x.
120+
{name: "in merges with equality", entries: []string{"env in (qa)", "env==prod"}, matches: ptr.To(true)},
121+
{name: "not equal", entries: []string{"env=prod", "version!=v1"}, matches: ptr.To(false)},
122+
{name: "does not exist", entries: []string{"!legacy"}, matches: ptr.To(true)},
123+
// Only separate entries are merged, one entry keeps the standard semantics.
124+
{name: "one entry is not merged", entries: []string{"env=qa,env=prod"}, matches: ptr.To(false)},
125+
{name: "one entry with several keys", entries: []string{"env=prod,version=v1"}, matches: ptr.To(true)},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
selector, err := ParseNamespaceSelector(tt.entries)
131+
require.NoError(t, err)
132+
if tt.matches == nil {
133+
assert.Nil(t, selector)
134+
return
135+
}
136+
require.NotNil(t, selector)
137+
assert.Equal(t, *tt.matches, selector.Matches(nsLabels), selector.String())
138+
})
139+
}
140+
}
141+
68142
func TestNewConfigFromFile(t *testing.T) {
69143
// Create a temporary config file
70144
fileContent := `
71145
log_level: debug
72146
controller_name: test-controller
73147
disable_gateway_api: true
148+
namespace_selector:
149+
- "team=a"
74150
`
75151
tempFile, err := os.CreateTemp("", "config-*.yaml")
76152
assert.NoError(t, err)
@@ -87,4 +163,5 @@ disable_gateway_api: true
87163
assert.Equal(t, "debug", cfg.LogLevel)
88164
assert.Equal(t, "test-controller", cfg.ControllerName)
89165
assert.Equal(t, true, cfg.DisableGatewayAPI)
166+
assert.Equal(t, []string{"team=a"}, cfg.NamespaceSelector)
90167
}

0 commit comments

Comments
 (0)