diff --git a/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java new file mode 100644 index 00000000000..fc3ce3a2723 --- /dev/null +++ b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/ext_proc/v3/ExternalProcessorGrpc.java @@ -0,0 +1,522 @@ +package io.envoyproxy.envoy.service.ext_proc.v3; + +import static io.grpc.MethodDescriptor.generateFullMethodName; + +/** + *
+ * A service that can access and modify HTTP requests and responses
+ * as part of a filter chain.
+ * The overall external processing protocol works like this:
+ * 1. The data plane sends to the service information about the HTTP request.
+ * 2. The service sends back a ProcessingResponse message that directs
+ *    the data plane to either stop processing, continue without it, or send
+ *    it the next chunk of the message body.
+ * 3. If so requested, the data plane sends the server the message body in
+ *    chunks, or the entire body at once. In either case, the server may send
+ *    back a ProcessingResponse for each message it receives, or wait for
+ *    a certain amount of body chunks received before streaming back the
+ *    ProcessingResponse messages.
+ * 4. If so requested, the data plane sends the server the HTTP trailers,
+ *    and the server sends back a ProcessingResponse.
+ * 5. At this point, request processing is done, and we pick up again
+ *    at step 1 when the data plane receives a response from the upstream
+ *    server.
+ * 6. At any point above, if the server closes the gRPC stream cleanly,
+ *    then the data plane proceeds without consulting the server.
+ * 7. At any point above, if the server closes the gRPC stream with an error,
+ *    then the data plane returns a 500 error to the client, unless the filter
+ *    was configured to ignore errors.
+ * In other words, the process is a request/response conversation, but
+ * using a gRPC stream to make it easier for the server to
+ * maintain state.
+ * 
+ */ +@io.grpc.stub.annotations.GrpcGenerated +public final class ExternalProcessorGrpc { + + private ExternalProcessorGrpc() {} + + public static final java.lang.String SERVICE_NAME = "envoy.service.ext_proc.v3.ExternalProcessor"; + + // Static method descriptors that strictly reflect the proto. + private static volatile io.grpc.MethodDescriptor getProcessMethod; + + @io.grpc.stub.annotations.RpcMethod( + fullMethodName = SERVICE_NAME + '/' + "Process", + requestType = io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest.class, + responseType = io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse.class, + methodType = io.grpc.MethodDescriptor.MethodType.BIDI_STREAMING) + public static io.grpc.MethodDescriptor getProcessMethod() { + io.grpc.MethodDescriptor getProcessMethod; + if ((getProcessMethod = ExternalProcessorGrpc.getProcessMethod) == null) { + synchronized (ExternalProcessorGrpc.class) { + if ((getProcessMethod = ExternalProcessorGrpc.getProcessMethod) == null) { + ExternalProcessorGrpc.getProcessMethod = getProcessMethod = + io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName(generateFullMethodName(SERVICE_NAME, "Process")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller( + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest.getDefaultInstance())) + .setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller( + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse.getDefaultInstance())) + .setSchemaDescriptor(new ExternalProcessorMethodDescriptorSupplier("Process")) + .build(); + } + } + } + return getProcessMethod; + } + + /** + * Creates a new async stub that supports all call types for the service + */ + public static ExternalProcessorStub newStub(io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public ExternalProcessorStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorStub(channel, callOptions); + } + }; + return ExternalProcessorStub.newStub(factory, channel); + } + + /** + * Creates a new blocking-style stub that supports all types of calls on the service + */ + public static ExternalProcessorBlockingV2Stub newBlockingV2Stub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public ExternalProcessorBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorBlockingV2Stub(channel, callOptions); + } + }; + return ExternalProcessorBlockingV2Stub.newStub(factory, channel); + } + + /** + * Creates a new blocking-style stub that supports unary and streaming output calls on the service + */ + public static ExternalProcessorBlockingStub newBlockingStub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public ExternalProcessorBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorBlockingStub(channel, callOptions); + } + }; + return ExternalProcessorBlockingStub.newStub(factory, channel); + } + + /** + * Creates a new ListenableFuture-style stub that supports unary calls on the service + */ + public static ExternalProcessorFutureStub newFutureStub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public ExternalProcessorFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorFutureStub(channel, callOptions); + } + }; + return ExternalProcessorFutureStub.newStub(factory, channel); + } + + /** + *
+   * A service that can access and modify HTTP requests and responses
+   * as part of a filter chain.
+   * The overall external processing protocol works like this:
+   * 1. The data plane sends to the service information about the HTTP request.
+   * 2. The service sends back a ProcessingResponse message that directs
+   *    the data plane to either stop processing, continue without it, or send
+   *    it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in
+   *    chunks, or the entire body at once. In either case, the server may send
+   *    back a ProcessingResponse for each message it receives, or wait for
+   *    a certain amount of body chunks received before streaming back the
+   *    ProcessingResponse messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers,
+   *    and the server sends back a ProcessingResponse.
+   * 5. At this point, request processing is done, and we pick up again
+   *    at step 1 when the data plane receives a response from the upstream
+   *    server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly,
+   *    then the data plane proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error,
+   *    then the data plane returns a 500 error to the client, unless the filter
+   *    was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but
+   * using a gRPC stream to make it easier for the server to
+   * maintain state.
+   * 
+ */ + public interface AsyncService { + + /** + *
+     * This begins the bidirectional stream that the data plane will use to
+     * give the server control over what the filter does. The actual
+     * protocol is described by the ProcessingRequest and ProcessingResponse
+     * messages below.
+     * 
+ */ + default io.grpc.stub.StreamObserver process( + io.grpc.stub.StreamObserver responseObserver) { + return io.grpc.stub.ServerCalls.asyncUnimplementedStreamingCall(getProcessMethod(), responseObserver); + } + } + + /** + * Base class for the server implementation of the service ExternalProcessor. + *
+   * A service that can access and modify HTTP requests and responses
+   * as part of a filter chain.
+   * The overall external processing protocol works like this:
+   * 1. The data plane sends to the service information about the HTTP request.
+   * 2. The service sends back a ProcessingResponse message that directs
+   *    the data plane to either stop processing, continue without it, or send
+   *    it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in
+   *    chunks, or the entire body at once. In either case, the server may send
+   *    back a ProcessingResponse for each message it receives, or wait for
+   *    a certain amount of body chunks received before streaming back the
+   *    ProcessingResponse messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers,
+   *    and the server sends back a ProcessingResponse.
+   * 5. At this point, request processing is done, and we pick up again
+   *    at step 1 when the data plane receives a response from the upstream
+   *    server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly,
+   *    then the data plane proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error,
+   *    then the data plane returns a 500 error to the client, unless the filter
+   *    was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but
+   * using a gRPC stream to make it easier for the server to
+   * maintain state.
+   * 
+ */ + public static abstract class ExternalProcessorImplBase + implements io.grpc.BindableService, AsyncService { + + @java.lang.Override public final io.grpc.ServerServiceDefinition bindService() { + return ExternalProcessorGrpc.bindService(this); + } + } + + /** + * A stub to allow clients to do asynchronous rpc calls to service ExternalProcessor. + *
+   * A service that can access and modify HTTP requests and responses
+   * as part of a filter chain.
+   * The overall external processing protocol works like this:
+   * 1. The data plane sends to the service information about the HTTP request.
+   * 2. The service sends back a ProcessingResponse message that directs
+   *    the data plane to either stop processing, continue without it, or send
+   *    it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in
+   *    chunks, or the entire body at once. In either case, the server may send
+   *    back a ProcessingResponse for each message it receives, or wait for
+   *    a certain amount of body chunks received before streaming back the
+   *    ProcessingResponse messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers,
+   *    and the server sends back a ProcessingResponse.
+   * 5. At this point, request processing is done, and we pick up again
+   *    at step 1 when the data plane receives a response from the upstream
+   *    server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly,
+   *    then the data plane proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error,
+   *    then the data plane returns a 500 error to the client, unless the filter
+   *    was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but
+   * using a gRPC stream to make it easier for the server to
+   * maintain state.
+   * 
+ */ + public static final class ExternalProcessorStub + extends io.grpc.stub.AbstractAsyncStub { + private ExternalProcessorStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected ExternalProcessorStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorStub(channel, callOptions); + } + + /** + *
+     * This begins the bidirectional stream that the data plane will use to
+     * give the server control over what the filter does. The actual
+     * protocol is described by the ProcessingRequest and ProcessingResponse
+     * messages below.
+     * 
+ */ + public io.grpc.stub.StreamObserver process( + io.grpc.stub.StreamObserver responseObserver) { + return io.grpc.stub.ClientCalls.asyncBidiStreamingCall( + getChannel().newCall(getProcessMethod(), getCallOptions()), responseObserver); + } + } + + /** + * A stub to allow clients to do synchronous rpc calls to service ExternalProcessor. + *
+   * A service that can access and modify HTTP requests and responses
+   * as part of a filter chain.
+   * The overall external processing protocol works like this:
+   * 1. The data plane sends to the service information about the HTTP request.
+   * 2. The service sends back a ProcessingResponse message that directs
+   *    the data plane to either stop processing, continue without it, or send
+   *    it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in
+   *    chunks, or the entire body at once. In either case, the server may send
+   *    back a ProcessingResponse for each message it receives, or wait for
+   *    a certain amount of body chunks received before streaming back the
+   *    ProcessingResponse messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers,
+   *    and the server sends back a ProcessingResponse.
+   * 5. At this point, request processing is done, and we pick up again
+   *    at step 1 when the data plane receives a response from the upstream
+   *    server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly,
+   *    then the data plane proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error,
+   *    then the data plane returns a 500 error to the client, unless the filter
+   *    was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but
+   * using a gRPC stream to make it easier for the server to
+   * maintain state.
+   * 
+ */ + public static final class ExternalProcessorBlockingV2Stub + extends io.grpc.stub.AbstractBlockingStub { + private ExternalProcessorBlockingV2Stub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected ExternalProcessorBlockingV2Stub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorBlockingV2Stub(channel, callOptions); + } + + /** + *
+     * This begins the bidirectional stream that the data plane will use to
+     * give the server control over what the filter does. The actual
+     * protocol is described by the ProcessingRequest and ProcessingResponse
+     * messages below.
+     * 
+ */ + @io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918") + public io.grpc.stub.BlockingClientCall + process() { + return io.grpc.stub.ClientCalls.blockingBidiStreamingCall( + getChannel(), getProcessMethod(), getCallOptions()); + } + } + + /** + * A stub to allow clients to do limited synchronous rpc calls to service ExternalProcessor. + *
+   * A service that can access and modify HTTP requests and responses
+   * as part of a filter chain.
+   * The overall external processing protocol works like this:
+   * 1. The data plane sends to the service information about the HTTP request.
+   * 2. The service sends back a ProcessingResponse message that directs
+   *    the data plane to either stop processing, continue without it, or send
+   *    it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in
+   *    chunks, or the entire body at once. In either case, the server may send
+   *    back a ProcessingResponse for each message it receives, or wait for
+   *    a certain amount of body chunks received before streaming back the
+   *    ProcessingResponse messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers,
+   *    and the server sends back a ProcessingResponse.
+   * 5. At this point, request processing is done, and we pick up again
+   *    at step 1 when the data plane receives a response from the upstream
+   *    server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly,
+   *    then the data plane proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error,
+   *    then the data plane returns a 500 error to the client, unless the filter
+   *    was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but
+   * using a gRPC stream to make it easier for the server to
+   * maintain state.
+   * 
+ */ + public static final class ExternalProcessorBlockingStub + extends io.grpc.stub.AbstractBlockingStub { + private ExternalProcessorBlockingStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected ExternalProcessorBlockingStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorBlockingStub(channel, callOptions); + } + } + + /** + * A stub to allow clients to do ListenableFuture-style rpc calls to service ExternalProcessor. + *
+   * A service that can access and modify HTTP requests and responses
+   * as part of a filter chain.
+   * The overall external processing protocol works like this:
+   * 1. The data plane sends to the service information about the HTTP request.
+   * 2. The service sends back a ProcessingResponse message that directs
+   *    the data plane to either stop processing, continue without it, or send
+   *    it the next chunk of the message body.
+   * 3. If so requested, the data plane sends the server the message body in
+   *    chunks, or the entire body at once. In either case, the server may send
+   *    back a ProcessingResponse for each message it receives, or wait for
+   *    a certain amount of body chunks received before streaming back the
+   *    ProcessingResponse messages.
+   * 4. If so requested, the data plane sends the server the HTTP trailers,
+   *    and the server sends back a ProcessingResponse.
+   * 5. At this point, request processing is done, and we pick up again
+   *    at step 1 when the data plane receives a response from the upstream
+   *    server.
+   * 6. At any point above, if the server closes the gRPC stream cleanly,
+   *    then the data plane proceeds without consulting the server.
+   * 7. At any point above, if the server closes the gRPC stream with an error,
+   *    then the data plane returns a 500 error to the client, unless the filter
+   *    was configured to ignore errors.
+   * In other words, the process is a request/response conversation, but
+   * using a gRPC stream to make it easier for the server to
+   * maintain state.
+   * 
+ */ + public static final class ExternalProcessorFutureStub + extends io.grpc.stub.AbstractFutureStub { + private ExternalProcessorFutureStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected ExternalProcessorFutureStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new ExternalProcessorFutureStub(channel, callOptions); + } + } + + private static final int METHODID_PROCESS = 0; + + private static final class MethodHandlers implements + io.grpc.stub.ServerCalls.UnaryMethod, + io.grpc.stub.ServerCalls.ServerStreamingMethod, + io.grpc.stub.ServerCalls.ClientStreamingMethod, + io.grpc.stub.ServerCalls.BidiStreamingMethod { + private final AsyncService serviceImpl; + private final int methodId; + + MethodHandlers(AsyncService serviceImpl, int methodId) { + this.serviceImpl = serviceImpl; + this.methodId = methodId; + } + + @java.lang.Override + @java.lang.SuppressWarnings("unchecked") + public void invoke(Req request, io.grpc.stub.StreamObserver responseObserver) { + switch (methodId) { + default: + throw new AssertionError(); + } + } + + @java.lang.Override + @java.lang.SuppressWarnings("unchecked") + public io.grpc.stub.StreamObserver invoke( + io.grpc.stub.StreamObserver responseObserver) { + switch (methodId) { + case METHODID_PROCESS: + return (io.grpc.stub.StreamObserver) serviceImpl.process( + (io.grpc.stub.StreamObserver) responseObserver); + default: + throw new AssertionError(); + } + } + } + + public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) { + return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor()) + .addMethod( + getProcessMethod(), + io.grpc.stub.ServerCalls.asyncBidiStreamingCall( + new MethodHandlers< + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest, + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse>( + service, METHODID_PROCESS))) + .build(); + } + + private static abstract class ExternalProcessorBaseDescriptorSupplier + implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier { + ExternalProcessorBaseDescriptorSupplier() {} + + @java.lang.Override + public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() { + return io.envoyproxy.envoy.service.ext_proc.v3.ExternalProcessorProto.getDescriptor(); + } + + @java.lang.Override + public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() { + return getFileDescriptor().findServiceByName("ExternalProcessor"); + } + } + + private static final class ExternalProcessorFileDescriptorSupplier + extends ExternalProcessorBaseDescriptorSupplier { + ExternalProcessorFileDescriptorSupplier() {} + } + + private static final class ExternalProcessorMethodDescriptorSupplier + extends ExternalProcessorBaseDescriptorSupplier + implements io.grpc.protobuf.ProtoMethodDescriptorSupplier { + private final java.lang.String methodName; + + ExternalProcessorMethodDescriptorSupplier(java.lang.String methodName) { + this.methodName = methodName; + } + + @java.lang.Override + public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() { + return getServiceDescriptor().findMethodByName(methodName); + } + } + + private static volatile io.grpc.ServiceDescriptor serviceDescriptor; + + public static io.grpc.ServiceDescriptor getServiceDescriptor() { + io.grpc.ServiceDescriptor result = serviceDescriptor; + if (result == null) { + synchronized (ExternalProcessorGrpc.class) { + result = serviceDescriptor; + if (result == null) { + serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME) + .setSchemaDescriptor(new ExternalProcessorFileDescriptorSupplier()) + .addMethod(getProcessMethod()) + .build(); + } + } + } + return result; + } +} diff --git a/xds/src/main/java/io/grpc/xds/ExtAuthzConfigParser.java b/xds/src/main/java/io/grpc/xds/ExtAuthzConfigParser.java deleted file mode 100644 index 853e8a5c03a..00000000000 --- a/xds/src/main/java/io/grpc/xds/ExtAuthzConfigParser.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.grpc.xds; - -import com.google.common.collect.ImmutableList; -import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; -import io.grpc.internal.GrpcUtil; -import io.grpc.xds.client.Bootstrapper.BootstrapInfo; -import io.grpc.xds.client.Bootstrapper.ServerInfo; -import io.grpc.xds.internal.MatcherParser; -import io.grpc.xds.internal.extauthz.ExtAuthzConfig; -import io.grpc.xds.internal.extauthz.ExtAuthzParseException; -import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; -import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; -import io.grpc.xds.internal.headermutations.HeaderMutationRulesParseException; -import io.grpc.xds.internal.headermutations.HeaderMutationRulesParser; - - -/** - * Parser for {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz}. - */ -final class ExtAuthzConfigParser { - - private ExtAuthzConfigParser() {} - - /** - * Parses the {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto to - * create an {@link ExtAuthzConfig} instance. - * - * @param extAuthzProto The ext_authz proto to parse. - * @return An {@link ExtAuthzConfig} instance. - * @throws ExtAuthzParseException if the proto is invalid or contains unsupported features. - */ - public static ExtAuthzConfig parse( - ExtAuthz extAuthzProto, BootstrapInfo bootstrapInfo, ServerInfo serverInfo) - throws ExtAuthzParseException { - if (!extAuthzProto.hasGrpcService()) { - throw new ExtAuthzParseException( - "unsupported ExtAuthz service type: only grpc_service is supported"); - } - GrpcServiceConfig grpcServiceConfig; - try { - grpcServiceConfig = - GrpcServiceConfigParser.parse(extAuthzProto.getGrpcService(), bootstrapInfo, serverInfo); - } catch (GrpcServiceParseException e) { - throw new ExtAuthzParseException("Failed to parse GrpcService config: " + e.getMessage(), e); - } - ExtAuthzConfig.Builder builder = ExtAuthzConfig.builder().grpcService(grpcServiceConfig) - .failureModeAllow(extAuthzProto.getFailureModeAllow()) - .failureModeAllowHeaderAdd(extAuthzProto.getFailureModeAllowHeaderAdd()) - .includePeerCertificate(extAuthzProto.getIncludePeerCertificate()) - .denyAtDisable(extAuthzProto.getDenyAtDisable().getDefaultValue().getValue()); - - if (extAuthzProto.hasFilterEnabled()) { - try { - builder.filterEnabled( - MatcherParser.parseFractionMatcher(extAuthzProto.getFilterEnabled().getDefaultValue())); - } catch (IllegalArgumentException e) { - throw new ExtAuthzParseException(e.getMessage()); - } - } - - if (extAuthzProto.hasStatusOnError()) { - builder.statusOnError( - GrpcUtil.httpStatusToGrpcStatus(extAuthzProto.getStatusOnError().getCodeValue())); - } - - if (extAuthzProto.hasAllowedHeaders()) { - builder.allowedHeaders(extAuthzProto.getAllowedHeaders().getPatternsList().stream() - .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); - } - - if (extAuthzProto.hasDisallowedHeaders()) { - builder.disallowedHeaders(extAuthzProto.getDisallowedHeaders().getPatternsList().stream() - .map(MatcherParser::parseStringMatcher).collect(ImmutableList.toImmutableList())); - } - - if (extAuthzProto.hasDecoderHeaderMutationRules()) { - try { - builder.decoderHeaderMutationRules( - HeaderMutationRulesParser.parse(extAuthzProto.getDecoderHeaderMutationRules())); - } catch (HeaderMutationRulesParseException e) { - throw new ExtAuthzParseException(e.getMessage(), e); - } - } - - return builder.build(); - } -} diff --git a/xds/src/main/java/io/grpc/xds/ExternalProcessorFilter.java b/xds/src/main/java/io/grpc/xds/ExternalProcessorFilter.java new file mode 100644 index 00000000000..ab695f8b76e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/ExternalProcessorFilter.java @@ -0,0 +1,1621 @@ +/* + * Copyright 2024 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import com.google.common.io.ByteStreams; +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import com.google.protobuf.Duration; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import com.google.protobuf.util.Durations; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.config.core.v3.HeaderMap; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExtProcOverrides; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExtProcPerRoute; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.HeaderForwardingRules; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ProcessingMode; +import io.envoyproxy.envoy.service.ext_proc.v3.BodyMutation; +import io.envoyproxy.envoy.service.ext_proc.v3.BodyResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.CommonResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.ExternalProcessorGrpc; +import io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation; +import io.envoyproxy.envoy.service.ext_proc.v3.HttpBody; +import io.envoyproxy.envoy.service.ext_proc.v3.HttpHeaders; +import io.envoyproxy.envoy.service.ext_proc.v3.HttpTrailers; +import io.envoyproxy.envoy.service.ext_proc.v3.ImmediateResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest; +import io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.ProtocolConfiguration; +import io.envoyproxy.envoy.service.ext_proc.v3.StreamedBodyResponse; +import io.grpc.Attributes; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Deadline; +import io.grpc.ForwardingClientCall.SimpleForwardingClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.internal.DelayedClientCall; +import io.grpc.internal.SerializingExecutor; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.xds.internal.MatcherParser; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.grpcservice.CachedChannelManager; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderMutationDisallowedException; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesParseException; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesParser; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; + +/** + * Filter for external processing as per gRFC A93. + */ +public class ExternalProcessorFilter implements Filter { + static final String TYPE_URL = + "type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor"; + + private final CachedChannelManager cachedChannelManager; + + public ExternalProcessorFilter(String name) { + this(name, new CachedChannelManager()); + } + + ExternalProcessorFilter(String name, CachedChannelManager cachedChannelManager) { + this.cachedChannelManager = checkNotNull(cachedChannelManager, "cachedChannelManager"); + } + + @Override + public void close() { + cachedChannelManager.close(); + } + + static final class Provider implements Filter.Provider { + @Override + public String[] typeUrls() { + return new String[]{TYPE_URL}; + } + + @Override + public boolean isClientFilter() { + return true; + } + + @Override + public ExternalProcessorFilter newInstance(String name) { + return new ExternalProcessorFilter(name); + } + + @Override + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); + } + ExternalProcessor externalProcessor; + try { + externalProcessor = ((Any) rawProtoMessage).unpack(ExternalProcessor.class); + } catch (InvalidProtocolBufferException e) { + return ConfigOrError.fromError("Invalid proto: " + e); + } + + return ExternalProcessorFilterConfig.create(externalProcessor, context); + } + + @Override + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); + } + ExtProcPerRoute perRoute; + try { + perRoute = ((Any) rawProtoMessage).unpack(ExtProcPerRoute.class); + } catch (InvalidProtocolBufferException e) { + return ConfigOrError.fromError("Invalid proto: " + e); + } + ExtProcOverrides overrides = perRoute.hasOverrides() + ? perRoute.getOverrides() : ExtProcOverrides.getDefaultInstance(); + return ExternalProcessorFilterOverrideConfig.create(overrides, context); + } + } + + @Nullable + @Override + public ClientInterceptor buildClientInterceptor(FilterConfig filterConfig, + @Nullable FilterConfig overrideConfig, ScheduledExecutorService scheduler) { + ExternalProcessorFilterConfig extProcFilterConfig = + (ExternalProcessorFilterConfig) filterConfig; + if (overrideConfig != null) { + extProcFilterConfig = mergeConfigs(extProcFilterConfig, + (ExternalProcessorFilterOverrideConfig) overrideConfig); + } + return new ExternalProcessorInterceptor(extProcFilterConfig, cachedChannelManager, scheduler); + } + + private static ExternalProcessorFilterConfig mergeConfigs( + ExternalProcessorFilterConfig extProcFilterConfig, + ExternalProcessorFilterOverrideConfig extProcFilterConfigOverride) { + ExternalProcessor parentProto = extProcFilterConfig.getExternalProcessor(); + ExternalProcessor.Builder mergedProtoBuilder = parentProto.toBuilder(); + + if (extProcFilterConfigOverride.hasProcessingMode()) { + mergedProtoBuilder.setProcessingMode(extProcFilterConfigOverride.getProcessingMode()); + } + + if (extProcFilterConfigOverride.hasRequestAttributes()) { + mergedProtoBuilder.clearRequestAttributes() + .addAllRequestAttributes(extProcFilterConfigOverride.getRequestAttributesList()); + } + if (extProcFilterConfigOverride.hasResponseAttributes()) { + mergedProtoBuilder.clearResponseAttributes() + .addAllResponseAttributes(extProcFilterConfigOverride.getResponseAttributesList()); + } + if (extProcFilterConfigOverride.hasGrpcService()) { + mergedProtoBuilder.setGrpcService(extProcFilterConfigOverride.getGrpcService()); + } + + if (extProcFilterConfigOverride.hasFailureModeAllow()) { + mergedProtoBuilder.setFailureModeAllow(extProcFilterConfigOverride.getFailureModeAllow()); + } + + GrpcServiceConfig grpcServiceConfig = + extProcFilterConfigOverride.getGrpcServiceConfig() != null + ? extProcFilterConfigOverride.getGrpcServiceConfig() + : extProcFilterConfig.getGrpcServiceConfig(); + + return new ExternalProcessorFilterConfig( + mergedProtoBuilder.build(), + grpcServiceConfig, + extProcFilterConfig.getMutationRulesConfig(), + extProcFilterConfig.getForwardRulesConfig()); + } + + private static ConfigOrError> parseAndValidate( + ProcessingMode mode, GrpcService grpcService, boolean isParent, FilterContext context) { + if (mode.getRequestBodyMode() != ProcessingMode.BodySendMode.GRPC + && mode.getRequestBodyMode() != ProcessingMode.BodySendMode.NONE) { + return ConfigOrError.fromError("Invalid request_body_mode: " + mode.getRequestBodyMode() + + ". Only GRPC and NONE are supported."); + } + if (mode.getResponseBodyMode() != ProcessingMode.BodySendMode.GRPC + && mode.getResponseBodyMode() != ProcessingMode.BodySendMode.NONE) { + return ConfigOrError.fromError("Invalid response_body_mode: " + mode.getResponseBodyMode() + + ". Only GRPC and NONE are supported."); + } + + try { + GrpcServiceConfig grpcServiceConfig = null; + if (grpcService != null && grpcService.hasGoogleGrpc()) { + grpcServiceConfig = GrpcServiceConfigParser.parse( + grpcService, context.bootstrapInfo(), context.serverInfo()); + } else if (isParent) { + return ConfigOrError.fromError("Error parsing GrpcService config: " + + "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcService); + } + return ConfigOrError.fromConfig(Optional.ofNullable(grpcServiceConfig)); + } catch (GrpcServiceParseException e) { + return ConfigOrError.fromError("Error parsing GrpcService config: " + e.getMessage()); + } + } + + static final class ExternalProcessorFilterConfig implements FilterConfig { + + private final ExternalProcessor externalProcessor; + private final GrpcServiceConfig grpcServiceConfig; + private final Optional mutationRulesConfig; + private final Optional forwardRulesConfig; + + static ConfigOrError create( + ExternalProcessor externalProcessor, FilterContext context) { + ProcessingMode mode = externalProcessor.getProcessingMode(); + GrpcService grpcService = externalProcessor.getGrpcService(); + HeaderMutationRulesConfig mutationRulesConfig = null; + HeaderForwardingRulesConfig forwardRulesConfig = null; + + if (externalProcessor.hasMutationRules()) { + try { + mutationRulesConfig = + HeaderMutationRulesParser.parse(externalProcessor.getMutationRules()); + } catch (HeaderMutationRulesParseException e) { + return ConfigOrError.fromError("Error parsing HeaderMutationRules: " + e.getMessage()); + } + } + + if (externalProcessor.hasForwardRules()) { + forwardRulesConfig = + HeaderForwardingRulesConfig.create(externalProcessor.getForwardRules()); + } + + if (externalProcessor.hasDeferredCloseTimeout()) { + Duration deferredCloseTimeout = externalProcessor.getDeferredCloseTimeout(); + try { + Durations.checkValid(deferredCloseTimeout); + } catch (IllegalArgumentException e) { + return ConfigOrError.fromError("Invalid deferred_close_timeout: " + e.getMessage()); + } + long deferredCloseTimeoutNanos = Durations.toNanos(deferredCloseTimeout); + if (deferredCloseTimeoutNanos <= 0) { + return ConfigOrError.fromError("deferred_close_timeout must be positive"); + } + } + + ConfigOrError> parsed = + parseAndValidate(mode, grpcService, true, context); + if (parsed.errorDetail != null) { + return ConfigOrError.fromError(parsed.errorDetail); + } + + return ConfigOrError.fromConfig(new ExternalProcessorFilterConfig( + externalProcessor, parsed.config.orElse(null), + Optional.ofNullable(mutationRulesConfig), + Optional.ofNullable(forwardRulesConfig))); + } + + ExternalProcessorFilterConfig( + ExternalProcessor externalProcessor, + GrpcServiceConfig grpcServiceConfig, + Optional mutationRulesConfig, + Optional forwardRulesConfig) { + this.externalProcessor = checkNotNull(externalProcessor, "externalProcessor"); + this.grpcServiceConfig = grpcServiceConfig; + this.mutationRulesConfig = mutationRulesConfig; + this.forwardRulesConfig = forwardRulesConfig; + } + + @Override + public String typeUrl() { + return TYPE_URL; + } + + ExternalProcessor getExternalProcessor() { + return externalProcessor; + } + + GrpcServiceConfig getGrpcServiceConfig() { + return grpcServiceConfig; + } + + Optional getMutationRulesConfig() { + return mutationRulesConfig; + } + + Optional getForwardRulesConfig() { + return forwardRulesConfig; + } + + ImmutableList getRequestAttributes() { + return ImmutableList.copyOf(externalProcessor.getRequestAttributesList()); + } + + boolean getDisableImmediateResponse() { + return externalProcessor.getDisableImmediateResponse(); + } + + long getDeferredCloseTimeoutNanos() { + if (externalProcessor.hasDeferredCloseTimeout()) { + return Durations.toNanos(externalProcessor.getDeferredCloseTimeout()); + } + return TimeUnit.SECONDS.toNanos(5); + } + + boolean getObservabilityMode() { + return externalProcessor.getObservabilityMode(); + } + + boolean getFailureModeAllow() { + return externalProcessor.getFailureModeAllow(); + } + } + + static final class ExternalProcessorFilterOverrideConfig implements FilterConfig { + private final ExtProcOverrides extProcOverrides; + private final GrpcServiceConfig grpcServiceConfig; + + static ConfigOrError create( + ExtProcOverrides overrides, FilterContext context) { + ConfigOrError> parsed = + parseAndValidate( + overrides.getProcessingMode(), overrides.getGrpcService(), false, context); + if (parsed.errorDetail != null) { + return ConfigOrError.fromError(parsed.errorDetail); + } + return ConfigOrError.fromConfig( + new ExternalProcessorFilterOverrideConfig(overrides, parsed.config.orElse(null))); + } + + ExternalProcessorFilterOverrideConfig( + ExtProcOverrides extProcOverrides, GrpcServiceConfig grpcServiceConfig) { + this.extProcOverrides = checkNotNull(extProcOverrides, "extProcOverrides"); + this.grpcServiceConfig = grpcServiceConfig; + } + + @Override + public String typeUrl() { + return TYPE_URL; + } + + boolean hasProcessingMode() { + return extProcOverrides.hasProcessingMode(); + } + + ProcessingMode getProcessingMode() { + return extProcOverrides.getProcessingMode(); + } + + boolean hasRequestAttributes() { + return extProcOverrides.getRequestAttributesCount() > 0; + } + + List getRequestAttributesList() { + return extProcOverrides.getRequestAttributesList(); + } + + boolean hasResponseAttributes() { + return extProcOverrides.getResponseAttributesCount() > 0; + } + + List getResponseAttributesList() { + return extProcOverrides.getResponseAttributesList(); + } + + boolean hasGrpcService() { + return extProcOverrides.hasGrpcService(); + } + + GrpcService getGrpcService() { + return extProcOverrides.getGrpcService(); + } + + boolean hasFailureModeAllow() { + return extProcOverrides.hasFailureModeAllow(); + } + + boolean getFailureModeAllow() { + return extProcOverrides.hasFailureModeAllow() + && extProcOverrides.getFailureModeAllow().getValue(); + } + + GrpcServiceConfig getGrpcServiceConfig() { + return grpcServiceConfig; + } + } + + static final class HeaderForwardingRulesConfig { + private final ImmutableList allowedHeaders; + private final ImmutableList disallowedHeaders; + + HeaderForwardingRulesConfig( + ImmutableList allowedHeaders, + ImmutableList disallowedHeaders) { + this.allowedHeaders = checkNotNull(allowedHeaders, "allowedHeaders"); + this.disallowedHeaders = checkNotNull(disallowedHeaders, "disallowedHeaders"); + } + + static HeaderForwardingRulesConfig create(HeaderForwardingRules proto) { + ImmutableList allowedHeaders = ImmutableList.of(); + if (proto.hasAllowedHeaders()) { + allowedHeaders = MatcherParser.parseListStringMatcher(proto.getAllowedHeaders()); + } + ImmutableList disallowedHeaders = ImmutableList.of(); + if (proto.hasDisallowedHeaders()) { + disallowedHeaders = MatcherParser.parseListStringMatcher(proto.getDisallowedHeaders()); + } + return new HeaderForwardingRulesConfig(allowedHeaders, disallowedHeaders); + } + + boolean isAllowed(String headerName) { + String lowerHeaderName = headerName.toLowerCase(Locale.ROOT); + if (!allowedHeaders.isEmpty()) { + boolean matched = false; + for (Matchers.StringMatcher matcher : allowedHeaders) { + if (matcher.matches(lowerHeaderName)) { + matched = true; + break; + } + } + if (!matched) { + return false; + } + } + if (!disallowedHeaders.isEmpty()) { + for (Matchers.StringMatcher matcher : disallowedHeaders) { + if (matcher.matches(lowerHeaderName)) { + return false; + } + } + } + return true; + } + } + + static final class ExternalProcessorInterceptor implements ClientInterceptor { + private final CachedChannelManager cachedChannelManager; + private final ExternalProcessorFilterConfig filterConfig; + private final ScheduledExecutorService scheduler; + + private static final MethodDescriptor.Marshaller RAW_MARSHALLER = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(InputStream value) { + return value; + } + + @Override + public InputStream parse(InputStream stream) { + return stream; + } + }; + + ExternalProcessorInterceptor(ExternalProcessorFilterConfig filterConfig, + CachedChannelManager cachedChannelManager, + ScheduledExecutorService scheduler) { + this.filterConfig = filterConfig; + this.cachedChannelManager = checkNotNull(cachedChannelManager, "cachedChannelManager"); + this.scheduler = checkNotNull(scheduler, "scheduler"); + } + + @VisibleForTesting + ExternalProcessorFilterConfig getFilterConfig() { + return filterConfig; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions callOptions, + Channel next) { + SerializingExecutor serializingExecutor = new SerializingExecutor(callOptions.getExecutor()); + + ExternalProcessorGrpc.ExternalProcessorStub extProcStub = ExternalProcessorGrpc.newStub( + cachedChannelManager.getChannel(filterConfig.grpcServiceConfig)) + .withExecutor(serializingExecutor); + + if (filterConfig.grpcServiceConfig.timeout() != null + && filterConfig.grpcServiceConfig.timeout().isPresent()) { + long timeoutNanos = filterConfig.grpcServiceConfig.timeout().get().toNanos(); + if (timeoutNanos > 0) { + extProcStub = extProcStub.withDeadlineAfter(timeoutNanos, TimeUnit.NANOSECONDS); + } + } + + ImmutableList initialMetadata = filterConfig.grpcServiceConfig.initialMetadata(); + extProcStub = extProcStub.withInterceptors(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor extMethod, + CallOptions extCallOptions, + Channel extNext) { + return new SimpleForwardingClientCall( + extNext.newCall(extMethod, extCallOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + for (HeaderValue headerValue : initialMetadata) { + String key = headerValue.key(); + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + if (headerValue.rawValue().isPresent()) { + Metadata.Key metadataKey = + Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER); + headers.put(metadataKey, headerValue.rawValue().get().toByteArray()); + } + } else { + if (headerValue.value().isPresent()) { + Metadata.Key metadataKey = + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + headers.put(metadataKey, headerValue.value().get()); + } + } + } + super.start(responseListener, headers); + } + }; + } + }); + + MethodDescriptor rawMethod = + method.toBuilder(RAW_MARSHALLER, RAW_MARSHALLER).build(); + ClientCall rawCall = next.newCall(rawMethod, callOptions); + + // Create a local subclass instance to buffer outbound actions + DataPlaneDelayedCall delayedCall = + new DataPlaneDelayedCall<>( + serializingExecutor, scheduler, callOptions.getDeadline()); + + DataPlaneClientCall dataPlaneCall = new DataPlaneClientCall( + delayedCall, rawCall, extProcStub, filterConfig, filterConfig.getMutationRulesConfig(), + scheduler, method, next); + + return new ClientCall() { + @Override + public void start(Listener responseListener, Metadata headers) { + dataPlaneCall.start(new Listener() { + @Override + public void onHeaders(Metadata headers) { + responseListener.onHeaders(headers); + } + + @Override + public void onMessage(InputStream message) { + responseListener.onMessage(method.getResponseMarshaller().parse(message)); + } + + @Override + public void onClose(Status status, Metadata trailers) { + responseListener.onClose(status, trailers); + } + + @Override + public void onReady() { + responseListener.onReady(); + } + }, headers); + } + + @Override + public void request(int numMessages) { + dataPlaneCall.request(numMessages); + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + dataPlaneCall.cancel(message, cause); + } + + @Override + public void halfClose() { + dataPlaneCall.halfClose(); + } + + @Override + public void sendMessage(ReqT message) { + dataPlaneCall.sendMessage(method.getRequestMarshaller().stream(message)); + } + + @Override + public boolean isReady() { + return dataPlaneCall.isReady(); + } + + @Override + public void setMessageCompression(boolean enabled) { + dataPlaneCall.setMessageCompression(enabled); + } + + @Override + public Attributes getAttributes() { + return dataPlaneCall.getAttributes(); + } + }; + } + + // --- SHARED UTILITY METHODS --- + private static HeaderMap toHeaderMap( + Metadata metadata, Optional forwardRules) { + HeaderMap.Builder builder = + HeaderMap.newBuilder(); + + for (String key : metadata.keys()) { + if (forwardRules.isPresent() && !forwardRules.get().isAllowed(key)) { + continue; + } + // Skip binary headers for this basic mapping + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Metadata.Key binKey = Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER); + Iterable values = metadata.getAll(binKey); + if (values != null) { + for (byte[] binValue : values) { + String encoded = BaseEncoding.base64().encode(binValue); + io.envoyproxy.envoy.config.core.v3.HeaderValue headerValue = + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey(key.toLowerCase(Locale.ROOT)) + .setValue(encoded) + .build(); + builder.addHeaders(headerValue); + } + } + } else { + Metadata.Key asciiKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + Iterable values = metadata.getAll(asciiKey); + if (values != null) { + for (String value : values) { + io.envoyproxy.envoy.config.core.v3.HeaderValue headerValue = + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey(key.toLowerCase(Locale.ROOT)) + .setValue(value) + .build(); + builder.addHeaders(headerValue); + } + } + } + } + return builder.build(); + } + + private static ImmutableMap collectAttributes( + ImmutableList requestedAttributes, + MethodDescriptor method, + Channel channel, + Metadata headers) { + if (requestedAttributes.isEmpty()) { + return ImmutableMap.of(); + } + ImmutableMap.Builder attributes = ImmutableMap.builder(); + for (String attr : requestedAttributes) { + switch (attr) { + case "request.path": + case "request.url_path": + attributes.put(attr, encodeAttribute("/" + method.getFullMethodName())); + break; + case "request.host": + attributes.put(attr, encodeAttribute(channel.authority())); + break; + case "request.method": + attributes.put(attr, encodeAttribute("POST")); + break; + case "request.headers": + attributes.put(attr, encodeHeaders(headers)); + break; + case "request.referer": + String referer = getHeaderValue(headers, "referer"); + if (referer != null) { + attributes.put(attr, encodeAttribute(referer)); + } + break; + case "request.useragent": + String ua = getHeaderValue(headers, "user-agent"); + if (ua != null) { + attributes.put(attr, encodeAttribute(ua)); + } + break; + case "request.id": + String id = getHeaderValue(headers, "x-request-id"); + if (id != null) { + attributes.put(attr, encodeAttribute(id)); + } + break; + case "request.query": + attributes.put(attr, encodeAttribute("")); + break; + default: + // "Not set" attributes or unrecognized ones (already validated) are skipped. + break; + } + } + return attributes.buildOrThrow(); + } + + private static Struct encodeAttribute(String value) { + return Struct.newBuilder() + .putFields("", Value.newBuilder().setStringValue(value).build()) + .build(); + } + + private static Struct encodeHeaders(Metadata headers) { + Struct.Builder builder = Struct.newBuilder(); + for (String key : headers.keys()) { + String value = getHeaderValue(headers, key); + if (value != null) { + builder.putFields(key.toLowerCase(Locale.ROOT), + Value.newBuilder().setStringValue(value).build()); + } + } + return builder.build(); + } + + @Nullable + private static String getHeaderValue(Metadata headers, String headerName) { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Metadata.Key key; + try { + key = Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER); + } catch (IllegalArgumentException e) { + return null; + } + Iterable values = headers.getAll(key); + if (values == null) { + return null; + } + java.util.List encoded = new ArrayList<>(); + for (byte[] v : values) { + encoded.add(BaseEncoding.base64().omitPadding().encode(v)); + } + return com.google.common.base.Joiner.on(",").join(encoded); + } + Metadata.Key key; + try { + key = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + } catch (IllegalArgumentException e) { + return null; + } + Iterable values = headers.getAll(key); + return values == null ? null : com.google.common.base.Joiner.on(",").join(values); + } + + /** + * A local subclass to expose the protected constructor of DelayedClientCall. + */ + private static class DataPlaneDelayedCall extends DelayedClientCall { + DataPlaneDelayedCall( + Executor executor, ScheduledExecutorService scheduler, @Nullable Deadline deadline) { + super(executor, scheduler, deadline); + } + } + + /** + * Handles the bidirectional stream with the External Processor. + * Buffers the actual RPC start until the Ext Proc header response is received. + */ + private static class DataPlaneClientCall + extends SimpleForwardingClientCall { + private enum EventType { + REQUEST_HEADERS, + REQUEST_BODY, + RESPONSE_HEADERS, + RESPONSE_BODY, + RESPONSE_TRAILERS + } + + private final ExternalProcessorGrpc.ExternalProcessorStub stub; + private final ExternalProcessorFilterConfig config; + private final ClientCall rawCall; + private final DataPlaneDelayedCall delayedCall; + private final ScheduledExecutorService scheduler; + private final Object streamLock = new Object(); + private final Queue expectedResponses = new ConcurrentLinkedQueue<>(); + private volatile ClientCallStreamObserver extProcClientCallRequestObserver; + private final Queue pendingProcessingRequests = + new ConcurrentLinkedQueue<>(); + private volatile DataPlaneListener wrappedListener; + private final HeaderMutationFilter mutationFilter; + private final HeaderMutator mutator = HeaderMutator.create(); + private final AtomicInteger pendingRequests = new AtomicInteger(0); + private final ProcessingMode currentProcessingMode; + private final MethodDescriptor method; + private final Channel channel; + + private volatile Metadata requestHeaders; + final AtomicBoolean activated = new AtomicBoolean(false); + final AtomicBoolean extProcStreamFailed = new AtomicBoolean(false); + final AtomicBoolean extProcStreamCompleted = new AtomicBoolean(false); + final AtomicBoolean passThroughMode = new AtomicBoolean(false); + final AtomicBoolean notifiedApp = new AtomicBoolean(false); + final AtomicBoolean drainingExtProcStream = new AtomicBoolean(false); + final AtomicBoolean halfClosed = new AtomicBoolean(false); + final AtomicBoolean requestSideClosed = new AtomicBoolean(false); + final AtomicBoolean isProcessingTrailers = new AtomicBoolean(false); + + protected DataPlaneClientCall( + DataPlaneDelayedCall delayedCall, + ClientCall rawCall, + ExternalProcessorGrpc.ExternalProcessorStub stub, + ExternalProcessorFilterConfig config, + Optional mutationRulesConfig, + ScheduledExecutorService scheduler, + MethodDescriptor method, + Channel channel) { + super(delayedCall); + this.delayedCall = delayedCall; + this.rawCall = rawCall; + this.stub = stub; + this.config = config; + this.currentProcessingMode = config.getExternalProcessor().getProcessingMode(); + this.mutationFilter = new HeaderMutationFilter(mutationRulesConfig); + this.scheduler = scheduler; + this.method = method; + this.channel = channel; + } + + private void activateCall() { + if (extProcStreamFailed.get() || !activated.compareAndSet(false, true)) { + return; + } + Runnable toRun = delayedCall.setCall(rawCall); + if (toRun != null) { + toRun.run(); + } + drainPendingRequests(); + onReadyNotify(); + } + + private boolean checkCompressionSupport(BodyResponse bodyResponse) { + if (bodyResponse.hasResponse() && bodyResponse.getResponse().hasBodyMutation()) { + BodyMutation mutation = + bodyResponse.getResponse().getBodyMutation(); + if (mutation.hasStreamedResponse() + && mutation.getStreamedResponse().getGrpcMessageCompressed()) { + StatusRuntimeException ex = Status.UNAVAILABLE + .withDescription("gRPC message compression not supported in ext_proc") + .asRuntimeException(); + if (!extProcStreamCompleted.get() && extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onError(ex); + } + activateCall(); + extProcStreamFailed.set(true); + delayedCall.cancel("gRPC message compression not supported in ext_proc", ex); + closeExtProcStream(); + return false; + } + } + return true; + } + + private void applyHeaderMutations(Metadata metadata, + HeaderMutation mutation) + throws HeaderMutationDisallowedException { + if (metadata == null) { + return; + } + ImmutableList.Builder headersToModify = ImmutableList.builder(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValueOption protoOption + : mutation.getSetHeadersList()) { + io.envoyproxy.envoy.config.core.v3.HeaderValue protoHeader = protoOption.getHeader(); + HeaderValue headerValue; + if (protoHeader.getKey().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + headerValue = HeaderValue.create(protoHeader.getKey(), + ByteString.copyFrom( + BaseEncoding.base64().decode(protoHeader.getValue()))); + } else { + headerValue = HeaderValue.create(protoHeader.getKey(), protoHeader.getValue()); + } + headersToModify.add(HeaderValueOption.create( + headerValue, + HeaderValueOption.HeaderAppendAction.valueOf(protoOption.getAppendAction().name()), + protoOption.getKeepEmptyValue())); + } + + HeaderMutations mutations = HeaderMutations.create( + headersToModify.build(), + ImmutableList.copyOf(mutation.getRemoveHeadersList())); + + HeaderMutations filteredMutations = mutationFilter.filter(mutations); + mutator.applyMutations(filteredMutations, metadata); + } + + @Override + public void start(Listener responseListener, Metadata headers) { + this.requestHeaders = headers; + this.wrappedListener = new DataPlaneListener(responseListener, rawCall, this); + + // DelayedClientCall.start will buffer the listener and headers until setCall is called. + super.start(wrappedListener, headers); + + stub.process(new ClientResponseObserver() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + synchronized (streamLock) { + extProcClientCallRequestObserver = requestStream; + while (!pendingProcessingRequests.isEmpty()) { + requestStream.onNext(pendingProcessingRequests.poll()); + } + } + requestStream.setOnReadyHandler(DataPlaneClientCall.this::onExtProcStreamReady); + } + + @Override + public void onNext(ProcessingResponse response) { + try { + if (response.hasImmediateResponse()) { + if (config.getDisableImmediateResponse()) { + onError(Status.UNAVAILABLE + .withDescription( + "Immediate response is disabled but received from external processor") + .asRuntimeException()); + return; + } + handleImmediateResponse(response.getImmediateResponse(), wrappedListener); + return; + } + + if (config.getObservabilityMode()) { + return; + } + + EventType expected = expectedResponses.peek(); + EventType received = null; + if (response.hasRequestHeaders()) { + received = EventType.REQUEST_HEADERS; + } else if (response.hasRequestBody()) { + received = EventType.REQUEST_BODY; + } else if (response.hasResponseHeaders()) { + received = EventType.RESPONSE_HEADERS; + } else if (response.hasResponseBody()) { + received = EventType.RESPONSE_BODY; + } else if (response.hasResponseTrailers()) { + received = EventType.RESPONSE_TRAILERS; + } + + if (received != null) { + if (expected == null || expected != received) { + onError(Status.UNAVAILABLE + .withDescription("Protocol error: received response out of order. Expected: " + + expected + ", Received: " + received) + .asRuntimeException()); + return; + } + expectedResponses.poll(); + } + + if (response.getRequestDrain()) { + drainingExtProcStream.set(true); + halfCloseExtProcStream(); + activateCall(); + } + + // 1. Client Headers + if (response.hasRequestHeaders()) { + if (response.getRequestHeaders().hasResponse()) { + if (response.getRequestHeaders().getResponse().getStatus() + == CommonResponse.ResponseStatus.CONTINUE_AND_REPLACE) { + onError(Status.UNAVAILABLE + .withDescription("CONTINUE_AND_REPLACE is not supported") + .asRuntimeException()); + return; + } + applyHeaderMutations( + requestHeaders, + response.getRequestHeaders().getResponse().getHeaderMutation()); + } + activateCall(); + } + // 2. Client Message (Request Body) + else if (response.hasRequestBody()) { + if (checkCompressionSupport(response.getRequestBody())) { + handleRequestBodyResponse(response.getRequestBody()); + } + } + // 3. Client Trailers + else if (response.hasRequestTrailers()) { + wrappedListener.proceedWithClose(); + } + // 4. Server Headers + else if (response.hasResponseHeaders()) { + if (response.getResponseHeaders().hasResponse()) { + if (response.getResponseHeaders().getResponse().getStatus() + == CommonResponse.ResponseStatus.CONTINUE_AND_REPLACE) { + onError(Status.UNAVAILABLE + .withDescription("CONTINUE_AND_REPLACE is not supported") + .asRuntimeException()); + return; + } + Metadata target = wrappedListener.trailersOnly.get() + ? wrappedListener.savedTrailers : wrappedListener.savedHeaders; + applyHeaderMutations( + target, response.getResponseHeaders().getResponse().getHeaderMutation()); + } + if (wrappedListener.trailersOnly.get()) { + wrappedListener.proceedWithClose(); + } else { + wrappedListener.proceedWithHeaders(); + } + } + // 5. Server Message (Response Body) + else if (response.hasResponseBody()) { + if (checkCompressionSupport(response.getResponseBody())) { + handleResponseBodyResponse(response.getResponseBody(), wrappedListener); + } + } + // 6. Response Trailers + else if (response.hasResponseTrailers()) { + if (response.getResponseTrailers().hasHeaderMutation()) { + applyHeaderMutations( + wrappedListener.savedTrailers, + response.getResponseTrailers().getHeaderMutation() + ); + } + } + + checkEndOfStream(response); + } catch (Throwable t) { + onError(t); + } + } + + @Override + public void onError(Throwable t) { + if (extProcStreamCompleted.compareAndSet(false, true)) { + synchronized (streamLock) { + if (extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onError(t); + extProcClientCallRequestObserver = null; + } + } + if (config.getFailureModeAllow()) { + handleFailOpen(wrappedListener); + } else { + extProcStreamFailed.set(true); + String message = "External processor stream failed"; + delayedCall.cancel(message, t); + wrappedListener.proceedWithClose(); + } + } + } + + @Override + public void onCompleted() { + if (extProcStreamCompleted.compareAndSet(false, true)) { + drainingExtProcStream.set(false); + handleFailOpen(wrappedListener); + } + } + }); + + boolean sendRequestHeaders = + currentProcessingMode.getRequestHeaderMode() == ProcessingMode.HeaderSendMode.SEND + || currentProcessingMode.getRequestHeaderMode() + == ProcessingMode.HeaderSendMode.DEFAULT; + + if (sendRequestHeaders) { + sendToExtProc(ProcessingRequest.newBuilder() + .setRequestHeaders(HttpHeaders.newBuilder() + .setHeaders(toHeaderMap(headers, config.getForwardRulesConfig())) + .setEndOfStream(false) + .build()) + .putAllAttributes( + collectAttributes(config.getRequestAttributes(), method, channel, headers)) + .setProtocolConfig(ProtocolConfiguration.newBuilder() + .setRequestBodyMode(currentProcessingMode.getRequestBodyMode()) + .setResponseBodyMode(currentProcessingMode.getResponseBodyMode()) + .build()) + .build()); + } + + if (config.getObservabilityMode() || !sendRequestHeaders) { + activateCall(); + } + } + + private void sendToExtProc(ProcessingRequest request) { + synchronized (streamLock) { + if (extProcStreamCompleted.get()) { + return; + } + + if (request.hasRequestHeaders()) { + expectedResponses.add(EventType.REQUEST_HEADERS); + } else if (request.hasRequestBody()) { + expectedResponses.add(EventType.REQUEST_BODY); + } else if (request.hasResponseHeaders()) { + expectedResponses.add(EventType.RESPONSE_HEADERS); + } else if (request.hasResponseBody()) { + expectedResponses.add(EventType.RESPONSE_BODY); + } else if (request.hasResponseTrailers()) { + expectedResponses.add(EventType.RESPONSE_TRAILERS); + } + + if (extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onNext(request); + } else { + pendingProcessingRequests.add(request); + } + } + } + + private void onExtProcStreamReady() { + drainPendingRequests(); + onReadyNotify(); + } + + private void drainPendingRequests() { + int toRequest = pendingRequests.getAndSet(0); + if (toRequest > 0) { + super.request(toRequest); + } + } + + private void closeExtProcStream() { + synchronized (streamLock) { + if (extProcStreamCompleted.compareAndSet(false, true)) { + if (extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onCompleted(); + } + } + } + } + + private void halfCloseExtProcStream() { + synchronized (streamLock) { + if (!extProcStreamCompleted.get() && extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onCompleted(); + } + } + } + + private void onReadyNotify() { + wrappedListener.onReadyNotify(); + } + + private boolean isSidecarReady() { + if (extProcStreamCompleted.get()) { + return true; + } + if (drainingExtProcStream.get()) { + return false; + } + synchronized (streamLock) { + ClientCallStreamObserver observer = extProcClientCallRequestObserver; + return observer != null && observer.isReady(); + } + } + + @Override + public boolean isReady() { + if (passThroughMode.get()) { + return super.isReady(); + } + if (extProcStreamCompleted.get()) { + return super.isReady(); + } + if (!activated.get() && !config.getObservabilityMode()) { + return false; + } + boolean sidecarReady = isSidecarReady(); + if (config.getObservabilityMode()) { + return super.isReady() && sidecarReady; + } + return sidecarReady; + } + + @Override + public void request(int numMessages) { + if (passThroughMode.get() || extProcStreamCompleted.get()) { + super.request(numMessages); + return; + } + if (!isSidecarReady()) { + pendingRequests.addAndGet(numMessages); + return; + } + super.request(numMessages); + } + + @Override + public void sendMessage(InputStream message) { + if (requestSideClosed.get()) { + // External processor already closed the request stream. Discard further messages. + return; + } + + if (passThroughMode.get() || extProcStreamCompleted.get()) { + super.sendMessage(message); + return; + } + + if (currentProcessingMode.getRequestBodyMode() == ProcessingMode.BodySendMode.NONE) { + super.sendMessage(message); + return; + } + + // Mode is GRPC + try { + byte[] bodyBytes = ByteStreams.toByteArray(message); + sendToExtProc(ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setBody(ByteString.copyFrom(bodyBytes)) + .setEndOfStream(false) + .build()) + .build()); + + if (config.getObservabilityMode()) { + super.sendMessage(new ByteArrayInputStream(bodyBytes)); + } + } catch (IOException e) { + rawCall.cancel("Failed to serialize message for External Processor", e); + } + } + + @Override + public void halfClose() { + halfClosed.set(true); + if (passThroughMode.get() || extProcStreamCompleted.get()) { + if (requestSideClosed.compareAndSet(false, true)) { + super.halfClose(); + } + return; + } + + if (currentProcessingMode.getRequestBodyMode() == ProcessingMode.BodySendMode.NONE) { + if (requestSideClosed.compareAndSet(false, true)) { + super.halfClose(); + } + return; + } + + // Mode is GRPC + sendToExtProc(ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setEndOfStreamWithoutMessage(true) + .build()) + .build()); + + // Defer super.halfClose() until ext-proc response signals end_of_stream. + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + synchronized (streamLock) { + if (!extProcStreamCompleted.get() && extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onError( + Status.CANCELLED + .withDescription(message) + .withCause(cause) + .asRuntimeException()); + } + } + super.cancel(message, cause); + } + + private void handleRequestBodyResponse(BodyResponse bodyResponse) { + if (bodyResponse.hasResponse() && bodyResponse.getResponse().hasBodyMutation()) { + BodyMutation mutation = bodyResponse.getResponse().getBodyMutation(); + if (mutation.hasStreamedResponse()) { + StreamedBodyResponse streamed = mutation.getStreamedResponse(); + if (!streamed.getBody().isEmpty()) { + super.sendMessage(streamed.getBody().newInput()); + } + } + } + // If the application already half-closed, and we just received a response from + // the sidecar for the last part of the request body, we can now half-close the data plane. + if (halfClosed.get()) { + if (requestSideClosed.compareAndSet(false, true)) { + super.halfClose(); + } + } + } + + private void handleResponseBodyResponse( + BodyResponse bodyResponse, DataPlaneListener listener) { + if (bodyResponse.hasResponse() && bodyResponse.getResponse().hasBodyMutation()) { + BodyMutation mutation = bodyResponse.getResponse().getBodyMutation(); + if (mutation.hasStreamedResponse()) { + StreamedBodyResponse streamed = mutation.getStreamedResponse(); + if (!streamed.getBody().isEmpty()) { + listener.onExternalBody(streamed.getBody()); + } + if (streamed.getEndOfStream() || streamed.getEndOfStreamWithoutMessage()) { + listener.proceedWithClose(); + } + } + } + } + + private void handleImmediateResponse(ImmediateResponse immediate, DataPlaneListener listener) + throws HeaderMutationDisallowedException { + Status status = Status.fromCodeValue(immediate.getGrpcStatus().getStatus()); + if (!immediate.getDetails().isEmpty()) { + status = status.withDescription(immediate.getDetails()); + } + + Metadata trailers = new Metadata(); + if (immediate.hasHeaders()) { + applyHeaderMutations(trailers, immediate.getHeaders()); + } + + // ImmediateResponse should take precedence over any other closure + // if it arrives before the app is notified. + listener.savedStatus = status; + listener.savedTrailers = trailers; + + if (isProcessingTrailers.get()) { + // If sent in response to a server trailers event, sets the status and optionally + // headers to be included in the trailers. + listener.unblockAfterStreamComplete(); + } else { + // If sent in response to any other event, it will cause the data plane RPC to + // immediately fail with the specified status as if it were an out-of-band + // cancellation. + rawCall.cancel(status.getDescription(), null); + listener.unblockAfterStreamComplete(); + } + closeExtProcStream(); + } + + private void handleFailOpen(DataPlaneListener listener) { + activateCall(); + listener.unblockAfterStreamComplete(); + closeExtProcStream(); + } + + private void checkEndOfStream(ProcessingResponse response) { + boolean terminal = false; + if (response.hasResponseTrailers()) { + terminal = true; + } else if (response.hasResponseHeaders() && wrappedListener.trailersOnly.get()) { + terminal = true; + } + + if (terminal) { + wrappedListener.unblockAfterStreamComplete(); + closeExtProcStream(); + } + } + } + + private static class DataPlaneListener extends ClientCall.Listener { + private final ClientCall.Listener delegate; + private final ClientCall rawCall; + private final DataPlaneClientCall dataPlaneClientCall; + private final Queue savedMessages = new ConcurrentLinkedQueue<>(); + private volatile Metadata savedHeaders; + private volatile Metadata savedTrailers; + private volatile Status savedStatus; + private final AtomicBoolean terminationTriggered = new AtomicBoolean(false); + private final AtomicBoolean responseHeadersSent = new AtomicBoolean(false); + private final AtomicBoolean trailersOnly = new AtomicBoolean(false); + + protected DataPlaneListener( + ClientCall.Listener delegate, + ClientCall rawCall, + DataPlaneClientCall dataPlaneClientCall) { + this.delegate = checkNotNull(delegate, "delegate"); + this.rawCall = rawCall; + this.dataPlaneClientCall = dataPlaneClientCall; + } + + @Override + public void onReady() { + dataPlaneClientCall.drainPendingRequests(); + onReadyNotify(); + } + + void onReadyNotify() { + delegate.onReady(); + } + + @Override + public void onHeaders(Metadata headers) { + responseHeadersSent.set(true); + boolean sendResponseHeaders = + dataPlaneClientCall.currentProcessingMode.getResponseHeaderMode() + == ProcessingMode.HeaderSendMode.SEND + || dataPlaneClientCall.currentProcessingMode.getResponseHeaderMode() + == ProcessingMode.HeaderSendMode.DEFAULT; + + if (dataPlaneClientCall.passThroughMode.get() + || dataPlaneClientCall.extProcStreamCompleted.get() + || !sendResponseHeaders) { + delegate.onHeaders(headers); + return; + } + + this.savedHeaders = headers; + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseHeaders(HttpHeaders.newBuilder() + .setHeaders( + toHeaderMap(headers, dataPlaneClientCall.config.getForwardRulesConfig())) + .build()) + .build()); + + if (dataPlaneClientCall.config.getObservabilityMode()) { + proceedWithHeaders(); + } + } + + void proceedWithHeaders() { + if (savedHeaders != null) { + delegate.onHeaders(savedHeaders); + savedHeaders = null; + InputStream msg; + while ((msg = savedMessages.poll()) != null) { + onMessage(msg); + } + onReadyNotify(); + if (savedStatus != null) { + triggerCloseHandshake(); + } + } + } + + @Override + public void onMessage(InputStream message) { + if (dataPlaneClientCall.passThroughMode.get()) { + delegate.onMessage(message); + return; + } + + if (savedHeaders != null) { + savedMessages.add(message); + return; + } + + if (dataPlaneClientCall.extProcStreamCompleted.get() + || dataPlaneClientCall.currentProcessingMode.getResponseBodyMode() + != ProcessingMode.BodySendMode.GRPC) { + delegate.onMessage(message); + return; + } + + try { + byte[] bodyBytes = ByteStreams.toByteArray(message); + sendResponseBodyToExtProc(bodyBytes, false); + + if (dataPlaneClientCall.config.getObservabilityMode()) { + delegate.onMessage(new ByteArrayInputStream(bodyBytes)); + } + } catch (IOException e) { + rawCall.cancel("Failed to read server response", e); + } + } + + @Override + public void onClose(Status status, Metadata trailers) { + if (dataPlaneClientCall.extProcStreamFailed.get()) { + if (dataPlaneClientCall.notifiedApp.compareAndSet(false, true)) { + delegate.onClose(Status.UNAVAILABLE.withDescription("External processor stream failed") + .withCause(status.getCause()), new Metadata()); + } + return; + } + if (dataPlaneClientCall.passThroughMode.get()) { + if (dataPlaneClientCall.notifiedApp.compareAndSet(false, true)) { + delegate.onClose(status, trailers); + } + return; + } + + this.savedStatus = status; + this.savedTrailers = trailers; + + if (dataPlaneClientCall.extProcStreamCompleted.get()) { + proceedWithClose(); + return; + } + + if (savedHeaders != null) { + return; + } + + if (!responseHeadersSent.get()) { + trailersOnly.set(true); + } + + triggerCloseHandshake(); + + if (dataPlaneClientCall.config.getObservabilityMode()) { + proceedWithClose(); + @SuppressWarnings("unused") + ScheduledFuture unused = dataPlaneClientCall.scheduler.schedule( + dataPlaneClientCall::closeExtProcStream, + dataPlaneClientCall.config.getDeferredCloseTimeoutNanos(), + TimeUnit.NANOSECONDS); + } + } + + private void triggerCloseHandshake() { + if (dataPlaneClientCall.extProcStreamCompleted.get() + || !terminationTriggered.compareAndSet(false, true)) { + return; + } + + if (trailersOnly.get()) { + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseHeaders(HttpHeaders.newBuilder() + .setHeaders( + toHeaderMap( + savedTrailers, + dataPlaneClientCall.config.getForwardRulesConfig())) + .setEndOfStream(true) + .build()) + .build()); + return; + } + + boolean sendResponseTrailers = + dataPlaneClientCall.currentProcessingMode.getResponseTrailerMode() + == ProcessingMode.HeaderSendMode.SEND; + + if (sendResponseTrailers) { + dataPlaneClientCall.isProcessingTrailers.set(true); + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseTrailers(HttpTrailers.newBuilder() + .setTrailers( + toHeaderMap( + savedTrailers, + dataPlaneClientCall.config.getForwardRulesConfig())) + .build()) + .build()); + } else { + // Send EOS signal via empty body + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseBody(HttpBody.newBuilder() + .setEndOfStreamWithoutMessage(true) + .build()) + .build()); + + if (dataPlaneClientCall.config.getObservabilityMode()) { + // In observability mode we don't wait for handshake response + proceedWithClose(); + } + } + } + + private void sendResponseBodyToExtProc(@Nullable byte[] bodyBytes, boolean endOfStream) { + if (dataPlaneClientCall.extProcStreamCompleted.get() + || dataPlaneClientCall.currentProcessingMode.getResponseBodyMode() + != ProcessingMode.BodySendMode.GRPC) { + return; + } + + HttpBody.Builder bodyBuilder = + HttpBody.newBuilder(); + if (bodyBytes != null) { + bodyBuilder.setBody(ByteString.copyFrom(bodyBytes)); + } + bodyBuilder.setEndOfStream(endOfStream); + + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseBody(bodyBuilder.build()) + .build()); + } + + void proceedWithClose() { + if (savedStatus != null) { + if (dataPlaneClientCall.notifiedApp.compareAndSet(false, true)) { + delegate.onClose(savedStatus, savedTrailers); + } + savedStatus = null; + savedTrailers = null; + } + } + + void onExternalBody(ByteString body) { + delegate.onMessage(body.newInput()); + } + + void unblockAfterStreamComplete() { + proceedWithHeaders(); + dataPlaneClientCall.passThroughMode.set(true); + proceedWithClose(); + } + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/FaultFilter.java b/xds/src/main/java/io/grpc/xds/FaultFilter.java index 0f3bb5b0557..e0533889d74 100644 --- a/xds/src/main/java/io/grpc/xds/FaultFilter.java +++ b/xds/src/main/java/io/grpc/xds/FaultFilter.java @@ -104,7 +104,8 @@ public FaultFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { HTTPFault httpFaultProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -119,8 +120,9 @@ public ConfigOrError parseFilterConfig(Message rawProtoMessage) { } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { - return parseFilterConfig(rawProtoMessage); + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { + return parseFilterConfig(rawProtoMessage, context); } private static ConfigOrError parseHttpFault(HTTPFault httpFault) { diff --git a/xds/src/main/java/io/grpc/xds/Filter.java b/xds/src/main/java/io/grpc/xds/Filter.java index 416d929becf..d70b3063a50 100644 --- a/xds/src/main/java/io/grpc/xds/Filter.java +++ b/xds/src/main/java/io/grpc/xds/Filter.java @@ -16,10 +16,14 @@ package io.grpc.xds; + +import com.google.auto.value.AutoValue; import com.google.common.base.MoreObjects; import com.google.protobuf.Message; import io.grpc.ClientInterceptor; import io.grpc.ServerInterceptor; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; import java.io.Closeable; import java.util.Objects; import java.util.concurrent.ScheduledExecutorService; @@ -93,13 +97,15 @@ default boolean isServerFilter() { * Parses the top-level filter config from raw proto message. The message may be either a {@link * com.google.protobuf.Any} or a {@link com.google.protobuf.Struct}. */ - ConfigOrError parseFilterConfig(Message rawProtoMessage); + ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context); /** * Parses the per-filter override filter config from raw proto message. The message may be * either a {@link com.google.protobuf.Any} or a {@link com.google.protobuf.Struct}. */ - ConfigOrError parseFilterConfigOverride(Message rawProtoMessage); + ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context); } /** Uses the FilterConfigs produced above to produce an HTTP filter interceptor for clients. */ @@ -125,6 +131,27 @@ default ServerInterceptor buildServerInterceptor( @Override default void close() {} + /** Context carrying dynamic metadata for a filter. */ + @AutoValue + abstract static class FilterContext { + abstract BootstrapInfo bootstrapInfo(); + + abstract ServerInfo serverInfo(); + + static Builder builder() { + return new AutoValue_Filter_FilterContext.Builder(); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder bootstrapInfo(BootstrapInfo info); + + abstract Builder serverInfo(ServerInfo info); + + abstract FilterContext build(); + } + } + /** Filter config with instance name. */ final class NamedFilterConfig { // filter instance name diff --git a/xds/src/main/java/io/grpc/xds/FilterRegistry.java b/xds/src/main/java/io/grpc/xds/FilterRegistry.java index da3a59fe8c1..29241fc1da8 100644 --- a/xds/src/main/java/io/grpc/xds/FilterRegistry.java +++ b/xds/src/main/java/io/grpc/xds/FilterRegistry.java @@ -17,6 +17,7 @@ package io.grpc.xds; import com.google.common.annotations.VisibleForTesting; +import io.grpc.internal.GrpcUtil; import java.util.HashMap; import java.util.Map; import javax.annotation.Nullable; @@ -39,6 +40,9 @@ static synchronized FilterRegistry getDefaultRegistry() { new RouterFilter.Provider(), new RbacFilter.Provider(), new GcpAuthenticationFilter.Provider()); + if (GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false)) { + instance.register(new ExternalProcessorFilter.Provider()); + } } return instance; } diff --git a/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java b/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java index 8ec02f4f809..78d20edec46 100644 --- a/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java +++ b/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java @@ -86,7 +86,8 @@ public GcpAuthenticationFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { GcpAuthnFilterConfig gcpAuthnProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -121,8 +122,8 @@ public ConfigOrError parseFilterConfig(Message rawProto @Override public ConfigOrError parseFilterConfigOverride( - Message rawProtoMessage) { - return parseFilterConfig(rawProtoMessage); + Message rawProtoMessage, FilterContext context) { + return parseFilterConfig(rawProtoMessage, context); } } diff --git a/xds/src/main/java/io/grpc/xds/RbacFilter.java b/xds/src/main/java/io/grpc/xds/RbacFilter.java index 91df1e68802..035bfd06607 100644 --- a/xds/src/main/java/io/grpc/xds/RbacFilter.java +++ b/xds/src/main/java/io/grpc/xds/RbacFilter.java @@ -94,7 +94,8 @@ public RbacFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { RBAC rbacProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -109,7 +110,8 @@ public ConfigOrError parseFilterConfig(Message rawProtoMessage) { } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { RBACPerRoute rbacPerRoute; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); diff --git a/xds/src/main/java/io/grpc/xds/RouterFilter.java b/xds/src/main/java/io/grpc/xds/RouterFilter.java index 504c4213149..c80e57c9010 100644 --- a/xds/src/main/java/io/grpc/xds/RouterFilter.java +++ b/xds/src/main/java/io/grpc/xds/RouterFilter.java @@ -61,13 +61,14 @@ public RouterFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromConfig(ROUTER_CONFIG); } @Override public ConfigOrError parseFilterConfigOverride( - Message rawProtoMessage) { + Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromError("Router Filter should not have override config"); } } diff --git a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java index 041b659b4c3..4bf1b0066c2 100644 --- a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java @@ -527,7 +527,7 @@ static io.grpc.xds.HttpConnectionManager parseHttpConnectionManager( "HttpConnectionManager contains duplicate HttpFilter: " + filterName); } StructOrError filterConfig = - parseHttpFilter(httpFilter, filterRegistry, isForClient); + parseHttpFilter(httpFilter, filterRegistry, isForClient, args); if ((i == proto.getHttpFiltersCount() - 1) && (filterConfig == null || !isTerminalFilter(filterConfig.getStruct()))) { throw new ResourceInvalidException("The last HttpFilter must be a terminal filter: " @@ -581,7 +581,8 @@ private static boolean isTerminalFilter(Filter.FilterConfig filterConfig) { @Nullable // Returns null if the filter is optional but not supported. static StructOrError parseHttpFilter( io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter - httpFilter, FilterRegistry filterRegistry, boolean isForClient) { + httpFilter, FilterRegistry filterRegistry, boolean isForClient, + XdsResourceType.Args args) { String filterName = httpFilter.getName(); boolean isOptional = httpFilter.getIsOptional(); if (!httpFilter.hasTypedConfig()) { @@ -616,7 +617,13 @@ static StructOrError parseHttpFilter( "HttpFilter [" + filterName + "](" + typeUrl + ") is required but unsupported for " + ( isForClient ? "client" : "server")); } - ConfigOrError filterConfig = provider.parseFilterConfig(rawConfig); + + Filter.FilterContext filterContext = Filter.FilterContext.builder() + .bootstrapInfo(args.getBootstrapInfo()) + .serverInfo(args.getServerInfo()) + .build(); + ConfigOrError filterConfig = + provider.parseFilterConfig(rawConfig, filterContext); if (filterConfig.errorDetail != null) { return StructOrError.fromError( "Invalid filter config for HttpFilter [" + filterName + "]: " + filterConfig.errorDetail); diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java index 69b0b824433..78550582d27 100644 --- a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java @@ -110,7 +110,7 @@ final class XdsNameResolver extends NameResolver { @Nullable private final String targetAuthority; private final String serviceAuthority; - // Encoded version of the service authority as per + // Encoded version of the service authority as per // https://datatracker.ietf.org/doc/html/rfc3986#section-3.2. private final String encodedServiceAuthority; private final String overrideAuthority; @@ -154,8 +154,8 @@ final class XdsNameResolver extends NameResolver { this(target, targetAuthority, name, overrideAuthority, serviceConfigParser, syncContext, scheduler, bootstrapOverride == null - ? SharedXdsClientPoolProvider.getDefaultProvider() - : new SharedXdsClientPoolProvider(), + ? SharedXdsClientPoolProvider.getDefaultProvider() + : new SharedXdsClientPoolProvider(), ThreadSafeRandomImpl.instance, FilterRegistry.getDefaultRegistry(), bootstrapOverride, metricRecorder, nameResolverArgs); } @@ -172,8 +172,8 @@ final class XdsNameResolver extends NameResolver { // The name might have multiple slashes so encode it before verifying. serviceAuthority = checkNotNull(name, "name"); - this.encodedServiceAuthority = - GrpcUtil.checkAuthority(GrpcUtil.AuthorityEscaper.encodeAuthority(serviceAuthority)); + this.encodedServiceAuthority = + GrpcUtil.checkAuthority(GrpcUtil.AuthorityEscaper.encodeAuthority(serviceAuthority)); this.overrideAuthority = overrideAuthority; this.serviceConfigParser = checkNotNull(serviceConfigParser, "serviceConfigParser"); @@ -234,7 +234,7 @@ public void start(Listener2 listener) { } String ldsResourceName = expandPercentS(listenerNameTemplate, replacement); if (!XdsClient.isResourceNameValid(ldsResourceName, XdsListenerResource.getInstance().typeUrl()) - ) { + ) { listener.onError(Status.INVALID_ARGUMENT.withDescription( "invalid listener resource URI for service authority: " + serviceAuthority)); return; @@ -921,8 +921,8 @@ private void cleanUpRoutes(Status error) { // the config selector handles the error message itself. listener.onResult2(ResolutionResult.newBuilder() .setAttributes(Attributes.newBuilder() - .set(InternalConfigSelector.KEY, configSelector) - .build()) + .set(InternalConfigSelector.KEY, configSelector) + .build()) .setServiceConfig(emptyServiceConfig) .build()); } diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java b/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java index 51b1ff49bf0..c395c363acb 100644 --- a/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java @@ -63,7 +63,7 @@ public XdsNameResolverProvider() { } private XdsNameResolverProvider(String scheme, - @Nullable Map bootstrapOverride) { + @Nullable Map bootstrapOverride) { this.scheme = checkNotNull(scheme, "scheme"); this.bootstrapOverride = bootstrapOverride; } @@ -73,7 +73,7 @@ private XdsNameResolverProvider(String scheme, * and bootstrap. */ public static XdsNameResolverProvider createForTest(String scheme, - @Nullable Map bootstrapOverride) { + @Nullable Map bootstrapOverride) { return new XdsNameResolverProvider(scheme, bootstrapOverride); } diff --git a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java index 24ec0659b42..890a2936861 100644 --- a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java @@ -198,7 +198,7 @@ private static StructOrError parseVirtualHost( routes.add(route.getStruct()); } StructOrError> overrideConfigs = - parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry); + parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry, args); if (overrideConfigs.getErrorDetail() != null) { return StructOrError.fromError( "VirtualHost [" + proto.getName() + "] contains invalid HttpFilter config: " @@ -210,7 +210,12 @@ private static StructOrError parseVirtualHost( @VisibleForTesting static StructOrError> parseOverrideFilterConfigs( - Map rawFilterConfigMap, FilterRegistry filterRegistry) { + Map rawFilterConfigMap, FilterRegistry filterRegistry, + XdsResourceType.Args args) { + Filter.FilterContext context = Filter.FilterContext.builder() + .bootstrapInfo(args.getBootstrapInfo()) + .serverInfo(args.getServerInfo()) + .build(); Map overrideConfigs = new HashMap<>(); for (String name : rawFilterConfigMap.keySet()) { Any anyConfig = rawFilterConfigMap.get(name); @@ -254,7 +259,7 @@ static StructOrError> parseOverrideFilterConfigs( "HttpFilter [" + name + "](" + typeUrl + ") is required but unsupported"); } ConfigOrError filterConfig = - provider.parseFilterConfigOverride(rawConfig); + provider.parseFilterConfigOverride(rawConfig, context); if (filterConfig.errorDetail != null) { return StructOrError.fromError( "Invalid filter config for HttpFilter [" + name + "]: " + filterConfig.errorDetail); @@ -281,7 +286,7 @@ static StructOrError parseRoute( } StructOrError> overrideConfigsOrError = - parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry); + parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry, args); if (overrideConfigsOrError.getErrorDetail() != null) { return StructOrError.fromError( "Route [" + proto.getName() + "] contains invalid HttpFilter config: " @@ -490,7 +495,7 @@ static StructOrError parseRouteAction( for (io.envoyproxy.envoy.config.route.v3.WeightedCluster.ClusterWeight clusterWeight : clusterWeights) { StructOrError clusterWeightOrError = - parseClusterWeight(clusterWeight, filterRegistry); + parseClusterWeight(clusterWeight, filterRegistry, args); if (clusterWeightOrError.getErrorDetail() != null) { return StructOrError.fromError("RouteAction contains invalid ClusterWeight: " + clusterWeightOrError.getErrorDetail()); @@ -599,9 +604,9 @@ private static StructOrError parseRet @VisibleForTesting static StructOrError parseClusterWeight( io.envoyproxy.envoy.config.route.v3.WeightedCluster.ClusterWeight proto, - FilterRegistry filterRegistry) { + FilterRegistry filterRegistry, XdsResourceType.Args args) { StructOrError> overrideConfigs = - parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry); + parseOverrideFilterConfigs(proto.getTypedPerFilterConfigMap(), filterRegistry, args); if (overrideConfigs.getErrorDetail() != null) { return StructOrError.fromError( "ClusterWeight [" + proto.getName() + "] contains invalid HttpFilter config: " diff --git a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java index 91b77b05d01..4303ef5efce 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -16,11 +16,22 @@ package io.grpc.xds.internal; +import com.google.common.collect.ImmutableList; import com.google.re2j.Pattern; import com.google.re2j.PatternSyntaxException; // TODO(zivy@): may reuse common matchers parsers. public final class MatcherParser { + /** Translate ListStringMatcher envoy proto to a list of internal StringMatcher. */ + public static ImmutableList parseListStringMatcher( + io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher proto) { + ImmutableList.Builder matchers = ImmutableList.builder(); + for (io.envoyproxy.envoy.type.matcher.v3.StringMatcher matcherProto : proto.getPatternsList()) { + matchers.add(parseStringMatcher(matcherProto)); + } + return matchers.build(); + } + /** Translates envoy proto HeaderMatcher to internal HeaderMatcher.*/ public static Matchers.HeaderMatcher parseHeaderMatcher( io.envoyproxy.envoy.config.route.v3.HeaderMatcher proto) { diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java deleted file mode 100644 index 5aeb44c6e2a..00000000000 --- a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.grpc.xds.internal.extauthz; - -import com.google.auto.value.AutoValue; -import com.google.common.collect.ImmutableList; -import io.grpc.Status; -import io.grpc.xds.internal.Matchers; -import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; -import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; -import java.util.Optional; - -/** - * Represents the configuration for the external authorization (ext_authz) filter. This class - * encapsulates the settings defined in the - * {@link io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz} proto, providing a - * structured, immutable representation for use within gRPC. It includes configurations for the gRPC - * service used for authorization, header mutation rules, and other filter behaviors. - */ -@AutoValue -public abstract class ExtAuthzConfig { - - /** Creates a new builder for creating {@link ExtAuthzConfig} instances. */ - public static Builder builder() { - return new AutoValue_ExtAuthzConfig.Builder().allowedHeaders(ImmutableList.of()) - .disallowedHeaders(ImmutableList.of()).statusOnError(Status.PERMISSION_DENIED) - .filterEnabled(Matchers.FractionMatcher.create(100, 100)); - } - - /** - * The gRPC service configuration for the external authorization service. This is a required - * field. - * - * @see ExtAuthz#getGrpcService() - */ - public abstract GrpcServiceConfig grpcService(); - - /** - * Changes the filter's behavior on errors from the authorization service. If {@code true}, the - * filter will accept the request even if the authorization service fails or returns an error. - * - * @see ExtAuthz#getFailureModeAllow() - */ - public abstract boolean failureModeAllow(); - - /** - * Determines if the {@code x-envoy-auth-failure-mode-allowed} header is added to the request when - * {@link #failureModeAllow()} is true. - * - * @see ExtAuthz#getFailureModeAllowHeaderAdd() - */ - public abstract boolean failureModeAllowHeaderAdd(); - - /** - * Specifies if the peer certificate is sent to the external authorization service. - * - * @see ExtAuthz#getIncludePeerCertificate() - */ - public abstract boolean includePeerCertificate(); - - /** - * The gRPC status returned to the client when the authorization server returns an error or is - * unreachable. Defaults to {@code PERMISSION_DENIED}. - * - * @see io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz#getStatusOnError() - */ - public abstract Status statusOnError(); - - /** - * Specifies whether to deny requests when the filter is disabled. Defaults to {@code false}. - * - * @see ExtAuthz#getDenyAtDisable() - */ - public abstract boolean denyAtDisable(); - - /** - * The fraction of requests that will be checked by the authorization service. Defaults to all - * requests. - * - * @see ExtAuthz#getFilterEnabled() - */ - public abstract Matchers.FractionMatcher filterEnabled(); - - /** - * Specifies which request headers are sent to the authorization service. If empty, all headers - * are sent. - * - * @see ExtAuthz#getAllowedHeaders() - */ - public abstract ImmutableList allowedHeaders(); - - /** - * Specifies which request headers are not sent to the authorization service. This overrides - * {@link #allowedHeaders()}. - * - * @see ExtAuthz#getDisallowedHeaders() - */ - public abstract ImmutableList disallowedHeaders(); - - /** - * Rules for what modifications an ext_authz server may make to request headers. - * - * @see ExtAuthz#getDecoderHeaderMutationRules() - */ - public abstract Optional decoderHeaderMutationRules(); - - @AutoValue.Builder - public abstract static class Builder { - public abstract Builder grpcService(GrpcServiceConfig grpcService); - - public abstract Builder failureModeAllow(boolean failureModeAllow); - - public abstract Builder failureModeAllowHeaderAdd(boolean failureModeAllowHeaderAdd); - - public abstract Builder includePeerCertificate(boolean includePeerCertificate); - - public abstract Builder statusOnError(Status statusOnError); - - public abstract Builder denyAtDisable(boolean denyAtDisable); - - public abstract Builder filterEnabled(Matchers.FractionMatcher filterEnabled); - - public abstract Builder allowedHeaders(Iterable allowedHeaders); - - public abstract Builder disallowedHeaders(Iterable disallowedHeaders); - - public abstract Builder decoderHeaderMutationRules(HeaderMutationRulesConfig rules); - - public abstract ExtAuthzConfig build(); - } -} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java new file mode 100644 index 00000000000..779cea29e5d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds.internal.grpcservice; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.common.annotations.VisibleForTesting; +import io.grpc.ManagedChannel; +import io.grpc.xds.client.ConfiguredChannelCredentials.ChannelCredsConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Concrete class managing the lifecycle of a single ManagedChannel for a GrpcServiceConfig. + */ +public class CachedChannelManager { + private final Function channelCreator; + private final Object lock = new Object(); + + private final AtomicReference channelHolder = new AtomicReference<>(); + private boolean closed; + + /** + * Default constructor for production that creates a channel using the config's target and + * credentials. + */ + public CachedChannelManager() { + this(config -> { + GoogleGrpcConfig googleGrpc = config.googleGrpc(); + return io.grpc.Grpc.newChannelBuilder(googleGrpc.target(), + googleGrpc.configuredChannelCredentials().channelCredentials()).build(); + }); + } + + /** + * Constructor for testing to inject a channel creator. + */ + @VisibleForTesting + public CachedChannelManager(Function channelCreator) { + this.channelCreator = checkNotNull(channelCreator, "channelCreator"); + } + + /** + * Returns a ManagedChannel for the given configuration. If the target or credentials config + * changes, the old channel is shut down and a new one is created. + */ + public ManagedChannel getChannel(GrpcServiceConfig config) { + GoogleGrpcConfig googleGrpc = config.googleGrpc(); + ChannelKey newChannelKey = ChannelKey.of( + googleGrpc.target(), + googleGrpc.configuredChannelCredentials().channelCredsConfig()); + + // 1. Fast path: Lock-free read + ChannelHolder holder = channelHolder.get(); + if (holder != null && holder.channelKey().equals(newChannelKey)) { + return holder.channel(); + } + + ManagedChannel oldChannel = null; + ManagedChannel newChannel; + + // 2. Slow path: Update with locking + synchronized (lock) { + if (closed) { + throw new IllegalStateException("CachedChannelManager is closed"); + } + holder = channelHolder.get(); // Double check + if (holder != null && holder.channelKey().equals(newChannelKey)) { + return holder.channel(); + } + + // 3. Create inside lock to avoid creation storms + newChannel = channelCreator.apply(config); + ChannelHolder newHolder = ChannelHolder.create(newChannelKey, newChannel); + + if (holder != null) { + oldChannel = holder.channel(); + } + channelHolder.set(newHolder); + } + + // 4. Shutdown outside lock + if (oldChannel != null) { + oldChannel.shutdown(); + } + + return newChannel; + } + + /** Removes underlying resources on shutdown. */ + public void close() { + synchronized (lock) { + closed = true; + ChannelHolder holder = channelHolder.getAndSet(null); + if (holder != null) { + holder.channel().shutdown(); + } + } + } + + @AutoValue + abstract static class ChannelKey { + static ChannelKey of(String target, ChannelCredsConfig credentialsConfig) { + return new AutoValue_CachedChannelManager_ChannelKey(target, credentialsConfig); + } + + abstract String target(); + + abstract ChannelCredsConfig channelCredsConfig(); + } + + @AutoValue + abstract static class ChannelHolder { + static ChannelHolder create(ChannelKey channelKey, ManagedChannel channel) { + return new AutoValue_CachedChannelManager_ChannelHolder(channelKey, channel); + } + + abstract ChannelKey channelKey(); + + abstract ManagedChannel channel(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java similarity index 58% rename from xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java rename to xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java index 78edea5c305..1e7008ca8e2 100644 --- a/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/ChannelCredsConfig.java @@ -14,21 +14,14 @@ * limitations under the License. */ -package io.grpc.xds.internal.extauthz; +package io.grpc.xds.internal.grpcservice; /** - * A custom exception for signaling errors during the parsing of external authorization - * (ext_authz) configurations. + * Configuration for channel credentials. */ -public class ExtAuthzParseException extends Exception { - - private static final long serialVersionUID = 0L; - - public ExtAuthzParseException(String message) { - super(message); - } - - public ExtAuthzParseException(String message, Throwable cause) { - super(message, cause); - } +public interface ChannelCredsConfig { + /** + * Returns the type of the credentials. + */ + String type(); } diff --git a/xds/src/test/java/io/grpc/xds/ExtAuthzConfigParserTest.java b/xds/src/test/java/io/grpc/xds/ExtAuthzConfigParserTest.java deleted file mode 100644 index fa2718cbe63..00000000000 --- a/xds/src/test/java/io/grpc/xds/ExtAuthzConfigParserTest.java +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright 2025 The gRPC Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.grpc.xds; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; - -import com.google.protobuf.Any; -import com.google.protobuf.BoolValue; -import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; -import io.envoyproxy.envoy.config.core.v3.GrpcService; -import io.envoyproxy.envoy.config.core.v3.HeaderValue; -import io.envoyproxy.envoy.config.core.v3.RuntimeFeatureFlag; -import io.envoyproxy.envoy.config.core.v3.RuntimeFractionalPercent; -import io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3.ExtAuthz; -import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; -import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3.GoogleDefaultCredentials; -import io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher; -import io.envoyproxy.envoy.type.matcher.v3.RegexMatcher; -import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; -import io.envoyproxy.envoy.type.v3.FractionalPercent; -import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; -import io.grpc.Status; -import io.grpc.xds.client.Bootstrapper.BootstrapInfo; -import io.grpc.xds.client.Bootstrapper.ServerInfo; -import io.grpc.xds.client.EnvoyProtoData.Node; -import io.grpc.xds.internal.Matchers; -import io.grpc.xds.internal.extauthz.ExtAuthzConfig; -import io.grpc.xds.internal.extauthz.ExtAuthzParseException; -import io.grpc.xds.internal.headermutations.HeaderMutationRulesConfig; -import java.util.Collections; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -@RunWith(JUnit4.class) -public class ExtAuthzConfigParserTest { - - private static final Any GOOGLE_DEFAULT_CHANNEL_CREDS = - Any.pack(GoogleDefaultCredentials.newBuilder().build()); - private static final Any FAKE_ACCESS_TOKEN_CALL_CREDS = - Any.pack(AccessTokenCredentials.newBuilder().setToken("fake-token").build()); - - private static BootstrapInfo dummyBootstrapInfo() { - return BootstrapInfo.builder() - .servers( - Collections.singletonList(ServerInfo.create("test_target", Collections.emptyMap()))) - .node(Node.newBuilder().build()).build(); - } - - private static ServerInfo dummyServerInfo() { - return ServerInfo.create("test_target", Collections.emptyMap(), false, true, false, false); - } - - private ExtAuthz.Builder extAuthzBuilder; - - @Before - public void setUp() { - extAuthzBuilder = ExtAuthz.newBuilder() - .setGrpcService(GrpcService.newBuilder().setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() - .setTargetUri("test-cluster") - .addChannelCredentialsPlugin(GOOGLE_DEFAULT_CHANNEL_CREDS) - .addCallCredentialsPlugin(FAKE_ACCESS_TOKEN_CALL_CREDS).build()) - .build()); - } - - @Test - public void parse_missingGrpcService_throws() { - ExtAuthz extAuthz = ExtAuthz.newBuilder().build(); - try { - ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - fail("Expected ExtAuthzParseException"); - } catch (ExtAuthzParseException e) { - assertThat(e).hasMessageThat() - .isEqualTo("unsupported ExtAuthz service type: only grpc_service is supported"); - } - } - - @Test - public void parse_invalidGrpcService_throws() { - ExtAuthz extAuthz = ExtAuthz.newBuilder() - .setGrpcService(GrpcService.newBuilder().build()) - .build(); - try { - ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - fail("Expected ExtAuthzParseException"); - } catch (ExtAuthzParseException e) { - assertThat(e).hasMessageThat().startsWith("Failed to parse GrpcService config:"); - } - } - - @Test - public void parse_invalidAllowExpression_throws() { - ExtAuthz extAuthz = extAuthzBuilder - .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() - .setAllowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) - .build(); - try { - ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - fail("Expected ExtAuthzParseException"); - } catch (ExtAuthzParseException e) { - assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for allow_expression:"); - } - } - - @Test - public void parse_invalidDisallowExpression_throws() { - ExtAuthz extAuthz = extAuthzBuilder - .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() - .setDisallowExpression(RegexMatcher.newBuilder().setRegex("[invalid").build()).build()) - .build(); - try { - ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - fail("Expected ExtAuthzParseException"); - } catch (ExtAuthzParseException e) { - assertThat(e).hasMessageThat().startsWith("Invalid regex pattern for disallow_expression:"); - } - } - - @Test - public void parse_success() throws ExtAuthzParseException { - ExtAuthz extAuthz = - extAuthzBuilder - .setGrpcService(extAuthzBuilder.getGrpcServiceBuilder() - .setTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(5).build()) - .addInitialMetadata( - HeaderValue.newBuilder().setKey("key").setValue("value").build()) - .build()) - .setFailureModeAllow(true).setFailureModeAllowHeaderAdd(true) - .setIncludePeerCertificate(true) - .setStatusOnError( - io.envoyproxy.envoy.type.v3.HttpStatus.newBuilder().setCodeValue(403).build()) - .setDenyAtDisable( - RuntimeFeatureFlag.newBuilder().setDefaultValue(BoolValue.of(true)).build()) - .setFilterEnabled(RuntimeFractionalPercent.newBuilder() - .setDefaultValue(FractionalPercent.newBuilder().setNumerator(50) - .setDenominator(DenominatorType.TEN_THOUSAND).build()) - .build()) - .setAllowedHeaders(ListStringMatcher.newBuilder() - .addPatterns(StringMatcher.newBuilder().setExact("allowed-header").build()).build()) - .setDisallowedHeaders(ListStringMatcher.newBuilder() - .addPatterns(StringMatcher.newBuilder().setPrefix("disallowed-").build()).build()) - .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() - .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()) - .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) - .setDisallowAll(BoolValue.of(true)).setDisallowIsError(BoolValue.of(true)).build()) - .build(); - - ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - - assertThat(config.grpcService().googleGrpc().target()).isEqualTo("test-cluster"); - assertThat(config.grpcService().timeout().get().getSeconds()).isEqualTo(5); - assertThat(config.grpcService().initialMetadata()).isNotEmpty(); - assertThat(config.failureModeAllow()).isTrue(); - assertThat(config.failureModeAllowHeaderAdd()).isTrue(); - assertThat(config.includePeerCertificate()).isTrue(); - assertThat(config.statusOnError().getCode()).isEqualTo(Status.PERMISSION_DENIED.getCode()); - assertThat(config.statusOnError().getDescription()).isEqualTo("HTTP status code 403"); - assertThat(config.denyAtDisable()).isTrue(); - assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(50, 10_000)); - assertThat(config.allowedHeaders()).hasSize(1); - assertThat(config.allowedHeaders().get(0).matches("allowed-header")).isTrue(); - assertThat(config.disallowedHeaders()).hasSize(1); - assertThat(config.disallowedHeaders().get(0).matches("disallowed-foo")).isTrue(); - assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); - HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); - assertThat(rules.allowExpression().get().pattern()).isEqualTo("allow.*"); - assertThat(rules.disallowExpression().get().pattern()).isEqualTo("disallow.*"); - assertThat(rules.disallowAll()).isTrue(); - assertThat(rules.disallowIsError()).isTrue(); - } - - @Test - public void parse_saneDefaults() throws ExtAuthzParseException { - ExtAuthz extAuthz = extAuthzBuilder.build(); - - ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - - assertThat(config.failureModeAllow()).isFalse(); - assertThat(config.failureModeAllowHeaderAdd()).isFalse(); - assertThat(config.includePeerCertificate()).isFalse(); - assertThat(config.statusOnError()).isEqualTo(Status.PERMISSION_DENIED); - assertThat(config.denyAtDisable()).isFalse(); - assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(100, 100)); - assertThat(config.allowedHeaders()).isEmpty(); - assertThat(config.disallowedHeaders()).isEmpty(); - assertThat(config.decoderHeaderMutationRules().isPresent()).isFalse(); - } - - @Test - public void parse_headerMutationRules_allowExpressionOnly() throws ExtAuthzParseException { - ExtAuthz extAuthz = extAuthzBuilder - .setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() - .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow.*").build()).build()) - .build(); - - ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - - assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); - HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); - assertThat(rules.allowExpression().get().pattern()).isEqualTo("allow.*"); - assertThat(rules.disallowExpression().isPresent()).isFalse(); - } - - @Test - public void parse_headerMutationRules_disallowExpressionOnly() throws ExtAuthzParseException { - ExtAuthz extAuthz = - extAuthzBuilder.setDecoderHeaderMutationRules(HeaderMutationRules.newBuilder() - .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow.*").build()) - .build()).build(); - - ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - - assertThat(config.decoderHeaderMutationRules().isPresent()).isTrue(); - HeaderMutationRulesConfig rules = config.decoderHeaderMutationRules().get(); - assertThat(rules.allowExpression().isPresent()).isFalse(); - assertThat(rules.disallowExpression().get().pattern()).isEqualTo("disallow.*"); - } - - @Test - public void parse_filterEnabled_hundred() throws ExtAuthzParseException { - ExtAuthz extAuthz = extAuthzBuilder - .setFilterEnabled(RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent - .newBuilder().setNumerator(25).setDenominator(DenominatorType.HUNDRED).build()).build()) - .build(); - - ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - - assertThat(config.filterEnabled()).isEqualTo(Matchers.FractionMatcher.create(25, 100)); - } - - @Test - public void parse_filterEnabled_million() throws ExtAuthzParseException { - ExtAuthz extAuthz = extAuthzBuilder - .setFilterEnabled( - RuntimeFractionalPercent.newBuilder().setDefaultValue(FractionalPercent.newBuilder() - .setNumerator(123456).setDenominator(DenominatorType.MILLION).build()).build()) - .build(); - - ExtAuthzConfig config = ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - - assertThat(config.filterEnabled()) - .isEqualTo(Matchers.FractionMatcher.create(123456, 1_000_000)); - } - - @Test - public void parse_filterEnabled_unrecognizedDenominator() { - ExtAuthz extAuthz = extAuthzBuilder.setFilterEnabled(RuntimeFractionalPercent.newBuilder() - .setDefaultValue( - FractionalPercent.newBuilder().setNumerator(1).setDenominatorValue(4).build()) - .build()).build(); - - try { - ExtAuthzConfigParser.parse(extAuthz, - dummyBootstrapInfo(), - dummyServerInfo()); - fail("Expected ExtAuthzParseException"); - } catch (ExtAuthzParseException e) { - assertThat(e).hasMessageThat().isEqualTo("Unknown denominator type: UNRECOGNIZED"); - } - } -} diff --git a/xds/src/test/java/io/grpc/xds/ExternalProcessorFilterTest.java b/xds/src/test/java/io/grpc/xds/ExternalProcessorFilterTest.java new file mode 100644 index 00000000000..9c003d37346 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/ExternalProcessorFilterTest.java @@ -0,0 +1,7578 @@ +/* + * Copyright 2024 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExtProcOverrides; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExtProcPerRoute; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.HeaderForwardingRules; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ProcessingMode; +import io.envoyproxy.envoy.service.ext_proc.v3.BodyMutation; +import io.envoyproxy.envoy.service.ext_proc.v3.BodyResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.CommonResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.ExternalProcessorGrpc; +import io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation; +import io.envoyproxy.envoy.service.ext_proc.v3.HeadersResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.ImmediateResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest; +import io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.StreamedBodyResponse; +import io.envoyproxy.envoy.service.ext_proc.v3.TrailersResponse; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Deadline; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.internal.FakeClock; +import io.grpc.internal.GrpcUtil; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ServerCallStreamObserver; +import io.grpc.stub.ServerCalls; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.GrpcCleanupRule; +import io.grpc.util.MutableHandlerRegistry; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterConfig; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterOverrideConfig; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorInterceptor; +import io.grpc.xds.client.Bootstrapper; +import io.grpc.xds.client.EnvoyProtoData.Node; +import io.grpc.xds.internal.grpcservice.CachedChannelManager; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.SocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mockito; + +/** + * Unit tests for {@link ExternalProcessorFilter}. + */ +@RunWith(JUnit4.class) +public class ExternalProcessorFilterTest { + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + private MutableHandlerRegistry dataPlaneServiceRegistry; + + private String dataPlaneServerName; + private String extProcServerName; + private final FakeClock fakeClock = new FakeClock(); + private ScheduledExecutorService scheduler; + private ExternalProcessorFilter.Provider provider; + private Filter.FilterContext filterContext; + private Bootstrapper.BootstrapInfo bootstrapInfo; + private Bootstrapper.ServerInfo serverInfo; + + // Define a simple test service + private static final MethodDescriptor METHOD_SAY_HELLO = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test.TestService/SayHello") + .setRequestMarshaller(new StringMarshaller()) + .setResponseMarshaller(new StringMarshaller()) + .build(); + + private static final MethodDescriptor METHOD_CLIENT_STREAMING = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.CLIENT_STREAMING) + .setFullMethodName("test.TestService/ClientStreaming") + .setRequestMarshaller(new StringMarshaller()) + .setResponseMarshaller(new StringMarshaller()) + .build(); + + private static final MethodDescriptor METHOD_BIDI_STREAMING = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName("test.TestService/BidiStreaming") + .setRequestMarshaller(new StringMarshaller()) + .setResponseMarshaller(new StringMarshaller()) + .build(); + + private static class StringMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(String value) { + return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public String parse(InputStream stream) { + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int nRead; + byte[] data = new byte[1024]; + while ((nRead = stream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + buffer.flush(); + return new String(buffer.toByteArray(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private static class InProcessNameResolverProvider extends NameResolverProvider { + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + if ("in-process".equals(targetUri.getScheme())) { + return new NameResolver() { + @Override + public String getServiceAuthority() { + return "localhost"; + } + + @Override + public void start(Listener2 listener) { + } + + @Override + public void shutdown() { + } + }; + } + return null; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + + @Override + public String getDefaultScheme() { + return "in-process"; + } + + @Override + public Collection> getProducedSocketAddressTypes() { + return Collections.emptyList(); + } + } + + @Before + public void setUp() throws Exception { + NameResolverRegistry.getDefaultRegistry().register(new InProcessNameResolverProvider()); + + dataPlaneServiceRegistry = new MutableHandlerRegistry(); + dataPlaneServerName = InProcessServerBuilder.generateName(); + extProcServerName = InProcessServerBuilder.generateName(); + scheduler = fakeClock.getScheduledExecutorService(); + provider = new ExternalProcessorFilter.Provider(); + + bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .node(Node.newBuilder().build()) + .servers( + Collections.singletonList( + Bootstrapper.ServerInfo.create( + "test_target", Collections.emptyMap()))) + .build(); + + serverInfo = + Bootstrapper.ServerInfo.create( + "test_target", Collections.emptyMap(), false, true, false, false); + + filterContext = Filter.FilterContext.builder() + .bootstrapInfo(bootstrapInfo) + .serverInfo(serverInfo) + .build(); + + grpcCleanup.register(InProcessServerBuilder.forName(dataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneServiceRegistry) + .directExecutor() + .build().start()); + } + + + + private ExternalProcessor.Builder createBaseProto(String targetName) { + return ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + targetName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()); + } + + // --- Category 1: Configuration Parsing & Provider --- + + @Test + public void givenValidConfig_whenParsed_thenReturnsFilterConfig() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName).build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).isNull(); + assertThat(result.config).isNotNull(); + assertThat(result.config.typeUrl()).isEqualTo(ExternalProcessorFilter.TYPE_URL); + } + + @Test + public void givenUnsupportedBodyMode_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.BUFFERED) // Unsupported + .build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("Invalid request_body_mode"); + } + + @Test + public void givenInvalidGrpcService_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder().build()) // Invalid: no GoogleGrpc + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("GrpcService must have GoogleGrpc"); + } + + @Test + public void givenInvalidDeferredCloseTimeout_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setDeferredCloseTimeout( + com.google.protobuf.Duration.newBuilder().setSeconds(315576000001L).build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("Invalid deferred_close_timeout"); + } + + @Test + public void givenNegativeDeferredCloseTimeout_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setDeferredCloseTimeout( + com.google.protobuf.Duration.newBuilder().setSeconds(0).setNanos(0).build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("deferred_close_timeout must be positive"); + } + + + @Test + public void provider_registeredInFilterRegistry_basedOnFlag() { + // Test with flag true + System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "true"); + try { + FilterRegistry registry = FilterRegistry.newRegistry().register( + new FaultFilter.Provider(), + new RouterFilter.Provider(), + new RbacFilter.Provider(), + new GcpAuthenticationFilter.Provider()); + if (GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false)) { + registry.register(new ExternalProcessorFilter.Provider()); + } + assertThat(registry.get(ExternalProcessorFilter.TYPE_URL)).isNotNull(); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT"); + } + + // Test with flag false + System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "false"); + try { + FilterRegistry registry = FilterRegistry.newRegistry().register( + new FaultFilter.Provider(), + new RouterFilter.Provider(), + new RbacFilter.Provider(), + new GcpAuthenticationFilter.Provider()); + if (GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false)) { + registry.register(new ExternalProcessorFilter.Provider()); + } + assertThat(registry.get(ExternalProcessorFilter.TYPE_URL)).isNull(); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT"); + } + } + + // --- Category 2: Configuration Override --- + + @Test + public void givenOverrideConfig_whenGrpcServiceOverridden_thenUsesNewService() throws Exception { + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///parent") + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + + GrpcService overrideService = GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///override") + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .setGrpcService(overrideService) + .build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + assertThat(interceptor.getFilterConfig().getExternalProcessor().getGrpcService() + .getGoogleGrpc().getTargetUri()).isEqualTo("in-process:///override"); + } + + @Test + public void givenOverrideConfig_whenOverridesMissing_thenFallsBackToDefaultInstance() + throws Exception { + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder().build(); + + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + assertThat(overrideConfig.hasProcessingMode()).isFalse(); + assertThat(overrideConfig.hasRequestAttributes()).isFalse(); + assertThat(overrideConfig.hasResponseAttributes()).isFalse(); + assertThat(overrideConfig.hasGrpcService()).isFalse(); + assertThat(overrideConfig.hasFailureModeAllow()).isFalse(); + assertThat(overrideConfig.getGrpcServiceConfig()).isNull(); + } + + @Test + public void givenOverrideConfig_whenFailureModeAllowOverridden_thenTakesEffect() + throws Exception { + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setFailureModeAllow(false) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .setFailureModeAllow(com.google.protobuf.BoolValue.of(true)) + .build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + assertThat(interceptor.getFilterConfig().getFailureModeAllow()).isTrue(); + } + + + + @Test + public void givenOverrideConfig_whenOtherFieldsOverridden_thenReplaced() throws Exception { + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .addRequestAttributes("attr1") + .addResponseAttributes("attr2") + .setFailureModeAllow(false) + .build(); + + GrpcService overrideService = GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///overridden") + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .addRequestAttributes("attr3") + .addResponseAttributes("attr4") + .setGrpcService(overrideService) + .setFailureModeAllow(com.google.protobuf.BoolValue.of(true)) + .build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + ExternalProcessor mergedProto = interceptor.getFilterConfig().getExternalProcessor(); + + assertThat(mergedProto.getRequestAttributesList()).containsExactly("attr3"); + assertThat(mergedProto.getResponseAttributesList()).containsExactly("attr4"); + assertThat(mergedProto.getGrpcService()).isEqualTo(overrideService); + assertThat(interceptor.getFilterConfig().getFailureModeAllow()).isTrue(); + } + + @Test + public void givenOverrideConfig_whenProcessingModeOverridden_thenReplacesWholeMode() + throws Exception { + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + ProcessingMode mergedMode = + interceptor.getFilterConfig().getExternalProcessor().getProcessingMode(); + // Full replacement: requestBodyMode becomes GRPC, others become defaults (0/DEFAULT/NONE) + assertThat(mergedMode.getRequestBodyMode()).isEqualTo(ProcessingMode.BodySendMode.GRPC); + assertThat(mergedMode.getRequestHeaderMode()).isEqualTo(ProcessingMode.HeaderSendMode.DEFAULT); + assertThat(mergedMode.getResponseHeaderMode()).isEqualTo(ProcessingMode.HeaderSendMode.DEFAULT); + assertThat(mergedMode.getResponseBodyMode()).isEqualTo(ProcessingMode.BodySendMode.NONE); + } + + @Test + public void givenOverrideConfig_whenAllFieldsOverridden_thenAllTakeEffect() throws Exception { + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setFailureModeAllow(false) + .build(); + + GrpcService overrideService = GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///override") + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .setFailureModeAllow(com.google.protobuf.BoolValue.of(true)) + .setGrpcService(overrideService) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .addRequestAttributes("attr-over") + .build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + ExternalProcessorFilterConfig mergedConfig = interceptor.getFilterConfig(); + assertThat(mergedConfig.getFailureModeAllow()).isTrue(); + assertThat(mergedConfig.getExternalProcessor().getGrpcService()).isEqualTo(overrideService); + assertThat(mergedConfig.getExternalProcessor().getProcessingMode().getRequestBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + assertThat(mergedConfig.getExternalProcessor().getRequestAttributesList()) + .containsExactly("attr-over"); + } + + @Test + public void givenOverrideConfig_whenSomeFieldsOverridden_thenMergedCorrectly() throws Exception { + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setFailureModeAllow(false) + .addRequestAttributes("attr-parent") + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .setFailureModeAllow(com.google.protobuf.BoolValue.of(true)) + // requestAttributes NOT set in override + .build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + ExternalProcessorFilterConfig mergedConfig = interceptor.getFilterConfig(); + assertThat(mergedConfig.getFailureModeAllow()).isTrue(); + assertThat(mergedConfig.getExternalProcessor().getRequestAttributesList()) + .containsExactly("attr-parent"); + } + + + @Test + public void givenOverrideConfig_whenDisableImmediateResponseOverridden_thenInheritedFromParent() + throws Exception { + // disable_immediate_response is NOT in ExtProcOverrides. + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setDisableImmediateResponse(true) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder().build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + assertThat(interceptor.getFilterConfig().getDisableImmediateResponse()).isTrue(); + } + + @Test + public void givenOverrideConfig_whenMutationRulesOverridden_thenInheritedFromParent() + throws Exception { + // mutation_rules is NOT in ExtProcOverrides. + io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules rules = + io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules.newBuilder() + .setDisallowAll(com.google.protobuf.BoolValue.newBuilder().setValue(true).build()) + .build(); + + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setMutationRules(rules) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder().build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + assertThat(interceptor.getFilterConfig().getMutationRulesConfig().get().disallowAll()) + .isTrue(); + } + + @Test + public void givenOverrideConfig_whenDeferredCloseTimeoutOverridden_thenInheritedFromParent() + throws Exception { + // deferred_close_timeout is NOT in ExtProcOverrides. + ExternalProcessor parentProto = createBaseProto(extProcServerName) + .setDeferredCloseTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(10).build()) + .build(); + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder().build()) + .build(); + + ConfigOrError parentResult = + provider.parseFilterConfig(Any.pack(parentProto), filterContext); + assertThat(parentResult.errorDetail).isNull(); + ExternalProcessorFilterConfig parentConfig = parentResult.config; + ConfigOrError overrideResult = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + assertThat(overrideResult.errorDetail).isNull(); + ExternalProcessorFilterOverrideConfig overrideConfig = overrideResult.config; + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test"); + ExternalProcessorInterceptor interceptor = (ExternalProcessorInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + assertThat(interceptor.getFilterConfig().getDeferredCloseTimeoutNanos()) + .isEqualTo(TimeUnit.SECONDS.toNanos(10)); + } + + // --- Category 3: Client Interceptor & Lifecycle --- + + @Test + @SuppressWarnings("unchecked") + public void givenInterceptor_whenCallIntercepted_thenExtProcStubUsesSerializingExecutor() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicReference capturedExecutor = new AtomicReference<>(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + if (method.equals(ExternalProcessorGrpc.getProcessMethod())) { + capturedExecutor.set(callOptions.getExecutor()); + } + return next.newCall(method, callOptions); + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(capturedExecutor.get()).isNotNull(); + assertThat(capturedExecutor.get().getClass().getName()).contains("SerializingExecutor"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenGrpcServiceWithTimeout_whenCallIntercepted_thenExtProcStubHasCorrectDeadline() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .setTimeout(com.google.protobuf.Duration.newBuilder().setSeconds(5).build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicReference capturedDeadline = new AtomicReference<>(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + if (method.equals(ExternalProcessorGrpc.getProcessMethod())) { + capturedDeadline.set(callOptions.getDeadline()); + } + return next.newCall(method, callOptions); + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(capturedDeadline.get()).isNotNull(); + assertThat(capturedDeadline.get().timeRemaining(TimeUnit.SECONDS)).isAtLeast(4); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenGrpcServiceWithInitialMetadata_whenCallIntercepted_thenSendsMetadata() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .addInitialMetadata(io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("x-init-key").setValue("init-val").build()) + .addInitialMetadata( + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("x-bin-key-bin") + .setRawValue(ByteString.copyFrom(new byte[] {1, 2, 3})) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final AtomicReference capturedHeaders = new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + ServerServiceDefinition interceptedExtProc = ServerInterceptors.intercept( + extProcImpl, + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + capturedHeaders.set(headers); + return next.startCall(call, headers); + } + }); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(interceptedExtProc) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(capturedHeaders.get()).isNotNull(); + assertThat( + capturedHeaders + .get() + .get(Metadata.Key.of("x-init-key", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("init-val"); + assertThat( + capturedHeaders + .get() + .get(Metadata.Key.of("x-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER))) + .isEqualTo(new byte[] {1, 2, 3}); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 4: Request Header Processing --- + + @Test + public void givenProcessingMode_whenRequestHeadersSent_thenProtocolConfigIsPopulated() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(2); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + capturedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + sidecarLatch.countDown(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).directExecutor().build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(capturedRequests).hasSize(3); + + // First request (RequestHeaders) should have protocol_config + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasRequestHeaders()).isTrue(); + assertThat(firstReq.hasProtocolConfig()).isTrue(); + assertThat(firstReq.getProtocolConfig().getRequestBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + assertThat(firstReq.getProtocolConfig().getResponseBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + + // Second request (ResponseHeaders) should NOT have protocol_config + ProcessingRequest secondReq = capturedRequests.get(1); + assertThat(secondReq.hasResponseHeaders()).isTrue(); + assertThat(secondReq.hasProtocolConfig()).isFalse(); + + // Third request (RequestBody) should NOT have protocol_config + ProcessingRequest thirdReq = capturedRequests.get(2); + assertThat(thirdReq.hasRequestBody()).isTrue(); + assertThat(thirdReq.hasProtocolConfig()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("FutureReturnValueIgnored") + public void givenPendingData_whenImmediateResponseReceived_thenDeliversDataBeforeStatus() + throws Exception { + final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + final List appEvents = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch finishLatch = new CountDownLatch(1); + final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); + final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); + final Metadata.Key immediateKey = + Metadata.Key.of("x-immediate-header", Metadata.ASCII_STRING_MARSHALLER); + final AtomicReference appTrailers = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + sidecarResponseExecutor.submit(() -> { + synchronized (responseObserver) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse(ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .setDetails("Immediate Auth Failure") + .setHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("x-immediate-header") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + }); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + extProcCompletedLatch.countDown(); + } + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).directExecutor().build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test-filter", channelManager); + ExternalProcessor proto = createBaseProto(extProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + ClientInterceptor interceptor = filter.buildClientInterceptor(filterConfig, null, scheduler); + + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(1); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) { + call.sendMessage("server-response"); + call.close(Status.OK, new Metadata()); + } + }; + }) + .build()); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ManagedChannel channel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + Channel interceptedChannel = io.grpc.ClientInterceptors.intercept(channel, interceptor); + + ClientCall call = + interceptedChannel.newCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor())); + call.start(new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + appEvents.add("HEADERS"); + } + + @Override + public void onMessage(String message) { + appEvents.add("MESSAGE"); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appEvents.add("CLOSE:" + status.getCode()); + appTrailers.set(trailers); + finishLatch.countDown(); + } + }, new Metadata()); + + call.request(1); + call.sendMessage("request-body"); + call.halfClose(); + + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appEvents).containsExactly("HEADERS", "MESSAGE", "CLOSE:UNAUTHENTICATED"); + assertThat(appTrailers.get().get(immediateKey)).isEqualTo("true"); + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + sidecarResponseExecutor.shutdown(); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestHeaderModeSend_whenStartCalled_thenCallIsBuffered() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch requestSentLatch = new CountDownLatch(1); + final AtomicReference capturedRequest = new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + capturedRequest.set(request); + requestSentLatch.countDown(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicBoolean dataPlaneStarted = new AtomicBoolean(false); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + dataPlaneStarted.set(true); + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(requestSentLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequest.get().hasRequestHeaders()).isTrue(); + + // Verify main call NOT yet started + assertThat(dataPlaneStarted.get()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestHeaderModeSend_whenExtProcRespondsWithMutations_thenCallIsActivated() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + new Thread(() -> { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("x-mutated") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + }).start(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicReference capturedHeaders = new AtomicReference<>(); + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + capturedHeaders.set(headers); + return next.startCall(call, headers); + } + })); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + Metadata headers = new Metadata(); + proxyCall.start(new ClientCall.Listener() {}, headers); + + // Send message and half-close to trigger unary call + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify main call started with mutated headers + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + Metadata finalHeaders = capturedHeaders.get(); + assertThat( + finalHeaders.get(Metadata.Key.of("x-mutated", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("true"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestHeaderModeSkip_whenStartCalled_thenCallIsActivated() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final AtomicInteger sidecarMessages = new AtomicInteger(0); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + sidecarMessages.incrementAndGet(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + Metadata headers = new Metadata(); + proxyCall.start(new ClientCall.Listener() {}, headers); + + // Send message and half-close to trigger unary call + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify main call started immediately + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify sidecar RECEIVED message about headers because default is SEND + assertThat(sidecarMessages.get()).isEqualTo(1); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 5: Body Mutation: Outbound/Request (GRPC Mode) --- + + @Test + @SuppressWarnings("unchecked") + public void givenRequestBodyModeGrpc_whenSendMessageCalled_thenMessageSentToExtProc() + throws Exception { + String uniqueExtProcServerName = "extProc-sendMessage-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-sendMessage-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch bodySentLatch = new CountDownLatch(1); + final AtomicReference capturedRequest = new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + new Thread(() -> { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + if (capturedRequest.get() == null + && !request.getRequestBody().getBody().isEmpty()) { + capturedRequest.set(request); + bodySentLatch.countDown(); + } + BodyResponse.Builder bodyResponse = BodyResponse.newBuilder(); + if (request.getRequestBody().getBody().isEmpty() + && request.getRequestBody().getEndOfStreamWithoutMessage()) { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()); + } else { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(request.getRequestBody().getEndOfStream()) + .build()) + .build()) + .build()); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(bodyResponse.build()) + .build()); + } + }).start(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("Hello World"); + proxyCall.halfClose(); + + assertThat(bodySentLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequest.get().getRequestBody().getBody().toStringUtf8()) + .contains("Hello World"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestBodyModeGrpc_whenExtProcRespondsWithMutatedBody_thenMutatedBodyForwarded() + throws Exception { + String uniqueExtProcServerName = + "extProc-mutatedBody-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-mutatedBody-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + new Thread(() -> { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + return; + } + if (request.hasRequestBody()) { + BodyResponse.Builder bodyResponse = BodyResponse.newBuilder(); + if (request.getRequestBody().getBody().isEmpty() + && request.getRequestBody().getEndOfStreamWithoutMessage()) { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()); + } else { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("Mutated")) + .setEndOfStream(request.getRequestBody().getEndOfStream()) + .build()) + .build()) + .build()); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(bodyResponse.build()) + .build()); + } + }).start(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicReference receivedBody = new AtomicReference<>(); + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + receivedBody.set(request); + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("Original"); + proxyCall.halfClose(); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedBody.get()).isEqualTo("Mutated"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenExtProcSignaledEndOfStream_whenClientSendsMoreMessages_thenMessagesDiscarded() + throws Exception { + String uniqueExtProcServerName = + "extProc-discarded-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-discarded-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final AtomicInteger sidecarMessages = new AtomicInteger(0); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + sidecarMessages.incrementAndGet(); + boolean triggerEos = + request.getRequestBody().getBody().toStringUtf8().equals("Trigger EOS"); + BodyResponse.Builder bodyResponse = BodyResponse.newBuilder(); + if (triggerEos || (request.getRequestBody().getBody().isEmpty() + && request.getRequestBody().getEndOfStreamWithoutMessage())) { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) // SEND ORIGINAL BODY! + .setEndOfStream(true) + .build()) + .build()) + .build()); + } else { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(request.getRequestBody().getEndOfStream()) + .build()) + .build()) + .build()); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(bodyResponse.build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicInteger dataPlaneMessages = new AtomicInteger(0); + final CountDownLatch dataPlaneHalfCloseLatch = new CountDownLatch(1); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + dataPlaneMessages.incrementAndGet(); + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + dataPlaneHalfCloseLatch.countDown(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("Trigger EOS"); + proxyCall.halfClose(); + + assertThat(dataPlaneHalfCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(dataPlaneMessages.get()).isEqualTo(1); + + proxyCall.sendMessage("Too late"); + assertThat(dataPlaneMessages.get()).isEqualTo(1); + + // Verify sidecar received Trigger EOS and half-close + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestBodyModeGrpc_whenHalfCloseCalled_thenSuperHalfCloseDeferred() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch halfCloseLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestBody() + && request.getRequestBody().getEndOfStreamWithoutMessage()) { + halfCloseLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final CountDownLatch dataPlaneHalfCloseLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + // Should only be called AFTER sidecar response + dataPlaneHalfCloseLatch.countDown(); + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.halfClose(); + + // Verify sidecar received end_of_stream_without_message + assertThat(halfCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify main call NOT yet started (data plane server NOT yet reached) + assertThat(dataPlaneHalfCloseLatch.getCount()).isEqualTo(1); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenDeferredHalfClose_whenExtProcRespondsWithEndOfStream_thenSuperHalfCloseCalled() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch halfCloseLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + ProcessingResponse.Builder response = ProcessingResponse.newBuilder(); + if (request.hasRequestHeaders()) { + response.setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .build()) + .build()); + } else if (request.hasRequestBody()) { + response.setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getRequestBody().getBody()) + .setEndOfStream(request.getRequestBody().getEndOfStream()) + .setEndOfStreamWithoutMessage( + request.getRequestBody().getEndOfStreamWithoutMessage()) + .build()) + .build()) + .build()) + .build()); + if (request.getRequestBody().getEndOfStream() + || request.getRequestBody().getEndOfStreamWithoutMessage()) { + halfCloseLatch.countDown(); + } + } else if (request.hasResponseHeaders()) { + response.setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .build()) + .build()); + } else if (request.hasResponseBody()) { + response.setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .setEndOfStream(request.getResponseBody().getEndOfStream()) + .build()) + .build()) + .build()) + .build()); + } + responseObserver.onNext(response.build()); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + final java.util.concurrent.CountDownLatch dataPlaneHalfClosedLatch = + new java.util.concurrent.CountDownLatch(1); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void halfClose() { + dataPlaneHalfClosedLatch.countDown(); + super.halfClose(); + } + }; + } + }) + .directExecutor() + .build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.halfClose(); + + // Verify super.halfClose() was called after sidecar response + assertThat(dataPlaneHalfClosedLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenResponseHeaderModeSend_whenExtProcRespondsWithMutatedHeaders_thenSent() + throws Exception { + String uniqueExtProcServerName = + "extProc-resp-headers-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-resp-headers-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + Metadata.Key mutatedKey = + Metadata.Key.of("mutated-header", Metadata.ASCII_STRING_MARSHALLER); + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + ProcessingResponse.Builder response = ProcessingResponse.newBuilder(); + if (request.hasRequestHeaders()) { + response.setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + response.setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption.newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("mutated-header") + .setValue("mutated-value") + .build()) + .build()) + .build()) + .build()) + .build()); + } + responseObserver.onNext(response.build()); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + call.sendHeaders(new Metadata()); + return next.startCall(call, headers); + } + })); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + final AtomicReference receivedHeaders = new AtomicReference<>(); + final CountDownLatch headersLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onHeaders(Metadata headers) { + receivedHeaders.set(headers); + headersLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + proxyCall.request(1); + proxyCall.halfClose(); + + // Verify application receives mutated response headers + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedHeaders.get().get(mutatedKey)).isEqualTo("mutated-value"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 6: Body Mutation: Inbound/Response (GRPC Mode) --- + + @Test + @SuppressWarnings("unchecked") + public void givenResponseBodyModeGrpc_whenOnMessageCalled_thenMessageSentToExtProc() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch sidecarBodyLatch = new CountDownLatch(1); + final AtomicReference capturedRequest = new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + if (capturedRequest.get() == null && !request.getResponseBody().getBody().isEmpty()) { + capturedRequest.set(request); + sidecarBodyLatch.countDown(); + } + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .setEndOfStream(request.getResponseBody().getEndOfStream()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).executor(scheduler).build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + // Data Plane Server + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .executor(scheduler) + .build().start()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Server Message"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).executor(scheduler).build()); + + final CountDownLatch appMessageLatch = new CountDownLatch(1); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(scheduler); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + appMessageLatch.countDown(); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }, new Metadata()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + proxyCall.request(1); + proxyCall.sendMessage("Hello"); + proxyCall.halfClose(); + + long startTime = System.currentTimeMillis(); + while (sidecarBodyLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(10); + } + assertThat(capturedRequest.get().getResponseBody().getBody().toStringUtf8()) + .isEqualTo("Server Message"); + + while ((appMessageLatch.getCount() > 0 || appCloseLatch.getCount() > 0) + && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(10); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenResponseBodyModeGrpc_whenExtProcRespondsWithMutatedBody_thenMutatedDelivered() + throws Exception { + final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + // External Processor Server + MutableHandlerRegistry extProcRegistry = new MutableHandlerRegistry(); + final CountDownLatch sidecarBodyLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("Mutated Server")) + .setEndOfStream(request.getResponseBody().getEndOfStream()) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarBodyLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + extProcRegistry.addService(extProcImpl); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .fallbackHandlerRegistry(extProcRegistry) + .executor(scheduler) + .build().start()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).executor(scheduler).build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + // Data Plane Server + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .executor(scheduler) + .build().start()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Original"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(scheduler) + .build()); + + final CountDownLatch appMessageLatch = new CountDownLatch(1); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final AtomicReference capturedMessage = new AtomicReference<>(); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(scheduler); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onMessage(String message) { + capturedMessage.set(message); + appMessageLatch.countDown(); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }, new Metadata()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + proxyCall.request(1); + proxyCall.sendMessage("Hello"); + proxyCall.halfClose(); + + long startTime = System.currentTimeMillis(); + while (sidecarBodyLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(10); + } + while (appMessageLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(10); + } + assertThat(capturedMessage.get()).isEqualTo("Mutated Server"); + while (appCloseLatch.getCount() > 0 && System.currentTimeMillis() - startTime < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(10); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenResponseBodyModeGrpc_whenExtProcRespondsWithEndOfStream_thenClosePropagated() + throws Exception { + final String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + final String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + // External Processor Server + final CountDownLatch sidecarEosLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarEosLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final CountDownLatch dataPlaneServerLatch = new CountDownLatch(1); + final AtomicReference> dataPlaneResponseObserver = + new AtomicReference<>(); + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .directExecutor() + .build().start()); + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + dataPlaneResponseObserver.set(responseObserver); + dataPlaneServerLatch.countDown(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + appCloseLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("Trigger"); + proxyCall.halfClose(); + + assertThat(dataPlaneServerLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Original call closes on server side + dataPlaneResponseObserver.get().onNext("Response"); + dataPlaneResponseObserver.get().onCompleted(); + + // Sidecar responds with EOS + assertThat(sidecarEosLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify app listener notified + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().isOk()).isTrue(); + + channelManager.close(); + } + + // --- Category 7: Outbound Backpressure (isReady / onReady) --- + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityTrue_whenExtProcBusy_thenIsReadyReturnsFalse() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall< + ReqT, RespT>(next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for activation (sidecar needs to respond to headers) + long startTime = System.currentTimeMillis(); + while (!proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + // Sidecar busy + sidecarReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestDrainActive_whenIsReadyCalled_thenReturnsFalse() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch drainLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestDrain(true) + .build()); + drainLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + // Don't complete responseObserver immediately to allow test to check draining state + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(drainLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // isReady() must return false during drain. + // Use a small loop because of SerializingExecutor delay even with directExecutor. + long start = System.currentTimeMillis(); + while (proxyCall.isReady() && System.currentTimeMillis() - start < 2000) { + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenCongestionInExtProc_whenExtProcBecomesReady_thenTriggersOnReady() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } + }; + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + // No-op + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final CountDownLatch onReadyLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onReady() { + onReadyLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Wait for sidecar call to start and listener to be captured + long startTime = System.currentTimeMillis(); + while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(sidecarListenerRef.get()).isNotNull(); + + // Trigger sidecar onReady + sidecarListenerRef.get().onReady(); + + // Verify app listener notified + assertThat(onReadyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenDrainingStream_whenExtProcStreamCompletes_thenOnReady() throws Exception { + String uniqueExtProcServerName = + "extProc-draining-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-draining-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch sidecarFinishLatch = new CountDownLatch(1); + final CountDownLatch sidecarOnNextLatch = new CountDownLatch(1); + final CountDownLatch sidecarOnCompletedLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + new Thread(() -> { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestDrain(true) + .build()); + sidecarOnNextLatch.countDown(); + try { + if (sidecarFinishLatch.await(5, TimeUnit.SECONDS)) { + sidecarOnCompletedLatch.countDown(); + responseObserver.onCompleted(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(scheduler) + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).executor(scheduler).build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + final CountDownLatch dataPlaneFinishLatch = new CountDownLatch(1); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + new Thread(() -> { + try { + if (dataPlaneFinishLatch.await(5, TimeUnit.SECONDS)) { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + final CountDownLatch onReadyLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onReady() { + onReadyLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + proxyCall.request(1); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + // Wait for sidecar to send drain and test to observe it + assertThat(sidecarOnNextLatch.await(5, TimeUnit.SECONDS)).isTrue(); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + assertThat(proxyCall.isReady()).isFalse(); + + // Now let sidecar complete + sidecarFinishLatch.countDown(); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + dataPlaneFinishLatch.countDown(); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + assertThat(sidecarOnCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + + // After sidecar stream completes, it should trigger onReady and become ready + assertThat(onReadyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { + fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + channelManager.close(); + for (int i = 0; i < 10; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + } + } + + @Test + @SuppressWarnings("unchecked") + public void givenDrainingStream_whenExtProcStreamCompletes_thenMessagesProceed() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch sidecarFinishLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + new Thread(() -> { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestDrain(true) + .build()); + try { + if (sidecarFinishLatch.await(5, TimeUnit.SECONDS)) { + responseObserver.onCompleted(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + // Already handled in the background thread + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicReference dataPlaneReceivedMessage = new AtomicReference<>(); + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + final CountDownLatch dataPlaneFinishLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + dataPlaneReceivedMessage.set(request); + new Thread(() -> { + try { + if (dataPlaneFinishLatch.await(5, TimeUnit.SECONDS)) { + responseObserver.onNext("Direct Response"); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference appReceivedMessage = new AtomicReference<>(); + final CountDownLatch appLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onMessage(String message) { + appReceivedMessage.set(message); + appLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Wait for drain to be processed + long startTime = System.currentTimeMillis(); + while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isFalse(); + + // Now let sidecar complete + sidecarFinishLatch.countDown(); + + // Wait for it to become ready again + startTime = System.currentTimeMillis(); + while (!proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + // Request messages from server + proxyCall.request(1); + + // 1. Verify application message is forwarded to data plane WITHOUT sidecar contact + proxyCall.sendMessage("Direct Message"); + proxyCall.halfClose(); + + // Let data plane finish + dataPlaneFinishLatch.countDown(); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(dataPlaneReceivedMessage.get()).isEqualTo("Direct Message"); + + // 2. Verify server response is delivered to application WITHOUT sidecar call + assertThat(appLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessage.get()).isEqualTo("Direct Response"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 8: Inbound Backpressure (request(n) / pendingRequests) --- + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityTrue_whenExtProcBusy_thenAppRequestsBuffered() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall< + ReqT, RespT>(next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) { + dataPlaneRequestCount.incrementAndGet(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for sidecar call to start + long startTime = System.currentTimeMillis(); + while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(sidecarListenerRef.get()).isNotNull(); + + // Sidecar is busy + sidecarReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + + proxyCall.request(5); + + // Verify data plane call NOT requested yet (due to observability mode and sidecar busy) + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + + // Sidecar becomes ready + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); + + // After sidecar becomes ready, pending requests should be drained to data plane. + // In real data plane, request(5) will eventually allow 5 messages. + // We don't have a direct way to check the raw request count on the server + // easily without more mocks, but we can verify it's no longer blocked. + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenRequestDrainActive_whenAppRequestsMessages_thenRequestsBuffered() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestDrain(true) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for drain to be processed + long startTime = System.currentTimeMillis(); + while (proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isFalse(); + + // App requests more messages + proxyCall.request(3); + + // proxyCall.isReady() should remain false during drain + assertThat(proxyCall.isReady()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenBufferedRequests_whenExtProcStreamBecomesReady_thenDataPlaneDrained() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall< + ReqT, RespT>(next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for sidecar call to start + long startTime = System.currentTimeMillis(); + while (sidecarListenerRef.get() == null && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(sidecarListenerRef.get()).isNotNull(); + + // Sidecar is busy initially + sidecarReady.set(false); + + // Request from application + proxyCall.request(10); + + // Sidecar becomes ready + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); + + // Verify buffered request drained + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenExtProcStreamCompleted_whenAppRequestsMessages_thenRequestsForwarded() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + // Immediately complete the stream from server side + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for sidecar stream completion + long startTime = System.currentTimeMillis(); + while (!proxyCall.isReady() && System.currentTimeMillis() - startTime < 5000) { + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.request(7); + + // proxyCall.isReady() should remain true as sidecar is gone + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 9: Error Handling & Security --- + + @Test + @SuppressWarnings("unchecked") + public void givenFailureModeAllowFalse_whenExtProcStreamFails_thenDataPlaneCallCancelled() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setFailureModeAllow(false) // Fail Closed + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server triggers error + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + // Fail the stream immediately on headers + responseObserver.onError( + Status.INTERNAL + .withDescription("Simulated sidecar failure") + .asRuntimeException()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Verify application receives UNAVAILABLE due to sidecar failure + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenFailureModeAllowTrue_whenExtProcStreamFails_thenCallFailsOpen() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setFailureModeAllow(true) // Fail Open + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + new Thread(() -> { + responseObserver.onError(Status.INTERNAL.asRuntimeException()); + }).start(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Send message and half-close to trigger unary call reaching server + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify data plane call reached (failed open) + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponse_whenReceived_thenDataPlaneCallCancelled() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse(ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .setDetails("Custom security rejection") + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final AtomicBoolean dataPlaneStarted = new AtomicBoolean(false); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + dataPlaneStarted.set(true); + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Verify app listener notified with the correct status and details + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNAUTHENTICATED); + assertThat(closedStatus.get().getDescription()).isEqualTo("Custom security rejection"); + + // Data plane call should NOT have been started as sidecar rejected immediately on headers + assertThat(dataPlaneStarted.get()).isFalse(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseDisabled_whenReceived_thenSidecarStreamErrored() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setDisableImmediateResponse(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server sends immediate response despite being disabled + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(extProcServerName) + .addService(new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setImmediateResponse( + ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.UNAUTHENTICATED.getCode().value()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }) + .executor(fakeClock.getScheduledExecutorService()) + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = + CallOptions.DEFAULT.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + for (int i = 0; i < 1000 && closedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + // Verify app listener notified with an error (not the sidecar's UNAUTHENTICATED) + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // It might be INTERNAL (from our onError) or UNAVAILABLE (if stream cancels) + assertThat(closedStatus.get().getCode()) + .isAnyOf(Status.Code.INTERNAL, Status.Code.UNAVAILABLE); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + channelManager.close(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityMode_whenDataPlaneClosed_thenSidecarCloseIsDeferred() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(true) + .setDeferredCloseTimeout( + com.google.protobuf.Duration.newBuilder().setSeconds(10).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch sidecarCompletedLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + sidecarCompletedLatch.countDown(); + } + }; + } + }; + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .executor(fakeClock.getScheduledExecutorService()) + .build() + .start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }; + + CallOptions callOptions = + CallOptions.DEFAULT.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Data plane closes immediately + proxyCall.halfClose(); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("test"); + responseObserver.onCompleted(); + })) + .build()); + proxyCall.request(1); + + // Wait for app onClose + for (int i = 0; i < 1000 && appCloseLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // At this point, app received onClose, but sidecar should NOT be completed yet + assertThat(sidecarCompletedLatch.getCount()).isEqualTo(1); + + // Fast forward time to trigger deferred close + fakeClock.forwardTime(10, TimeUnit.SECONDS); + + for (int i = 0; i < 100 && sidecarCompletedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + assertThat(sidecarCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 && (!dataPlaneChannel.isTerminated() || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + channelManager.close(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void givenUnsupportedCompressionInResponse_whenReceived_thenStreamErrored() + throws Exception { + String uniqueExtProcServerName = + "extProc-compression-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-compression-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + // Simulate sidecar sending compressed body mutation (unsupported) + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setGrpcMessageCompressed(true) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(fakeClock.getScheduledExecutorService()) + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + return next.startCall(call, headers); + } + })); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = + CallOptions.DEFAULT.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Wait for sidecar to receive headers and filter to activate call + for (int i = 0; i < 5000 && closedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); + Thread.sleep(1); + } + + // Trigger request body processing to hit the unsupported compression check + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify application receives UNAVAILABLE with correct description + for (int i = 0; i < 10000 && closedLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.MILLISECONDS); + } + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenUnsupportedCompressionInResponseBody_whenReceived_thenStreamErrored() + throws Exception { + String uniqueExtProcServerName = + "extProc-resp-compression-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-resp-compression-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseBody()) { + // Simulate sidecar sending compressed body mutation (unsupported) for response body + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setGrpcMessageCompressed(true) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify application receives UNAVAILABLE with correct description + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseInTrailers_whenReceived_thenDataPlaneCallStatusIsOverridden() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (request.hasResponseTrailers()) { + new Thread(() -> { + responseObserver.onNext( + ProcessingResponse.newBuilder() + .setImmediateResponse( + ImmediateResponse.newBuilder() + .setGrpcStatus( + io.envoyproxy.envoy.service.ext_proc.v3.GrpcStatus.newBuilder() + .setStatus(Status.DATA_LOSS.getCode().value()) + .build()) + .setDetails("Sidecar detected data loss") + .setHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation + .newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("x-sidecar-extra") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + }).start(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference closedStatus = new AtomicReference<>(); + final AtomicReference closedTrailers = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedTrailers.set(trailers); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Request message to allow the call to complete + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify application receives the OVERRIDDEN status and merged trailers + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.DATA_LOSS); + assertThat(closedStatus.get().getDescription()).isEqualTo("Sidecar detected data loss"); + assertThat( + closedTrailers + .get() + .get(Metadata.Key.of("x-sidecar-extra", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("true"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenHeaderSendModeDefault_whenProcessing_thenFollowsDefaultBehavior() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + uniqueExtProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.DEFAULT) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.DEFAULT).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final AtomicInteger sidecarRequestHeaderCount = new AtomicInteger(0); + final AtomicInteger sidecarResponseHeaderCount = new AtomicInteger(0); + final AtomicInteger sidecarResponseTrailerCount = new AtomicInteger(0); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + sidecarRequestHeaderCount.incrementAndGet(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + sidecarResponseHeaderCount.incrementAndGet(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseTrailers()) { + sidecarResponseTrailerCount.incrementAndGet(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder().build()) + .build()); + responseObserver.onCompleted(); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + }; + final io.grpc.Server extProcServer = + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(fakeClock.getScheduledExecutorService()) + .build() + .start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + }); + + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + final io.grpc.Server dataPlaneServer = + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .executor(fakeClock.getScheduledExecutorService()) + .build() + .start()); + uniqueRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("test"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(fakeClock.getScheduledExecutorService()) + .build()); + + try { + final CountDownLatch finishLatch = new CountDownLatch(1); + CallOptions callOptions = + CallOptions.DEFAULT.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + finishLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + for (int i = 0; i < 1000 && finishLatch.getCount() > 0; i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Defaults: Request headers SENT, Response headers SENT, Response trailers SKIPPED + assertThat(sidecarRequestHeaderCount.get()).isEqualTo(1); + assertThat(sidecarResponseHeaderCount.get()).isEqualTo(1); + assertThat(sidecarResponseTrailerCount.get()).isEqualTo(0); + + proxyCall.cancel("Cleanup", null); + } finally { + dataPlaneChannel.shutdownNow(); + dataPlaneServer.shutdownNow(); + extProcServer.shutdownNow(); + for (int i = 0; + i < 100 + && (!dataPlaneChannel.isTerminated() + || !dataPlaneServer.isTerminated() + || !extProcServer.isTerminated()); + i++) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(1); + } + channelManager.close(); + } + } + + // --- Category 10: Resource Management --- + + @Test + public void givenFilter_whenClosed_thenCachedChannelManagerIsClosed() throws Exception { + CachedChannelManager mockChannelManager = Mockito.mock(CachedChannelManager.class); + + ExternalProcessorFilter filter = new ExternalProcessorFilter("test", mockChannelManager); + + filter.close(); + + Mockito.verify(mockChannelManager).close(); + } + + // --- Category 11: Data plane rpc cancellation --- + + @Test + @SuppressWarnings("unchecked") + public void givenActiveRpc_whenDataPlaneCallCancelled_thenExtProcStreamIsErrored() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // External Processor Server + final CountDownLatch cancelLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + cancelLatch.countDown(); + } + + @Override + public void onCompleted() { + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + // No-op + })) + .build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for activation + for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { + fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + // Application cancels the RPC + proxyCall.cancel("User cancelled", null); + + // Verify sidecar stream also cancelled + assertThat(cancelLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + channelManager.close(); + } + + // --- Category 12: Flow Control when side stream is full --- + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityModeFalse_whenExtProcBusy_thenIsReadyReturnsFalse() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(false) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // Sidecar server + final CountDownLatch sidecarActionLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + new Thread(() -> { + if (request.hasRequestHeaders()) { + sidecarActionLatch.countDown(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + }).start(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicBoolean dataPlaneReady = new AtomicBoolean(true); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + return dataPlaneReady.get() && super.isReady(); + } + }; + } + }) + .build()); + + CallOptions callOptions2 = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions2, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for activation + assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { + fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + // Sidecar becomes busy -> proxyCall becomes busy + sidecarReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + + // Sidecar becomes ready, but Data Plane is busy -> proxyCall is STILL ready because Normal Mode + sidecarReady.set(true); + dataPlaneReady.set(false); + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityModeFalse_whenExtProcBusy_thenAppRequestsAreBuffered() + throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + .addChannelCredentialsPlugin(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.grpc_service." + + "channel_credentials.insecure.v3.InsecureCredentials") + .build()) + .build()) + .build()) + .setObservabilityMode(false) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + // Sidecar server + final CountDownLatch sidecarActionLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + @SuppressWarnings("unchecked") + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + new Thread(() -> { + if (request.hasRequestHeaders()) { + sidecarActionLatch.countDown(); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + }).start(); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + new Thread(() -> responseObserver.onCompleted()).start(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + final AtomicBoolean sidecarReady = new AtomicBoolean(true); + final AtomicReference> sidecarListenerRef = + new AtomicReference<>(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + sidecarListenerRef.set((Listener) responseListener); + super.start(responseListener, headers); + } + + @Override + public boolean isReady() { + return sidecarReady.get(); + } + }; + } + }) + .build()); + }); + + ExternalProcessorInterceptor interceptor = new ExternalProcessorInterceptor( + filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })) + .build()); + + final AtomicInteger dataPlaneRequestCount = new AtomicInteger(0); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .directExecutor() + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void request(int numMessages) { + dataPlaneRequestCount.addAndGet(numMessages); + super.request(numMessages); + } + }; + } + }) + .build()); + + CallOptions callOptions = CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptor.interceptCall(METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Wait for activation + assertThat(sidecarActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + for (int i = 0; i < 50 && !proxyCall.isReady(); i++) { + fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); + Thread.sleep(10); + } + assertThat(proxyCall.isReady()).isTrue(); + + // Sidecar busy -> request(5) should be buffered + sidecarReady.set(false); + proxyCall.request(5); + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + + // Sidecar becomes ready -> buffered requests should be drained + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); + + long startTime2 = System.currentTimeMillis(); + while (dataPlaneRequestCount.get() < 5 && System.currentTimeMillis() - startTime2 < 5000) { + fakeClock.forwardTime(1, TimeUnit.SECONDS); + Thread.sleep(10); + } + assertThat(dataPlaneRequestCount.get()).isEqualTo(5); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 13: Streaming Completeness (Client & Bi-Di) --- + + @Test + @SuppressWarnings({"unchecked", "FutureReturnValueIgnored"}) + public void givenClientStreamingRpc_whenExtProcMutatesAll_thenAllTargetsReceiveMutatedData() + throws Exception { + String uniqueExtProcServerName = + "extProc-client-stream-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-client-stream-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final Metadata.Key reqKey = + Metadata.Key.of("req-mutated", Metadata.ASCII_STRING_MARSHALLER); + + final List receivedPhases = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch sidecarActionLatch = new CountDownLatch(6); + final ExecutorService sidecarResponseExecutor = Executors.newSingleThreadExecutor(); + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + sidecarResponseExecutor.submit(() -> { + synchronized (responseObserver) { + ProcessingResponse.Builder resp = ProcessingResponse.newBuilder(); + if (request.hasRequestHeaders()) { + receivedPhases.add("REQ_HEADERS"); + resp.setRequestHeaders( + HeadersResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setHeaderMutation( + HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("req-mutated") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + if (request.getRequestBody().getEndOfStream() + || request.getRequestBody().getEndOfStreamWithoutMessage()) { + receivedPhases.add("REQ_BODY_EOS"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()); + } else { + receivedPhases.add("REQ_BODY_MSG"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8( + "MutatedRequest")) + .build()) + .build()) + .build()) + .build()); + } + } else if (request.hasResponseHeaders()) { + receivedPhases.add("RESP_HEADERS"); + resp.setResponseHeaders(HeadersResponse.newBuilder().build()); + } else if (request.hasResponseBody()) { + receivedPhases.add("RESP_BODY"); + resp.setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .setEndOfStream(request.getResponseBody().getEndOfStream()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseTrailers()) { + receivedPhases.add("RESP_TRAILERS"); + resp.setResponseTrailers(TrailersResponse.newBuilder().build()); + responseObserver.onNext(resp.build()); + responseObserver.onCompleted(); + sidecarActionLatch.countDown(); + return; + } + responseObserver.onNext(resp.build()); + sidecarActionLatch.countDown(); + } + }); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + final ExecutorService testExecutor = Executors.newFixedThreadPool(20); + final ExecutorService sidecarExecutor = Executors.newSingleThreadExecutor(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl).executor(sidecarExecutor).build().start()); + + // Data Plane Server (Client Streaming) + final AtomicReference serverReceivedHeaders = new AtomicReference<>(); + final AtomicReference serverReceivedBody = new AtomicReference<>(); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + uniqueRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_CLIENT_STREAMING, ServerCalls.asyncClientStreamingCall( + new ServerCalls.ClientStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) { + serverReceivedBody.set(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Ack"); + responseObserver.onCompleted(); + } + }; + } + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + serverReceivedHeaders.set(headers); + return next.startCall(call, headers); + } + })); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .executor(testExecutor) + .build().start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(testExecutor) + .build()); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(testExecutor) + .build()); + }); + ScheduledExecutorService sidecarRealScheduler = Executors.newSingleThreadScheduledExecutor(); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, sidecarRealScheduler); + + final CountDownLatch finishLatch = new CountDownLatch(1); + final AtomicReference headersFromInterceptor = new AtomicReference<>(); + Channel interceptingChannel = + io.grpc.ClientInterceptors.intercept( + dataPlaneChannel, + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + super.start( + new io.grpc.ForwardingClientCallListener + .SimpleForwardingClientCallListener(responseListener) { + @Override + public void onHeaders(Metadata headers) { + headersFromInterceptor.set(headers); + super.onHeaders(headers); + } + }, headers); + } + }; + } + }); + + final AtomicReference clientReceivedBody = new AtomicReference<>(); + StreamObserver requestObserver = ClientCalls.asyncClientStreamingCall( + interceptor.interceptCall( + METHOD_CLIENT_STREAMING, + CallOptions.DEFAULT.withExecutor(testExecutor), + interceptingChannel), + new StreamObserver() { + @Override + public void onNext(String value) { + clientReceivedBody.set(value); + } + + @Override + public void onError(Throwable t) { + finishLatch.countDown(); + } + + @Override + public void onCompleted() { + finishLatch.countDown(); + } + }); + + requestObserver.onNext("OriginalRequest"); + requestObserver.onCompleted(); + + if (!sidecarActionLatch.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Sidecar actions failed. Received: " + receivedPhases); + } + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List expectedPhases = + Arrays.asList( + "REQ_HEADERS", + "REQ_BODY_MSG", + "REQ_BODY_EOS", + "RESP_HEADERS", + "RESP_BODY", + "RESP_TRAILERS"); + assertThat(receivedPhases).containsExactlyElementsIn(expectedPhases).inOrder(); + + assertThat(serverReceivedHeaders.get().get(reqKey)).isEqualTo("true"); + assertThat(serverReceivedBody.get()).isEqualTo("MutatedRequest"); + assertThat(clientReceivedBody.get()).isEqualTo("Ack"); + + sidecarRealScheduler.shutdown(); + sidecarResponseExecutor.shutdown(); + testExecutor.shutdown(); + sidecarExecutor.shutdown(); + channelManager.close(); + } + + @Test + @SuppressWarnings({"unchecked", "FutureReturnValueIgnored"}) + public void givenBidiStreamingRpc_whenExtProcMutatesAll_thenAllTargetsReceiveMutatedData() + throws Exception { + String uniqueExtProcServerName = + "extProc-bidi-stream-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-bidi-stream-" + InProcessServerBuilder.generateName(); + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final Metadata.Key reqKey = + Metadata.Key.of("req-mutated", Metadata.ASCII_STRING_MARSHALLER); + + final List receivedPhases = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch sidecarBidiLatch = new CountDownLatch(6); + final ExecutorService bidiSidecarResponseExecutor = Executors.newSingleThreadExecutor(); + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase bidiExtProcImpl; + bidiExtProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + bidiSidecarResponseExecutor.submit(() -> { + synchronized (responseObserver) { + ProcessingResponse.Builder resp = ProcessingResponse.newBuilder(); + if (request.hasRequestHeaders()) { + receivedPhases.add("REQ_HEADERS"); + resp.setRequestHeaders( + HeadersResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setHeaderMutation( + HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("req-mutated") + .setValue("true") + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasRequestBody()) { + if (request.getRequestBody().getEndOfStream() + || request.getRequestBody().getEndOfStreamWithoutMessage()) { + receivedPhases.add("REQ_BODY_EOS"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()); + } else { + receivedPhases.add("REQ_BODY_MSG"); + resp.setRequestBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody( + ByteString.copyFromUtf8("MutatedBidiReq")) + .build()) + .build()) + .build()) + .build()); + } + } else if (request.hasResponseHeaders()) { + receivedPhases.add("RESP_HEADERS"); + resp.setResponseHeaders(HeadersResponse.newBuilder().build()); + } else if (request.hasResponseBody()) { + receivedPhases.add("RESP_BODY"); + resp.setResponseBody( + BodyResponse.newBuilder() + .setResponse( + CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setBody(request.getResponseBody().getBody()) + .setEndOfStream( + request.getResponseBody().getEndOfStream()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseTrailers()) { + receivedPhases.add("RESP_TRAILERS"); + resp.setResponseTrailers(TrailersResponse.newBuilder().build()); + responseObserver.onNext(resp.build()); + responseObserver.onCompleted(); + sidecarBidiLatch.countDown(); + return; + } + responseObserver.onNext(resp.build()); + sidecarBidiLatch.countDown(); + } + }); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + final ExecutorService bidiTestExecutor = Executors.newFixedThreadPool(20); + final ExecutorService sidecarExecutor = Executors.newSingleThreadExecutor(); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(bidiExtProcImpl) + .executor(sidecarExecutor) + .build() + .start()); + + // Data Plane Server (Bidi) + final AtomicReference serverReceivedHeaders = new AtomicReference<>(); + MutableHandlerRegistry uniqueBidiRegistry = new MutableHandlerRegistry(); + uniqueBidiRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(String value) { + responseObserver.onNext(value + "Echo"); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + serverReceivedHeaders.set(headers); + return next.startCall(call, headers); + } + })); + grpcCleanup.register( + InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueBidiRegistry) + .executor(bidiTestExecutor) + .build() + .start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(bidiTestExecutor) + .build()); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(bidiTestExecutor) + .build()); + }); + ScheduledExecutorService bidiRealScheduler = Executors.newSingleThreadScheduledExecutor(); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, bidiRealScheduler); + + final AtomicReference clientReceivedBody = new AtomicReference<>(); + final CountDownLatch finishLatch = new CountDownLatch(1); + final AtomicReference bidiHeadersFromInterceptor = new AtomicReference<>(); + + Channel bidiInterceptingChannel = + io.grpc.ClientInterceptors.intercept( + dataPlaneChannel, + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new io.grpc.ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + super.start( + new io.grpc.ForwardingClientCallListener + .SimpleForwardingClientCallListener(responseListener) { + @Override + public void onHeaders(Metadata headers) { + bidiHeadersFromInterceptor.set(headers); + super.onHeaders(headers); + } + }, headers); + } + }; + } + }); + + StreamObserver bidiRequestObserver = ClientCalls.asyncBidiStreamingCall( + interceptor.interceptCall( + METHOD_BIDI_STREAMING, + CallOptions.DEFAULT.withExecutor(bidiTestExecutor), + bidiInterceptingChannel), + new StreamObserver() { + @Override + public void onNext(String value) { + clientReceivedBody.set(value); + } + + @Override + public void onError(Throwable t) { + finishLatch.countDown(); + } + + @Override + public void onCompleted() { + finishLatch.countDown(); + } + }); + + bidiRequestObserver.onNext("Bidi"); + bidiRequestObserver.onCompleted(); + + if (!sidecarBidiLatch.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Sidecar bidi actions failed. Received: " + receivedPhases); + } + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List expectedPhases = + Arrays.asList( + "REQ_HEADERS", + "REQ_BODY_MSG", + "REQ_BODY_EOS", + "RESP_HEADERS", + "RESP_BODY", + "RESP_TRAILERS"); + assertThat(receivedPhases).containsExactlyElementsIn(expectedPhases).inOrder(); + + assertThat(serverReceivedHeaders.get().get(reqKey)).isEqualTo("true"); + assertThat(clientReceivedBody.get()).isEqualTo("MutatedBidiReqEcho"); + + bidiRealScheduler.shutdown(); + bidiSidecarResponseExecutor.shutdown(); + bidiTestExecutor.shutdown(); + sidecarExecutor.shutdown(); + channelManager.close(); + } + + // --- Category 14: Header Forwarding Rules --- + + @Test + public void givenAllowedHeaders_whenHeadersForwarded_thenOnlyAllowedAreSent() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final AtomicReference + capturedHeaders = new AtomicReference<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + capturedHeaders.set(request.getRequestHeaders()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + // Config with forward_rules: allowed_headers = ["x-allowed-*", "content-type"] + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setForwardRules(HeaderForwardingRules.newBuilder() + .setAllowedHeaders( + io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher.newBuilder() + .addPatterns( + io.envoyproxy.envoy.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("x-allowed-") + .build()) + .addPatterns( + io.envoyproxy.envoy.type.matcher.v3.StringMatcher.newBuilder() + .setExact("content-type") + .build()) + .build()) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod( + METHOD_SAY_HELLO, + ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })) + .build()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + Metadata headers = new Metadata(); + headers.put( + Metadata.Key.of("x-allowed-1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + headers.put( + Metadata.Key.of("x-disallowed", Metadata.ASCII_STRING_MARSHALLER), "v2"); + headers.put( + Metadata.Key.of("content-type", Metadata.ASCII_STRING_MARSHALLER), "application/grpc"); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }, headers); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List headerNames = new ArrayList<>(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue hv : + capturedHeaders.get().getHeaders().getHeadersList()) { + headerNames.add(hv.getKey()); + } + assertThat(headerNames).contains("x-allowed-1"); + assertThat(headerNames).contains("content-type"); + assertThat(headerNames).doesNotContain("x-disallowed"); + + channelManager.close(); + } + + @Test + public void givenDisallowedHeaders_whenHeadersForwarded_thenSkipped() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final AtomicReference capturedHeaders = + new AtomicReference<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + capturedHeaders.set(request.getRequestHeaders()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + // Config with forward_rules: disallowed_headers = ["x-secret", "authorization"] + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setForwardRules(HeaderForwardingRules.newBuilder() + .setDisallowedHeaders( + io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher.newBuilder() + .addPatterns( + io.envoyproxy.envoy.type.matcher.v3.StringMatcher.newBuilder() + .setExact("x-secret") + .build()) + .addPatterns( + io.envoyproxy.envoy.type.matcher.v3.StringMatcher.newBuilder() + .setExact("authorization") + .build()) + .build()) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-foo", Metadata.ASCII_STRING_MARSHALLER), "v1"); + headers.put(Metadata.Key.of("x-secret", Metadata.ASCII_STRING_MARSHALLER), "v2"); + headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "v3"); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }, headers); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List headerNames = new ArrayList<>(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue hv : + capturedHeaders.get().getHeaders().getHeadersList()) { + headerNames.add(hv.getKey()); + } + assertThat(headerNames).contains("x-foo"); + assertThat(headerNames).doesNotContain("x-secret"); + assertThat(headerNames).doesNotContain("authorization"); + + channelManager.close(); + } + + @Test + public void givenBothRules_whenHeadersForwarded_thenBothAreApplied() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final AtomicReference capturedHeaders = + new AtomicReference<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + capturedHeaders.set(request.getRequestHeaders()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + // Config with forward_rules: allowed = ["x-foo-*"], disallowed = ["x-foo-secret"] + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setForwardRules(HeaderForwardingRules.newBuilder() + .setAllowedHeaders( + io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher.newBuilder() + .addPatterns( + io.envoyproxy.envoy.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("x-foo-") + .build()) + .build()) + .setDisallowedHeaders( + io.envoyproxy.envoy.type.matcher.v3.ListStringMatcher.newBuilder() + .addPatterns( + io.envoyproxy.envoy.type.matcher.v3.StringMatcher.newBuilder() + .setExact("x-foo-secret") + .build()) + .build()) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-foo-1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + headers.put(Metadata.Key.of("x-foo-secret", Metadata.ASCII_STRING_MARSHALLER), "v2"); + headers.put(Metadata.Key.of("x-bar", Metadata.ASCII_STRING_MARSHALLER), "v3"); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }, headers); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + List headerNames = new ArrayList<>(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue hv : + capturedHeaders.get().getHeaders().getHeadersList()) { + headerNames.add(hv.getKey()); + } + assertThat(headerNames).contains("x-foo-1"); + assertThat(headerNames).doesNotContain("x-foo-secret"); + assertThat(headerNames).doesNotContain("x-bar"); + + channelManager.close(); + } + + // --- Category 15: Request Attributes --- + + @Test + public void parseFilterConfig_withUnrecognizedRequestAttribute_isIgnored() { + ExternalProcessor proto = createBaseProto(extProcServerName) + .addRequestAttributes("invalid.attribute") + .build(); + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(result.errorDetail).isNull(); + assertThat(result.config.getRequestAttributes()).containsExactly("invalid.attribute"); + } + + @Test + public void parseFilterConfig_withRecognizedRequestAttributes_succeeds() { + ExternalProcessor proto = createBaseProto(extProcServerName) + .addRequestAttributes("request.path") + .addRequestAttributes("request.host") + .addRequestAttributes("request.scheme") // Recognized but not set + .build(); + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(result.errorDetail).isNull(); + assertThat(result.config.getRequestAttributes()).containsExactly( + "request.path", "request.host", "request.scheme"); + } + + @Test + public void givenRequestAttributes_whenHeaderPhase_thenAttributesSent() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .addRequestAttributes("request.path") + .addRequestAttributes("request.host") + .addRequestAttributes("request.method") + .addRequestAttributes("request.query") + .build(); + + final AtomicReference capturedRequest = new AtomicReference<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final CountDownLatch callLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + capturedRequest.set(request); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + callLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(callLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + ProcessingRequest request = capturedRequest.get(); + java.util.Map attributes = request.getAttributesMap(); + assertThat(attributes.get("request.path").getFieldsOrThrow("").getStringValue()) + .isEqualTo("/test.TestService/SayHello"); + assertThat(attributes.get("request.host").getFieldsOrThrow("").getStringValue()) + .isEqualTo(dataPlaneChannel.authority()); + + channelManager.close(); + } + + @Test + public void givenMetadataAttributes_whenHeadersPresent_thenAttributesSent() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .addRequestAttributes("request.referer") + .addRequestAttributes("request.useragent") + .addRequestAttributes("request.id") + .addRequestAttributes("request.headers") + .build(); + + final AtomicReference capturedRequest = new AtomicReference<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final CountDownLatch callLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + capturedRequest.set(request); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody() + && (request.getResponseBody().getEndOfStream() + || request.getResponseBody().getEndOfStreamWithoutMessage())) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation( + BodyMutation.newBuilder() + .setStreamedResponse( + StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER), "http://google.com"); + headers.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "custom-ua"); + headers.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "req-123"); + headers.put(Metadata.Key.of("custom-header", Metadata.ASCII_STRING_MARSHALLER), "val"); + + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(Executors.newSingleThreadExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + callLatch.countDown(); + } + }, headers); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(callLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + ProcessingRequest request = capturedRequest.get(); + java.util.Map attributes = request.getAttributesMap(); + assertThat(attributes.get("request.referer").getFieldsOrThrow("").getStringValue()) + .isEqualTo("http://google.com"); + + channelManager.close(); + } + + // --- Category 16: Response Trailers --- + + @Test + public void givenResponseTrailerModeSend_whenCallCloses_thenResponseTrailersSentToExtProc() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference capturedRequest = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasResponseTrailers()) { + capturedRequest.set(request); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } else if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build() + .start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + // Improved Data Plane Server with trailers + MutableHandlerRegistry uniqueDataPlaneRegistry = new MutableHandlerRegistry(); + uniqueDataPlaneRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + return next.startCall( + new io.grpc.ForwardingServerCall.SimpleForwardingServerCall(call) { + @Override + public void close(Status status, Metadata trailers) { + trailers.put( + Metadata.Key.of("x-trailer", Metadata.ASCII_STRING_MARSHALLER), "val"); + super.close(status, trailers); + } + }, headers); + } + })); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueDataPlaneRegistry) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch callLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + callLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(callLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequest.get().hasResponseTrailers()).isTrue(); + assertThat(capturedRequest.get().getResponseTrailers().getTrailers().getHeadersList()) + .isNotEmpty(); + + channelManager.close(); + } + + @Test + public void givenResponseTrailerModeDefault_whenCallCloses_thenResponseTrailersNotSentToExtProc() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final AtomicInteger sidecarTrailerCount = new AtomicInteger(0); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final CountDownLatch sidecarHeadersLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasResponseTrailers()) { + sidecarTrailerCount.incrementAndGet(); + } else if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarHeadersLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build() + .start()); + + // DEFAULT mode for trailers (interpreted as SKIP) + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.DEFAULT) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + MutableHandlerRegistry uniqueDataPlaneRegistry = new MutableHandlerRegistry(); + uniqueDataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueDataPlaneRegistry) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarHeadersLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(10, TimeUnit.SECONDS)).isTrue(); + // Wait a bit to ensure no trailers arrive + Thread.sleep(500); + assertThat(sidecarTrailerCount.get()).isEqualTo(0); + + channelManager.close(); + } + + // --- Category 17: Trailers-Only Response --- + + @Test + public void givenTrailersOnly_whenResponseReceived_thenResponseHeadersSentWithEos() + throws Exception { + String myExtProcServerName = InProcessServerBuilder.generateName(); + final AtomicReference + capturedResponseHeadersRequest = new AtomicReference<>(); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + class MyExtProcImpl extends io.envoyproxy.envoy.service.ext_proc.v3.ExternalProcessorGrpc + .ExternalProcessorImplBase { + @Override + public io.grpc.stub.StreamObserver< + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest> + process( + final io.grpc.stub.StreamObserver< + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse> + responseObserver) { + ((io.grpc.stub.ServerCallStreamObserver< + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse>) + responseObserver) + .request(100); + return new io.grpc.stub.StreamObserver< + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest>() { + @Override + public void onNext( + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext( + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse.newBuilder() + .setRequestHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeadersResponse.newBuilder() + .build()) + .build()); + } else if (request.hasResponseHeaders()) { + capturedResponseHeadersRequest.set(request); + // Sidecar mutates the trailers-only headers (which are the trailers) + responseObserver.onNext( + io.envoyproxy.envoy.service.ext_proc.v3.ProcessingResponse.newBuilder() + .setResponseHeaders( + io.envoyproxy.envoy.service.ext_proc.v3.HeadersResponse.newBuilder() + .setResponse( + io.envoyproxy.envoy.service.ext_proc.v3.CommonResponse + .newBuilder() + .setHeaderMutation( + io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation + .newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3 + .HeaderValueOption.newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3 + .HeaderValue.newBuilder() + .setKey("x-mutated-trailer") + .setValue("val") + .build()) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + } + + io.envoyproxy.envoy.service.ext_proc.v3.ExternalProcessorGrpc.ExternalProcessorImplBase + extProcImpl = new MyExtProcImpl(); + grpcCleanup.register(InProcessServerBuilder.forName(myExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + // Explicitly enable response headers for this test + io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor proto = + createBaseProto(myExtProcServerName) + .setProcessingMode( + io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ProcessingMode.newBuilder() + .setResponseHeaderMode( + io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.ProcessingMode + .HeaderSendMode.SEND) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(myExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + // Data plane server returns trailers-only (onError results in trailers-only) + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onError( + Status.UNAUTHENTICATED + .withDescription("force-trailers-only") + .asRuntimeException()); + })).build()); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final AtomicReference capturedAppTrailers = new AtomicReference<>(); + final CountDownLatch callLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(Executors.newSingleThreadExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedAppTrailers.set(trailers); + callLatch.countDown(); + } + }, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(callLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + ProcessingRequest req = capturedResponseHeadersRequest.get(); + assertThat(req.hasResponseHeaders()).isTrue(); + assertThat(req.getResponseHeaders().getEndOfStream()).isTrue(); + + Metadata appTrailers = capturedAppTrailers.get(); + assertThat( + appTrailers.get( + Metadata.Key.of("x-mutated-trailer", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("val"); + + channelManager.close(); + } + + // --- Category 18: Response Ordering Checks --- + + @Test + public void givenOutOfOrderReqResponses_whenMessageArrivesBeforeHeaders_thenFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference extProcError = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + // Violate order: send RequestBody response before RequestHeaders response + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); // Complete stream to allow cleanup + } + } + + @Override + public void onError(Throwable t) { + extProcError.set(t); + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final AtomicReference appStatus = new AtomicReference<>(); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + appStatus.set(status); + appCloseLatch.countDown(); + } + }, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // The call should fail with UNAVAILABLE status + // due to stream failure triggered by protocol error + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(appStatus.get().getDescription()).contains("External processor stream failed"); + + channelManager.close(); + } + + @Test + public void givenValidOrder_whenResponsesArriveInOrder_thenSucceeds() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName).build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + // Verify that headers are processed correctly and the ordering check passes + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + // Clean up by cancelling the call + proxyCall.cancel("Test finished", null); + + channelManager.close(); + } + + // --- Category 19: Header Response Status Checks --- + + @Test + public void givenRequestHeadersResponse_whenStatusIsContinueAndReplace_thenFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final CountDownLatch sidecarFinishedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setStatus(CommonResponse.ResponseStatus.CONTINUE_AND_REPLACE) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + sidecarFinishedLatch.countDown(); + } + + @Override + public void onCompleted() { + sidecarFinishedLatch.countDown(); + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + // Enable fail-open + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(true) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final AtomicReference appStatus = new AtomicReference<>(); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + appStatus.set(status); + appCloseLatch.countDown(); + } + }, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + try { + proxyCall.halfClose(); + } catch (IllegalStateException ignored) { + // ignore + } + + assertThat(sidecarLatch.await(30, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarFinishedLatch.await(30, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(30, TimeUnit.SECONDS)).isTrue(); + + // Call should succeed due to fail-open + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.OK); + + channelManager.close(); + } + + @Test + public void givenResponseHeadersResponse_whenStatusIsContinueAndReplace_thenFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final CountDownLatch sidecarFinishedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setStatus(CommonResponse.ResponseStatus.CONTINUE_AND_REPLACE) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @Override + public void onError(Throwable t) { + sidecarFinishedLatch.countDown(); + } + + @Override + public void onCompleted() { + sidecarFinishedLatch.countDown(); + responseObserver.onCompleted(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build().start()); + + // Enable response headers and fail-open + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(true) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorInterceptor interceptor = + new ExternalProcessorInterceptor(filterConfig, channelManager, scheduler); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final AtomicReference appStatus = new AtomicReference<>(); + ClientCall proxyCall = + interceptor.interceptCall( + METHOD_SAY_HELLO, + CallOptions.DEFAULT.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override public void onClose(Status status, Metadata trailers) { + appStatus.set(status); + appCloseLatch.countDown(); + } + }, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + try { + proxyCall.halfClose(); + } catch (IllegalStateException ignored) { + // ignore + } + + assertThat(sidecarLatch.await(30, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarFinishedLatch.await(30, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(30, TimeUnit.SECONDS)).isTrue(); + + // The call should succeed due to fail-open + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.OK); + + channelManager.close(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/FaultFilterTest.java b/xds/src/test/java/io/grpc/xds/FaultFilterTest.java index 8f0a33951b0..494df4bed92 100644 --- a/xds/src/test/java/io/grpc/xds/FaultFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/FaultFilterTest.java @@ -26,6 +26,10 @@ import io.envoyproxy.envoy.type.v3.FractionalPercent.DenominatorType; import io.grpc.Status.Code; import io.grpc.internal.GrpcUtil; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.EnvoyProtoData.Node; +import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -45,11 +49,16 @@ public void filterType_clientOnly() { public void parseFaultAbort_convertHttpStatus() { Any rawConfig = Any.pack( HTTPFault.newBuilder().setAbort(FaultAbort.newBuilder().setHttpStatus(404)).build()); - FaultConfig faultConfig = FILTER_PROVIDER.parseFilterConfig(rawConfig).config; + FaultConfig faultConfig = FILTER_PROVIDER.parseFilterConfig( + rawConfig, getFilterContext()).config; + assertThat(faultConfig.faultAbort()).isNotNull(); assertThat(faultConfig.faultAbort().status().getCode()) .isEqualTo(GrpcUtil.httpStatusToGrpcStatus(404).getCode()); - FaultConfig faultConfigOverride = FILTER_PROVIDER.parseFilterConfigOverride(rawConfig).config; + FaultConfig faultConfigOverride = + FILTER_PROVIDER.parseFilterConfigOverride( + rawConfig, getFilterContext()).config; + assertThat(faultConfigOverride.faultAbort()).isNotNull(); assertThat(faultConfigOverride.faultAbort().status().getCode()) .isEqualTo(GrpcUtil.httpStatusToGrpcStatus(404).getCode()); } @@ -95,4 +104,17 @@ public void parseFaultAbort_withGrpcStatus() { .isEqualTo(FaultConfig.FractionalPercent.DenominatorType.MILLION); assertThat(faultAbort.status().getCode()).isEqualTo(Code.DEADLINE_EXCEEDED); } + + private static Filter.FilterContext getFilterContext() { + return Filter.FilterContext.builder() + .bootstrapInfo(BootstrapInfo.builder() + .servers(Collections.singletonList( + ServerInfo.create( + "test_target", Collections.emptyMap()))) + .node(Node.newBuilder().build()) + .build()) + .serverInfo(ServerInfo.create( + "test_target", Collections.emptyMap(), false, true, false, false)) + .build(); + } } diff --git a/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java b/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java index f252c6f4ec1..579701580b2 100644 --- a/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java @@ -65,6 +65,9 @@ import io.grpc.xds.XdsEndpointResource.EdsUpdate; import io.grpc.xds.XdsListenerResource.LdsUpdate; import io.grpc.xds.XdsRouteConfigureResource.RdsUpdate; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.EnvoyProtoData.Node; import io.grpc.xds.client.Locality; import io.grpc.xds.client.XdsResourceType; import io.grpc.xds.client.XdsResourceType.ResourceInvalidException; @@ -112,8 +115,8 @@ public void testParseFilterConfig_withValidConfig() { .build(); Any anyMessage = Any.pack(config); - ConfigOrError result = FILTER_PROVIDER.parseFilterConfig(anyMessage); - + ConfigOrError result = + FILTER_PROVIDER.parseFilterConfig(anyMessage, getFilterContext()); assertNotNull(result.config); assertNull(result.errorDetail); assertEquals(20L, result.config.getCacheSize()); @@ -126,8 +129,8 @@ public void testParseFilterConfig_withZeroCacheSize() { .build(); Any anyMessage = Any.pack(config); - ConfigOrError result = FILTER_PROVIDER.parseFilterConfig(anyMessage); - + ConfigOrError result = + FILTER_PROVIDER.parseFilterConfig(anyMessage, getFilterContext()); assertNull(result.config); assertNotNull(result.errorDetail); assertTrue(result.errorDetail.contains("cache_config.cache_size must be greater than zero")); @@ -137,7 +140,7 @@ public void testParseFilterConfig_withZeroCacheSize() { public void testParseFilterConfig_withInvalidMessageType() { Message invalidMessage = Empty.getDefaultInstance(); ConfigOrError result = - FILTER_PROVIDER.parseFilterConfig(invalidMessage); + FILTER_PROVIDER.parseFilterConfig(invalidMessage, getFilterContext()); assertNull(result.config); assertThat(result.errorDetail).contains("Invalid config type"); @@ -468,8 +471,9 @@ private static LdsUpdate getLdsUpdate() { private static RdsUpdate getRdsUpdate() { RouteConfiguration routeConfiguration = buildRouteConfiguration("my-server", RDS_NAME, CLUSTER_NAME); - XdsResourceType.Args args = new XdsResourceType.Args( - XdsTestUtils.EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", null, null, null); + XdsResourceType.Args args = + new XdsResourceType.Args(XdsTestUtils.EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", + XdsTestUtils.EMPTY_BOOTSTRAP, null, null); try { return XdsRouteConfigureResource.getInstance().doParse(args, routeConfiguration); } catch (ResourceInvalidException ex) { @@ -521,4 +525,17 @@ private static CdsUpdate getCdsUpdateWithIncorrectAudienceWrapper() throws IOExc .lbPolicyConfig(getWrrLbConfigAsMap()); return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); } + + private static Filter.FilterContext getFilterContext() { + return Filter.FilterContext.builder() + .bootstrapInfo(BootstrapInfo.builder() + .servers(Collections.singletonList( + ServerInfo.create( + "test_target", Collections.emptyMap()))) + .node(Node.newBuilder().build()) + .build()) + .serverInfo(ServerInfo.create( + "test_target", Collections.emptyMap(), false, true, false, false)) + .build(); + } } diff --git a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java index a1b1adae17f..6ac21d536f6 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java @@ -1048,7 +1048,9 @@ public void parseClusterWeight() { .setWeight(UInt32Value.newBuilder().setValue(30)) .build(); ClusterWeight clusterWeight = - XdsRouteConfigureResource.parseClusterWeight(proto, filterRegistry).getStruct(); + XdsRouteConfigureResource + .parseClusterWeight(proto, filterRegistry, getXdsResourceTypeArgs(true)) + .getStruct(); assertThat(clusterWeight.name()).isEqualTo("cluster-foo"); assertThat(clusterWeight.weight()).isEqualTo(30); } @@ -1254,7 +1256,8 @@ public void parseHttpFilter_unsupportedButOptional() { .setIsOptional(true) .setTypedConfig(Any.pack(StringValue.of("unsupported"))) .build(); - assertThat(XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true)).isNull(); + assertThat(XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true, + getXdsResourceTypeArgs(true))).isNull(); } private static class SimpleFilterConfig implements FilterConfig { @@ -1293,12 +1296,14 @@ public TestFilter newInstance(String name) { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig(Message rawProtoMessage, + FilterContext context) { return ConfigOrError.fromConfig(new SimpleFilterConfig(rawProtoMessage)); } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage, + FilterContext context) { return ConfigOrError.fromConfig(new SimpleFilterConfig(rawProtoMessage)); } } @@ -1318,7 +1323,7 @@ public void parseHttpFilter_typedStructMigration() { .setValue(rawStruct) .build())).build(); FilterConfig config = XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, - true).getStruct(); + true, getXdsResourceTypeArgs(true)).getStruct(); assertThat(((SimpleFilterConfig)config).getConfig()).isEqualTo(rawStruct); HttpFilter httpFilterNewTypeStruct = HttpFilter.newBuilder() @@ -1329,7 +1334,7 @@ public void parseHttpFilter_typedStructMigration() { .setValue(rawStruct) .build())).build(); config = XdsListenerResource.parseHttpFilter(httpFilterNewTypeStruct, filterRegistry, - true).getStruct(); + true, getXdsResourceTypeArgs(true)).getStruct(); assertThat(((SimpleFilterConfig)config).getConfig()).isEqualTo(rawStruct); } @@ -1355,7 +1360,7 @@ public void parseOverrideHttpFilter_typedStructMigration() { .build()) ); Map map = XdsRouteConfigureResource.parseOverrideFilterConfigs( - rawFilterMap, filterRegistry).getStruct(); + rawFilterMap, filterRegistry, getXdsResourceTypeArgs(true)).getStruct(); assertThat(((SimpleFilterConfig)map.get("struct-0")).getConfig()).isEqualTo(rawStruct0); assertThat(((SimpleFilterConfig)map.get("struct-1")).getConfig()).isEqualTo(rawStruct1); } @@ -1367,7 +1372,8 @@ public void parseHttpFilter_unsupportedAndRequired() { .setName("unsupported.filter") .setTypedConfig(Any.pack(StringValue.of("string value"))) .build(); - assertThat(XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true) + assertThat(XdsListenerResource + .parseHttpFilter(httpFilter, filterRegistry, true, getXdsResourceTypeArgs(true)) .getErrorDetail()).isEqualTo( "HttpFilter [unsupported.filter]" + "(type.googleapis.com/google.protobuf.StringValue) is required but unsupported " @@ -1384,7 +1390,8 @@ public void parseHttpFilter_routerFilterForClient() { .setTypedConfig(Any.pack(Router.getDefaultInstance())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, true /* isForClient */).getStruct(); + httpFilter, filterRegistry, true /* isForClient */, getXdsResourceTypeArgs(true)) + .getStruct(); assertThat(config.typeUrl()).isEqualTo(RouterFilter.TYPE_URL); } @@ -1398,7 +1405,8 @@ public void parseHttpFilter_routerFilterForServer() { .setTypedConfig(Any.pack(Router.getDefaultInstance())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, false /* isForClient */).getStruct(); + httpFilter, filterRegistry, false /* isForClient */, getXdsResourceTypeArgs(false)) + .getStruct(); assertThat(config.typeUrl()).isEqualTo(RouterFilter.TYPE_URL); } @@ -1425,7 +1433,8 @@ public void parseHttpFilter_faultConfigForClient() { .build())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, true /* isForClient */).getStruct(); + httpFilter, filterRegistry, true /* isForClient */, getXdsResourceTypeArgs(true)) + .getStruct(); assertThat(config).isInstanceOf(FaultConfig.class); } @@ -1452,7 +1461,8 @@ public void parseHttpFilter_faultConfigUnsupportedForServer() { .build())) .build(); StructOrError config = - XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, false /* isForClient */); + XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, false /* isForClient */, + getXdsResourceTypeArgs(false)); assertThat(config.getErrorDetail()).isEqualTo( "HttpFilter [envoy.fault](" + FaultFilter.TYPE_URL + ") is required but " + "unsupported for server"); @@ -1481,7 +1491,8 @@ public void parseHttpFilter_rbacConfigForServer() { .build())) .build(); FilterConfig config = XdsListenerResource.parseHttpFilter( - httpFilter, filterRegistry, false /* isForClient */).getStruct(); + httpFilter, filterRegistry, false /* isForClient */, getXdsResourceTypeArgs(false)) + .getStruct(); assertThat(config).isInstanceOf(RbacConfig.class); } @@ -1508,7 +1519,8 @@ public void parseHttpFilter_rbacConfigUnsupportedForClient() { .build())) .build(); StructOrError config = - XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true /* isForClient */); + XdsListenerResource.parseHttpFilter(httpFilter, filterRegistry, true /* isForClient */, + getXdsResourceTypeArgs(true)); assertThat(config.getErrorDetail()).isEqualTo( "HttpFilter [envoy.auth](" + RbacFilter.TYPE_URL + ") is required but " + "unsupported for client"); @@ -1533,7 +1545,8 @@ public void parseOverrideRbacFilterConfig() { .build(); Map configOverrides = ImmutableMap.of("envoy.auth", Any.pack(rbacPerRoute)); Map parsedConfigs = - XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getStruct(); assertThat(parsedConfigs).hasSize(1); assertThat(parsedConfigs).containsKey("envoy.auth"); @@ -1554,7 +1567,8 @@ public void parseOverrideFilterConfigs_unsupportedButOptional() { .setIsOptional(true).setConfig(Any.pack(StringValue.of("string value"))) .build())); Map parsedConfigs = - XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getStruct(); assertThat(parsedConfigs).hasSize(1); assertThat(parsedConfigs).containsKey("envoy.fault"); @@ -1573,7 +1587,9 @@ public void parseOverrideFilterConfigs_unsupportedAndRequired() { Any.pack(io.envoyproxy.envoy.config.route.v3.FilterConfig.newBuilder() .setIsOptional(false).setConfig(Any.pack(StringValue.of("string value"))) .build())); - assertThat(XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + assertThat(XdsRouteConfigureResource + .parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getErrorDetail()).isEqualTo( "HttpFilter [unsupported.filter]" + "(type.googleapis.com/google.protobuf.StringValue) is required but unsupported"); @@ -1583,7 +1599,9 @@ public void parseOverrideFilterConfigs_unsupportedAndRequired() { Any.pack(httpFault), "unsupported.filter", Any.pack(StringValue.of("string value"))); - assertThat(XdsRouteConfigureResource.parseOverrideFilterConfigs(configOverrides, filterRegistry) + assertThat(XdsRouteConfigureResource + .parseOverrideFilterConfigs(configOverrides, filterRegistry, + getXdsResourceTypeArgs(true)) .getErrorDetail()).isEqualTo( "HttpFilter [unsupported.filter]" + "(type.googleapis.com/google.protobuf.StringValue) is required but unsupported"); @@ -3613,7 +3631,7 @@ private static Filter buildHttpConnectionManagerFilter(HttpFilter... httpFilters private XdsResourceType.Args getXdsResourceTypeArgs(boolean isTrustedServer) { return new XdsResourceType.Args( - ServerInfo.create("http://td", "", false, isTrustedServer, false, false), "1.0", null, null, null, null + ServerInfo.create("http://td", "", false, isTrustedServer, false, false), "1.0", null, XdsTestUtils.EMPTY_BOOTSTRAP, null, null ); } } diff --git a/xds/src/test/java/io/grpc/xds/RbacFilterTest.java b/xds/src/test/java/io/grpc/xds/RbacFilterTest.java index 334e159dd1d..1dd0d93b119 100644 --- a/xds/src/test/java/io/grpc/xds/RbacFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/RbacFilterTest.java @@ -54,6 +54,9 @@ import io.grpc.Status; import io.grpc.testing.TestMethodDescriptors; import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.EnvoyProtoData.Node; import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine; import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AlwaysTrueMatcher; import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AuthConfig; @@ -120,7 +123,7 @@ public void ipPortParser() { } @Test - @SuppressWarnings({"unchecked", "deprecation"}) + @SuppressWarnings("unchecked") public void portRangeParser() { List permissionList = Arrays.asList( Permission.newBuilder().setDestinationPortRange( @@ -299,7 +302,7 @@ public void handleException() { .putPolicies("policy-name", Policy.newBuilder().setCondition(Expr.newBuilder().build()).build()) .build()).build(); - result = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + result = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(result.errorDetail).isNotNull(); } @@ -321,7 +324,8 @@ public void overrideConfig() { RbacConfig original = RbacConfig.create(authconfig); RBACPerRoute rbacPerRoute = RBACPerRoute.newBuilder().build(); - RbacConfig override = FILTER_PROVIDER.parseFilterConfigOverride(Any.pack(rbacPerRoute)).config; + RbacConfig override = FILTER_PROVIDER.parseFilterConfigOverride(Any.pack(rbacPerRoute), + getFilterContext()).config; assertThat(override).isEqualTo(RbacConfig.create(null)); ServerInterceptor interceptor = FILTER_PROVIDER.newInstance(name).buildServerInterceptor(original, override); @@ -346,22 +350,26 @@ public void ignoredConfig() { Message rawProto = io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC.newBuilder() .setRules(RBAC.newBuilder().setAction(Action.LOG) .putPolicies("policy-name", Policy.newBuilder().build()).build()).build(); - ConfigOrError result = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError result = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(result.config).isEqualTo(RbacConfig.create(null)); } @Test public void testOrderIndependenceOfPolicies() { Message rawProto = buildComplexRbac(ImmutableList.of(1, 2, 3, 4, 5, 6), true); - ConfigOrError ascFirst = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError ascFirst = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); rawProto = buildComplexRbac(ImmutableList.of(1, 2, 3, 4, 5, 6), false); - ConfigOrError ascLast = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError ascLast = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(ascFirst.config).isEqualTo(ascLast.config); rawProto = buildComplexRbac(ImmutableList.of(6, 5, 4, 3, 2, 1), true); - ConfigOrError decFirst = FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto)); + ConfigOrError decFirst = + FILTER_PROVIDER.parseFilterConfig(Any.pack(rawProto), getFilterContext()); assertThat(ascFirst.config).isEqualTo(decFirst.config); } @@ -390,7 +398,7 @@ private ConfigOrError parseRaw(List permissionList, List principalList) { Message rawProto = buildRbac(permissionList, principalList); Any proto = Any.pack(rawProto); - return FILTER_PROVIDER.parseFilterConfig(proto); + return FILTER_PROVIDER.parseFilterConfig(proto, getFilterContext()); } private io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC buildRbac( @@ -458,6 +466,19 @@ private ConfigOrError parseOverride(List permissionList, RBACPerRoute rbacPerRoute = RBACPerRoute.newBuilder().setRbac( buildRbac(permissionList, principalList)).build(); Any proto = Any.pack(rbacPerRoute); - return FILTER_PROVIDER.parseFilterConfigOverride(proto); + return FILTER_PROVIDER.parseFilterConfigOverride(proto, getFilterContext()); + } + + private Filter.FilterContext getFilterContext() { + return Filter.FilterContext.builder() + .bootstrapInfo(BootstrapInfo.builder() + .servers(Collections.singletonList( + ServerInfo.create( + "test_target", Collections.emptyMap()))) + .node(Node.newBuilder().build()) + .build()) + .serverInfo(ServerInfo.create( + "test_target", Collections.emptyMap(), false, true, false, false)) + .build(); } } diff --git a/xds/src/test/java/io/grpc/xds/StatefulFilter.java b/xds/src/test/java/io/grpc/xds/StatefulFilter.java index 4ef662c7ccd..7626222dc04 100644 --- a/xds/src/test/java/io/grpc/xds/StatefulFilter.java +++ b/xds/src/test/java/io/grpc/xds/StatefulFilter.java @@ -128,12 +128,13 @@ public synchronized int getCount() { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig(Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromConfig(Config.fromProto(rawProtoMessage, typeUrl)); } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterContext context) { return ConfigOrError.fromConfig(Config.fromProto(rawProtoMessage, typeUrl)); } } diff --git a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java index e78f97635ed..631218eb696 100644 --- a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java @@ -1264,7 +1264,7 @@ private static void createAndDeliverClusterUpdates( for (String clusterName : clusterNames) { CdsUpdate.Builder forEds = CdsUpdate .forEds(clusterName, clusterName, null, null, null, null, false, null) - .roundRobinLbPolicy(); + .roundRobinLbPolicy(); xdsClient.deliverCdsUpdate(clusterName, forEds.build()); EdsUpdate edsUpdate = new EdsUpdate(clusterName, XdsTestUtils.createMinimalLbEndpointsMap("127.0.0.3"), Collections.emptyList()); @@ -1989,7 +1989,7 @@ public void generateServiceConfig_forPerMethodConfig() throws IOException { // timeout and retry with empty retriable status codes assertThat(XdsNameResolver.generateServiceConfigWithMethodConfig( - timeoutNano, retryPolicyWithEmptyStatusCodes)) + timeoutNano, retryPolicyWithEmptyStatusCodes)) .isEqualTo(expectedServiceConfig); // retry only @@ -2042,7 +2042,7 @@ public void generateServiceConfig_forPerMethodConfig() throws IOException { // retry with emtry retriable status codes only assertThat(XdsNameResolver.generateServiceConfigWithMethodConfig( - null, retryPolicyWithEmptyStatusCodes)) + null, retryPolicyWithEmptyStatusCodes)) .isEqualTo(expectedServiceConfig); } @@ -2546,9 +2546,9 @@ public BootstrapInfo getBootstrapInfo() { @Override @SuppressWarnings("unchecked") public void watchXdsResource(XdsResourceType resourceType, - String resourceName, - ResourceWatcher watcher, - Executor syncContext) { + String resourceName, + ResourceWatcher watcher, + Executor syncContext) { switch (resourceType.typeName()) { case "LDS": @@ -2577,8 +2577,8 @@ public void watchXdsResource(XdsResourceType resou @SuppressWarnings("unchecked") @Override public void cancelXdsResourceWatch(XdsResourceType type, - String resourceName, - ResourceWatcher watcher) { + String resourceName, + ResourceWatcher watcher) { switch (type.typeName()) { case "LDS": assertThat(ldsResource).isNotNull(); diff --git a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java index f81957ee311..93113411b5e 100644 --- a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java +++ b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java @@ -88,6 +88,11 @@ public class XdsTestUtils { static final Bootstrapper.ServerInfo EMPTY_BOOTSTRAPPER_SERVER_INFO = Bootstrapper.ServerInfo.create( "td.googleapis.com", InsecureChannelCredentials.create(), false, true, false, false); + static final Bootstrapper.BootstrapInfo EMPTY_BOOTSTRAP = + Bootstrapper.BootstrapInfo.builder() + .servers(com.google.common.collect.ImmutableList.of(EMPTY_BOOTSTRAPPER_SERVER_INFO)) + .node(io.grpc.xds.client.EnvoyProtoData.Node.newBuilder().setId("node-id").build()) + .build(); public static final String ENDPOINT_HOSTNAME = "data-host"; public static final int ENDPOINT_PORT = 1234; @@ -252,7 +257,7 @@ static XdsConfig getDefaultXdsConfig(String serverHostName) RouteConfiguration routeConfiguration = buildRouteConfiguration(serverHostName, RDS_NAME, CLUSTER_NAME); XdsResourceType.Args args = new XdsResourceType.Args( - EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", null, null, null); + EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", EMPTY_BOOTSTRAP, null, null); XdsRouteConfigureResource.RdsUpdate rdsUpdate = XdsRouteConfigureResource.getInstance().doParse(args, routeConfiguration); diff --git a/xds/third_party/envoy/import.sh b/xds/third_party/envoy/import.sh index 74b8af750ab..55481d29b76 100755 --- a/xds/third_party/envoy/import.sh +++ b/xds/third_party/envoy/import.sh @@ -77,6 +77,8 @@ envoy/data/accesslog/v3/accesslog.proto envoy/extensions/clusters/aggregate/v3/cluster.proto envoy/extensions/filters/common/fault/v3/fault.proto envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto +envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto +envoy/extensions/filters/http/ext_proc/v3/processing_mode.proto envoy/extensions/common/matching/v3/extension_matcher.proto envoy/extensions/filters/http/fault/v3/fault.proto envoy/extensions/filters/http/composite/v3/composite.proto @@ -107,6 +109,7 @@ envoy/service/auth/v3/attribute_context.proto envoy/service/auth/v3/external_auth.proto envoy/service/discovery/v3/ads.proto envoy/service/discovery/v3/discovery.proto +envoy/service/ext_proc/v3/external_processor.proto envoy/service/load_stats/v3/lrs.proto envoy/service/rate_limit_quota/v3/rlqs.proto envoy/service/status/v3/csds.proto diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto new file mode 100644 index 00000000000..b07811d5235 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/ext_proc.proto @@ -0,0 +1,500 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.ext_proc.v3; + +import "envoy/config/common/mutation_rules/v3/mutation_rules.proto"; +import "envoy/config/core/v3/base.proto"; +import "envoy/config/core/v3/extension.proto"; +import "envoy/config/core/v3/grpc_service.proto"; +import "envoy/config/core/v3/http_service.proto"; +import "envoy/extensions/filters/http/ext_proc/v3/processing_mode.proto"; +import "envoy/type/matcher/v3/string.proto"; +import "envoy/type/v3/http_status.proto"; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +import "xds/annotations/v3/status.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/migrate.proto"; +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3"; +option java_outer_classname = "ExtProcProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_proc/v3;ext_procv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: External Processing Filter] +// External Processing Filter +// [#extension: envoy.filters.http.ext_proc] + +// The External Processing filter allows an external service to act on HTTP traffic in a flexible way. + +// The filter communicates with an external gRPC service called an "external processor" +// that can do a variety of things with the request and response: +// +// * Access and modify the HTTP headers on the request, response, or both. +// * Access and modify the HTTP request and response bodies. +// * Access and modify the dynamic stream metadata. +// * Immediately send an HTTP response downstream and terminate other processing. +// +// The filter communicates with the server using a gRPC bidirectional stream. After the initial +// request, the external server is in control over what additional data is sent to it +// and how it should be processed. +// +// By implementing the protocol specified by the stream, the external server can choose: +// +// * Whether it receives the response message at all. +// * Whether it receives the message body at all, in separate chunks, or as a single buffer. +// * To modify request or response trailers if they already exist. +// +// The filter supports up to six different processing steps. Each is represented by +// a gRPC stream message that is sent to the external processor. For each message, the +// processor must send a matching response. +// +// * Request headers: Contains the headers from the original HTTP request. +// * Request body: If the body is present, the behavior depends on the +// body send mode. In ``BUFFERED`` or ``BUFFERED_PARTIAL`` mode, the body is sent to the external +// processor in a single message. In ``STREAMED`` or ``FULL_DUPLEX_STREAMED`` mode, the body will +// be split across multiple messages sent to the external processor. In ``GRPC`` mode, as each +// gRPC message arrives, it will be sent to the external processor (there will be exactly one +// gRPC message in each message sent to the external processor). In ``NONE`` mode, the body will +// not be sent to the external processor. +// * Request trailers: Delivered if they are present and if the trailer mode is set +// to ``SEND``. +// * Response headers: Contains the headers from the HTTP response. Keep in mind +// that if the upstream system sends them before processing the request body that +// this message may arrive before the complete body. +// * Response body: Sent according to the processing mode like the request body. +// * Response trailers: Delivered according to the processing mode like the +// request trailers. +// +// By default, the processor sends only the request and response headers messages. +// This may be changed to include any of the six steps by changing the ``processing_mode`` +// setting of the filter configuration, or by setting the ``mode_override`` of any response +// from the external processor. The latter is only enabled if ``allow_mode_override`` is +// set to true. This way, a processor may, for example, use information +// in the request header to determine whether the message body must be examined, or whether +// the data plane should simply stream it straight through. +// +// All of this together allows a server to process the filter traffic in fairly +// sophisticated ways. For example: +// +// * A server may choose to examine all or part of the HTTP message bodies depending +// on the content of the headers. +// * A server may choose to immediately reject some messages based on their HTTP +// headers (or other dynamic metadata) and more carefully examine others. +// +// The protocol itself is based on a bidirectional gRPC stream. The data plane will send the server +// :ref:`ProcessingRequest ` +// messages, and the server must reply with +// :ref:`ProcessingResponse `. +// +// Stats about each gRPC call are recorded in a :ref:`dynamic filter state +// ` object in a namespace matching the filter +// name. +// +// [#next-free-field: 26] +message ExternalProcessor { + // Describes the route cache action to be taken when an external processor response + // is received in response to request headers. + enum RouteCacheAction { + // The default behavior is to clear the route cache only when the + // :ref:`clear_route_cache ` + // field is set in an external processor response. + DEFAULT = 0; + + // Always clear the route cache irrespective of the ``clear_route_cache`` bit in + // the external processor response. + CLEAR = 1; + + // Do not clear the route cache irrespective of the ``clear_route_cache`` bit in + // the external processor response. Setting to ``RETAIN`` is equivalent to setting the + // :ref:`disable_clear_route_cache ` + // to true. + RETAIN = 2; + } + + reserved 4; + + reserved "async_mode"; + + // Configuration for the gRPC service that the filter will communicate with. + // Only one of ``grpc_service`` or ``http_service`` can be set. + // It is required that one of them must be set. + config.core.v3.GrpcService grpc_service = 1 + [(udpa.annotations.field_migrate).oneof_promotion = "ext_proc_service_type"]; + + // Configuration for the HTTP service that the filter will communicate with. + // Only one of ``http_service`` or + // :ref:`grpc_service ` + // can be set. It is required that one of them must be set. + // + // If ``http_service`` is set, the + // :ref:`processing_mode ` + // cannot be configured to send any body or trailers. i.e., ``http_service`` only supports + // sending request or response headers to the side stream server. + // + // With this configuration, the data plane behavior is: + // + // 1. The headers are first put in a proto message + // :ref:`ProcessingRequest `. + // + // 2. This proto message is then transcoded into a JSON text. + // + // 3. The data plane then sends an HTTP POST message with content-type as "application/json", + // and this JSON text as body to the side stream server. + // + // After the side-stream receives this HTTP request message, it is expected to do as follows: + // + // 1. It converts the body, which is a JSON string, into a ``ProcessingRequest`` + // proto message to examine and mutate the headers. + // + // 2. It then sets the mutated headers into a new proto message + // :ref:`ProcessingResponse `. + // + // 3. It converts the ``ProcessingResponse`` proto message into a JSON text. + // + // 4. It then sends an HTTP response back to the data plane with status code as ``"200"``, + // ``content-type`` as ``"application/json"`` and sets the JSON text as the body. + // + ExtProcHttpService http_service = 20 [ + (udpa.annotations.field_migrate).oneof_promotion = "ext_proc_service_type", + (xds.annotations.v3.field_status).work_in_progress = true + ]; + + // By default, if in the following cases: + // + // 1. The gRPC stream cannot be established. + // + // 2. The gRPC stream is closed prematurely with an error. + // + // 3. The external processing timeouts. + // + // 4. The ext_proc server sends back spurious response messages. + // + // The filter will fail and a local reply with error code + // 504(for timeout case) or 500(for all other cases), will be sent to the downstream. + // + // However, with this parameter set to true and if the above cases happen, the processing + // continues without error. + // + bool failure_mode_allow = 2; + + // Specifies default options for how HTTP headers, trailers, and bodies are + // sent. See ``ProcessingMode`` for details. + ProcessingMode processing_mode = 3; + + // The data plane provides a number of :ref:`attributes ` + // for expressive policies. Each attribute name provided in this field will be + // matched against that list and populated in the + // :ref:`ProcessingRequest.attributes ` field. + // See the :ref:`attribute documentation ` + // for the list of supported attributes and their types. + repeated string request_attributes = 5; + + // The data plane provides a number of :ref:`attributes ` + // for expressive policies. Each attribute name provided in this field will be + // matched against that list and populated in the + // :ref:`ProcessingRequest.attributes ` field. + // See the :ref:`attribute documentation ` + // for the list of supported attributes and their types. + repeated string response_attributes = 6; + + // Specifies the timeout for each individual message sent on the stream. + // Whenever the data plane sends a message on the stream that requires a + // response, it will reset this timer, and will stop processing and return + // an error (subject to the processing mode) if the timer expires before a + // matching response is received. There is no timeout when the filter is + // running in observability mode or when the body send mode is + // ``FULL_DUPLEX_STREAMED`` or ``GRPC``. Zero is a valid config which means + // the timer will be triggered immediately. If not configured, default is + // 200 milliseconds. + google.protobuf.Duration message_timeout = 7 [(validate.rules).duration = { + lte {seconds: 3600} + gte {} + }]; + + // Optional additional prefix to use when emitting statistics. This allows to distinguish + // emitted statistics between configured ``ext_proc`` filters in an HTTP filter chain. + string stat_prefix = 8; + + // Rules that determine what modifications an external processing server may + // make to message headers. If not set, all headers may be modified except + // for "host", ":authority", ":scheme", ":method", and headers that start + // with the header prefix set via + // :ref:`header_prefix ` + // (which is usually "x-envoy"). + // Note that changing headers such as "host" or ":authority" may not in itself + // change the data plane's routing decision, as routes can be cached. To also force the + // route to be recomputed, set the + // :ref:`clear_route_cache ` + // field to true in the same response. + config.common.mutation_rules.v3.HeaderMutationRules mutation_rules = 9; + + // Specify the upper bound of + // :ref:`override_message_timeout ` + // If not specified, by default it is 0, which will effectively disable the ``override_message_timeout`` API. + google.protobuf.Duration max_message_timeout = 10 [(validate.rules).duration = { + lte {seconds: 3600} + gte {} + }]; + + // Allow headers matching the ``forward_rules`` to be forwarded to the external processing server. + // If not set, all headers are forwarded to the external processing server. + HeaderForwardingRules forward_rules = 12; + + // Additional metadata to be added to the filter state for logging purposes. The metadata + // will be added to StreamInfo's filter state under the namespace corresponding to the + // ext_proc filter name. + google.protobuf.Struct filter_metadata = 13; + + // If ``allow_mode_override`` is set to true, the filter config :ref:`processing_mode + // ` + // can be overridden by the response message from the external processing server + // :ref:`mode_override `. + // If not set, ``mode_override`` API in the response message will be ignored. + // Mode override is not supported if the body send mode is ``FULL_DUPLEX_STREAMED``. + bool allow_mode_override = 14; + + // If set to true, ignore the + // :ref:`immediate_response ` + // message in an external processor response. In such case, no local reply will be sent. + // Instead, the stream to the external processor will be closed. There will be no + // more external processing for this stream from now on. + bool disable_immediate_response = 15; + + // Options related to the sending and receiving of dynamic metadata. + MetadataOptions metadata_options = 16; + + // If true, send each part of the HTTP request or response specified by ``ProcessingMode`` + // without pausing on filter chain iteration. It is "Send and Go" mode that can be used + // by external processor to observe the request's data and status. In this mode: + // + // 1. Only ``STREAMED``, ``GRPC``, and ``NONE`` body processing modes are supported; for any + // other body processing mode, the body will not be sent. + // + // 2. External processor should not send back processing response, as any responses will be ignored. + // This also means that + // :ref:`message_timeout ` + // restriction doesn't apply to this mode. + // + // 3. External processor may still close the stream to indicate that no more messages are needed. + // + // .. warning:: + // + // Flow control is a necessary mechanism to prevent the fast sender (either downstream client or upstream server) + // from overwhelming the external processor when its processing speed is slower. + // This protective measure is being explored and developed but has not been ready yet, so please use your own + // discretion when enabling this feature. + // This work is currently tracked under https://github.com/envoyproxy/envoy/issues/33319. + // + bool observability_mode = 17; + + // Prevents clearing the route-cache when the + // :ref:`clear_route_cache ` + // field is set in an external processor response. + // Only one of ``disable_clear_route_cache`` or ``route_cache_action`` can be set. + // It is recommended to set ``route_cache_action`` which supersedes ``disable_clear_route_cache``. + bool disable_clear_route_cache = 11 + [(udpa.annotations.field_migrate).oneof_promotion = "clear_route_cache_type"]; + + // Specifies the action to be taken when an external processor response is + // received in response to request headers. It is recommended to set this field rather than set + // :ref:`disable_clear_route_cache `. + // Only one of ``disable_clear_route_cache`` or ``route_cache_action`` can be set. + RouteCacheAction route_cache_action = 18 + [(udpa.annotations.field_migrate).oneof_promotion = "clear_route_cache_type"]; + + // Specifies the deferred closure timeout for gRPC stream that connects to external processor. Currently, the deferred stream closure + // is only used in :ref:`observability_mode `. + // In observability mode, gRPC streams may be held open to the external processor longer than the lifetime of the regular client to + // backend stream lifetime. In this case, the data plane will eventually timeout the external processor stream according to this time limit. + // The default value is 5000 milliseconds (5 seconds) if not specified. + google.protobuf.Duration deferred_close_timeout = 19; + + // Send body to the side stream server once it arrives without waiting for the header response from that server. + // It only works for ``STREAMED`` body processing mode. For any other body + // processing modes, it is ignored. + // The server has two options upon receiving a header request: + // + // 1. Instant Response: send the header response as soon as the header request is received. + // + // 2. Delayed Response: wait for the body before sending any response. + // + // In all scenarios, the header-body ordering must always be maintained. + // + // If enabled the data plane will ignore the + // :ref:`mode_override ` + // value that the server sends in the header response. This is because the data plane may have already + // sent the body to the server, prior to processing the header response. + bool send_body_without_waiting_for_header_response = 21; + + // When :ref:`allow_mode_override + // ` is enabled and + // ``allowed_override_modes`` is configured, the filter config :ref:`processing_mode + // ` + // can only be overridden by the response message from the external processing server iff the + // :ref:`mode_override ` is allowed by + // the ``allowed_override_modes`` allow-list below. + // Since ``request_header_mode`` is not applicable in any way, it's ignored in comparison. + repeated ProcessingMode allowed_override_modes = 22; + + // Decorator to introduce custom logic that runs after the ``ProcessingRequest`` is constructed, but + // before it is sent to the External Processor. The ``ProcessingRequest`` may be modified. + // + // .. note:: + // Processing request modifiers are currently in alpha. + // + // [#extension-category: envoy.http.ext_proc.processing_request_modifiers] + config.core.v3.TypedExtensionConfig processing_request_modifier = 25 + [(xds.annotations.v3.field_status).work_in_progress = true]; + + // Decorator to introduce custom logic that runs after a message received from + // the External Processor is processed, but before continuing filter chain iteration. + // + // .. note:: + // Response processors are currently in alpha. + // + // [#extension-category: envoy.http.ext_proc.response_processors] + config.core.v3.TypedExtensionConfig on_processing_response = 23 + [(xds.annotations.v3.field_status).work_in_progress = true]; + + // Sets the HTTP status code that is returned to the client when the external processing server returns + // an error, fails to respond, or cannot be reached. + // + // The default status is ``HTTP 500 Internal Server Error``. + type.v3.HttpStatus status_on_error = 24; +} + +// ExtProcHttpService is used for HTTP communication between the filter and the external processing service. +message ExtProcHttpService { + // Sets the HTTP service which the external processing requests must be sent to. + config.core.v3.HttpService http_service = 1; +} + +// The MetadataOptions structure defines options for the sending and receiving of +// dynamic metadata. Specifically, which namespaces to send to the server, whether +// metadata returned by the server may be written, and how that metadata may be written. +message MetadataOptions { + message MetadataNamespaces { + // Specifies a list of metadata namespaces whose values, if present, + // will be passed to the ``ext_proc`` service as an opaque ``protobuf::Struct``. + repeated string untyped = 1; + + // Specifies a list of metadata namespaces whose values, if present, + // will be passed to the ``ext_proc`` service as a ``protobuf::Any``. This allows + // envoy and the external processing server to share the protobuf message + // definition for safe parsing. + repeated string typed = 2; + } + + // Describes which typed or untyped filter dynamic metadata namespaces to forward to + // the external processing server. + MetadataNamespaces forwarding_namespaces = 1; + + // Describes which typed or untyped filter dynamic metadata namespaces to accept from + // the external processing server. Set to empty or leave unset to disallow writing + // any received dynamic metadata. Receiving of typed metadata is not supported. + MetadataNamespaces receiving_namespaces = 2; + + // Describes which cluster metadata namespaces to forward to + // the external processing server. + // .. note:: + // This is the least specific metadata. Should there be any namespace collision, + // cluster level metadata can be overridden by filter metadata. + MetadataNamespaces cluster_metadata_forwarding_namespaces = 3; +} + +// The HeaderForwardingRules structure specifies what headers are +// allowed to be forwarded to the external processing server. +// +// This works as below: +// +// 1. If neither ``allowed_headers`` nor ``disallowed_headers`` is set, all headers are forwarded. +// 2. If both ``allowed_headers`` and ``disallowed_headers`` are set, only headers in the +// ``allowed_headers`` but not in the ``disallowed_headers`` are forwarded. +// 3. If ``allowed_headers`` is set, and ``disallowed_headers`` is not set, only headers in +// the ``allowed_headers`` are forwarded. +// 4. If ``disallowed_headers`` is set, and ``allowed_headers`` is not set, all headers except +// headers in the ``disallowed_headers`` are forwarded. +message HeaderForwardingRules { + // If set, specifically allow any header in this list to be forwarded to the external + // processing server. This can be overridden by the below ``disallowed_headers``. + type.matcher.v3.ListStringMatcher allowed_headers = 1; + + // If set, specifically disallow any header in this list to be forwarded to the external + // processing server. This overrides the above ``allowed_headers`` if a header matches both. + type.matcher.v3.ListStringMatcher disallowed_headers = 2; +} + +// Extra settings that may be added to per-route configuration for a +// virtual host or cluster. +message ExtProcPerRoute { + oneof override { + option (validate.required) = true; + + // Disable the filter for this particular vhost or route. + // If disabled is specified in multiple per-filter-configs, the most specific one will be used. + bool disabled = 1 [(validate.rules).bool = {const: true}]; + + // Override aspects of the configuration for this route. A set of + // overrides in a more specific configuration will override a "disabled" + // flag set in a less-specific one. + ExtProcOverrides overrides = 2; + } +} + +// Overrides that may be set on a per-route basis +// [#next-free-field: 10] +message ExtProcOverrides { + // Set a different processing mode for this route than the default. + ProcessingMode processing_mode = 1; + + // [#not-implemented-hide:] + // Set a different asynchronous processing option than the default. + // Deprecated and not implemented. + bool async_mode = 2 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // [#not-implemented-hide:] + // Set different optional attributes than the default setting of the + // ``request_attributes`` field. + repeated string request_attributes = 3; + + // [#not-implemented-hide:] + // Set different optional properties than the default setting of the + // ``response_attributes`` field. + repeated string response_attributes = 4; + + // Set a different gRPC service for this route than the default. + config.core.v3.GrpcService grpc_service = 5; + + // Options related to the sending and receiving of dynamic metadata. + // Lists of forwarding and receiving namespaces will be overridden in their entirety, + // meaning the most-specific config that specifies this override will be the final + // config used. It is the prerogative of the control plane to ensure this + // most-specific config contains the correct final overrides. + MetadataOptions metadata_options = 6; + + // Additional metadata to include into streams initiated to the ``ext_proc`` gRPC + // service. This can be used for scenarios in which additional ad hoc + // authorization headers (e.g. ``x-foo-bar: baz-key``) are to be injected or + // when a route needs to partially override inherited metadata. + repeated config.core.v3.HeaderValue grpc_initial_metadata = 7; + + // If true, the filter will not fail closed if the gRPC stream is prematurely closed + // or could not be opened. This field is the per-route override of + // :ref:`failure_mode_allow `. + google.protobuf.BoolValue failure_mode_allow = 8; + + // Decorator to introduce custom logic that runs after the ``ProcessingRequest`` is constructed, but + // before it is sent to the External Processor. The ``ProcessingRequest`` may be modified. + // This is a per-route override of + // :ref:`processing_request_modifier `. + config.core.v3.TypedExtensionConfig processing_request_modifier = 9 + [(xds.annotations.v3.field_status).work_in_progress = true]; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/processing_mode.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/processing_mode.proto new file mode 100644 index 00000000000..e2ec8946283 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_proc/v3/processing_mode.proto @@ -0,0 +1,152 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.ext_proc.v3; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3"; +option java_outer_classname = "ProcessingModeProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_proc/v3;ext_procv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: External Processing Filter] +// External Processing Filter Processing Mode +// [#extension: envoy.filters.http.ext_proc] + +// This configuration describes which parts of an HTTP request and +// response are sent to a remote server and how they are delivered. + +// [#next-free-field: 7] +message ProcessingMode { + // Control how headers and trailers are handled + enum HeaderSendMode { + // When used to configure the ext_proc filter :ref:`processing_mode + // `, + // the default HeaderSendMode depends on which part of the message is being processed. By + // default, request and response headers are sent, while trailers are skipped. + // + // When used in :ref:`mode_override + // ` or + // :ref:`allowed_override_modes + // `, + // a value of DEFAULT indicates that there is no change from the behavior that is configured for + // the filter in :ref:`processing_mode + // `. + DEFAULT = 0; + + // Send the header or trailer. + SEND = 1; + + // Do not send the header or trailer. + SKIP = 2; + } + + // Control how the request and response bodies are handled + // When body mutation by external processor is enabled, ext_proc filter will always remove + // the content length header in four cases below because content length can not be guaranteed + // to be set correctly: + // 1) STREAMED BodySendMode: header processing completes before body mutation comes back. + // 2) BUFFERED_PARTIAL BodySendMode: body is buffered and could be injected in different phases. + // 3) BUFFERED BodySendMode + SKIP HeaderSendMode: header processing (e.g., update content-length) is skipped. + // 4) FULL_DUPLEX_STREAMED BodySendMode: header processing completes before body mutation comes back. + // + // In Envoy's http1 codec implementation, removing content length will enable chunked transfer + // encoding whenever feasible. The recipient (either client or server) must be able + // to parse and decode the chunked transfer coding. + // (see `details in RFC9112 `_). + // + // In BUFFERED BodySendMode + SEND HeaderSendMode, content length header is allowed but it is + // external processor's responsibility to set the content length correctly matched to the length + // of mutated body. If they don't match, the corresponding body mutation will be rejected and + // local reply will be sent with an error message. + enum BodySendMode { + // Do not send the body at all. This is the default. + NONE = 0; + + // Stream the body to the server in pieces as they are seen. + STREAMED = 1; + + // Buffer the message body in memory and send the entire body at once. + // If the body exceeds the configured buffer limit, then the + // downstream system will receive an error. + BUFFERED = 2; + + // Buffer the message body in memory and send the entire body in one + // chunk. If the body exceeds the configured buffer limit, then the body contents + // up to the buffer limit will be sent. + BUFFERED_PARTIAL = 3; + + // The ext_proc client (the data plane) streams the body to the server in pieces as they arrive. + // + // 1) The server may choose to buffer any number chunks of data before processing them. + // After it finishes buffering, the server processes the buffered data. Then it splits the processed + // data into any number of chunks, and streams them back to the ext_proc client one by one. + // The server may continuously do so until the complete body is processed. + // The individual response chunk size is recommended to be no greater than 64K bytes, or + // :ref:`max_receive_message_length ` + // if EnvoyGrpc is used. + // + // 2) The server may also choose to buffer the entire message, including the headers (if header mode is + // ``SEND``), the entire body, and the trailers (if present), before sending back any response. + // The server response has to maintain the headers-body-trailers ordering. + // + // 3) Note that the server might also choose not to buffer data. That is, upon receiving a + // body request, it could process the data and send back a body response immediately. + // + // In this body mode: + // * The corresponding trailer mode has to be set to ``SEND``. + // * The client will send body and trailers (if present) to the server as they arrive. + // Sending the trailers (if present) is to inform the server the complete body arrives. + // In case there are no trailers, then the client will set + // :ref:`end_of_stream ` + // to true as part of the last body chunk request to notify the server that no other data is to be sent. + // * The server needs to send + // :ref:`StreamedBodyResponse ` + // to the client in the body response. + // * The client will stream the body chunks in the responses from the server to the upstream/downstream as they arrive. + + FULL_DUPLEX_STREAMED = 4; + + // [#not-implemented-hide:] + // A mode for gRPC traffic. This is similar to ``FULL_DUPLEX_STREAMED``, + // except that instead of sending raw chunks of the HTTP/2 DATA frames, + // the ext_proc client will de-frame the individual gRPC messages inside + // the HTTP/2 DATA frames, and as each message is de-framed, it will be + // sent to the ext_proc server as a :ref:`request_body + // ` + // or :ref:`response_body + // `. + // The ext_proc server will stream back individual gRPC messages in the + // :ref:`StreamedBodyResponse ` + // field, but the number of messages sent by the ext_proc server + // does not need to equal the number of messages sent by the data + // plane. This allows the ext_proc server to change the number of + // messages sent on the stream. + // In this mode, the client will send body and trailers to the server as + // they arrive. + GRPC = 5; + } + + // How to handle the request header. Default is "SEND". + // Note this field is ignored in :ref:`mode_override + // `, since mode + // overrides can only affect messages exchanged after the request header is processed. + HeaderSendMode request_header_mode = 1 [(validate.rules).enum = {defined_only: true}]; + + // How to handle the response header. Default is "SEND". + HeaderSendMode response_header_mode = 2 [(validate.rules).enum = {defined_only: true}]; + + // How to handle the request body. Default is "NONE". + BodySendMode request_body_mode = 3 [(validate.rules).enum = {defined_only: true}]; + + // How do handle the response body. Default is "NONE". + BodySendMode response_body_mode = 4 [(validate.rules).enum = {defined_only: true}]; + + // How to handle the request trailers. Default is "SKIP". + HeaderSendMode request_trailer_mode = 5 [(validate.rules).enum = {defined_only: true}]; + + // How to handle the response trailers. Default is "SKIP". + HeaderSendMode response_trailer_mode = 6 [(validate.rules).enum = {defined_only: true}]; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto b/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto new file mode 100644 index 00000000000..1c033c08d26 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/service/ext_proc/v3/external_processor.proto @@ -0,0 +1,533 @@ +syntax = "proto3"; + +package envoy.service.ext_proc.v3; + +import "envoy/config/core/v3/base.proto"; +import "envoy/extensions/filters/http/ext_proc/v3/processing_mode.proto"; +import "envoy/type/v3/http_status.proto"; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; + +import "xds/annotations/v3/status.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.service.ext_proc.v3"; +option java_outer_classname = "ExternalProcessorProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3;ext_procv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: External processing service] + +// A service that can access and modify HTTP requests and responses +// as part of a filter chain. +// The overall external processing protocol works like this: +// +// 1. The data plane sends to the service information about the HTTP request. +// 2. The service sends back a ProcessingResponse message that directs +// the data plane to either stop processing, continue without it, or send +// it the next chunk of the message body. +// 3. If so requested, the data plane sends the server the message body in +// chunks, or the entire body at once. In either case, the server may send +// back a ProcessingResponse for each message it receives, or wait for +// a certain amount of body chunks received before streaming back the +// ProcessingResponse messages. +// 4. If so requested, the data plane sends the server the HTTP trailers, +// and the server sends back a ProcessingResponse. +// 5. At this point, request processing is done, and we pick up again +// at step 1 when the data plane receives a response from the upstream +// server. +// 6. At any point above, if the server closes the gRPC stream cleanly, +// then the data plane proceeds without consulting the server. +// 7. At any point above, if the server closes the gRPC stream with an error, +// then the data plane returns a 500 error to the client, unless the filter +// was configured to ignore errors. +// +// In other words, the process is a request/response conversation, but +// using a gRPC stream to make it easier for the server to +// maintain state. +service ExternalProcessor { + // This begins the bidirectional stream that the data plane will use to + // give the server control over what the filter does. The actual + // protocol is described by the ProcessingRequest and ProcessingResponse + // messages below. + rpc Process(stream ProcessingRequest) returns (stream ProcessingResponse) { + } +} + +// This message specifies the filter protocol configurations which will be sent to the ext_proc +// server in a :ref:`ProcessingRequest `. +// If the server does not support these protocol configurations, it may choose to close the gRPC stream. +// If the server supports these protocol configurations, it should respond based on the API specifications. +message ProtocolConfiguration { + // Specify the filter configuration :ref:`request_body_mode + // ` + envoy.extensions.filters.http.ext_proc.v3.ProcessingMode.BodySendMode request_body_mode = 1 + [(validate.rules).enum = {defined_only: true}]; + + // Specify the filter configuration :ref:`response_body_mode + // ` + envoy.extensions.filters.http.ext_proc.v3.ProcessingMode.BodySendMode response_body_mode = 2 + [(validate.rules).enum = {defined_only: true}]; + + // Specify the filter configuration :ref:`send_body_without_waiting_for_header_response + // ` + // If the client is waiting for a header response from the server, setting ``true`` means the client will send body to the server + // as they arrive. Setting ``false`` means the client will buffer the arrived data and not send it to the server immediately. + bool send_body_without_waiting_for_header_response = 3; +} + +// This represents the different types of messages that the data plane can send +// to an external processing server. +// [#next-free-field: 12] +message ProcessingRequest { + reserved 1; + + reserved "async_mode"; + + // Each request message will include one of the following sub-messages. Which + // ones are set for a particular HTTP request/response depend on the + // processing mode. + oneof request { + option (validate.required) = true; + + // Information about the HTTP request headers, as well as peer info and additional + // properties. Unless ``observability_mode`` is ``true``, the server must send back a + // HeaderResponse message, an ImmediateResponse message, or close the stream. + HttpHeaders request_headers = 2; + + // Information about the HTTP response headers, as well as peer info and additional + // properties. Unless ``observability_mode`` is ``true``, the server must send back a + // HeaderResponse message or close the stream. + HttpHeaders response_headers = 3; + + // A chunk of the HTTP request body. Unless ``observability_mode`` is true, the server must send back + // a BodyResponse message, an ImmediateResponse message, or close the stream. + HttpBody request_body = 4; + + // A chunk of the HTTP response body. Unless ``observability_mode`` is ``true``, the server must send back + // a BodyResponse message or close the stream. + HttpBody response_body = 5; + + // The HTTP trailers for the request path. Unless ``observability_mode`` is ``true``, the server + // must send back a TrailerResponse message or close the stream. + // + // This message is only sent if the trailers processing mode is set to ``SEND`` and + // the original downstream request has trailers. + HttpTrailers request_trailers = 6; + + // The HTTP trailers for the response path. Unless ``observability_mode`` is ``true``, the server + // must send back a TrailerResponse message or close the stream. + // + // This message is only sent if the trailers processing mode is set to ``SEND`` and + // the original upstream response has trailers. + HttpTrailers response_trailers = 7; + } + + // Dynamic metadata associated with the request. + config.core.v3.Metadata metadata_context = 8; + + // The values of properties selected by the ``request_attributes`` + // or ``response_attributes`` list in the configuration. Each entry + // in the list is populated from the standard + // :ref:`attributes ` supported in the data plane. + map attributes = 9; + + // Specify whether the filter that sent this request is running in :ref:`observability_mode + // ` + // and defaults to false. + // + // * A value of ``false`` indicates that the server must respond + // to this message by either sending back a matching ProcessingResponse message, + // or by closing the stream. + // * A value of ``true`` indicates that the server should not respond to this message, as any + // responses will be ignored. However, it may still close the stream to indicate that no more messages + // are needed. + // + bool observability_mode = 10; + + // Specify the filter protocol configurations to be sent to the server. + // ``protocol_config`` is only encoded in the first ``ProcessingRequest`` message from the client to the server. + ProtocolConfiguration protocol_config = 11; +} + +// This represents the different types of messages the server may send back to the data plane +// when the ``observability_mode`` field in the received ProcessingRequest is set to false. +// +// * If the corresponding ``BodySendMode`` in the +// :ref:`processing_mode ` +// is not set to ``FULL_DUPLEX_STREAMED``, then for every received ProcessingRequest, +// the server must send back exactly one ProcessingResponse message. +// * If it is set to ``FULL_DUPLEX_STREAMED``, the server must follow the API defined +// for this mode to send the ProcessingResponse messages. +// [#next-free-field: 13] +message ProcessingResponse { + // The response type that is sent by the server. + oneof response { + option (validate.required) = true; + + // The server must send back this message in response to a message with the + // ``request_headers`` field set. + HeadersResponse request_headers = 1; + + // The server must send back this message in response to a message with the + // ``response_headers`` field set. + HeadersResponse response_headers = 2; + + // The server must send back this message in response to a message with + // the ``request_body`` field set. + BodyResponse request_body = 3; + + // The server must send back this message in response to a message with + // the ``response_body`` field set. + BodyResponse response_body = 4; + + // The server must send back this message in response to a message with + // the ``request_trailers`` field set. + TrailersResponse request_trailers = 5; + + // The server must send back this message in response to a message with + // the ``response_trailers`` field set. + TrailersResponse response_trailers = 6; + + // If specified, attempt to create a locally generated response, send it + // downstream, and stop processing additional filters and ignore any + // additional messages received from the remote server for this request or + // response. If a response has already started -- for example, if this + // message is sent response to a ``response_body`` message -- then + // this will either ship the reply directly to the downstream codec, + // or reset the stream. + ImmediateResponse immediate_response = 7; + + // The server sends back this message to initiate or continue local response streaming. + // The server must initiate local response streaming with the ``headers_response`` in response to a ProcessingRequest + // with the ``request_headers`` only. + // The server may follow up with multiple messages containing ``body_response``. The server must indicate + // end of stream by setting ``end_of_stream`` to ``true`` in the ``headers_response`` + // or ``body_response`` message or by sending a ``trailers_response`` message. + // The client may send a ``request_body`` or ``request_trailers`` to the server depending on configuration. + // The streaming local response can only be sent when the ``request_header_mode`` in the filter + // :ref:`processing_mode ` + // is set to ``SEND``. The ext_proc server should not send StreamedImmediateResponse if it did not observe request headers, + // as it will result in the race with the upstream server response and reset of the client request. + // Presently only the FULL_DUPLEX_STREAMED or NONE body modes are supported. + StreamedImmediateResponse streamed_immediate_response = 11; + } + + // Optional metadata that will be emitted as dynamic metadata to be consumed by + // following filters. This metadata will be placed in the namespace(s) specified by the top-level + // field name(s) of the struct. + google.protobuf.Struct dynamic_metadata = 8; + + // Override how parts of the HTTP request and response are processed + // for the duration of this particular request/response only. Servers + // may use this to intelligently control how requests are processed + // based on the headers and other metadata that they see. + // This field is only applicable when servers responding to the header requests. + // If it is set in the response to the body or trailer requests, it will be ignored by the data plane. + // It is also ignored by the data plane when the ext_proc filter config + // :ref:`allow_mode_override + // ` + // is set to false, or + // :ref:`send_body_without_waiting_for_header_response + // ` + // is set to true. + envoy.extensions.filters.http.ext_proc.v3.ProcessingMode mode_override = 9; + + // [#not-implemented-hide:] + // Used only in ``FULL_DUPLEX_STREAMED`` and ``GRPC`` body send modes. + // Instructs the data plane to stop sending body data and to send a + // half-close on the ext_proc stream. The ext_proc server should then echo + // back all subsequent body contents as-is until it sees the client's + // half-close, at which point the ext_proc server can terminate the stream + // with an OK status. This provides a safe way for the ext_proc server + // to indicate that it does not need to see the rest of the stream; + // without this, the ext_proc server could not terminate the stream + // early, because it would wind up dropping any body contents that the + // client had already sent before it saw the ext_proc stream termination. + bool request_drain = 12; + + // When ext_proc server receives a request message, in case it needs more + // time to process the message, it sends back a ProcessingResponse message + // with a new timeout value. When the data plane receives this response + // message, it ignores other fields in the response, just stop the original + // timer, which has the timeout value specified in + // :ref:`message_timeout + // ` + // and start a new timer with this ``override_message_timeout`` value and keep the + // data plane ext_proc filter state machine intact. + // Has to be >= 1ms and <= + // :ref:`max_message_timeout ` + // Such message can be sent at most once in a particular data plane ext_proc filter processing state. + // To enable this API, one has to set ``max_message_timeout`` to a number >= 1ms. + google.protobuf.Duration override_message_timeout = 10; +} + +// The following are messages that are sent to the server. + +// This message is sent to the external server when the HTTP request and responses +// are first received. +message HttpHeaders { + // The HTTP request headers. All header keys will be + // lower-cased, because HTTP header keys are case-insensitive. + // The header value is encoded in the + // :ref:`raw_value ` field. + config.core.v3.HeaderMap headers = 1; + + // [#not-implemented-hide:] + // This field is deprecated and not implemented. Attributes will be sent in + // the top-level :ref:`attributes attributes = 2 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // If ``true``, then there is no message body associated with this + // request or response. + bool end_of_stream = 3; +} + +// This message is sent to the external server when the HTTP request and +// response bodies are received. +message HttpBody { + // The contents of the body in the HTTP request/response. Note that in + // streaming mode multiple ``HttpBody`` messages may be sent. + // + // In ``GRPC`` body send mode, a separate ``HttpBody`` message will be + // sent for each message in the gRPC stream. + bytes body = 1; + + // If ``true``, this will be the last ``HttpBody`` message that will be sent and no + // trailers will be sent for the current request/response. + bool end_of_stream = 2; + + // This field is used in ``GRPC`` body send mode when ``end_of_stream`` is + // true and ``body`` is empty. Those values would normally indicate an + // empty message on the stream with the end-of-stream bit set. + // However, if the half-close happens after the last message on the + // stream was already sent, then this field will be true to indicate an + // end-of-stream with *no* message (as opposed to an empty message). + bool end_of_stream_without_message = 3; + + // This field is used in ``GRPC`` body send mode to indicate whether + // the message is compressed. This will never be set to true by gRPC + // but may be set to true by a proxy like Envoy. + bool grpc_message_compressed = 4; +} + +// This message is sent to the external server when the HTTP request and +// response trailers are received. +message HttpTrailers { + // The header value is encoded in the + // :ref:`raw_value ` field. + config.core.v3.HeaderMap trailers = 1; +} + +// The following are messages that may be sent back by the server. + +// This message is sent by the external server to the data plane after ``HttpHeaders`` was +// sent to it. +message HeadersResponse { + // Details the modifications (if any) to be made by the data plane to the current + // request/response. + CommonResponse response = 1; +} + +// This message is sent by the external server to the data plane after ``HttpBody`` was +// sent to it. +message BodyResponse { + // Details the modifications (if any) to be made by the data plane to the current + // request/response. + CommonResponse response = 1; +} + +// This message is sent by the external server to the data plane after ``HttpTrailers`` was +// sent to it. +message TrailersResponse { + // Details the modifications (if any) to be made by the data plane to the current + // request/response trailers. + HeaderMutation header_mutation = 1; +} + +// This message is sent by the external server to the data plane after ``HttpHeaders`` +// to initiate local response streaming. The server may follow up with multiple messages containing ``body_response``. +// The server must indicate end of stream by setting ``end_of_stream`` to ``true`` in the ``headers_response`` +// or ``body_response`` message or by sending a ``trailers_response`` message. +message StreamedImmediateResponse { + oneof response { + // Response headers to be sent downstream. The ":status" header must be set. + HttpHeaders headers_response = 1; + + // Response body to be sent downstream. + StreamedBodyResponse body_response = 2; + + // Response trailers to be sent downstream. + config.core.v3.HeaderMap trailers_response = 3; + } +} + +// This message contains common fields between header and body responses. +// [#next-free-field: 6] +message CommonResponse { + // The status of the response. + enum ResponseStatus { + // Apply the mutation instructions in this message to the + // request or response, and then continue processing the filter + // stream as normal. This is the default. + CONTINUE = 0; + + // Apply the specified header mutation, replace the body with the body + // specified in the body mutation (if present), and do not send any + // further messages for this request or response even if the processing + // mode is configured to do so. + // + // When used in response to a request_headers or response_headers message, + // this status makes it possible to either completely replace the body + // while discarding the original body, or to add a body to a message that + // formerly did not have one. + // + // In other words, this response makes it possible to turn an HTTP GET + // into a POST, PUT, or PATCH. + // + // Not supported if the body send mode is ``GRPC``. + CONTINUE_AND_REPLACE = 1; + } + + // If set, provide additional direction on how the data plane should + // handle the rest of the HTTP filter chain. + ResponseStatus status = 1 [(validate.rules).enum = {defined_only: true}]; + + // Instructions on how to manipulate the headers. When responding to an + // HttpBody request, header mutations will only take effect if + // the current processing mode for the body is BUFFERED. + HeaderMutation header_mutation = 2; + + // Replace the body of the last message sent to the remote server on this + // stream. If responding to an HttpBody request, simply replace or clear + // the body chunk that was sent with that request. Body mutations may take + // effect in response either to ``header`` or ``body`` messages. When it is + // in response to ``header`` messages, it only take effect if the + // :ref:`status ` + // is set to CONTINUE_AND_REPLACE. + BodyMutation body_mutation = 3; + + // [#not-implemented-hide:] + // Add new trailers to the message. This may be used when responding to either a + // HttpHeaders or HttpBody message, but only if this message is returned + // along with the CONTINUE_AND_REPLACE status. + // The header value is encoded in the + // :ref:`raw_value ` field. + config.core.v3.HeaderMap trailers = 4; + + // Clear the route cache for the current client request. This is necessary + // if the remote server modified headers that are used to calculate the route. + // This field is ignored in the response direction. This field is also ignored + // if the data plane ext_proc filter is in the upstream filter chain. + bool clear_route_cache = 5; +} + +// This message causes the filter to attempt to create a locally +// generated response, send it downstream, stop processing +// additional filters, and ignore any additional messages received +// from the remote server for this request or response. If a response +// has already started, then this will either ship the reply directly +// to the downstream codec, or reset the stream. +// [#next-free-field: 6] +message ImmediateResponse { + // The response code to return. + type.v3.HttpStatus status = 1 [(validate.rules).message = {required: true}]; + + // Apply changes to the default headers, which will include content-type. + HeaderMutation headers = 2; + + // The message body to return with the response which is sent using the + // text/plain content type, or encoded in the grpc-message header. + bytes body = 3; + + // If set, then include a gRPC status trailer. + GrpcStatus grpc_status = 4; + + // A string detailing why this local reply was sent, which may be included + // in log and debug output (e.g. this populates the %RESPONSE_CODE_DETAILS% + // command operator field for use in access logging). + string details = 5; +} + +// This message specifies a gRPC status for an ImmediateResponse message. +message GrpcStatus { + // The actual gRPC status. + uint32 status = 1; +} + +// Change HTTP headers or trailers by appending, replacing, or removing +// headers. +message HeaderMutation { + // Add or replace HTTP headers. Attempts to set the value of + // any ``x-envoy`` header, and attempts to set the ``:method``, + // ``:authority``, ``:scheme``, or ``host`` headers will be ignored. + // The header value is encoded in the + // :ref:`raw_value ` field. + repeated config.core.v3.HeaderValueOption set_headers = 1; + + // Remove these HTTP headers. Attempts to remove system headers -- + // any header starting with ``:``, plus ``host`` -- will be ignored. + repeated string remove_headers = 2; +} + +// The body response message corresponding to ``FULL_DUPLEX_STREAMED`` or ``GRPC`` body modes. +message StreamedBodyResponse { + // In ``FULL_DUPLEX_STREAMED`` body send mode, contains the body response chunk that will be + // passed to the upstream/downstream by the data plane. In ``GRPC`` body send mode, contains + // a serialized gRPC message to be passed to the upstream/downstream by the data plane. + bytes body = 1; + + // The server sets this flag to true if it has received a body request with + // :ref:`end_of_stream ` set to true, + // and this is the last chunk of body responses. + // Note that in ``GRPC`` body send mode, this allows the ext_proc + // server to tell the data plane to send a half close after a client + // message, which will result in discarding any other messages sent by + // the client application. + bool end_of_stream = 2; + + // This field is used in ``GRPC`` body send mode when ``end_of_stream`` is + // true and ``body`` is empty. Those values would normally indicate an + // empty message on the stream with the end-of-stream bit set. + // However, if the half-close happens after the last message on the + // stream was already sent, then this field will be true to indicate an + // end-of-stream with *no* message (as opposed to an empty message). + bool end_of_stream_without_message = 3; + + // This field is used in ``GRPC`` body send mode to indicate whether + // the message is compressed. This will never be set to true by gRPC + // but may be set to true by a proxy like Envoy. + bool grpc_message_compressed = 4; +} + +// This message specifies the body mutation the server sends to the data plane. +message BodyMutation { + // The type of mutation for the body. + oneof mutation { + // The entire body to replace. + // Should only be used when the corresponding ``BodySendMode`` in the + // :ref:`processing_mode ` + // is not set to ``FULL_DUPLEX_STREAMED`` or ``GRPC``. + bytes body = 1; + + // Clear the corresponding body chunk. + // Should only be used when the corresponding ``BodySendMode`` in the + // :ref:`processing_mode ` + // is not set to ``FULL_DUPLEX_STREAMED`` or ``GRPC``. + // Clear the corresponding body chunk. + bool clear_body = 2; + + // Must be used when the corresponding ``BodySendMode`` in the + // :ref:`processing_mode ` + // is set to ``FULL_DUPLEX_STREAMED`` or ``GRPC``. + StreamedBodyResponse streamed_response = 3 + [(xds.annotations.v3.field_status).work_in_progress = true]; + } +}