Skip to content

Commit 4bbe676

Browse files
committed
fix several minor issues and update comments
1 parent b45ce2e commit 4bbe676

15 files changed

Lines changed: 358 additions & 27 deletions

File tree

gateway/gateway-controller/pkg/models/runtime_deploy_config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,17 @@ func (rdc *RuntimeDeployConfig) ValidateResolution() error {
333333
"route %q: canonical chain key %q belongs to routing partition (vhost) %q, but the route serves %q",
334334
routeKey, canonical, vhost, route.Vhost)
335335
}
336+
} else if canonical != routeKey {
337+
// A composed operation key is the one redirect an identity route may
338+
// carry. Any other key that merely happens to exist is refused, because
339+
// the failure it hides is silent: a route pointed at another route's
340+
// chain — a public route carrying "GET|/admin|h", say — passes the
341+
// existence check above and then runs that route's authentication and
342+
// rate limits instead of its own. Same class as the cross-partition case,
343+
// without a composed key's structure to detect it from.
344+
return fmt.Errorf(
345+
"route %q: canonical chain key %q is neither the route key nor a composed operation key",
346+
routeKey, canonical)
336347
}
337348
continue
338349
}

gateway/gateway-controller/pkg/policyxds/route_resolution_test.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ func TestEmptyResolutionFieldsAreOmitted(t *testing.T) {
151151
}
152152
}
153153

