Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/ingress_opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type ingressControllerOpts struct {
SyncAPIURL string
SyncAPINamespaceID string
SyncAPIToken string
MaxConcurrentReconciles int `validate:"min=1"`
}

const (
Expand All @@ -39,6 +40,7 @@ const (
syncAPIURL = "sync-api-url"
syncAPINamespaceID = "sync-api-namespace-id"
syncAPIToken = "sync-api-token" //nolint:gosec
maxConcurrentReconciles = "max-concurrent-reconciles"
)

func (s *ingressControllerOpts) setupFlags(flags *pflag.FlagSet) {
Expand All @@ -53,6 +55,8 @@ func (s *ingressControllerOpts) setupFlags(flags *pflag.FlagSet) {
flags.StringVar(&s.SyncAPIURL, syncAPIURL, "", "unified API sync URL")
flags.StringVar(&s.SyncAPINamespaceID, syncAPINamespaceID, "", "unified API sync namespace ID")
flags.StringVar(&s.SyncAPIToken, syncAPIToken, "", "unified API sync token")
flags.IntVar(&s.MaxConcurrentReconciles, maxConcurrentReconciles, 1,
"maximum number of concurrent ingress reconciles")
Comment on lines +58 to +59

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 The flag help text doesn't mention the thread-safety or memory risks described in the PR. Operators who set this above 1 will silently lose ingress routes (TOCTOU race) and risk OOMKill without leader election.

Red test:

func TestMaxConcurrentReconcilesFlagHelpMentionsRisk(t *testing.T) {
    fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
    opts := &ingressControllerOpts{}
    opts.setupFlags(fs)
    f := fs.Lookup("max-concurrent-reconciles")
    require.NotNil(t, f)
    // Fails today: no thread-safety warning in usage text.
    assert.Contains(t, f.Usage, "thread-safe")
}
Suggested change
flags.IntVar(&s.MaxConcurrentReconciles, maxConcurrentReconciles, 1,
"maximum number of concurrent ingress reconciles")
flags.IntVar(&s.MaxConcurrentReconciles, maxConcurrentReconciles, 1,
"maximum number of concurrent ingress reconciles; values above 1 are experimental: "+
"DataBrokerReconciler is not thread-safe, so concurrent reconciles can race and silently drop routes. "+
"Raising this also multiplies per-pod memory by the concurrency factor.")

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!

Fix in Claude Code Fix in Codex

}

func (s *ingressControllerOpts) Validate() error {
Expand All @@ -76,6 +80,7 @@ func (s *ingressControllerOpts) getIngressControllerOptions() ([]ingress.Option,
ingress.WithNamespaces(s.Namespaces),
ingress.WithAnnotationPrefix(s.AnnotationPrefix),
ingress.WithControllerName(s.ClassName),
ingress.WithMaxConcurrentReconciles(s.MaxConcurrentReconciles),
}
if name, err := s.getGlobalSettings(); err != nil {
return nil, err
Expand Down
21 changes: 21 additions & 0 deletions controllers/ingress/concurrency_test.go
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)
}
19 changes: 19 additions & 0 deletions controllers/ingress/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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{}).
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Concurrent reconciles race on DataBrokerReconciler

DataBrokerReconciler is explicitly documented as "not thread-safe". When maxConcurrentReconciles > 1, multiple Reconcile goroutines call Upsert/Delete on the same shared instance. Both follow getConfig → mutate → saveConfig with no locking; two goroutines observing the same prev race and the last Put wins, silently dropping the other goroutine's ingress route.

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)

Fix in Claude Code Fix in Codex

if err != nil {
return err
Expand Down
34 changes: 34 additions & 0 deletions pomerium/envoy/validate_cache.go
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
}
44 changes: 44 additions & 0 deletions pomerium/envoy/validate_cache_test.go
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))
}
11 changes: 10 additions & 1 deletion pomerium/envoy/validate_envoy.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package envoy

import (
"context"

"crypto/sha256"
"os"
"os/exec"
"path/filepath"
Expand All @@ -33,6 +33,14 @@ func Validate(ctx context.Context, bootstrap *envoy_config_bootstrap_v3.Bootstra
return nil, err
}

// Skip the (expensive) envoy subprocess if this exact config was already
// validated successfully. saveConfig revalidates the whole config on every
// change, so identical configs are validated repeatedly without this cache.
hash := sha256.Sum256(bs)
if defaultValidationCache.hit(hash) {
return &ValidateResult{Valid: true, Message: "OK (cached)"}, nil
}

cfgName := filepath.Join(os.TempDir(), id+".pb")
err = os.WriteFile(cfgName, bs, ownerRW)
if err != nil {
Expand Down Expand Up @@ -62,6 +70,7 @@ func Validate(ctx context.Context, bootstrap *envoy_config_bootstrap_v3.Bootstra
// all other errors are returned as errors
return nil, err
}
defaultValidationCache.store(hash)
return &ValidateResult{
Valid: true,
Message: "OK",
Expand Down
48 changes: 46 additions & 2 deletions pomerium/sync_databroker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Fallback triggered on non-validation errors

saveConfig can fail for reasons other than envoy bootstrap validation — removeUnusedCerts, and especially r.Put (transient databroker network error). When it does, the code still falls through to buildConfigIncremental, logging the misleading "batch config validation failed" message and spinning up O(n) envoy subprocesses before failing again on the second r.Put. During a restart where the databroker is momentarily unavailable, this can add several minutes of unnecessary envoy overhead and produce confusing logs.

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)

Fix in Claude Code Fix in Codex

}

// 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(
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions pomerium/sync_test.go
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")
}
Loading