Skip to content

Commit 1a2f133

Browse files
committed
feat: add support for multiple ExtensionManagers with sequential chaining
Add a new `extensionManagers` plural field to `EnvoyGatewaySpec` that allows registering multiple extension managers with sequential chaining semantics. Each extension's output becomes the next extension's input. Key changes: - Add `Name` field to `ExtensionManager` and `ExtensionManagers` list to `EnvoyGatewaySpec` - Add `GetExtensionManagers()` helper to normalize singular/plural fields - Add mutual exclusivity validation between singular and plural fields - Implement `CompositeManager` wrapping multiple managers behind the `Manager` interface - Implement `compositeXDSHookClient` with per-extension policy filtering and per-extension resource-type gating in `PostTranslateModifyHook` - Merge `TranslationConfig` using OR semantics across all managers - Add `CleanupHookConns()` to the `Manager` interface - Unify `NewManager` factory to handle 0, 1, and N extensions Signed-off-by: Marc Navarro Sonnenfeld <marcnavarro@tetrate.io>
1 parent 86c39db commit 1a2f133

17 files changed

Lines changed: 1311 additions & 51 deletions

File tree

api/v1alpha1/envoygateway_helpers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,19 @@ func (kcr *KubernetesClientRateLimit) GetQPSAndBurst() (float32, int) {
381381
return float32(qps), int(burst)
382382
}
383383

384+
// GetExtensionManagers normalizes the singular ExtensionManager and plural ExtensionManagers
385+
// fields into a single list. The plural field takes precedence. If only the singular field
386+
// is set, it is returned as a single-element list. Returns nil if neither is set.
387+
func (e *EnvoyGatewaySpec) GetExtensionManagers() []ExtensionManager {
388+
if len(e.ExtensionManagers) > 0 {
389+
return e.ExtensionManagers
390+
}
391+
if e.ExtensionManager != nil {
392+
return []ExtensionManager{*e.ExtensionManager}
393+
}
394+
return nil
395+
}
396+
384397
// ShouldIncludeClusters returns true if clusters should be included in the translation hook.
385398
// When TranslationConfig is nil, defaults to true for backward compatibility.
386399
// When TranslationConfig is explicitly set, uses the configuration.

