-
Notifications
You must be signed in to change notification settings - Fork 21
fix: avoid O(n^2) envoy validation on initial sync; cache validation; add reconcile concurrency flag #1477
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix: avoid O(n^2) envoy validation on initial sync; cache validation; add reconcile concurrency flag #1477
Changes from all commits
643f059
63dc53d
017e755
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package ingress | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestWithMaxConcurrentReconciles(t *testing.T) { | ||
| ic := &ingressController{} | ||
|
|
||
| // default zero value leaves controller-runtime default in place | ||
| assert.Equal(t, 0, ic.maxConcurrentReconciles) | ||
|
|
||
| WithMaxConcurrentReconciles(4)(ic) | ||
| assert.Equal(t, 4, ic.maxConcurrentReconciles) | ||
|
|
||
| // a value < 1 is still recorded; SetupWithManager decides whether to apply it | ||
| WithMaxConcurrentReconciles(0)(ic) | ||
| assert.Equal(t, 0, ic.maxConcurrentReconciles) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import ( | |
| ctrl "sigs.k8s.io/controller-runtime" | ||
| "sigs.k8s.io/controller-runtime/pkg/builder" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
| "sigs.k8s.io/controller-runtime/pkg/controller" | ||
| "sigs.k8s.io/controller-runtime/pkg/handler" | ||
| "sigs.k8s.io/controller-runtime/pkg/predicate" | ||
|
|
||
|
|
@@ -55,6 +56,10 @@ type ingressController struct { | |
| // globalSettings defines which global settings object to watch | ||
| globalSettings *types.NamespacedName | ||
|
|
||
| // maxConcurrentReconciles is the number of concurrent ingress reconciles. | ||
| // Defaults to 1 (controller-runtime default) when unset. | ||
| maxConcurrentReconciles int | ||
|
|
||
| // object Kinds are frequently used, do not change and are cached | ||
| endpointsKind string | ||
| ingressKind string | ||
|
|
@@ -119,6 +124,14 @@ func WithWatchSettings(name types.NamespacedName) Option { | |
| } | ||
| } | ||
|
|
||
| // WithMaxConcurrentReconciles sets the maximum number of concurrent ingress | ||
| // reconciles. A value < 1 leaves the controller-runtime default (1) in place. | ||
| func WithMaxConcurrentReconciles(n int) Option { | ||
| return func(ic *ingressController) { | ||
| ic.maxConcurrentReconciles = n | ||
| } | ||
| } | ||
|
|
||
| // SetupWithManager sets up the controller with the Manager | ||
| func (r *ingressController) SetupWithManager(mgr ctrl.Manager) error { | ||
| r.Client = mgr.GetClient() | ||
|
|
@@ -132,6 +145,11 @@ func (r *ingressController) SetupWithManager(mgr ctrl.Manager) error { | |
| r.endpointsKind = generic.GVKForType[*corev1.Endpoints](r.Scheme).Kind | ||
| r.ingressClassKind = generic.GVKForType[*networkingv1.IngressClass](r.Scheme).Kind | ||
|
|
||
| opts := controller.Options{} | ||
| if r.maxConcurrentReconciles > 0 { | ||
| opts.MaxConcurrentReconciles = r.maxConcurrentReconciles | ||
| } | ||
|
|
||
| err := ctrl.NewControllerManagedBy(mgr). | ||
| Named(controllerName). | ||
| For(&networkingv1.Ingress{}). | ||
|
|
@@ -146,6 +164,7 @@ func (r *ingressController) SetupWithManager(mgr ctrl.Manager) error { | |
| Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(r.getDependantIngressFn(r.serviceKind))). | ||
| Watches(&corev1.Endpoints{}, handler.EnqueueRequestsFromMapFunc(r.getDependantIngressFn(r.endpointsKind))). | ||
| WithEventFilter(predicate.ResourceVersionChangedPredicate{}). | ||
| WithOptions(opts). | ||
| Complete(r) | ||
|
Comment on lines
148
to
168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Red test (demonstrates the TOCTOU lost-write): func TestConcurrentUpsertLosesRoute(t *testing.T) {
ctrl := gomock.NewController(t)
var (mu sync.Mutex; stored = &pb.Config{})
mock := mock_databroker.NewMockDataBrokerServiceClient(ctrl)
mock.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).
Return(recordFor(&pb.Config{}), nil).AnyTimes()
mock.EXPECT().Put(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, req *databroker.PutRequest, _ ...grpc.CallOption) (*databroker.PutResponse, error) {
mu.Lock(); defer mu.Unlock()
stored = unpackConfig(req)
return &databroker.PutResponse{}, nil
}).AnyTimes()
r := &DataBrokerReconciler{ConfigID: IngressControllerConfigID, DataBrokerServiceClient: mock}
var wg sync.WaitGroup
for _, host := range []string{"a.example.com", "b.example.com"} {
wg.Add(1)
go func(h string) { defer wg.Done(); _, _ = r.Upsert(context.Background(), validIngressConfig("ing", h)) }(host)
}
wg.Wait()
// FAILS: only 1 route present
assert.Len(t, stored.GetRoutes(), 2, "both routes must be present")
}Context Used: For every finding, write a red test with proof and... (source) |
||
| if err != nil { | ||
| return err | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| // Package envoy contains functions for working with an embedded envoy binary. | ||
| package envoy | ||
|
|
||
| import ( | ||
| "crypto/sha256" | ||
| "sync" | ||
| ) | ||
|
|
||
| // defaultValidationCache is the process-wide cache of the last successfully | ||
| // validated config hash. It is shared across concurrent reconciles. | ||
| var defaultValidationCache = &validationCache{} | ||
|
|
||
| // validationCache caches the hash of the most recently successfully-validated | ||
| // config so that repeated validation of an unchanged config can skip the | ||
| // expensive embedded-envoy subprocess. It is safe for concurrent use. | ||
| type validationCache struct { | ||
| mu sync.RWMutex | ||
| lastHash [sha256.Size]byte | ||
| valid bool | ||
| } | ||
|
|
||
| // hit reports whether hash matches the last successfully-validated config. | ||
| func (c *validationCache) hit(hash [sha256.Size]byte) bool { | ||
| c.mu.RLock() | ||
| defer c.mu.RUnlock() | ||
| return c.valid && c.lastHash == hash | ||
| } | ||
|
|
||
| // store records hash as the last successfully-validated config. | ||
| func (c *validationCache) store(hash [sha256.Size]byte) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| c.lastHash, c.valid = hash, true | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package envoy | ||
|
|
||
| import ( | ||
| "crypto/sha256" | ||
| "sync" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestValidationCache(t *testing.T) { | ||
| c := &validationCache{} | ||
| h1 := sha256.Sum256([]byte("config-1")) | ||
| h2 := sha256.Sum256([]byte("config-2")) | ||
|
|
||
| // empty cache: never a hit | ||
| assert.False(t, c.hit(h1), "empty cache should not hit") | ||
|
|
||
| // after storing h1, only h1 hits | ||
| c.store(h1) | ||
| assert.True(t, c.hit(h1), "stored hash should hit") | ||
| assert.False(t, c.hit(h2), "different hash should miss") | ||
|
|
||
| // storing h2 replaces h1 (single-slot cache) | ||
| c.store(h2) | ||
| assert.True(t, c.hit(h2), "newly stored hash should hit") | ||
| assert.False(t, c.hit(h1), "previous hash should no longer hit") | ||
| } | ||
|
|
||
| func TestValidationCacheConcurrent(t *testing.T) { | ||
| c := &validationCache{} | ||
| h := sha256.Sum256([]byte("config")) | ||
| c.store(h) | ||
|
|
||
| // concurrent readers and writers must not race (run with -race). | ||
| var wg sync.WaitGroup | ||
| for i := 0; i < 50; i++ { | ||
| wg.Add(2) | ||
| go func() { defer wg.Done(); _ = c.hit(h) }() | ||
| go func() { defer wg.Done(); c.store(h) }() | ||
| } | ||
| wg.Wait() | ||
| assert.True(t, c.hit(h)) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,8 +107,53 @@ func (r *DataBrokerReconciler) Set(ctx context.Context, ics []*model.IngressConf | |
| if err != nil { | ||
| return false, fmt.Errorf("get config: %w", err) | ||
| } | ||
|
|
||
| // Build the merged config using only cheap (non-envoy) per-ingress validation. | ||
| // The expensive full-config envoy validation runs a single time, in saveConfig. | ||
| // This avoids the O(n^2) behavior of validating a growing config once per ingress. | ||
| next := buildConfigCheap(ctx, ics) | ||
|
|
||
| changed, err := r.saveConfig(ctx, prev, next, r.ConfigID) | ||
| if err == nil { | ||
| return changed, nil | ||
| } | ||
|
|
||
| // The single full-config envoy validation failed. Rather than reject every | ||
| // ingress, fall back to validating ingresses incrementally to isolate and skip | ||
| // only the offending one(s). This path performs O(n) envoy validations, but is | ||
| // only reached when a batch fails to validate as a whole. | ||
| logger.Error(err, "batch config validation failed; falling back to incremental validation to isolate invalid ingress(es)") | ||
| next = r.buildConfigIncremental(ctx, ics) | ||
| return r.saveConfig(ctx, prev, next, r.ConfigID) | ||
|
Comment on lines
+116
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Red test (fails today with a databroker-network error): func TestSet_FallbackNotTriggeredByNetworkError(t *testing.T) {
networkErr := status.Error(codes.Unavailable, "connection refused")
ctrl := gomock.NewController(t)
mock := mock_databroker.NewMockDataBrokerServiceClient(ctrl)
mock.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).
Return(recordFor(&pb.Config{}), nil).AnyTimes()
mock.EXPECT().Put(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, networkErr).Times(1) // must not be called a second time
r := &DataBrokerReconciler{ConfigID: IngressControllerConfigID, DataBrokerServiceClient: mock}
ics := []*model.IngressConfig{
validIngressConfig("a", "a.example.com"),
validIngressConfig("b", "b.example.com"),
}
_, err := r.Set(context.Background(), ics)
// Fails: Times(1) is violated because fallback calls saveConfig a second time.
require.ErrorIs(t, err, networkErr)
}Context Used: For every finding, write a red test with proof and... (source) |
||
| } | ||
|
|
||
| // buildConfigCheap merges the routes for all ingresses into a single config, using | ||
| // only cheap (non-envoy) validation to skip individually-invalid ingresses. | ||
| func buildConfigCheap(ctx context.Context, ics []*model.IngressConfig) *pb.Config { | ||
| logger := log.FromContext(ctx) | ||
| next := new(pb.Config) | ||
| for _, ic := range ics { | ||
| cfg := proto.Clone(next).(*pb.Config) | ||
| if err := multierror.Append( | ||
| upsertRoutes(ctx, cfg, ic), | ||
| validateCheap(ctx, cfg), | ||
| ).ErrorOrNil(); err != nil { | ||
| logger.Error(err, "skip ingress", "ingress", fmt.Sprintf("%s/%s", ic.Namespace, ic.Name)) | ||
| continue | ||
| } | ||
| addCerts(cfg, ic.Secrets) | ||
| next = cfg | ||
| } | ||
| return next | ||
| } | ||
|
|
||
| // buildConfigIncremental merges the routes for all ingresses into a single config, | ||
| // running the full envoy validation after each ingress so that an ingress which | ||
| // only fails full validation (not cheap validation) is isolated and skipped. This | ||
| // is the pre-caching behavior, retained as a fallback for the rare batch-failure case. | ||
| func (r *DataBrokerReconciler) buildConfigIncremental(ctx context.Context, ics []*model.IngressConfig) *pb.Config { | ||
| logger := log.FromContext(ctx) | ||
| next := new(pb.Config) | ||
| for _, ic := range ics { | ||
| cfg := proto.Clone(next).(*pb.Config) | ||
| if err := multierror.Append( | ||
|
|
@@ -121,8 +166,7 @@ func (r *DataBrokerReconciler) Set(ctx context.Context, ics []*model.IngressConf | |
| addCerts(cfg, ic.Secrets) | ||
| next = cfg | ||
| } | ||
|
|
||
| return r.saveConfig(ctx, prev, next, r.ConfigID) | ||
| return next | ||
| } | ||
|
|
||
| // SetConfig updates just the shared config settings | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| package pomerium | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| corev1 "k8s.io/api/core/v1" | ||
| networkingv1 "k8s.io/api/networking/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/types" | ||
| "k8s.io/apimachinery/pkg/util/intstr" | ||
|
|
||
| "github.com/pomerium/ingress-controller/model" | ||
|
|
||
| _ "github.com/pomerium/ingress-controller/internal" | ||
| ) | ||
|
|
||
| // validIngressConfig returns a minimal, valid IngressConfig for the given name/host. | ||
| func validIngressConfig(name, host string) *model.IngressConfig { | ||
| pathTypePrefix := networkingv1.PathTypePrefix | ||
| return &model.IngressConfig{ | ||
| AnnotationPrefix: "ingress.pomerium.io", | ||
| Ingress: &networkingv1.Ingress{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, | ||
| Spec: networkingv1.IngressSpec{ | ||
| Rules: []networkingv1.IngressRule{{ | ||
| Host: host, | ||
| IngressRuleValue: networkingv1.IngressRuleValue{ | ||
| HTTP: &networkingv1.HTTPIngressRuleValue{ | ||
| Paths: []networkingv1.HTTPIngressPath{{ | ||
| Path: "/", | ||
| PathType: &pathTypePrefix, | ||
| Backend: networkingv1.IngressBackend{ | ||
| Service: &networkingv1.IngressServiceBackend{ | ||
| Name: "service", | ||
| Port: networkingv1.ServiceBackendPort{Name: "http"}, | ||
| }, | ||
| }, | ||
| }}, | ||
| }, | ||
| }, | ||
| }}, | ||
| }, | ||
| }, | ||
| Services: map[types.NamespacedName]*corev1.Service{ | ||
| {Name: "service", Namespace: "default"}: { | ||
| ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default"}, | ||
| Spec: corev1.ServiceSpec{ | ||
| Ports: []corev1.ServicePort{{ | ||
| Name: "http", | ||
| Protocol: corev1.ProtocolTCP, | ||
| Port: 80, | ||
| TargetPort: intstr.FromInt(80), | ||
| }}, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // invalidIngressConfig returns an IngressConfig that fails route generation | ||
| // (empty host without the allow-empty-host annotation). | ||
| func invalidIngressConfig(name string) *model.IngressConfig { | ||
| ic := validIngressConfig(name, "") | ||
| return ic | ||
| } | ||
|
|
||
| func TestBuildConfigCheap_AllValid(t *testing.T) { | ||
| ctx := context.Background() | ||
| ics := []*model.IngressConfig{ | ||
| validIngressConfig("a", "a.localhost.pomerium.io"), | ||
| validIngressConfig("b", "b.localhost.pomerium.io"), | ||
| } | ||
|
|
||
| cfg := buildConfigCheap(ctx, ics) | ||
| require.NotNil(t, cfg) | ||
| assert.Len(t, cfg.GetRoutes(), 2, "both valid ingresses should produce a route") | ||
| } | ||
|
|
||
| func TestBuildConfigCheap_SkipsInvalid(t *testing.T) { | ||
| ctx := context.Background() | ||
| ics := []*model.IngressConfig{ | ||
| validIngressConfig("a", "a.localhost.pomerium.io"), | ||
| invalidIngressConfig("bad"), // empty host -> route generation fails | ||
| validIngressConfig("c", "c.localhost.pomerium.io"), | ||
| } | ||
|
|
||
| cfg := buildConfigCheap(ctx, ics) | ||
| require.NotNil(t, cfg) | ||
| // The invalid ingress is skipped; the two valid ones remain. | ||
| assert.Len(t, cfg.GetRoutes(), 2, "invalid ingress should be skipped, valid ones kept") | ||
|
|
||
| hosts := map[string]bool{} | ||
| for _, r := range cfg.GetRoutes() { | ||
| hosts[r.GetFrom()] = true | ||
| } | ||
| assert.True(t, hosts["https://a.localhost.pomerium.io"], "route a should be present") | ||
| assert.True(t, hosts["https://c.localhost.pomerium.io"], "route c should be present") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Red test:
Context Used: For every finding, write a red test with proof and... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!