154-
// The §5.2 golden: the complete emitted content for an existing kind's route, pinned
154+
// Golden test: the complete emitted content for an existing kind's route, pinned
155155
// value by value. It is a content comparison rather than a byte comparison on purpose
156156
// — the resource bytes are produced by anypb.New over a Struct whose map fields have
157157
// no defined wire order, and the LinearCache re-versions every resource it is handed
@@ -301,11 +301,36 @@ func TestValidateResolution(t *testing.T) {
301301
},
302302
},
303303
{
304-
name: "identity route with an explicit canonical key",
304+
name: "identity route with an explicit canonical key equal to its route key",
305+
rdc: &models.RuntimeDeployConfig{
306+
Routes: map[string]*models.Route{"GET|/pets|h": {CanonicalChainKey: "GET|/pets|h"}},
307+
PolicyChains: chains("GET|/pets|h"),
308+
},
309+
},
310+
{
311+
// A chain that exists but is neither this route's key nor a composed operation
312+
// key. Existence alone would accept it, and the route would then run whatever
313+
// policies that chain carries — the borrowed-policies failure is silent, so it
314+
// has to be refused here.
315+
name: "identity route pointed at an arbitrary existing chain",
305316
rdc: &models.RuntimeDeployConfig{
306317
Routes: map[string]*models.Route{"GET|/pets|h": {CanonicalChainKey: "shared-chain"}},
307-
PolicyChains: chains("shared-chain"),
318+
PolicyChains: chains("GET|/pets|h", "shared-chain"),
319+
},
320+
wantErr: `is neither the route key nor a composed operation key`,
321+
},
322+
{
323+
// The concrete shape of that mistake: a public route carrying another route's
324+
// key, which would silently borrow that route's authentication.
325+
name: "identity route pointed at another route's chain",
326+
rdc: &models.RuntimeDeployConfig{
327+
Routes: map[string]*models.Route{
328+
"GET|/pets|h": {CanonicalChainKey: "GET|/admin|h"},
329+
"GET|/admin|h": {},
330+
},
331+
PolicyChains: chains("GET|/pets|h", "GET|/admin|h"),
308332
},
333+
wantErr: `canonical chain key "GET|/admin|h" is neither the route key nor a composed operation key`,
309334
},
310335
{
311336
// An identity route deliberately redirected to a composed operation key:

gateway/gateway-controller/pkg/policyxds/snapshot.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,8 @@ func (t *Translator) createRouteConfigResource(
417417
data["resolver_config"] = decoded
418418
}
419419
// Omitted when empty, like every other resolution field: a route that declares
420-
// nothing must serialise byte-identically to how it did before the field existed
421-
// (§5.2), or every existing kind's RouteConfig re-versions for no reason.
420+
// nothing must serialise exactly as it did before the field existed, or every
421+
// existing kind's RouteConfig re-versions for no reason.
422422
if route.ResponseKind != "" {
423423
data["response_kind"] = route.ResponseKind
424424
}

gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func main() {
263263
slog.InfoContext(ctx, "Policy Engine listening on TCP port", "port", cfg.PolicyEngine.Server.ExtProcPort)
264264
}
265265

266-
grpcServer := grpc.NewServer()
266+
grpcServer := grpc.NewServer(extProcServerOptions(cfg)...)
267267
extprocv3.RegisterExternalProcessorServer(grpcServer, extprocServer)
268268

269269
// Enable block/mutex profiling sampling when pprof is enabled. These are the
@@ -452,3 +452,23 @@ func initializeFileConfig(ctx context.Context, cfg *config.Config, k *kernel.Ker
452452

453453
return nil
454454
}
455+
456+
// extProcServerOptions bounds the ext_proc gRPC server explicitly, rather than taking
457+
// gRPC's defaults: the receive default is 4 MiB whatever the body ceilings are configured
458+
// to be, the send default is unbounded, and the concurrent-stream default is effectively
459+
// unlimited. This is the hottest gRPC server in the data plane, so all three are set from
460+
// validated configuration (see config.Config.Validate, which refuses to start when a
461+
// message limit is below what the body ceilings require).
462+
func extProcServerOptions(cfg *config.Config) []grpc.ServerOption {
463+
server := cfg.PolicyEngine.Server
464+
slog.Info("ext_proc gRPC server limits",
465+
"max_recv_msg_bytes", server.MaxRecvMsgBytes,
466+
"max_send_msg_bytes", server.MaxSendMsgBytes,
467+
"max_concurrent_streams", server.MaxConcurrentStreams)
468+
469+
return []grpc.ServerOption{
470+
grpc.MaxRecvMsgSize(int(server.MaxRecvMsgBytes)),
471+
grpc.MaxSendMsgSize(int(server.MaxSendMsgBytes)),
472+
grpc.MaxConcurrentStreams(server.MaxConcurrentStreams),
473+
}
474+
}

gateway/gateway-runtime/policy-engine/cmd/policy-engine/main_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828

2929
"github.com/stretchr/testify/assert"
3030
"github.com/stretchr/testify/require"
31+
"google.golang.org/grpc"
3132

3233
"github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/config"
3334
"github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel"
@@ -332,3 +333,23 @@ func TestInitializeXDSClient_ValidConfig(t *testing.T) {
332333
// Note: Not calling Stop/Wait due to potential issues with context in test environment
333334
// The client will be cleaned up when the test exits
334335
}
336+
337+
// The ext_proc server must be constructed with all three bounds set. The values
338+
// themselves are validated in internal/config; what this pins is that none of the three
339+
// options is dropped from the construction, which is how this server silently ran on
340+
// gRPC's defaults — a 4 MiB receive cap and unbounded streams — before.
341+
func TestExtProcServerOptions(t *testing.T) {
342+
cfg := &config.Config{}
343+
cfg.PolicyEngine.Server.MaxRecvMsgBytes = 11 << 20
344+
cfg.PolicyEngine.Server.MaxSendMsgBytes = 11 << 20
345+
cfg.PolicyEngine.Server.MaxConcurrentStreams = 4096
346+
347+
opts := extProcServerOptions(cfg)
348+
assert.Len(t, opts, 3, "MaxRecvMsgSize, MaxSendMsgSize and MaxConcurrentStreams")
349+
350+
// And the real constructor accepts them, rather than this merely being a slice of
351+
// the right length.
352+
srv := grpc.NewServer(opts...)
353+
require.NotNil(t, srv)
354+
srv.Stop()
355+
}

gateway/gateway-runtime/policy-engine/internal/config/config.go

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,26 @@ const (
4040
// from a Content-Encoded body — the whole body when buffered, each chunk when
4141
// streaming. Applied when max_decompressed_bytes is unset for a direction.
4242
DefaultMaxDecompressedBytes int64 = 10 * 1024 * 1024 // 10 MiB
43+
44+
// ExtProcMessageOverheadBytes is the headroom an ext_proc message needs above the
45+
// body it carries: request/response headers, Envoy attributes, dynamic metadata and
46+
// protobuf framing all travel in the same message. The gRPC message limits are
47+
// validated against the body ceilings plus this, because a limit sized to the body
48+
// alone fails mid-request with ResourceExhausted on any request whose headers are
49+
// large — a failure that looks like a gateway fault rather than a misconfiguration.
50+
ExtProcMessageOverheadBytes int64 = 1 * 1024 * 1024 // 1 MiB
51+
52+
// DefaultMaxConcurrentStreams bounds in-flight ext_proc calls on the Envoy
53+
// connection, one stream per request being processed. gRPC's own default is
54+
// effectively unlimited, so an explicit value is what makes the stream budget a
55+
// bounded resource rather than whatever the peer asks for.
56+
//
57+
// Deliberately generous. This is not a load-shedding control: Envoy does not
58+
// degrade gracefully when it runs out of streams, it stalls, so a value below a
59+
// pod's peak concurrent in-flight requests costs availability rather than
60+
// protecting anything. Raise it if a single runtime instance legitimately carries
61+
// more concurrency than this.
62+
DefaultMaxConcurrentStreams uint32 = 10000
4363
)
4464

4565
// defaultFileSourceAllowlist is the policy-engine's default set of directories that
@@ -239,6 +259,34 @@ type ServerConfig struct {
239259

240260
// ExtProcPort is the port for the ext_proc gRPC server (TCP mode only)
241261
ExtProcPort int `koanf:"extproc_port"`
262+
263+
// MaxRecvMsgBytes and MaxSendMsgBytes bound one ext_proc message in each
264+
// direction. Both must accommodate the larger of the two body decompression
265+
// ceilings plus ExtProcMessageOverheadBytes, because both directions carry both
266+
// kinds of body: the engine receives a request body and may return a mutated one,
267+
// then receives a response body and may return a mutated one.
268+
//
269+
// They exist because gRPC's defaults are not this service's threat model — the
270+
// receive default is 4 MiB regardless of how the body ceilings are configured, and
271+
// the send default is unbounded.
272+
MaxRecvMsgBytes int64 `koanf:"max_recv_msg_bytes"`
273+
MaxSendMsgBytes int64 `koanf:"max_send_msg_bytes"`
274+
275+
// MaxConcurrentStreams bounds concurrent in-flight ext_proc calls. See
276+
// DefaultMaxConcurrentStreams for why this is a generous bound rather than a
277+
// load-shedding knob.
278+
MaxConcurrentStreams uint32 `koanf:"max_concurrent_streams"`
279+
}
280+
281+
// RequiredExtProcMessageBytes is the smallest message limit coherent with the
282+
// configured body ceilings. Both directions are sized off the larger ceiling, since
283+
// each carries request and response bodies alike.
284+
func (p PolicyEngine) RequiredExtProcMessageBytes() int64 {
285+
ceiling := p.RequestBody.MaxDecompressedBytes
286+
if p.ResponseBody.MaxDecompressedBytes > ceiling {
287+
ceiling = p.ResponseBody.MaxDecompressedBytes
288+
}
289+
return ceiling + ExtProcMessageOverheadBytes
242290
}
243291

244292
// PythonExecutorConfig holds configuration for the Python executor bridge.
@@ -511,8 +559,11 @@ func defaultConfig() *Config {
511559
return &Config{
512560
PolicyEngine: PolicyEngine{
513561
Server: ServerConfig{
514-
Mode: "",
515-
ExtProcPort: 9001,
562+
Mode: "",
563+
ExtProcPort: 9001,
564+
MaxRecvMsgBytes: DefaultMaxDecompressedBytes + ExtProcMessageOverheadBytes,
565+
MaxSendMsgBytes: DefaultMaxDecompressedBytes + ExtProcMessageOverheadBytes,
566+
MaxConcurrentStreams: DefaultMaxConcurrentStreams,
516567
},
517568
Admin: AdminConfig{
518569
Enabled: true,
@@ -682,6 +733,54 @@ func (c *Config) Validate() error {
682733
return fmt.Errorf("policy_engine.response_body.max_decompressed_bytes must be positive, got %d", c.PolicyEngine.ResponseBody.MaxDecompressedBytes)
683734
}
684735

736+
// ext_proc gRPC message and stream limits.
737+
//
738+
// Unset means "derive from the body ceilings" rather than "reject", so a Config built
739+
// in code (tests, embedders) stays usable and an operator who raises a body ceiling
740+
// does not also have to restate the message limits. Load() starts from
741+
// defaultConfig(), so a file-sourced config already carries values; this covers the
742+
// rest. Same normalise-then-validate shape as the router's
743+
// per_connection_buffer_limit_bytes.
744+
required := c.PolicyEngine.RequiredExtProcMessageBytes()
745+
if c.PolicyEngine.Server.MaxRecvMsgBytes == 0 {
746+
c.PolicyEngine.Server.MaxRecvMsgBytes = required
747+
}
748+
if c.PolicyEngine.Server.MaxSendMsgBytes == 0 {
749+
c.PolicyEngine.Server.MaxSendMsgBytes = required
750+
}
751+
if c.PolicyEngine.Server.MaxConcurrentStreams == 0 {
752+
c.PolicyEngine.Server.MaxConcurrentStreams = DefaultMaxConcurrentStreams
753+
}
754+
755+
// An *explicit* value below the ceiling is still refused: a message limit under the
756+
// body a policy is allowed to buffer fails mid-request with ResourceExhausted, which
757+
// surfaces as a gateway fault on live traffic instead of a startup error naming the
758+
// two settings that disagree. Refusing to start is the cheaper failure.
759+
if c.PolicyEngine.Server.MaxRecvMsgBytes < required {
760+
return fmt.Errorf(
761+
"policy_engine.server.max_recv_msg_bytes is %d, which is below the %d required by the configured "+
762+
"body decompression ceilings plus %d of ext_proc message overhead",
763+
c.PolicyEngine.Server.MaxRecvMsgBytes, required, ExtProcMessageOverheadBytes)
764+
}
765+
if c.PolicyEngine.Server.MaxSendMsgBytes < required {
766+
return fmt.Errorf(
767+
"policy_engine.server.max_send_msg_bytes is %d, which is below the %d required by the configured "+
768+
"body decompression ceilings plus %d of ext_proc message overhead",
769+
c.PolicyEngine.Server.MaxSendMsgBytes, required, ExtProcMessageOverheadBytes)
770+
}
771+
// grpc.MaxRecvMsgSize/MaxSendMsgSize take an int, so a value that does not survive
772+
// the conversion would silently become a different limit than the one configured.
773+
// int is 64-bit on every platform this ships on, making this unreachable there —
774+
// which is the point of asserting it here rather than at the conversion.
775+
for name, v := range map[string]int64{
776+
"max_recv_msg_bytes": c.PolicyEngine.Server.MaxRecvMsgBytes,
777+
"max_send_msg_bytes": c.PolicyEngine.Server.MaxSendMsgBytes,
778+
} {
779+
if int64(int(v)) != v {
780+
return fmt.Errorf("policy_engine.server.%s is %d, which does not fit this platform's int", name, v)
781+
}
782+
}
783+
685784
// Validate config mode
686785
if c.PolicyEngine.ConfigMode.Mode != "file" && c.PolicyEngine.ConfigMode.Mode != "xds" {
687786
return fmt.Errorf("invalid config_mode.mode: %s (must be 'file' or 'xds')", c.PolicyEngine.ConfigMode.Mode)

gateway/gateway-runtime/policy-engine/internal/config/config_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,3 +1654,73 @@ func TestDefaultConfig(t *testing.T) {
16541654
err := cfg.Validate()
16551655
assert.NoError(t, err)
16561656
}
1657+
1658+
// The ext_proc gRPC server carries every request and response body, so its message
1659+
// limits have to be coherent with the body ceilings. Incoherent config must stop the
1660+
// process at startup rather than fail mid-request with ResourceExhausted, which on live
1661+
// traffic looks like a gateway fault instead of a setting that needs changing.
1662+
func TestValidate_ExtProcMessageLimits(t *testing.T) {
1663+
baseline := func() *Config {
1664+
c := defaultConfig()
1665+
c.PolicyEngine.Server.Mode = "uds"
1666+
return c
1667+
}
1668+
1669+
t.Run("defaults are coherent with the default ceilings", func(t *testing.T) {
1670+
c := baseline()
1671+
require.NoError(t, c.Validate())
1672+
assert.Equal(t, DefaultMaxDecompressedBytes+ExtProcMessageOverheadBytes,
1673+
c.PolicyEngine.Server.MaxRecvMsgBytes)
1674+
assert.Equal(t, DefaultMaxConcurrentStreams, c.PolicyEngine.Server.MaxConcurrentStreams)
1675+
})
1676+
1677+
// Unset derives rather than rejects, so a Config built in code stays usable and an
1678+
// operator who raises a body ceiling need not restate the message limits.
1679+
t.Run("unset limits are derived from the ceilings", func(t *testing.T) {
1680+
c := baseline()
1681+
c.PolicyEngine.RequestBody.MaxDecompressedBytes = 50 * 1024 * 1024
1682+
c.PolicyEngine.Server.MaxRecvMsgBytes = 0
1683+
c.PolicyEngine.Server.MaxSendMsgBytes = 0
1684+
c.PolicyEngine.Server.MaxConcurrentStreams = 0
1685+
1686+
require.NoError(t, c.Validate())
1687+
want := int64(50*1024*1024) + ExtProcMessageOverheadBytes
1688+
assert.Equal(t, want, c.PolicyEngine.Server.MaxRecvMsgBytes)
1689+
assert.Equal(t, want, c.PolicyEngine.Server.MaxSendMsgBytes)
1690+
assert.Equal(t, DefaultMaxConcurrentStreams, c.PolicyEngine.Server.MaxConcurrentStreams)
1691+
})
1692+
1693+
// Both directions are sized off the *larger* ceiling, because each carries request
1694+
// and response bodies alike: the engine receives a request body and may return a
1695+
// mutated one, then receives a response body and may return a mutated one.
1696+
t.Run("the larger ceiling sets the requirement for both directions", func(t *testing.T) {
1697+
c := baseline()
1698+
c.PolicyEngine.RequestBody.MaxDecompressedBytes = 2 * 1024 * 1024
1699+
c.PolicyEngine.ResponseBody.MaxDecompressedBytes = 40 * 1024 * 1024
1700+
1701+
assert.Equal(t, int64(40*1024*1024)+ExtProcMessageOverheadBytes,
1702+
c.PolicyEngine.RequiredExtProcMessageBytes())
1703+
})
1704+
1705+
for name, mutate := range map[string]func(*Config){
1706+
"recv below the ceiling": func(c *Config) {
1707+
c.PolicyEngine.Server.MaxRecvMsgBytes = DefaultMaxDecompressedBytes
1708+
},
1709+
"send below the ceiling": func(c *Config) {
1710+
c.PolicyEngine.Server.MaxSendMsgBytes = DefaultMaxDecompressedBytes
1711+
},
1712+
"recv below a raised ceiling": func(c *Config) {
1713+
c.PolicyEngine.ResponseBody.MaxDecompressedBytes = 100 * 1024 * 1024
1714+
},
1715+
} {
1716+
t.Run("rejected: "+name, func(t *testing.T) {
1717+
c := baseline()
1718+
mutate(c)
1719+
err := c.Validate()
1720+
require.Error(t, err)
1721+
// The error has to name the two settings that disagree, or the operator is
1722+
// left guessing which of them to change.
1723+
assert.Regexp(t, `max_(recv|send)_msg_bytes is \d+, which is below the \d+ required`, err.Error())
1724+
})
1725+
}
1726+
}

gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -989,8 +989,9 @@ func (ec *PolicyExecutionContext) processResponseHeaders(
989989
// - The operation must have *declared* itself streaming. An Auto route is every kind
990990
// shipping today, and one with a buffered-only response policy meeting a chunked or
991991
// SSE upstream response has always simply buffered it. Turning that into a 500
992-
// would be a behavioural change for existing kinds, which §5.6 forbids outright —
993-
// the fail-closed behaviour is opt-in with the declaration, not a new global rule.
992+
// would be a behavioural change for existing kinds, which this must not introduce
993+
// — the fail-closed behaviour is opt-in with the declaration, not a new global
994+
// rule.
994995
// - responseNeedsStreaming looks at *this* response, not merely at the operation. A
995996
// streaming operation is entitled to answer with a 400 and a JSON error body, or a
996997
// bodyless 204; buffering those is correct, and the error the agent was trying to

gateway/gateway-runtime/policy-engine/internal/kernel/mapper.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ type RouteConfig struct {
5353
// gateway hold: that is Envoy's listener-wide
5454
// router.http_listener.per_connection_buffer_limit_bytes (1 MiB by default) and the
5555
// ext_proc gRPC server's receive limit, neither of which is per-route. Lowering
56-
// this value does not lower that. See §8 R3 in the design plan for the two
57-
// mechanisms that would.
56+
// this value does not lower that. Making it a real buffering bound needs either an
57+
// Envoy-side per-route cap (the buffer filter's max_request_bytes, which returns 413
58+
// before ext_proc collects the body) or streamed accumulation in the engine; neither
59+
// is built.
5860
MaxRequestBodyBytes int64
5961
}
6062

gateway/gateway-runtime/policy-engine/internal/kernel/resolution.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ func (ec *PolicyExecutionContext) bindPendingChainAndProcess(
226226
// collected the whole body and sent it here in one message by the time this runs.
227227
// The memory an unauthenticated caller can pin is bounded by Envoy's listener-wide
228228
// per_connection_buffer_limit_bytes and the ext_proc gRPC receive limit, neither of
229-
// which is per-route. See RouteConfig.MaxRequestBodyBytes and §8 R3.
229+
// which is per-route. See RouteConfig.MaxRequestBodyBytes.
230230
if limit := pending.route.EffectiveMaxRequestBodyBytes(); int64(len(wire)) > limit {
231231
return ec.denyResolution(ctx, &resolver.ResolutionError{
232232
Kind: resolver.FailurePayloadTooLarge,

0 commit comments

Comments
 (0)