api/v1alpha1/envoygateway_types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ type EnvoyGatewaySpec struct {
9898
// +optional
9999
ExtensionManager *ExtensionManager `json:"extensionManager,omitempty"`
100100

101+
// ExtensionManagers defines multiple extension managers to register for the Envoy Gateway Control Plane.
102+
// Each extension's output becomes the next extension's input, enabling sequential chaining.
103+
// Each entry must have a unique Name field for identification.
104+
// This field is mutually exclusive with ExtensionManager.
105+
//
106+
// Warning: Enabling Extension Servers may lead to complete security compromise of your system.
107+
// Users that control Extension Servers can inject arbitrary configuration to proxies,
108+
// leading to high Confidentiality, Integrity and Availability risks.
109+
//
110+
// +optional
111+
ExtensionManagers []ExtensionManager `json:"extensionManagers,omitempty"`
112+
101113
// ExtensionAPIs defines the settings related to specific Gateway API Extensions
102114
// implemented by Envoy Gateway
103115
//
@@ -660,6 +672,12 @@ type RateLimitRedisSettings struct {
660672
// ExtensionManager defines the configuration for registering an extension manager to
661673
// the Envoy Gateway control plane.
662674
type ExtensionManager struct {
675+
// Name is a unique identifier for this extension manager. Required when using
676+
// the plural ExtensionManagers field. Used for logging, metrics, and error identification.
677+
//
678+
// +optional
679+
Name string `json:"name,omitempty"`
680+
663681
// Resources defines the set of K8s resources the extension will handle as route
664682
// filter resources
665683
//

api/v1alpha1/validation/envoygateway_validate.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func ValidateEnvoyGateway(eg *egv1a1.EnvoyGateway) error {
5555
return err
5656
}
5757

58-
if err := validateEnvoyGatewayExtensionManager(eg.ExtensionManager); err != nil {
58+
if err := validateEnvoyGatewayExtensionManagers(eg); err != nil {
5959
return err
6060
}
6161

@@ -185,6 +185,31 @@ func validateEnvoyGatewayRateLimit(rateLimit *egv1a1.RateLimit) error {
185185
return nil
186186
}
187187

188+
func validateEnvoyGatewayExtensionManagers(eg *egv1a1.EnvoyGateway) error {
189+
if eg.ExtensionManager != nil && len(eg.ExtensionManagers) > 0 {
190+
return fmt.Errorf("extensionManager and extensionManagers are mutually exclusive")
191+
}
192+
193+
if len(eg.ExtensionManagers) > 0 {
194+
names := make(map[string]struct{})
195+
for i, em := range eg.ExtensionManagers {
196+
if em.Name == "" {
197+
return fmt.Errorf("extension manager at index %d: name is required", i)
198+
}
199+
if _, exists := names[em.Name]; exists {
200+
return fmt.Errorf("extension manager at index %d: duplicate name %q", i, em.Name)
201+
}
202+
names[em.Name] = struct{}{}
203+
if err := validateEnvoyGatewayExtensionManager(&eg.ExtensionManagers[i]); err != nil {
204+
return fmt.Errorf("extension manager %q: %w", em.Name, err)
205+
}
206+
}
207+
return nil
208+
}
209+
210+
return validateEnvoyGatewayExtensionManager(eg.ExtensionManager)
211+
}
212+
188213
func validateEnvoyGatewayExtensionManager(extensionManager *egv1a1.ExtensionManager) error {
189214
if extensionManager == nil {
190215
return nil

api/v1alpha1/validation/envoygateway_validate_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,115 @@ func TestValidateEnvoyGateway(t *testing.T) {
869869
},
870870
expect: false,
871871
},
872+
{
873+
name: "both extensionManager and extensionManagers set",
874+
eg: &egv1a1.EnvoyGateway{
875+
EnvoyGatewaySpec: egv1a1.EnvoyGatewaySpec{
876+
Gateway: egv1a1.DefaultGateway(),
877+
Provider: egv1a1.DefaultEnvoyGatewayProvider(),
878+
ExtensionManager: &egv1a1.ExtensionManager{
879+
Hooks: &egv1a1.ExtensionHooks{
880+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
881+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSRoute},
882+
},
883+
},
884+
Service: &egv1a1.ExtensionService{Host: "foo.extension", Port: 80},
885+
},
886+
ExtensionManagers: []egv1a1.ExtensionManager{
887+
{
888+
Name: "ext1",
889+
Hooks: &egv1a1.ExtensionHooks{
890+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
891+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSRoute},
892+
},
893+
},
894+
Service: &egv1a1.ExtensionService{Host: "bar.extension", Port: 80},
895+
},
896+
},
897+
},
898+
},
899+
expect: false,
900+
},
901+
{
902+
name: "extensionManagers with duplicate names",
903+
eg: &egv1a1.EnvoyGateway{
904+
EnvoyGatewaySpec: egv1a1.EnvoyGatewaySpec{
905+
Gateway: egv1a1.DefaultGateway(),
906+
Provider: egv1a1.DefaultEnvoyGatewayProvider(),
907+
ExtensionManagers: []egv1a1.ExtensionManager{
908+
{
909+
Name: "ext1",
910+
Hooks: &egv1a1.ExtensionHooks{
911+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
912+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSRoute},
913+
},
914+
},
915+
Service: &egv1a1.ExtensionService{Host: "foo.extension", Port: 80},
916+
},
917+
{
918+
Name: "ext1",
919+
Hooks: &egv1a1.ExtensionHooks{
920+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
921+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSRoute},
922+
},
923+
},
924+
Service: &egv1a1.ExtensionService{Host: "bar.extension", Port: 80},
925+
},
926+
},
927+
},
928+
},
929+
expect: false,
930+
},
931+
{
932+
name: "extensionManagers with missing name",
933+
eg: &egv1a1.EnvoyGateway{
934+
EnvoyGatewaySpec: egv1a1.EnvoyGatewaySpec{
935+
Gateway: egv1a1.DefaultGateway(),
936+
Provider: egv1a1.DefaultEnvoyGatewayProvider(),
937+
ExtensionManagers: []egv1a1.ExtensionManager{
938+
{
939+
Hooks: &egv1a1.ExtensionHooks{
940+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
941+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSRoute},
942+
},
943+
},
944+
Service: &egv1a1.ExtensionService{Host: "foo.extension", Port: 80},
945+
},
946+
},
947+
},
948+
},
949+
expect: false,
950+
},
951+
{
952+
name: "valid extensionManagers plural config",
953+
eg: &egv1a1.EnvoyGateway{
954+
EnvoyGatewaySpec: egv1a1.EnvoyGatewaySpec{
955+
Gateway: egv1a1.DefaultGateway(),
956+
Provider: egv1a1.DefaultEnvoyGatewayProvider(),
957+
ExtensionManagers: []egv1a1.ExtensionManager{
958+
{
959+
Name: "ai-gateway",
960+
Hooks: &egv1a1.ExtensionHooks{
961+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
962+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSRoute, egv1a1.XDSTranslation},
963+
},
964+
},
965+
Service: &egv1a1.ExtensionService{Host: "ai-gw.extension", Port: 80},
966+
},
967+
{
968+
Name: "observability",
969+
Hooks: &egv1a1.ExtensionHooks{
970+
XDSTranslator: &egv1a1.XDSTranslatorHooks{
971+
Post: []egv1a1.XDSTranslatorHook{egv1a1.XDSHTTPListener},
972+
},
973+
},
974+
Service: &egv1a1.ExtensionService{Host: "obs.extension", Port: 80},
975+
},
976+
},
977+
},
978+
},
979+
expect: true,
980+
},
872981
}
873982

874983
for _, tc := range testCases {
@@ -1133,3 +1242,48 @@ func TestEnvoyGatewayTelemetry(t *testing.T) {
11331242
assert.False(t, eg.Telemetry.Metrics.Prometheus.Disable)
11341243
assert.Nil(t, eg.Telemetry.Metrics.Sinks)
11351244
}
1245+
1246+
func TestGetExtensionManagers(t *testing.T) {
1247+
t.Run("both nil returns nil", func(t *testing.T) {
1248+
spec := egv1a1.EnvoyGatewaySpec{}
1249+
assert.Nil(t, spec.GetExtensionManagers())
1250+
})
1251+
1252+
t.Run("only singular set returns it as slice", func(t *testing.T) {
1253+
ext := egv1a1.ExtensionManager{Name: "ext1"}
1254+
spec := egv1a1.EnvoyGatewaySpec{
1255+
ExtensionManager: &ext,
1256+
}
1257+
result := spec.GetExtensionManagers()
1258+
require.Len(t, result, 1)
1259+
assert.Equal(t, "ext1", result[0].Name)
1260+
})
1261+
1262+
t.Run("only plural set returns plural", func(t *testing.T) {
1263+
spec := egv1a1.EnvoyGatewaySpec{
1264+
ExtensionManagers: []egv1a1.ExtensionManager{
1265+
{Name: "ext1"},
1266+
{Name: "ext2"},
1267+
},
1268+
}
1269+
result := spec.GetExtensionManagers()
1270+
require.Len(t, result, 2)
1271+
assert.Equal(t, "ext1", result[0].Name)
1272+
assert.Equal(t, "ext2", result[1].Name)
1273+
})
1274+
1275+
t.Run("both set returns plural (plural takes precedence)", func(t *testing.T) {
1276+
ext := egv1a1.ExtensionManager{Name: "singular"}
1277+
spec := egv1a1.EnvoyGatewaySpec{
1278+
ExtensionManager: &ext,
1279+
ExtensionManagers: []egv1a1.ExtensionManager{
1280+
{Name: "plural1"},
1281+
{Name: "plural2"},
1282+
},
1283+
}
1284+
result := spec.GetExtensionManagers()
1285+
require.Len(t, result, 2)
1286+
assert.Equal(t, "plural1", result[0].Name)
1287+
assert.Equal(t, "plural2", result[1].Name)
1288+
})
1289+
}

api/v1alpha1/zz_generated.deepcopy.go

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

internal/cmd/server.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,7 @@ func startRunners(ctx context.Context, cfg *config.Server, runnerErrors *message
290290
}
291291

292292
if extMgr != nil {
293-
// Close connections to extension services
294-
if mgr, ok := extMgr.(*extensionregistry.Manager); ok {
295-
mgr.CleanupHookConns()
296-
}
293+
extMgr.CleanupHookConns()
297294
}
298295

299296
return nil

0 commit comments

Comments
 (0)