diff --git a/.bazelrc b/.bazelrc index 554440cfe3d..453acedb593 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1 +1,3 @@ -build --cxxopt=-std=c++14 --host_cxxopt=-std=c++14 +build --cxxopt=-std=c++17 --host_cxxopt=-std=c++17 + +common:skip_android --deleted_packages=android,binder \ No newline at end of file diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 00000000000..71adf793964 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,13 @@ +have_fun: false +memory_config: + disabled: false +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: false + code_review: false + include_drafts: false +ignore_patterns: [] diff --git a/.github/workflows/branch-testing.yml b/.github/workflows/branch-testing.yml index ece8ec4cd58..2f462242f38 100644 --- a/.github/workflows/branch-testing.yml +++ b/.github/workflows/branch-testing.yml @@ -20,14 +20,14 @@ jobs: fail-fast: false # Should swap to true if we grow a large matrix steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 with: java-version: ${{ matrix.jre }} distribution: 'temurin' - name: Gradle cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index da1e2fed114..91eada5371d 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -9,5 +9,5 @@ jobs: name: "Gradle wrapper validation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: gradle/actions/wrapper-validation@v4 + - uses: actions/checkout@v6 + - uses: gradle/actions/wrapper-validation@v6 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 3070a1a2f7c..b29a5573512 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -13,7 +13,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 with: github-token: ${{ github.token }} issue-inactive-days: 90 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4fe75b0be78..0292e6602f9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -21,14 +21,14 @@ jobs: fail-fast: false # Should swap to true if we grow a large matrix steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 with: java-version: ${{ matrix.jre }} distribution: 'temurin' - name: Gradle cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -37,7 +37,7 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - name: Maven cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.m2/repository @@ -46,7 +46,7 @@ jobs: restore-keys: | ${{ runner.os }}-maven- - name: Protobuf cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /tmp/protobuf-cache key: ${{ runner.os }}-maven-${{ hashFiles('buildscripts/make_dependencies.sh') }} @@ -55,7 +55,7 @@ jobs: run: buildscripts/kokoro/unix.sh - name: Post Failure Upload Test Reports to Artifacts if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Test Reports (JRE ${{ matrix.jre }}) path: | @@ -71,7 +71,7 @@ jobs: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} run: ./gradlew :grpc-all:coveralls -PskipAndroid=true -x compileJava - name: Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -80,11 +80,15 @@ jobs: strategy: matrix: bzlmod: [true, false] + bazel_version: [8.7.0, 9.1.0] + exclude: + - bazel_version: 9.1.0 + bzlmod: false env: - USE_BAZEL_VERSION: 7.0.0 + USE_BAZEL_VERSION: ${{ matrix.bazel_version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check versions match in MODULE.bazel and repositories.bzl run: | @@ -92,7 +96,7 @@ jobs: <(sed -n '/GRPC_DEPS_START/,/GRPC_DEPS_END/ {/GRPC_DEPS_/! p}' repositories.bzl) - name: Bazel cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cache/bazel/*/cache @@ -100,11 +104,11 @@ jobs: key: ${{ runner.os }}-bazel-${{ env.USE_BAZEL_VERSION }}-${{ hashFiles('WORKSPACE', 'repositories.bzl') }} - name: Run bazel build - run: bazelisk build //... --enable_bzlmod=${{ matrix.bzlmod }} + run: bazelisk build //... --config=skip_android --enable_bzlmod=${{ matrix.bzlmod }} --enable_workspace=${{ !matrix.bzlmod }} - name: Run bazel test - run: bazelisk test //... --enable_bzlmod=${{ matrix.bzlmod }} + run: bazelisk test //... --config=skip_android --enable_bzlmod=${{ matrix.bzlmod }} --enable_workspace=${{ !matrix.bzlmod }} - name: Run example bazel build - run: bazelisk build //... --enable_bzlmod=${{ matrix.bzlmod }} + run: bazelisk build //... --enable_bzlmod=${{ matrix.bzlmod }} --enable_workspace=${{ !matrix.bzlmod }} working-directory: ./examples diff --git a/.gitignore b/.gitignore index 92a0e3d6d3a..b078d891adf 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ MODULE.bazel.lock .gitignore bin +# VsCode +.vscode + # OS X .DS_Store diff --git a/BUILD.bazel b/BUILD.bazel index fee2c0bd12d..27a99fb62eb 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -12,7 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -load("@rules_java//java:defs.bzl", "java_library", "java_plugin", "java_proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library", "java_plugin") load("@rules_jvm_external//:defs.bzl", "artifact") load(":java_grpc_library.bzl", "java_grpc_library") diff --git a/COMPILING.md b/COMPILING.md index b7df1319beb..78247394cee 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -150,3 +150,26 @@ $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses # Add 'export ANDROID_HOME=$HOME/Android/Sdk' to your .bashrc or equivalent ``` + +Building with Bazel +=================== + +grpc-java can also be built using [Bazel](https://bazel.build/). We support +the two most recent major versions of Bazel. +[Install bazelisk](https://github.com/bazelbuild/bazelisk#installation) +to ensure you're always building using the latest supported version, then try: + +``` +$ bazelisk build //... +``` + +Some parts of grpc-java depend on Android. Bazel can build these parts too but, +for size, licensing and maintenance reasons, it requires a locally installed +Android SDK. If you don't have the SDK and/or don't care about Android, use the +`skip_android` configuration to skip building the Android parts: + +```sh +$ bazelisk build //... --config=skip_android +``` + +You cannot run the tests from Bazel at this time. diff --git a/MODULE.bazel b/MODULE.bazel index 83ff9eaa539..9f0de3eb789 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,64 +1,75 @@ module( name = "grpc-java", - compatibility_level = 0, + version = "1.83.0-SNAPSHOT", # CURRENT_GRPC_VERSION repo_name = "io_grpc_grpc_java", - version = "1.77.0-SNAPSHOT", # CURRENT_GRPC_VERSION ) # GRPC_DEPS_START IO_GRPC_GRPC_JAVA_ARTIFACTS = [ "com.google.android:annotations:4.1.1.4", - "com.google.api.grpc:proto-google-common-protos:2.59.2", - "com.google.auth:google-auth-library-credentials:1.24.1", - "com.google.auth:google-auth-library-oauth2-http:1.24.1", + "com.google.api.grpc:proto-google-common-protos:2.64.1", + "com.google.auth:google-auth-library-credentials:1.42.1", + "com.google.auth:google-auth-library-oauth2-http:1.42.1", "com.google.auto.value:auto-value-annotations:1.11.0", "com.google.auto.value:auto-value:1.11.0", "com.google.code.findbugs:jsr305:3.0.2", - "com.google.code.gson:gson:2.11.0", - "com.google.errorprone:error_prone_annotations:2.36.0", + "com.google.code.gson:gson:2.14.0", + "com.google.errorprone:error_prone_annotations:2.50.0", "com.google.guava:failureaccess:1.0.1", - "com.google.guava:guava:33.4.8-android", + "com.google.guava:guava:33.6.0-android", "com.google.re2j:re2j:1.8", - "com.google.s2a.proto.v2:s2a-proto:0.1.2", - "com.google.truth:truth:1.4.2", + "com.google.s2a.proto.v2:s2a-proto:0.1.3", + "com.google.truth:truth:1.4.5", + "dev.cel:runtime:0.13.0", + "dev.cel:protobuf:0.13.0", + "dev.cel:common:0.13.0", "com.squareup.okhttp:okhttp:2.7.5", "com.squareup.okio:okio:2.10.0", # 3.0+ needs swapping to -jvm; need work to avoid flag-day - "io.netty:netty-buffer:4.1.124.Final", - "io.netty:netty-codec-http2:4.1.124.Final", - "io.netty:netty-codec-http:4.1.124.Final", - "io.netty:netty-codec-socks:4.1.124.Final", - "io.netty:netty-codec:4.1.124.Final", - "io.netty:netty-common:4.1.124.Final", - "io.netty:netty-handler-proxy:4.1.124.Final", - "io.netty:netty-handler:4.1.124.Final", - "io.netty:netty-resolver:4.1.124.Final", - "io.netty:netty-tcnative-boringssl-static:2.0.70.Final", - "io.netty:netty-tcnative-classes:2.0.70.Final", - "io.netty:netty-transport-native-epoll:jar:linux-x86_64:4.1.124.Final", - "io.netty:netty-transport-native-unix-common:4.1.124.Final", - "io.netty:netty-transport:4.1.124.Final", + "io.netty:netty-buffer:4.2.15.Final", + "io.netty:netty-codec-base:4.2.15.Final", + "io.netty:netty-codec-http2:4.2.15.Final", + "io.netty:netty-codec-http:4.2.15.Final", + "io.netty:netty-codec-socks:4.2.15.Final", + "io.netty:netty-common:4.2.15.Final", + "io.netty:netty-handler-proxy:4.2.15.Final", + "io.netty:netty-handler:4.2.15.Final", + "io.netty:netty-resolver:4.2.15.Final", + "io.netty:netty-tcnative-boringssl-static:2.0.75.Final", + "io.netty:netty-tcnative-classes:2.0.75.Final", + "io.netty:netty-transport-native-epoll:jar:linux-x86_64:4.2.15.Final", + "io.netty:netty-transport-native-unix-common:4.2.15.Final", + "io.netty:netty-transport:4.2.15.Final", "io.opencensus:opencensus-api:0.31.0", "io.opencensus:opencensus-contrib-grpc-metrics:0.31.0", "io.perfmark:perfmark-api:0.27.0", "junit:junit:4.13.2", + "org.mockito:mockito-core:4.4.0", "org.checkerframework:checker-qual:3.49.5", - "org.codehaus.mojo:animal-sniffer-annotations:1.24", + "org.codehaus.mojo:animal-sniffer-annotations:1.27", ] # GRPC_DEPS_END -bazel_dep(name = "bazel_jar_jar", version = "0.1.7") +bazel_dep(name = "abseil-cpp", version = "20250512.1") +bazel_dep(name = "bazel_jar_jar", version = "0.1.11.bcr.1") bazel_dep(name = "bazel_skylib", version = "1.7.1") -bazel_dep(name = "googleapis", repo_name = "com_google_googleapis", version = "0.0.0-20240326-1c8d509c5") -bazel_dep(name = "grpc-proto", repo_name = "io_grpc_grpc_proto", version = "0.0.0-20240627-ec30f58") -# Protobuf 25.5+ is incompatible with Bazel 7 with bzlmod -bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "24.4") +bazel_dep(name = "googleapis", version = "0.0.0-20260514-1dbb1a14", repo_name = "com_google_googleapis") +bazel_dep(name = "grpc-proto", version = "0.0.0-20240627-ec30f58.bcr.1", repo_name = "io_grpc_grpc_proto") +bazel_dep(name = "protobuf", version = "35.1", repo_name = "com_google_protobuf") +bazel_dep(name = "rules_proto", version = "7.1.0") +bazel_dep(name = "rules_android", version = "0.7.3") bazel_dep(name = "rules_cc", version = "0.0.9") -bazel_dep(name = "rules_java", version = "5.3.5") +bazel_dep(name = "rules_java", version = "9.1.0") bazel_dep(name = "rules_jvm_external", version = "6.0") -bazel_dep(name = "rules_proto", version = "5.3.0-21.7") -maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +android_sdk_repository_extension = use_extension( + "@rules_android//rules/android_sdk_repository:rule.bzl", + "android_sdk_repository_extension", +) +use_repo(android_sdk_repository_extension, "androidsdk") + +register_toolchains("@androidsdk//:sdk-toolchain", "@androidsdk//:all") +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( artifacts = IO_GRPC_GRPC_JAVA_ARTIFACTS, repositories = [ @@ -66,119 +77,120 @@ maven.install( ], strict_visibility = True, ) - use_repo(maven, "maven") +# Define a separate, dev-only extension import for Android deps. +# This prevents downstream non-Android users from having to resolve +# Google Maven (which is required for androidx.*) or rules_android transitively. +grpc_android_maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven", dev_dependency = True) +grpc_android_maven.install( + name = "grpc_android_maven", + # Set this explicitly since the default guess is incorrect under Bzlmod. + aar_import_bzl_label = "@rules_android//rules:rules.bzl", + artifacts = [ + "androidx.annotation:annotation:1.6.0", + "androidx.annotation:annotation-jvm:1.6.0", + "androidx.core:core:1.13.1", + "androidx.lifecycle:lifecycle-common:2.6.2", + ], + repositories = [ + "https://repo.maven.apache.org/maven2/", + "https://maven.google.com", + ], + strict_visibility = True, + # For Bazel 8+ compatibility. + use_starlark_android_rules = True, +) +use_repo(grpc_android_maven, "grpc_android_maven") + maven.override( coordinates = "com.google.protobuf:protobuf-java", target = "@com_google_protobuf//:protobuf_java", ) - maven.override( coordinates = "com.google.protobuf:protobuf-java-util", target = "@com_google_protobuf//:protobuf_java_util", ) - maven.override( coordinates = "com.google.protobuf:protobuf-javalite", target = "@com_google_protobuf//:protobuf_javalite", ) - maven.override( coordinates = "io.grpc:grpc-alts", target = "@io_grpc_grpc_java//alts", ) - maven.override( coordinates = "io.grpc:grpc-api", target = "@io_grpc_grpc_java//api", ) - maven.override( coordinates = "io.grpc:grpc-auth", target = "@io_grpc_grpc_java//auth", ) - maven.override( coordinates = "io.grpc:grpc-census", target = "@io_grpc_grpc_java//census", ) - maven.override( coordinates = "io.grpc:grpc-context", target = "@io_grpc_grpc_java//context", ) - maven.override( coordinates = "io.grpc:grpc-core", target = "@io_grpc_grpc_java//core:core_maven", ) - maven.override( coordinates = "io.grpc:grpc-googleapis", target = "@io_grpc_grpc_java//googleapis", ) - maven.override( coordinates = "io.grpc:grpc-grpclb", target = "@io_grpc_grpc_java//grpclb", ) - maven.override( coordinates = "io.grpc:grpc-inprocess", target = "@io_grpc_grpc_java//inprocess", ) - maven.override( coordinates = "io.grpc:grpc-netty", target = "@io_grpc_grpc_java//netty", ) - maven.override( coordinates = "io.grpc:grpc-netty-shaded", target = "@io_grpc_grpc_java//netty:shaded_maven", ) - maven.override( coordinates = "io.grpc:grpc-okhttp", target = "@io_grpc_grpc_java//okhttp", ) - maven.override( coordinates = "io.grpc:grpc-protobuf", target = "@io_grpc_grpc_java//protobuf", ) - maven.override( coordinates = "io.grpc:grpc-protobuf-lite", target = "@io_grpc_grpc_java//protobuf-lite", ) - maven.override( coordinates = "io.grpc:grpc-rls", target = "@io_grpc_grpc_java//rls", ) - maven.override( coordinates = "io.grpc:grpc-services", target = "@io_grpc_grpc_java//services:services_maven", ) - maven.override( coordinates = "io.grpc:grpc-stub", target = "@io_grpc_grpc_java//stub", ) - maven.override( coordinates = "io.grpc:grpc-testing", target = "@io_grpc_grpc_java//testing", ) - maven.override( coordinates = "io.grpc:grpc-xds", target = "@io_grpc_grpc_java//xds:xds_maven", ) - maven.override( coordinates = "io.grpc:grpc-util", target = "@io_grpc_grpc_java//util", diff --git a/README.md b/README.md index d696164ee32..f12fa0e1986 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ gRPC-Java - An RPC library and framework Supported Platforms ------------------- -gRPC-Java supports Java 8 and later. Android minSdkVersion 21 (Lollipop) and +gRPC-Java supports Java 8 and later. Android minSdkVersion 23 (Marshmallow) and later are supported with [Java 8 language desugaring][android-java-8]. TLS usage on Android typically requires Play Services Dynamic Security Provider. @@ -44,8 +44,8 @@ For a guided tour, take a look at the [quick start guide](https://grpc.io/docs/languages/java/quickstart) or the more explanatory [gRPC basics](https://grpc.io/docs/languages/java/basics). -The [examples](https://github.com/grpc/grpc-java/tree/v1.75.0/examples) and the -[Android example](https://github.com/grpc/grpc-java/tree/v1.75.0/examples/android) +The [examples](https://github.com/grpc/grpc-java/tree/v1.82.1/examples) and the +[Android example](https://github.com/grpc/grpc-java/tree/v1.82.1/examples/android) are standalone projects that showcase the usage of gRPC. Download @@ -56,34 +56,34 @@ Download [the JARs][]. Or for Maven with non-Android, add to your `pom.xml`: io.grpc grpc-netty-shaded - 1.75.0 + 1.82.1 runtime io.grpc grpc-protobuf - 1.75.0 + 1.82.1 io.grpc grpc-stub - 1.75.0 + 1.82.1 ``` Or for Gradle with non-Android, add to your dependencies: ```gradle -runtimeOnly 'io.grpc:grpc-netty-shaded:1.75.0' -implementation 'io.grpc:grpc-protobuf:1.75.0' -implementation 'io.grpc:grpc-stub:1.75.0' +runtimeOnly 'io.grpc:grpc-netty-shaded:1.82.1' +implementation 'io.grpc:grpc-protobuf:1.82.1' +implementation 'io.grpc:grpc-stub:1.82.1' ``` For Android client, use `grpc-okhttp` instead of `grpc-netty-shaded` and `grpc-protobuf-lite` instead of `grpc-protobuf`: ```gradle -implementation 'io.grpc:grpc-okhttp:1.75.0' -implementation 'io.grpc:grpc-protobuf-lite:1.75.0' -implementation 'io.grpc:grpc-stub:1.75.0' +implementation 'io.grpc:grpc-okhttp:1.82.1' +implementation 'io.grpc:grpc-protobuf-lite:1.82.1' +implementation 'io.grpc:grpc-stub:1.82.1' ``` For [Bazel](https://bazel.build), you can either @@ -91,7 +91,7 @@ For [Bazel](https://bazel.build), you can either (with the GAVs from above), or use `@io_grpc_grpc_java//api` et al (see below). [the JARs]: -https://search.maven.org/search?q=g:io.grpc%20AND%20v:1.75.0 +https://search.maven.org/search?q=g:io.grpc%20AND%20v:1.82.1 Development snapshots are available in [Sonatypes's snapshot repository](https://central.sonatype.com/repository/maven-snapshots/). @@ -121,9 +121,9 @@ For protobuf-based codegen integrated with the Maven build system, you can use protobuf-maven-plugin 0.6.1 - com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier} + com.google.protobuf:protoc:3.25.8:exe:${os.detected.classifier} grpc-java - io.grpc:protoc-gen-grpc-java:1.75.0:exe:${os.detected.classifier} + io.grpc:protoc-gen-grpc-java:1.82.1:exe:${os.detected.classifier} @@ -149,11 +149,11 @@ plugins { protobuf { protoc { - artifact = "com.google.protobuf:protoc:3.25.5" + artifact = "com.google.protobuf:protoc:3.25.8" } plugins { grpc { - artifact = 'io.grpc:protoc-gen-grpc-java:1.75.0' + artifact = 'io.grpc:protoc-gen-grpc-java:1.82.1' } } generateProtoTasks { @@ -182,11 +182,11 @@ plugins { protobuf { protoc { - artifact = "com.google.protobuf:protoc:3.25.5" + artifact = "com.google.protobuf:protoc:3.25.8" } plugins { grpc { - artifact = 'io.grpc:protoc-gen-grpc-java:1.75.0' + artifact = 'io.grpc:protoc-gen-grpc-java:1.82.1' } } generateProtoTasks { diff --git a/RELEASING.md b/RELEASING.md index c57829b8c25..14b8fa6f769 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -250,3 +250,7 @@ gRPC for things that will need a migration effort. When happy with the dependency upgrades, update the versions in `MODULE.bazel`, `repositories.bzl`, and the various `pom.xml` and `build.gradle` files in `examples/`. + +Upgrade the `uses:` for actions in `.github/workflows` to newer versions. Make +sure to see what changed in each new major version, but it is most often just +requiring a newer Node.js version. diff --git a/SECURITY.md b/SECURITY.md index 1555882c3c6..e710ceaabe1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -397,7 +397,10 @@ grpc-netty version | netty-handler version | netty-tcnative-boringssl-static ver 1.60.x-1.66.x | 4.1.100.Final | 2.0.61.Final 1.67.x-1.70.x | 4.1.110.Final | 2.0.65.Final 1.71.x-1.74.x | 4.1.110.Final | 2.0.70.Final -1.75.x- | 4.1.124.Final | 2.0.72.Final +1.75.x-1.76.x | 4.1.124.Final | 2.0.72.Final +1.77.x-1.78.x | 4.1.127.Final | 2.0.74.Final +1.79.x-1.80.x | 4.1.130.Final | 2.0.74.Final +1.81.x- | 4.1.132.Final | 2.0.75.Final _(grpc-netty-shaded avoids issues with keeping these versions in sync.)_ diff --git a/WORKSPACE b/WORKSPACE index 227ef332757..1efdf2793a8 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -1,35 +1,37 @@ workspace(name = "io_grpc_grpc_java") load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +load("//:repositories.bzl", "IO_GRPC_GRPC_JAVA_ARTIFACTS", "IO_GRPC_GRPC_JAVA_OVERRIDE_TARGETS", "grpc_java_repositories") + +grpc_java_repositories() http_archive( name = "rules_java", - url = "https://github.com/bazelbuild/rules_java/releases/download/5.3.5/rules_java-5.3.5.tar.gz", - sha256 = "c73336802d0b4882e40770666ad055212df4ea62cfa6edf9cb0f9d29828a0934", + sha256 = "47632cc506c858011853073449801d648e10483d4b50e080ec2549a4b2398960", + urls = [ + "https://github.com/bazelbuild/rules_java/releases/download/8.15.2/rules_java-8.15.2.tar.gz", + ], ) -http_archive( - name = "rules_jvm_external", - sha256 = "d31e369b854322ca5098ea12c69d7175ded971435e55c18dd9dd5f29cc5249ac", - strip_prefix = "rules_jvm_external-5.3", - url = "https://github.com/bazelbuild/rules_jvm_external/releases/download/5.3/rules_jvm_external-5.3.tar.gz", -) +load("@com_google_protobuf//:protobuf_deps.bzl", "PROTOBUF_MAVEN_ARTIFACTS", "protobuf_deps") -load("@rules_jvm_external//:defs.bzl", "maven_install") -load("//:repositories.bzl", "IO_GRPC_GRPC_JAVA_ARTIFACTS") -load("//:repositories.bzl", "IO_GRPC_GRPC_JAVA_OVERRIDE_TARGETS") -load("//:repositories.bzl", "grpc_java_repositories") +protobuf_deps() -grpc_java_repositories() +load("@rules_java//java:rules_java_deps.bzl", "rules_java_dependencies") + +rules_java_dependencies() + +load("@bazel_features//:deps.bzl", "bazel_features_deps") + +bazel_features_deps() load("@bazel_jar_jar//:jar_jar.bzl", "jar_jar_repositories") jar_jar_repositories() -load("@com_google_protobuf//:protobuf_deps.bzl", "PROTOBUF_MAVEN_ARTIFACTS") -load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps") +load("@rules_python//python:repositories.bzl", "py_repositories") -protobuf_deps() +py_repositories() load("@com_google_googleapis//:repository_rules.bzl", "switched_rules_by_language") @@ -37,6 +39,15 @@ switched_rules_by_language( name = "com_google_googleapis_imports", ) +http_archive( + name = "rules_jvm_external", + sha256 = "d31e369b854322ca5098ea12c69d7175ded971435e55c18dd9dd5f29cc5249ac", + strip_prefix = "rules_jvm_external-5.3", + url = "https://github.com/bazelbuild/rules_jvm_external/releases/download/5.3/rules_jvm_external-5.3.tar.gz", +) + +load("@rules_jvm_external//:defs.bzl", "maven_install") + maven_install( artifacts = IO_GRPC_GRPC_JAVA_ARTIFACTS + PROTOBUF_MAVEN_ARTIFACTS, override_targets = IO_GRPC_GRPC_JAVA_OVERRIDE_TARGETS, diff --git a/alts/BUILD.bazel b/alts/BUILD.bazel index 2d9d3508461..f02aa511ff5 100644 --- a/alts/BUILD.bazel +++ b/alts/BUILD.bazel @@ -1,6 +1,7 @@ -load("@rules_java//java:defs.bzl", "java_library", "java_proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_java//java:defs.bzl", "java_library") load("@rules_jvm_external//:defs.bzl", "artifact") -load("@rules_proto//proto:defs.bzl", "proto_library") load("//:java_grpc_library.bzl", "java_grpc_library") java_library( @@ -13,7 +14,6 @@ java_library( ":handshaker_java_proto", "//api", "//core:internal", - "//grpclb", "//netty", "//stub", "@com_google_protobuf//:protobuf_java", @@ -22,7 +22,7 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("io.netty:netty-buffer"), - artifact("io.netty:netty-codec"), + artifact("io.netty:netty-codec-base"), artifact("io.netty:netty-common"), artifact("io.netty:netty-handler"), artifact("io.netty:netty-transport"), diff --git a/alts/build.gradle b/alts/build.gradle index fe2e27784fc..c206a37bcef 100644 --- a/alts/build.gradle +++ b/alts/build.gradle @@ -14,12 +14,10 @@ dependencies { implementation project(':grpc-auth'), project(':grpc-core'), project(":grpc-context"), // Override google-auth dependency with our newer version - project(':grpc-grpclb'), project(':grpc-protobuf'), project(':grpc-stub'), libraries.protobuf.java, libraries.conscrypt, - libraries.guava.jre, // JRE required by protobuf-java-util from grpclb libraries.google.auth.oauth2Http def nettyDependency = implementation project(':grpc-netty') diff --git a/alts/src/main/java/io/grpc/alts/HandshakerServiceChannel.java b/alts/src/main/java/io/grpc/alts/HandshakerServiceChannel.java index fa1d4f7fa1c..c914385a451 100644 --- a/alts/src/main/java/io/grpc/alts/HandshakerServiceChannel.java +++ b/alts/src/main/java/io/grpc/alts/HandshakerServiceChannel.java @@ -25,7 +25,6 @@ import io.grpc.internal.SharedResourceHolder.Resource; import io.grpc.netty.NettyChannelBuilder; import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.util.concurrent.DefaultThreadFactory; import java.net.InetSocketAddress; @@ -37,10 +36,29 @@ * application will have at most one connection to the handshaker service. */ final class HandshakerServiceChannel { + // Port 8080 is necessary for ALTS handshake. + private static final int ALTS_PORT = 8080; + private static final String DEFAULT_TARGET = "metadata.google.internal.:8080"; static final Resource SHARED_HANDSHAKER_CHANNEL = - new ChannelResource("metadata.google.internal.:8080"); - + new ChannelResource(getHandshakerTarget(System.getenv("GCE_METADATA_HOST"))); + + /** + * Returns handshaker target. When GCE_METADATA_HOST is provided, it might contain port which we + * will discard and use ALTS_PORT instead. + */ + static String getHandshakerTarget(String envValue) { + if (envValue == null || envValue.isEmpty()) { + return DEFAULT_TARGET; + } + String host = envValue; + int portIndex = host.lastIndexOf(':'); + if (portIndex != -1) { + host = host.substring(0, portIndex); // Discard port if specified + } + return host + ":" + ALTS_PORT; // Utilize ALTS port in all cases + } + /** Returns a resource of handshaker service channel for testing only. */ static Resource getHandshakerChannelForTesting(String handshakerAddress) { return new ChannelResource(handshakerAddress); @@ -59,8 +77,10 @@ public ChannelResource(String target) { @Override public Channel create() { /* Use its own event loop thread pool to avoid blocking. */ + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API EventLoopGroup eventGroup = - new NioEventLoopGroup(1, new DefaultThreadFactory("handshaker pool", true)); + new io.netty.channel.nio.NioEventLoopGroup( + 1, new DefaultThreadFactory("handshaker pool", true)); NettyChannelBuilder channelBuilder = NettyChannelBuilder.forTarget(target) .channelType(NioSocketChannel.class, InetSocketAddress.class) diff --git a/alts/src/main/java/io/grpc/alts/internal/AltsProtocolNegotiator.java b/alts/src/main/java/io/grpc/alts/internal/AltsProtocolNegotiator.java index e0343f83c51..9c51cf6a053 100644 --- a/alts/src/main/java/io/grpc/alts/internal/AltsProtocolNegotiator.java +++ b/alts/src/main/java/io/grpc/alts/internal/AltsProtocolNegotiator.java @@ -30,7 +30,6 @@ import io.grpc.SecurityLevel; import io.grpc.Status; import io.grpc.alts.internal.RpcProtocolVersionsUtil.RpcVersionsCheckResult; -import io.grpc.grpclb.GrpclbConstants; import io.grpc.internal.ObjectPool; import io.grpc.netty.GrpcHttp2ConnectionHandler; import io.grpc.netty.InternalProtocolNegotiator; @@ -299,9 +298,7 @@ public ChannelHandler newHandler(GrpcHttp2ConnectionHandler grpcHandler) { isXdsDirectPath = isDirectPathCluster( grpcHandler.getEagAttributes().get(clusterNameAttrKey)); } - if (grpcHandler.getEagAttributes().get(GrpclbConstants.ATTR_LB_ADDR_AUTHORITY) != null - || grpcHandler.getEagAttributes().get(GrpclbConstants.ATTR_LB_PROVIDED_BACKEND) != null - || isXdsDirectPath) { + if (isXdsDirectPath) { TsiHandshaker handshaker = handshakerFactory.newHandshaker(grpcHandler.getAuthority(), negotiationLogger); NettyTsiHandshaker nettyHandshaker = new NettyTsiHandshaker(handshaker); diff --git a/alts/src/main/java/io/grpc/alts/internal/AltsTsiHandshaker.java b/alts/src/main/java/io/grpc/alts/internal/AltsTsiHandshaker.java index 007db9e1eed..2d6c322c1b1 100644 --- a/alts/src/main/java/io/grpc/alts/internal/AltsTsiHandshaker.java +++ b/alts/src/main/java/io/grpc/alts/internal/AltsTsiHandshaker.java @@ -80,7 +80,7 @@ public boolean processBytesFromPeer(ByteBuffer bytes) throws GeneralSecurityExce return true; } int remaining = bytes.remaining(); - // Call handshaker service to proceess the bytes. + // Call handshaker service to process the bytes. if (outputFrame == null) { checkState(!isClient, "Client handshaker should not process any frame at the beginning."); outputFrame = handshaker.startServerHandshake(bytes); diff --git a/alts/src/test/java/io/grpc/alts/HandshakerServiceChannelTest.java b/alts/src/test/java/io/grpc/alts/HandshakerServiceChannelTest.java index a3937904cd7..221001157f1 100644 --- a/alts/src/test/java/io/grpc/alts/HandshakerServiceChannelTest.java +++ b/alts/src/test/java/io/grpc/alts/HandshakerServiceChannelTest.java @@ -67,6 +67,24 @@ public void sharedChannel_authority() { } } + @Test + public void getHandshakerTarget_nullEnvVar() { + assertThat(HandshakerServiceChannel.getHandshakerTarget(null)) + .isEqualTo("metadata.google.internal.:8080"); + } + + @Test + public void getHandshakerTarget_envVarWithPort() { + assertThat(HandshakerServiceChannel.getHandshakerTarget("169.254.169.254:80")) + .isEqualTo("169.254.169.254:8080"); + } + + @Test + public void getHandshakerTarget_envVarWithHostOnly() { + assertThat(HandshakerServiceChannel.getHandshakerTarget("169.254.169.254")) + .isEqualTo("169.254.169.254:8080"); + } + @Test public void resource_works() { Channel channel = resource.create(); diff --git a/alts/src/test/java/io/grpc/alts/internal/AltsProtocolNegotiatorTest.java b/alts/src/test/java/io/grpc/alts/internal/AltsProtocolNegotiatorTest.java index 24392af75fd..d47607ed90f 100644 --- a/alts/src/test/java/io/grpc/alts/internal/AltsProtocolNegotiatorTest.java +++ b/alts/src/test/java/io/grpc/alts/internal/AltsProtocolNegotiatorTest.java @@ -202,8 +202,11 @@ public void operationComplete(ChannelFuture future) throws Exception { channel.flush(); // Capture the protected data written to the wire. - assertEquals(1, channel.outboundMessages().size()); - ByteBuf protectedData = channel.readOutbound(); + assertThat(channel.outboundMessages()).isNotEmpty(); + ByteBuf protectedData = channel.alloc().buffer(); + while (!channel.outboundMessages().isEmpty()) { + protectedData.writeBytes((ByteBuf) channel.readOutbound()); + } assertEquals(message.length(), writeCount.get()); // Read the protected message at the server and verify it matches the original message. @@ -327,16 +330,18 @@ public void doNotFlushEmptyBuffer() throws Exception { String message = "hello"; ByteBuf in = Unpooled.copiedBuffer(message, UTF_8); - assertEquals(0, protector.flushes.get()); + int flushes = protector.flushes.get(); Future done = channel.write(in); channel.flush(); + flushes++; done.get(5, TimeUnit.SECONDS); - assertEquals(1, protector.flushes.get()); + assertEquals(flushes, protector.flushes.get()); + // Flush does not propagate done = channel.write(Unpooled.EMPTY_BUFFER); channel.flush(); done.get(5, TimeUnit.SECONDS); - assertEquals(1, protector.flushes.get()); + assertEquals(flushes, protector.flushes.get()); } @Test diff --git a/alts/src/test/java/io/grpc/alts/internal/GoogleDefaultProtocolNegotiatorTest.java b/alts/src/test/java/io/grpc/alts/internal/GoogleDefaultProtocolNegotiatorTest.java index 9a520720beb..14c19e554ae 100644 --- a/alts/src/test/java/io/grpc/alts/internal/GoogleDefaultProtocolNegotiatorTest.java +++ b/alts/src/test/java/io/grpc/alts/internal/GoogleDefaultProtocolNegotiatorTest.java @@ -29,7 +29,6 @@ import io.grpc.ChannelLogger; import io.grpc.ChannelLogger.ChannelLogLevel; import io.grpc.ManagedChannel; -import io.grpc.grpclb.GrpclbConstants; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.internal.ObjectPool; import io.grpc.netty.GrpcHttp2ConnectionHandler; @@ -95,13 +94,6 @@ public void tearDown() { @Nullable abstract Attributes.Key getClusterNameAttrKey(); - @Test - public void altsHandler_lbProvidedBackend() { - Attributes attrs = - Attributes.newBuilder().set(GrpclbConstants.ATTR_LB_PROVIDED_BACKEND, true).build(); - subtest_altsHandler(attrs); - } - @Test public void tlsHandler_emptyAttributes() { subtest_tlsHandler(Attributes.EMPTY); diff --git a/android-interop-testing/build.gradle b/android-interop-testing/build.gradle index 17551465f05..b61d50a6763 100644 --- a/android-interop-testing/build.gradle +++ b/android-interop-testing/build.gradle @@ -33,15 +33,11 @@ android { defaultConfig { applicationId "io.grpc.android.integrationtest" - // Held back to 20 as Gradle fails to build at the 21 level. This is - // presumably a Gradle bug that can be revisited later. - // Maybe this issue: https://github.com/gradle/gradle/issues/20778 - minSdkVersion 20 + minSdkVersion 23 targetSdkVersion 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - multiDexEnabled = true } buildTypes { debug { minifyEnabled false } @@ -62,11 +58,11 @@ android { dependencies { implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'androidx.multidex:multidex:2.0.0' implementation libraries.androidx.annotation implementation 'com.google.android.gms:play-services-base:18.0.1' implementation project(':grpc-android'), + project(':grpc-api'), project(':grpc-core'), project(':grpc-census'), project(':grpc-okhttp'), @@ -76,6 +72,7 @@ dependencies { libraries.junit, libraries.truth, libraries.androidx.test.rules, + libraries.androidx.test.core, libraries.opencensus.contrib.grpc.metrics implementation (project(':grpc-services')) { @@ -83,8 +80,8 @@ dependencies { exclude group: 'com.google.guava' } - androidTestImplementation 'androidx.test.ext:junit:1.1.3', - 'androidx.test:runner:1.4.0' + androidTestImplementation libraries.androidx.test.ext.junit, + libraries.androidx.test.runner } // Checkstyle doesn't run automatically with android @@ -115,6 +112,25 @@ tasks.withType(JavaCompile).configureEach { "|") } +// Workaround error seen with Gradle 8.14.3 and AGP 7.4.1 when building: +// ./gradlew clean :grpc-android-interop-testing:build -PskipAndroid=false \ +// -Pandroid.useAndroidX=true --no-build-cache +// +// Error message: +// +// Execution failed for task ':grpc-android-interop-testing:mergeExtDexDebug'. +// > Could not resolve all files for configuration ':grpc-android-interop-testing:debugRuntimeClasspath'. +// > Failed to transform opencensus-contrib-grpc-metrics-0.31.1.jar (io.opencensus:opencensus-contrib-grpc-metrics:0.31.1) to match attributes {artifactType=android-dex, asm-transformed-variant=NONE, dexing-enable-desugaring=true, dexing-enable-jacoco-instrumentation=false, dexing-is-debuggable=true, dexing-min-sdk=23, org.gradle.category=library, org.gradle.libraryelements=jar, org.gradle.status=release, org.gradle.usage=java-runtime}. +// > Could not resolve all files for configuration ':grpc-android-interop-testing:debugRuntimeClasspath'. +// > Failed to transform grpc-api-1.81.0-SNAPSHOT.jar (project :grpc-api) to match attributes {artifactType=android-classes-jar, org.gradle.category=library, org.gradle.dependency.bundling=external, org.gradle.jvm.version=8, org.gradle.libraryelements=jar, org.gradle.usage=java-runtime}. +// > Execution failed for IdentityTransform: grpc-java/api/build/libs/grpc-api-1.81.0-SNAPSHOT.jar. +// > File/directory does not exist: grpc-java/api/build/libs/grpc-api-1.81.0-SNAPSHOT.jar +tasks.configureEach { task -> + if (task.name.equals("mergeExtDexDebug")) { + dependsOn(':grpc-api:jar') + } +} + afterEvaluate { // Hack to workaround "Task ':grpc-android-interop-testing:extractIncludeDebugProto' uses this // output of task ':grpc-context:jar' without declaring an explicit or implicit dependency." The diff --git a/android-interop-testing/src/androidTest/AndroidManifest.xml b/android-interop-testing/src/androidTest/AndroidManifest.xml index b0507f10ab9..3cc0a29a85f 100644 --- a/android-interop-testing/src/androidTest/AndroidManifest.xml +++ b/android-interop-testing/src/androidTest/AndroidManifest.xml @@ -5,8 +5,7 @@ android:name="androidx.test.runner.AndroidJUnitRunner" android:targetPackage="io.grpc.android.integrationtest" /> - + diff --git a/android-interop-testing/src/androidTest/java/io/grpc/android/integrationtest/UdsChannelInteropTest.java b/android-interop-testing/src/androidTest/java/io/grpc/android/integrationtest/UdsChannelInteropTest.java index f5e54da5d4e..5b98665ba29 100644 --- a/android-interop-testing/src/androidTest/java/io/grpc/android/integrationtest/UdsChannelInteropTest.java +++ b/android-interop-testing/src/androidTest/java/io/grpc/android/integrationtest/UdsChannelInteropTest.java @@ -19,9 +19,9 @@ import static org.junit.Assert.assertEquals; import android.net.LocalSocketAddress.Namespace; -import androidx.test.InstrumentationRegistry; +import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.rule.ActivityTestRule; +import androidx.test.platform.app.InstrumentationRegistry; import io.grpc.Grpc; import io.grpc.InsecureServerCredentials; import io.grpc.Server; @@ -60,8 +60,8 @@ public class UdsChannelInteropTest { // Ensures Looper is initialized for tests running on API level 15. Otherwise instantiating an // AsyncTask throws an exception. @Rule - public ActivityTestRule activityRule = - new ActivityTestRule(TesterActivity.class); + public ActivityScenarioRule activityRule = + new ActivityScenarioRule<>(TesterActivity.class); @Before public void setUp() throws IOException { diff --git a/android-interop-testing/src/main/AndroidManifest.xml b/android-interop-testing/src/main/AndroidManifest.xml index da7ccef5b1d..08c139e5880 100644 --- a/android-interop-testing/src/main/AndroidManifest.xml +++ b/android-interop-testing/src/main/AndroidManifest.xml @@ -14,8 +14,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:theme="@style/Base.V7.Theme.AppCompat.Light" - android:name="androidx.multidex.MultiDexApplication"> + android:theme="@style/Base.V7.Theme.AppCompat.Light"> diff --git a/android/BUILD.bazel b/android/BUILD.bazel new file mode 100644 index 00000000000..04b60f05eed --- /dev/null +++ b/android/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_android//rules:rules.bzl", "android_library") +load("@rules_jvm_external//:defs.bzl", "artifact") + +licenses(["notice"]) + +android_library( + name = "android", + srcs = glob([ + "src/main/java/**/*.java", + ]), + manifest = "src/main/AndroidManifest.xml", + custom_package = "io.grpc.android", + # TODO(jdcormie): Figure out how to expose this publicly without making + # existing non-android users configure rules_android and maven.google.com. + visibility = ["//:__subpackages__"], + deps = [ + "//api", + "//core:internal", + artifact("com.google.code.findbugs:jsr305"), + artifact("com.google.errorprone:error_prone_annotations"), + artifact("com.google.guava:guava"), + ], +) diff --git a/android/build.gradle b/android/build.gradle index d0ae3b9c886..e94bf03ff37 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -14,7 +14,7 @@ android { } compileSdkVersion 34 defaultConfig { - minSdkVersion 22 + minSdkVersion 23 targetSdkVersion 33 versionCode 1 versionName "1.0" diff --git a/android/src/main/java/io/grpc/android/UdsChannelBuilder.java b/android/src/main/java/io/grpc/android/UdsChannelBuilder.java index 7d41301704c..6f03aa0ee5e 100644 --- a/android/src/main/java/io/grpc/android/UdsChannelBuilder.java +++ b/android/src/main/java/io/grpc/android/UdsChannelBuilder.java @@ -21,6 +21,7 @@ import io.grpc.ExperimentalApi; import io.grpc.InsecureChannelCredentials; import io.grpc.ManagedChannelBuilder; +import io.grpc.internal.GrpcUtil; import java.lang.reflect.InvocationTargetException; import javax.annotation.Nullable; import javax.net.SocketFactory; @@ -81,7 +82,7 @@ public static ManagedChannelBuilder forPath(String path, Namespace namespace) OKHTTP_CHANNEL_BUILDER_CLASS .getMethod("socketFactory", SocketFactory.class) .invoke(builder, new UdsSocketFactory(path, namespace)); - return builder; + return builder.proxyDetector(GrpcUtil.NOOP_PROXY_DETECTOR); } catch (IllegalAccessException e) { throw new RuntimeException("Failed to create OkHttpChannelBuilder", e); } catch (NoSuchMethodException e) { diff --git a/api/BUILD.bazel b/api/BUILD.bazel index 4c5e750b5e4..6de00d6272d 100644 --- a/api/BUILD.bazel +++ b/api/BUILD.bazel @@ -15,3 +15,21 @@ java_library( artifact("com.google.guava:guava"), ], ) + +java_library( + name = "test_fixtures", + testonly = 1, + srcs = glob([ + "src/testFixtures/java/io/grpc/**/*.java", + ]), + visibility = ["//xds:__pkg__"], + deps = [ + "//core", + artifact("com.google.code.findbugs:jsr305"), + artifact("com.google.errorprone:error_prone_annotations"), + artifact("com.google.guava:guava"), + artifact("com.google.truth:truth"), + artifact("junit:junit"), + artifact("org.mockito:mockito-core"), + ], +) diff --git a/api/build.gradle b/api/build.gradle index 415a17f61f8..745fa00b3f1 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -73,6 +73,7 @@ tasks.named("javadoc").configure { exclude 'io/grpc/Internal?*.java' exclude 'io/grpc/MetricRecorder.java' exclude 'io/grpc/MetricSink.java' + exclude 'io/grpc/Uri.java' } tasks.named("sourcesJar").configure { diff --git a/api/src/main/java/io/grpc/Channel.java b/api/src/main/java/io/grpc/Channel.java index 60ff76ff082..e2787eb2f26 100644 --- a/api/src/main/java/io/grpc/Channel.java +++ b/api/src/main/java/io/grpc/Channel.java @@ -16,7 +16,6 @@ package io.grpc; -import javax.annotation.concurrent.ThreadSafe; /** * A virtual connection to a conceptual endpoint, to perform RPCs. A channel is free to have zero or @@ -29,8 +28,9 @@ * implementations using {@link ClientInterceptor}. It is expected that most application * code will not use this class directly but rather work with stubs that have been bound to a * Channel that was decorated during application initialization. + * + *

This class is thread-safe. */ -@ThreadSafe public abstract class Channel { /** * Create a {@link ClientCall} to the remote operation specified by the given diff --git a/api/src/main/java/io/grpc/ChannelLogger.java b/api/src/main/java/io/grpc/ChannelLogger.java index ce654ec9d5b..2cdf4c84724 100644 --- a/api/src/main/java/io/grpc/ChannelLogger.java +++ b/api/src/main/java/io/grpc/ChannelLogger.java @@ -16,15 +16,15 @@ package io.grpc; -import javax.annotation.concurrent.ThreadSafe; /** * A Channel-specific logger provided by GRPC library to {@link LoadBalancer} implementations. * Information logged here goes to Channelz, and to the Java logger of this class * as well. + * + *

This class is thread-safe. */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/5029") -@ThreadSafe public abstract class ChannelLogger { /** * Log levels. See the table below for the mapping from the ChannelLogger levels to Channelz diff --git a/api/src/main/java/io/grpc/ClientInterceptor.java b/api/src/main/java/io/grpc/ClientInterceptor.java index c27c31c8474..d6c8cd7e6fb 100644 --- a/api/src/main/java/io/grpc/ClientInterceptor.java +++ b/api/src/main/java/io/grpc/ClientInterceptor.java @@ -16,7 +16,6 @@ package io.grpc; -import javax.annotation.concurrent.ThreadSafe; /** * Interface for intercepting outgoing calls before they are dispatched by a {@link Channel}. @@ -37,8 +36,10 @@ * without completing the previous ones first. Refer to the * {@link io.grpc.ClientCall.Listener ClientCall.Listener} docs for more details regarding thread * safety of the returned listener. + * + *

This is thread-safe and should be considered + * for the errorprone ThreadSafe annotation in the future. */ -@ThreadSafe public interface ClientInterceptor { /** * Intercept {@link ClientCall} creation by the {@code next} {@link Channel}. diff --git a/api/src/main/java/io/grpc/ClientStreamTracer.java b/api/src/main/java/io/grpc/ClientStreamTracer.java index 42e1fdfebea..8e11e781e7c 100644 --- a/api/src/main/java/io/grpc/ClientStreamTracer.java +++ b/api/src/main/java/io/grpc/ClientStreamTracer.java @@ -19,13 +19,13 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.MoreObjects; -import javax.annotation.concurrent.ThreadSafe; /** * {@link StreamTracer} for the client-side. + * + *

This class is thread-safe. */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/2861") -@ThreadSafe public abstract class ClientStreamTracer extends StreamTracer { /** * Indicates how long the call was delayed, in nanoseconds, due to waiting for name resolution diff --git a/api/src/main/java/io/grpc/ConnectivityState.java b/api/src/main/java/io/grpc/ConnectivityState.java index 677039b2517..a7407efb2e9 100644 --- a/api/src/main/java/io/grpc/ConnectivityState.java +++ b/api/src/main/java/io/grpc/ConnectivityState.java @@ -20,7 +20,7 @@ * The connectivity states. * * @see - * more information + * more information */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/4359") public enum ConnectivityState { diff --git a/api/src/main/java/io/grpc/EquivalentAddressGroup.java b/api/src/main/java/io/grpc/EquivalentAddressGroup.java index bf8a864902c..2dd52fe7f21 100644 --- a/api/src/main/java/io/grpc/EquivalentAddressGroup.java +++ b/api/src/main/java/io/grpc/EquivalentAddressGroup.java @@ -55,6 +55,21 @@ public final class EquivalentAddressGroup { */ public static final Attributes.Key ATTR_LOCALITY_NAME = Attributes.Key.create("io.grpc.EquivalentAddressGroup.LOCALITY"); + /** + * The backend service associated with this EquivalentAddressGroup. + */ + @Attr + static final Attributes.Key ATTR_BACKEND_SERVICE = + Attributes.Key.create("io.grpc.EquivalentAddressGroup.BACKEND_SERVICE"); + /** + * Endpoint weight for load balancing purposes. While the type is Long, it must be a valid uint32. + * Must not be zero. The weight is proportional to the other endpoints; if an endpoint's weight is + * twice that of another endpoint, it is intended to receive twice the load. + */ + @Attr + static final Attributes.Key ATTR_WEIGHT = + Attributes.Key.create("io.grpc.EquivalentAddressGroup.ATTR_WEIGHT"); + private final List addrs; private final Attributes attrs; @@ -113,7 +128,9 @@ public Attributes getAttributes() { @Override public String toString() { - // TODO(zpencer): Summarize return value if addr is very large + // EquivalentAddressGroup is intended to contain a small number of addresses for the same + // endpoint(e.g., IPv4/IPv6). Aggregating many groups into a single EquivalentAddressGroup + // is no longer done, so this no longer needs summarization. return "[" + addrs + "/" + attrs + "]"; } diff --git a/api/src/main/java/io/grpc/FeatureFlags.java b/api/src/main/java/io/grpc/FeatureFlags.java new file mode 100644 index 00000000000..36fec1b9a59 --- /dev/null +++ b/api/src/main/java/io/grpc/FeatureFlags.java @@ -0,0 +1,54 @@ +/* + * 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; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; + +class FeatureFlags { + private static boolean enableRfc3986Uris = getFlag("GRPC_ENABLE_RFC3986_URIS", true); + + /** Whether to parse targets as RFC 3986 URIs (true), or use {@link java.net.URI} (false). */ + @VisibleForTesting + static boolean setRfc3986UrisEnabled(boolean value) { + boolean prevValue = enableRfc3986Uris; + enableRfc3986Uris = value; + return prevValue; + } + + /** Whether to parse targets as RFC 3986 URIs (true), or use {@link java.net.URI} (false). */ + static boolean getRfc3986UrisEnabled() { + return enableRfc3986Uris; + } + + static boolean getFlag(String envVarName, boolean enableByDefault) { + String envVar = System.getenv(envVarName); + if (envVar == null) { + envVar = System.getProperty(envVarName); + } + if (envVar != null) { + envVar = envVar.trim(); + } + if (enableByDefault) { + return Strings.isNullOrEmpty(envVar) || Boolean.parseBoolean(envVar); + } else { + return !Strings.isNullOrEmpty(envVar) && Boolean.parseBoolean(envVar); + } + } + + private FeatureFlags() {} +} diff --git a/api/src/main/java/io/grpc/ForwardingServerBuilder.java b/api/src/main/java/io/grpc/ForwardingServerBuilder.java index 9cef7cfa331..d1f183dd824 100644 --- a/api/src/main/java/io/grpc/ForwardingServerBuilder.java +++ b/api/src/main/java/io/grpc/ForwardingServerBuilder.java @@ -201,6 +201,12 @@ public Server build() { return delegate().build(); } + @Override + public T addMetricSink(MetricSink metricSink) { + delegate().addMetricSink(metricSink); + return thisT(); + } + @Override public String toString() { return MoreObjects.toStringHelper(this).add("delegate", delegate()).toString(); diff --git a/api/src/main/java/io/grpc/Grpc.java b/api/src/main/java/io/grpc/Grpc.java index baa9f5f0ab6..628f6fac399 100644 --- a/api/src/main/java/io/grpc/Grpc.java +++ b/api/src/main/java/io/grpc/Grpc.java @@ -56,6 +56,13 @@ private Grpc() { public static final Attributes.Key TRANSPORT_ATTR_SSL_SESSION = Attributes.Key.create("io.grpc.Grpc.TRANSPORT_ATTR_SSL_SESSION"); + /** + * The value for the custom label of per-RPC metrics. Defaults to empty string when unset. Must + * not be set to {@code null}. + */ + public static final CallOptions.Key CALL_OPTION_CUSTOM_LABEL = + CallOptions.Key.createWithDefault("io.grpc.Grpc.CALL_OPTION_CUSTOM_LABEL", ""); + /** * Annotation for transport attributes. It follows the annotation semantics defined * by {@link Attributes}. @@ -66,11 +73,10 @@ private Grpc() { public @interface TransportAttr {} /** - * Creates a channel builder with a target string and credentials. The target can be either a - * valid {@link NameResolver}-compliant URI, or an authority string. + * Creates a channel builder with a target string and credentials. The target can be either an RFC + * 3986 URI, or an authority string. * - *

A {@code NameResolver}-compliant URI is an absolute hierarchical URI as defined by {@link - * java.net.URI}. Example URIs: + *

Example URIs: *

    *
  • {@code "dns:///foo.googleapis.com:8080"}
  • *
  • {@code "dns:///foo.googleapis.com"}
  • @@ -78,13 +84,13 @@ private Grpc() { *
  • {@code "dns://8.8.8.8/foo.googleapis.com:8080"}
  • *
  • {@code "dns://8.8.8.8/foo.googleapis.com"}
  • *
  • {@code "zookeeper://zk.example.com:9900/example_service"}
  • + *
  • {@code "intent:#Intent;package=com.some.app;action=a;category=c;end;"}
  • *
* - *

An authority string will be converted to a {@code NameResolver}-compliant URI, which has - * the scheme from the name resolver with the highest priority (e.g. {@code "dns"}), - * no authority, and the original authority string as its path after properly escaped. - * We recommend libraries to specify the schema explicitly if it is known, since libraries cannot - * know which NameResolver will be default during runtime. + *

An authority string will be converted to a URI having the scheme of the name resolver with + * the highest priority (e.g. {@code "dns"}), the empty string as the authority, and + * {@code target} as its absolute path. We recommend libraries specify {@code target} as a URI + * instead since they cannot know which NameResolver will be default at runtime. * Example authority strings: *

    *
  • {@code "localhost"}
  • @@ -95,12 +101,46 @@ private Grpc() { *
  • {@code "[2001:db8:85a3:8d3:1319:8a2e:370:7348]"}
  • *
  • {@code "[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443"}
  • *
+ * + *

The URI form of {@code target} is preferred because it is less ambiguous. For example, the + * target string {@code foo:8080} is a valid authority string with host {@code foo} and port + * {@code 8080} but it is also a valid RFC 3986 URI with scheme {@code foo} and path {@code 8080}. + * gRPC prioritizes the URI form, which means {@code foo:8080} will be treated as a URI with + * scheme {@code foo}. Using {@code dns:///foo:8080} avoids this ambiguity. */ public static ManagedChannelBuilder newChannelBuilder( String target, ChannelCredentials creds) { return ManagedChannelRegistry.getDefaultRegistry().newChannelBuilder(target, creds); } + /** + * Creates a channel builder with a target string, credentials, and a specific + * name resolver registry. + * + *

The provided {@code nameResolverRegistry} is used to resolve the target address + * into physical addresses (e.g., DNS or custom schemes). + * + * @param target the target URI for the channel, such as {@code "localhost:8080"} + * or {@code "dns:///example.com"} + * @param creds the channel credentials to use for secure communication + * @param nameResolverRegistry the registry used to look up {@link NameResolver} + * providers for the target + * @return a {@link ManagedChannelBuilder} instance configured with the given parameters + * @throws IllegalArgumentException if no provider is available for the given target + * or credentials + * @since 1.83.0 + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12694") + public static ManagedChannelBuilder newChannelBuilder( + String target, + ChannelCredentials creds, + NameResolverRegistry nameResolverRegistry) { + return ManagedChannelRegistry.getDefaultRegistry().newChannelBuilder( + nameResolverRegistry, + target, + creds); + } + /** * Creates a channel builder from a host, port, and credentials. The host and port are combined to * form an authority string and then passed to {@link #newChannelBuilder(String, diff --git a/api/src/main/java/io/grpc/HandlerRegistry.java b/api/src/main/java/io/grpc/HandlerRegistry.java index 4aaf0114fb1..148573ada9a 100644 --- a/api/src/main/java/io/grpc/HandlerRegistry.java +++ b/api/src/main/java/io/grpc/HandlerRegistry.java @@ -19,12 +19,12 @@ import java.util.Collections; import java.util.List; import javax.annotation.Nullable; -import javax.annotation.concurrent.ThreadSafe; /** * Registry of services and their methods used by servers to dispatching incoming calls. + * + *

This class is thread-safe. */ -@ThreadSafe public abstract class HandlerRegistry { /** diff --git a/api/src/main/java/io/grpc/HttpConnectProxiedSocketAddress.java b/api/src/main/java/io/grpc/HttpConnectProxiedSocketAddress.java index d59c53db1d1..0df8dc452c1 100644 --- a/api/src/main/java/io/grpc/HttpConnectProxiedSocketAddress.java +++ b/api/src/main/java/io/grpc/HttpConnectProxiedSocketAddress.java @@ -23,6 +23,9 @@ import com.google.common.base.Objects; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import javax.annotation.Nullable; /** @@ -33,6 +36,8 @@ public final class HttpConnectProxiedSocketAddress extends ProxiedSocketAddress private final SocketAddress proxyAddress; private final InetSocketAddress targetAddress; + @SuppressWarnings("serial") + private final Map headers; @Nullable private final String username; @Nullable @@ -41,6 +46,7 @@ public final class HttpConnectProxiedSocketAddress extends ProxiedSocketAddress private HttpConnectProxiedSocketAddress( SocketAddress proxyAddress, InetSocketAddress targetAddress, + Map headers, @Nullable String username, @Nullable String password) { checkNotNull(proxyAddress, "proxyAddress"); @@ -53,6 +59,7 @@ private HttpConnectProxiedSocketAddress( } this.proxyAddress = proxyAddress; this.targetAddress = targetAddress; + this.headers = headers; this.username = username; this.password = password; } @@ -87,6 +94,14 @@ public InetSocketAddress getTargetAddress() { return targetAddress; } + /** + * Returns the custom HTTP headers to be sent during the HTTP CONNECT handshake. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479") + public Map getHeaders() { + return headers; + } + @Override public boolean equals(Object o) { if (!(o instanceof HttpConnectProxiedSocketAddress)) { @@ -95,13 +110,14 @@ public boolean equals(Object o) { HttpConnectProxiedSocketAddress that = (HttpConnectProxiedSocketAddress) o; return Objects.equal(proxyAddress, that.proxyAddress) && Objects.equal(targetAddress, that.targetAddress) + && Objects.equal(headers, that.headers) && Objects.equal(username, that.username) && Objects.equal(password, that.password); } @Override public int hashCode() { - return Objects.hashCode(proxyAddress, targetAddress, username, password); + return Objects.hashCode(proxyAddress, targetAddress, username, password, headers); } @Override @@ -109,6 +125,7 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("proxyAddr", proxyAddress) .add("targetAddr", targetAddress) + .add("headers", headers) .add("username", username) // Intentionally mask out password .add("hasPassword", password != null) @@ -129,6 +146,7 @@ public static final class Builder { private SocketAddress proxyAddress; private InetSocketAddress targetAddress; + private Map headers = Collections.emptyMap(); @Nullable private String username; @Nullable @@ -153,6 +171,18 @@ public Builder setTargetAddress(InetSocketAddress targetAddress) { return this; } + /** + * Sets custom HTTP headers to be sent during the HTTP CONNECT handshake. This is an optional + * field. The headers will be sent in addition to any authentication headers (if username and + * password are set). + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479") + public Builder setHeaders(Map headers) { + this.headers = Collections.unmodifiableMap( + new HashMap<>(checkNotNull(headers, "headers"))); + return this; + } + /** * Sets the username used to connect to the proxy. This is an optional field and can be {@code * null}. @@ -175,7 +205,8 @@ public Builder setPassword(@Nullable String password) { * Creates an {@code HttpConnectProxiedSocketAddress}. */ public HttpConnectProxiedSocketAddress build() { - return new HttpConnectProxiedSocketAddress(proxyAddress, targetAddress, username, password); + return new HttpConnectProxiedSocketAddress( + proxyAddress, targetAddress, headers, username, password); } } } diff --git a/api/src/main/java/io/grpc/InternalEquivalentAddressGroup.java b/api/src/main/java/io/grpc/InternalEquivalentAddressGroup.java new file mode 100644 index 00000000000..cd171208af7 --- /dev/null +++ b/api/src/main/java/io/grpc/InternalEquivalentAddressGroup.java @@ -0,0 +1,35 @@ +/* + * 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; + +@Internal +public final class InternalEquivalentAddressGroup { + private InternalEquivalentAddressGroup() {} + + /** + * Endpoint weight for load balancing purposes. While the type is Long, it must be a valid uint32. + * Must not be zero. The weight is proportional to the other endpoints; if an endpoint's weight is + * twice that of another endpoint, it is intended to receive twice the load. + */ + public static final Attributes.Key ATTR_WEIGHT = EquivalentAddressGroup.ATTR_WEIGHT; + + /** + * The backend service associated with this EquivalentAddressGroup. + */ + public static final Attributes.Key ATTR_BACKEND_SERVICE = + EquivalentAddressGroup.ATTR_BACKEND_SERVICE; +} diff --git a/api/src/main/java/io/grpc/InternalFeatureFlags.java b/api/src/main/java/io/grpc/InternalFeatureFlags.java new file mode 100644 index 00000000000..a1e771a7571 --- /dev/null +++ b/api/src/main/java/io/grpc/InternalFeatureFlags.java @@ -0,0 +1,41 @@ +/* + * 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; + +import com.google.common.annotations.VisibleForTesting; + +/** Global variables that govern major changes to the behavior of more than one grpc module. */ +@Internal +public class InternalFeatureFlags { + + /** Whether to parse targets as RFC 3986 URIs (true), or use {@link java.net.URI} (false). */ + @VisibleForTesting + public static boolean setRfc3986UrisEnabled(boolean value) { + return FeatureFlags.setRfc3986UrisEnabled(value); + } + + /** Whether to parse targets as RFC 3986 URIs (true), or use {@link java.net.URI} (false). */ + public static boolean getRfc3986UrisEnabled() { + return FeatureFlags.getRfc3986UrisEnabled(); + } + + public static boolean getFlag(String envVarName, boolean enableByDefault) { + return FeatureFlags.getFlag(envVarName, enableByDefault); + } + + private InternalFeatureFlags() {} +} diff --git a/api/src/main/java/io/grpc/InternalServiceProviders.java b/api/src/main/java/io/grpc/InternalServiceProviders.java index c72e01db67a..debc786a82a 100644 --- a/api/src/main/java/io/grpc/InternalServiceProviders.java +++ b/api/src/main/java/io/grpc/InternalServiceProviders.java @@ -17,7 +17,9 @@ package io.grpc; import com.google.common.annotations.VisibleForTesting; +import java.util.Iterator; import java.util.List; +import java.util.ServiceLoader; @Internal public final class InternalServiceProviders { @@ -27,12 +29,17 @@ private InternalServiceProviders() { /** * Accessor for method. */ - public static T load( + @Deprecated + public static List loadAll( Class klass, - Iterable> hardcoded, + Iterable> hardCodedClasses, ClassLoader classLoader, PriorityAccessor priorityAccessor) { - return ServiceProviders.load(klass, hardcoded, classLoader, priorityAccessor); + return loadAll( + klass, + ServiceLoader.load(klass, classLoader).iterator(), + () -> hardCodedClasses, + priorityAccessor); } /** @@ -40,10 +47,10 @@ public static T load( */ public static List loadAll( Class klass, - Iterable> hardCodedClasses, - ClassLoader classLoader, + Iterator serviceLoader, + Supplier>> hardCodedClasses, PriorityAccessor priorityAccessor) { - return ServiceProviders.loadAll(klass, hardCodedClasses, classLoader, priorityAccessor); + return ServiceProviders.loadAll(klass, serviceLoader, hardCodedClasses::get, priorityAccessor); } /** @@ -71,4 +78,8 @@ public static boolean isAndroid(ClassLoader cl) { } public interface PriorityAccessor extends ServiceProviders.PriorityAccessor {} + + public interface Supplier { + T get(); + } } diff --git a/api/src/main/java/io/grpc/InternalTcpMetrics.java b/api/src/main/java/io/grpc/InternalTcpMetrics.java new file mode 100644 index 00000000000..3dd89b6f76c --- /dev/null +++ b/api/src/main/java/io/grpc/InternalTcpMetrics.java @@ -0,0 +1,98 @@ +/* + * 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; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * TCP Metrics defined to be shared across transport implementations. + * These metrics and their definitions are specified in + * gRFC + * A80. + */ +@Internal +public final class InternalTcpMetrics { + + private InternalTcpMetrics() { + } + + private static final List OPTIONAL_LABELS = Arrays.asList( + "network.local.address", + "network.local.port", + "network.peer.address", + "network.peer.port"); + + public static final DoubleHistogramMetricInstrument MIN_RTT_INSTRUMENT = + MetricInstrumentRegistry.getDefaultRegistry() + .registerDoubleHistogram( + "grpc.tcp.min_rtt", + "Minimum round-trip time of a TCP connection", + "s", + Collections.emptyList(), + Collections.emptyList(), + OPTIONAL_LABELS, + false); + + public static final LongCounterMetricInstrument CONNECTIONS_CREATED_INSTRUMENT = + MetricInstrumentRegistry + .getDefaultRegistry() + .registerLongCounter( + "grpc.tcp.connections_created", + "The total number of TCP connections established.", + "{connection}", + Collections.emptyList(), + OPTIONAL_LABELS, + false); + + public static final LongUpDownCounterMetricInstrument CONNECTION_COUNT_INSTRUMENT = + MetricInstrumentRegistry + .getDefaultRegistry() + .registerLongUpDownCounter( + "grpc.tcp.connection_count", + "The current number of active TCP connections.", + "{connection}", + Collections.emptyList(), + OPTIONAL_LABELS, + false); + + public static final LongCounterMetricInstrument PACKETS_RETRANSMITTED_INSTRUMENT = + MetricInstrumentRegistry + .getDefaultRegistry() + .registerLongCounter( + "grpc.tcp.packets_retransmitted", + "The total number of packets retransmitted for all TCP connections.", + "{packet}", + Collections.emptyList(), + OPTIONAL_LABELS, + false); + + public static final LongCounterMetricInstrument RECURRING_RETRANSMITS_INSTRUMENT = + MetricInstrumentRegistry + .getDefaultRegistry() + .registerLongCounter( + "grpc.tcp.recurring_retransmits", + "The total number of times the retransmit timer " + + "popped for all TCP connections.", + "{timeout}", + Collections.emptyList(), + OPTIONAL_LABELS, + false); + +} diff --git a/api/src/main/java/io/grpc/LoadBalancer.java b/api/src/main/java/io/grpc/LoadBalancer.java index adc43b19841..5dd44a492ee 100644 --- a/api/src/main/java/io/grpc/LoadBalancer.java +++ b/api/src/main/java/io/grpc/LoadBalancer.java @@ -32,7 +32,6 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.concurrent.NotThreadSafe; -import javax.annotation.concurrent.ThreadSafe; /** * A pluggable component that receives resolved addresses from {@link NameResolver} and provides the @@ -64,7 +63,7 @@ * allows implementations to schedule tasks to be run in the same Synchronization Context, with or * without a delay, thus those tasks don't need to worry about synchronizing with the balancer * methods. - * + * *

However, the actual running thread may be the network thread, thus the following rules must be * followed to prevent blocking or even dead-locking in a network: * @@ -156,15 +155,16 @@ public String toString() { private int recursionCount; /** - * Handles newly resolved server groups and metadata attributes from name resolution system. - * {@code servers} contained in {@link EquivalentAddressGroup} should be considered equivalent - * but may be flattened into a single list if needed. - * - *

Implementations should not modify the given {@code servers}. + * Handles newly resolved addresses and metadata attributes from name resolution system. + * Addresses in {@link EquivalentAddressGroup} should be considered equivalent but may be + * flattened into a single list if needed. * * @param resolvedAddresses the resolved server addresses, attributes, and config. * @since 1.21.0 + * + * @deprecated Use instead {@link #acceptResolvedAddresses(ResolvedAddresses)} */ + @Deprecated public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { if (recursionCount++ == 0) { // Note that the information about the addresses actually being accepted will be lost @@ -179,12 +179,10 @@ public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { * EquivalentAddressGroup} addresses should be considered equivalent but may be flattened into a * single list if needed. * - *

Implementations can choose to reject the given addresses by returning {@code false}. - * - *

Implementations should not modify the given {@code addresses}. + * @param resolvedAddresses the resolved server addresses, attributes, and config + * @return {@code Status.OK} if the resolved addresses were accepted, otherwise an error to report + * to the name resolver * - * @param resolvedAddresses the resolved server addresses, attributes, and config. - * @return {@code true} if the resolved addresses were accepted. {@code false} if rejected. * @since 1.49.0 */ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { @@ -339,8 +337,8 @@ public ResolvedAddresses build() { public String toString() { return MoreObjects.toStringHelper(this) .add("addresses", addresses) - .add("attributes", attributes) .add("loadBalancingPolicyConfig", loadBalancingPolicyConfig) + .add("attributes", attributes) .toString(); } @@ -418,7 +416,16 @@ public void handleSubchannelState( * *

This method should always return a constant value. It's not specified when this will be * called. + * + *

Note that this method is only called when implementing {@code handleResolvedAddresses()} + * instead of {@code acceptResolvedAddresses()}. + * + * @deprecated Instead of overwriting this and {@code handleResolvedAddresses()}, only + * overwrite {@code acceptResolvedAddresses()} which indicates if the addresses provided + * by the name resolver are acceptable with the {@code boolean} return value. */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public boolean canHandleEmptyAddressListFromNameResolution() { return false; } @@ -442,7 +449,6 @@ public void requestConnection() {} * * @since 1.2.0 */ - @ThreadSafe @ExperimentalApi("https://github.com/grpc/grpc-java/issues/1771") public abstract static class SubchannelPicker { /** @@ -632,6 +638,8 @@ private PickResult( * stream is created at all in some cases. * @since 1.3.0 */ + // TODO(shivaspeaks): Need to deprecate old APIs and create new ones, + // per https://github.com/grpc/grpc-java/issues/12662. public static PickResult withSubchannel( Subchannel subchannel, @Nullable ClientStreamTracer.Factory streamTracerFactory) { return new PickResult( @@ -661,6 +669,28 @@ public static PickResult withSubchannel(Subchannel subchannel) { return withSubchannel(subchannel, null); } + /** + * Creates a new {@code PickResult} with the given {@code subchannel}, + * but retains all other properties from this {@code PickResult}. + * + * @since 1.80.0 + */ + public PickResult copyWithSubchannel(Subchannel subchannel) { + return new PickResult(checkNotNull(subchannel, "subchannel"), streamTracerFactory, + status, drop, authorityOverride); + } + + /** + * Creates a new {@code PickResult} with the given {@code streamTracerFactory}, + * but retains all other properties from this {@code PickResult}. + * + * @since 1.80.0 + */ + public PickResult copyWithStreamTracerFactory( + @Nullable ClientStreamTracer.Factory streamTracerFactory) { + return new PickResult(subchannel, streamTracerFactory, status, drop, authorityOverride); + } + /** * A decision to report a connectivity error to the RPC. If the RPC is {@link * CallOptions#withWaitForReady wait-for-ready}, it will stay buffered. Otherwise, it will fail @@ -998,9 +1028,10 @@ public String toString() { /** * Provides essentials for LoadBalancer implementations. * + *

This class is thread-safe. + * * @since 1.2.0 */ - @ThreadSafe @ExperimentalApi("https://github.com/grpc/grpc-java/issues/1771") public abstract static class Helper { /** @@ -1300,7 +1331,7 @@ public MetricRecorder getMetricRecorder() { } /** - * A logical connection to a server, or a group of equivalent servers represented by an {@link + * A logical connection to a server, or a group of equivalent servers represented by an {@link * EquivalentAddressGroup}. * *

It maintains at most one physical connection (aka transport) for sending new RPCs, while @@ -1519,9 +1550,10 @@ public interface SubchannelStateListener { /** * Factory to create {@link LoadBalancer} instance. * + *

This class is thread-safe. + * * @since 1.2.0 */ - @ThreadSafe @ExperimentalApi("https://github.com/grpc/grpc-java/issues/1771") public abstract static class Factory { /** diff --git a/api/src/main/java/io/grpc/LoadBalancerProvider.java b/api/src/main/java/io/grpc/LoadBalancerProvider.java index bb4c574211e..7dc30d6baaf 100644 --- a/api/src/main/java/io/grpc/LoadBalancerProvider.java +++ b/api/src/main/java/io/grpc/LoadBalancerProvider.java @@ -81,7 +81,7 @@ public abstract class LoadBalancerProvider extends LoadBalancer.Factory { * @return a tuple of the fully parsed and validated balancer configuration, else the Status. * @since 1.20.0 * @see - * A24-lb-policy-config.md + * A24-lb-policy-config.md */ public ConfigOrError parseLoadBalancingPolicyConfig(Map rawLoadBalancingPolicyConfig) { return UNKNOWN_CONFIG; diff --git a/api/src/main/java/io/grpc/LoadBalancerRegistry.java b/api/src/main/java/io/grpc/LoadBalancerRegistry.java index f6b69f978b8..a8fbc102f5f 100644 --- a/api/src/main/java/io/grpc/LoadBalancerRegistry.java +++ b/api/src/main/java/io/grpc/LoadBalancerRegistry.java @@ -26,6 +26,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.ServiceLoader; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -42,7 +43,6 @@ public final class LoadBalancerRegistry { private static final Logger logger = Logger.getLogger(LoadBalancerRegistry.class.getName()); private static LoadBalancerRegistry instance; - private static final Iterable> HARDCODED_CLASSES = getHardCodedClasses(); private final LinkedHashSet allProviders = new LinkedHashSet<>(); @@ -101,8 +101,10 @@ public static synchronized LoadBalancerRegistry getDefaultRegistry() { if (instance == null) { List providerList = ServiceProviders.loadAll( LoadBalancerProvider.class, - HARDCODED_CLASSES, - LoadBalancerProvider.class.getClassLoader(), + ServiceLoader + .load(LoadBalancerProvider.class, LoadBalancerProvider.class.getClassLoader()) + .iterator(), + LoadBalancerRegistry::getHardCodedClasses, new LoadBalancerPriorityAccessor()); instance = new LoadBalancerRegistry(); for (LoadBalancerProvider provider : providerList) { diff --git a/api/src/main/java/io/grpc/ManagedChannel.java b/api/src/main/java/io/grpc/ManagedChannel.java index 7875fdb57f2..2b1d89946bf 100644 --- a/api/src/main/java/io/grpc/ManagedChannel.java +++ b/api/src/main/java/io/grpc/ManagedChannel.java @@ -17,12 +17,12 @@ package io.grpc; import java.util.concurrent.TimeUnit; -import javax.annotation.concurrent.ThreadSafe; /** * A {@link Channel} that provides lifecycle management. + * + *

This class is thread-safe. */ -@ThreadSafe public abstract class ManagedChannel extends Channel { /** * Initiates an orderly shutdown in which preexisting calls continue but new calls are immediately diff --git a/api/src/main/java/io/grpc/ManagedChannelBuilder.java b/api/src/main/java/io/grpc/ManagedChannelBuilder.java index df867d09ba1..7681c8f7b62 100644 --- a/api/src/main/java/io/grpc/ManagedChannelBuilder.java +++ b/api/src/main/java/io/grpc/ManagedChannelBuilder.java @@ -45,11 +45,10 @@ public static ManagedChannelBuilder forAddress(String name, int port) { } /** - * Creates a channel with a target string, which can be either a valid {@link - * NameResolver}-compliant URI, or an authority string. + * Creates a channel with a target string, which can be either an RFC 3986 URI, or an authority + * string. * - *

A {@code NameResolver}-compliant URI is an absolute hierarchical URI as defined by {@link - * java.net.URI}. Example URIs: + *

Example URIs: *

    *
  • {@code "dns:///foo.googleapis.com:8080"}
  • *
  • {@code "dns:///foo.googleapis.com"}
  • @@ -57,13 +56,13 @@ public static ManagedChannelBuilder forAddress(String name, int port) { *
  • {@code "dns://8.8.8.8/foo.googleapis.com:8080"}
  • *
  • {@code "dns://8.8.8.8/foo.googleapis.com"}
  • *
  • {@code "zookeeper://zk.example.com:9900/example_service"}
  • + *
  • {@code "intent:#Intent;package=com.some.app;action=a;category=c;end;"}
  • *
* - *

An authority string will be converted to a {@code NameResolver}-compliant URI, which has - * the scheme from the name resolver with the highest priority (e.g. {@code "dns"}), - * no authority, and the original authority string as its path after properly escaped. - * We recommend libraries to specify the schema explicitly if it is known, since libraries cannot - * know which NameResolver will be default during runtime. + *

An authority string will be converted to a URI having the scheme of the name resolver with + * the highest priority (e.g. {@code "dns"}), the empty string as the authority, and + * {@code target} as its absolute path. We recommend libraries specify {@code target} as a URI + * instead since they cannot know which NameResolver will be default at runtime. * Example authority strings: *

    *
  • {@code "localhost"}
  • @@ -75,6 +74,12 @@ public static ManagedChannelBuilder forAddress(String name, int port) { *
  • {@code "[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443"}
  • *
* + *

The URI form of {@code target} is preferred because it is less ambiguous. For example, the + * target string {@code foo:8080} is a valid authority string with host {@code foo} and port + * {@code 8080} but it is also a valid RFC 3986 URI with scheme {@code foo} and path {@code 8080}. + * gRPC prioritizes the URI form, which means {@code foo:8080} will be treated as a URI with + * scheme {@code foo}. Using {@code dns:///foo:8080} avoids this ambiguity. + * *

Note that there is an open JDK bug on {@link java.net.URI} class parsing an ipv6 scope ID: * bugs.openjdk.org/browse/JDK-8199396. This method is exposed to this bug. If you experience an * issue, a work-around is to convert the scope ID to its numeric form (e.g. by using @@ -374,9 +379,17 @@ public T maxInboundMetadataSize(int bytes) { * notice when they are causing excessive load. Clients are strongly encouraged to use only as * small of a value as necessary. * + *

When the channel implementation supports TCP_USER_TIMEOUT, enabling keepalive will also + * enable TCP_USER_TIMEOUT for the connection. This requires all sent packets to receive + * a TCP acknowledgement before the keepalive timeout. The keepalive time is not used for + * TCP_USER_TIMEOUT, except as a signal to enable the feature. grpc-netty supports + * TCP_USER_TIMEOUT on Linux platforms supported by netty-transport-native-epoll. + * * @throws UnsupportedOperationException if unsupported * @see gRFC A8 * Client-side Keepalive + * @see gRFC A18 + * TCP User Timeout * @since 1.7.0 */ public T keepAliveTime(long keepAliveTime, TimeUnit timeUnit) { @@ -393,6 +406,8 @@ public T keepAliveTime(long keepAliveTime, TimeUnit timeUnit) { * @throws UnsupportedOperationException if unsupported * @see gRFC A8 * Client-side Keepalive + * @see gRFC A18 + * TCP User Timeout * @since 1.7.0 */ public T keepAliveTimeout(long keepAliveTimeout, TimeUnit timeUnit) { diff --git a/api/src/main/java/io/grpc/ManagedChannelProvider.java b/api/src/main/java/io/grpc/ManagedChannelProvider.java index 42941dfc809..8a61c097ed8 100644 --- a/api/src/main/java/io/grpc/ManagedChannelProvider.java +++ b/api/src/main/java/io/grpc/ManagedChannelProvider.java @@ -81,6 +81,31 @@ protected NewChannelBuilderResult newChannelBuilder(String target, ChannelCreden return NewChannelBuilderResult.error("ChannelCredentials are unsupported"); } + /** + * Creates a channel builder using the provided target, credentials, and resolution + * components. + * + *

This method allows for fine-grained control over name resolution by providing + * both a {@link NameResolverRegistry} and a specific {@link NameResolverProvider}. + * This returns a {@link NewChannelBuilderResult}, + * which may contain an error string if the provided credentials or target are + * not supported by this provider. + * + * @param target the target URI for the channel + * @param creds the channel credentials to use + * @param nameResolverRegistry the registry used for looking up name resolvers + * @param nameResolverProvider a specific provider to use, or {@code null} to + * search the registry + * @return a {@link NewChannelBuilderResult} containing either the builder or an + * error description + * @since 1.83.0 + */ + protected NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentials creds, + NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { + return newChannelBuilder(target, creds); + } + /** * Returns the {@link SocketAddress} types this ManagedChannelProvider supports. */ diff --git a/api/src/main/java/io/grpc/ManagedChannelRegistry.java b/api/src/main/java/io/grpc/ManagedChannelRegistry.java index aed5eca9abf..c70b6812651 100644 --- a/api/src/main/java/io/grpc/ManagedChannelRegistry.java +++ b/api/src/main/java/io/grpc/ManagedChannelRegistry.java @@ -29,6 +29,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.ServiceLoader; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.concurrent.ThreadSafe; @@ -100,8 +101,10 @@ public static synchronized ManagedChannelRegistry getDefaultRegistry() { if (instance == null) { List providerList = ServiceProviders.loadAll( ManagedChannelProvider.class, - getHardCodedClasses(), - ManagedChannelProvider.class.getClassLoader(), + ServiceLoader + .load(ManagedChannelProvider.class, ManagedChannelProvider.class.getClassLoader()) + .iterator(), + ManagedChannelRegistry::getHardCodedClasses, new ManagedChannelPriorityAccessor()); instance = new ManagedChannelRegistry(); for (ManagedChannelProvider provider : providerList) { @@ -155,13 +158,15 @@ ManagedChannelBuilder newChannelBuilder(String target, ChannelCredentials cre return newChannelBuilder(NameResolverRegistry.getDefaultRegistry(), target, creds); } - @VisibleForTesting ManagedChannelBuilder newChannelBuilder(NameResolverRegistry nameResolverRegistry, String target, ChannelCredentials creds) { NameResolverProvider nameResolverProvider = null; try { - URI uri = new URI(target); - nameResolverProvider = nameResolverRegistry.getProviderForScheme(uri.getScheme()); + String scheme = + FeatureFlags.getRfc3986UrisEnabled() + ? Uri.parse(target).getScheme() + : new URI(target).getScheme(); + nameResolverProvider = nameResolverRegistry.getProviderForScheme(scheme); } catch (URISyntaxException ignore) { // bad URI found, just ignore and continue } @@ -192,7 +197,7 @@ ManagedChannelBuilder newChannelBuilder(NameResolverRegistry nameResolverRegi continue; } ManagedChannelProvider.NewChannelBuilderResult result - = provider.newChannelBuilder(target, creds); + = provider.newChannelBuilder(target, creds, nameResolverRegistry, nameResolverProvider); if (result.getChannelBuilder() != null) { return result.getChannelBuilder(); } diff --git a/api/src/main/java/io/grpc/NameResolver.java b/api/src/main/java/io/grpc/NameResolver.java index 2ac4ecae69e..f52a38ba809 100644 --- a/api/src/main/java/io/grpc/NameResolver.java +++ b/api/src/main/java/io/grpc/NameResolver.java @@ -35,7 +35,6 @@ import java.util.concurrent.ScheduledExecutorService; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; -import javax.annotation.concurrent.ThreadSafe; /** * A pluggable component that resolves a target {@link URI} and return addresses to the caller. @@ -78,7 +77,7 @@ public abstract class NameResolver { * Starts the resolution. The method is not supposed to throw any exceptions. That might cause the * Channel that the name resolver is serving to crash. Errors should be propagated * through {@link Listener#onError}. - * + * *

An instance may not be started more than once, by any overload of this method, even after * an intervening call to {@link #shutdown}. * @@ -97,8 +96,14 @@ public void onError(Status error) { @Override public void onResult(ResolutionResult resolutionResult) { - listener.onAddresses(resolutionResult.getAddressesOrError().getValue(), - resolutionResult.getAttributes()); + StatusOr> addressesOrError = + resolutionResult.getAddressesOrError(); + if (addressesOrError.hasValue()) { + listener.onAddresses(addressesOrError.getValue(), + resolutionResult.getAttributes()); + } else { + listener.onError(addressesOrError.getStatus()); + } } }); } @@ -108,7 +113,7 @@ public void onResult(ResolutionResult resolutionResult) { * Starts the resolution. The method is not supposed to throw any exceptions. That might cause the * Channel that the name resolver is serving to crash. Errors should be propagated * through {@link Listener2#onError}. - * + * *

An instance may not be started more than once, by any overload of this method, even after * an intervening call to {@link #shutdown}. * @@ -152,6 +157,10 @@ public abstract static class Factory { * cannot be resolved by this factory. The decision should be solely based on the scheme of the * URI. * + *

This method will eventually be deprecated and removed as part of a migration from {@code + * java.net.URI} to {@code io.grpc.Uri}. Implementations will override {@link + * #newNameResolver(Uri, Args)} instead. + * * @param targetUri the target URI to be resolved, whose scheme must not be {@code null} * @param args other information that may be useful * @@ -159,6 +168,37 @@ public abstract static class Factory { */ public abstract NameResolver newNameResolver(URI targetUri, final Args args); + /** + * Creates a {@link NameResolver} for the given target URI. + * + *

Implementations return {@code null} if 'targetUri' cannot be resolved by this factory. The + * decision should be solely based on the target's scheme. + * + *

All {@link NameResolver.Factory} implementations should override this method, as it will + * eventually replace {@link #newNameResolver(URI, Args)}. For backwards compatibility, this + * default implementation delegates to {@link #newNameResolver(URI, Args)} if 'targetUri' can be + * converted to a java.net.URI. + * + *

NB: Conversion is not always possible, for example {@code scheme:#frag} is a valid {@link + * Uri} but not a valid {@link URI} because its path is empty. The default implementation throws + * IllegalArgumentException in these cases. + * + * @param targetUri the target URI to be resolved + * @param args other information that may be useful + * @throws IllegalArgumentException if targetUri does not have the expected form + * @since 1.79 + */ + public NameResolver newNameResolver(Uri targetUri, final Args args) { + // Not every io.grpc.Uri can be converted but in the ordinary ManagedChannel creation flow, + // any IllegalArgumentException thrown here would have happened anyway, just earlier. That's + // because parse/toString is transparent so java.net.URI#create here sees the original target + // string just like it did before the io.grpc.Uri migration. + // + // Throwing IAE shouldn't surprise non-framework callers either. After all, many existing + // Factory impls are picky about targetUri and throw IAE when it doesn't look how they expect. + return newNameResolver(URI.create(targetUri.toString()), args); + } + /** * Returns the default scheme, which will be used to construct a URI when {@link * ManagedChannelBuilder#forTarget(String)} is given an authority string instead of a compliant @@ -174,10 +214,11 @@ public abstract static class Factory { * *

All methods are expected to return quickly. * + *

This interface is thread-safe. + * * @since 1.0.0 */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/1770") - @ThreadSafe public interface Listener { /** * Handles updates on resolved addresses and attributes. @@ -314,7 +355,7 @@ public static final class Args { @Nullable private final ChannelLogger channelLogger; @Nullable private final Executor executor; @Nullable private final String overrideAuthority; - @Nullable private final MetricRecorder metricRecorder; + private final MetricRecorder metricRecorder; @Nullable private final NameResolverRegistry nameResolverRegistry; @Nullable private final IdentityHashMap, Object> customArgs; @@ -328,7 +369,8 @@ private Args(Builder builder) { this.channelLogger = builder.channelLogger; this.executor = builder.executor; this.overrideAuthority = builder.overrideAuthority; - this.metricRecorder = builder.metricRecorder; + this.metricRecorder = builder.metricRecorder != null ? builder.metricRecorder + : new MetricRecorder() {}; this.nameResolverRegistry = builder.nameResolverRegistry; this.customArgs = cloneCustomArgs(builder.customArgs); } @@ -456,7 +498,6 @@ public String getOverrideAuthority() { /** * Returns the {@link MetricRecorder} that the channel uses to record metrics. */ - @Nullable public MetricRecorder getMetricRecorder() { return metricRecorder; } @@ -639,7 +680,7 @@ public Builder setArg(Key key, T value) { * See {@link Args#getMetricRecorder()}. This is an optional field. */ public Builder setMetricRecorder(MetricRecorder metricRecorder) { - this.metricRecorder = metricRecorder; + this.metricRecorder = checkNotNull(metricRecorder, "metricRecorder"); return this; } diff --git a/api/src/main/java/io/grpc/NameResolverProvider.java b/api/src/main/java/io/grpc/NameResolverProvider.java index 70e22e366d5..220fa4edc10 100644 --- a/api/src/main/java/io/grpc/NameResolverProvider.java +++ b/api/src/main/java/io/grpc/NameResolverProvider.java @@ -65,7 +65,7 @@ public abstract class NameResolverProvider extends NameResolver.Factory { * * @since 1.40.0 * */ - protected String getScheme() { + public String getScheme() { return getDefaultScheme(); } diff --git a/api/src/main/java/io/grpc/NameResolverRegistry.java b/api/src/main/java/io/grpc/NameResolverRegistry.java index 26eb5552b9b..c5e9f7467ab 100644 --- a/api/src/main/java/io/grpc/NameResolverRegistry.java +++ b/api/src/main/java/io/grpc/NameResolverRegistry.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.ServiceLoader; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -125,8 +126,10 @@ public static synchronized NameResolverRegistry getDefaultRegistry() { if (instance == null) { List providerList = ServiceProviders.loadAll( NameResolverProvider.class, - getHardCodedClasses(), - NameResolverProvider.class.getClassLoader(), + ServiceLoader + .load(NameResolverProvider.class, NameResolverProvider.class.getClassLoader()) + .iterator(), + NameResolverRegistry::getHardCodedClasses, new NameResolverPriorityAccessor()); if (providerList.isEmpty()) { logger.warning("No NameResolverProviders found via ServiceLoader, including for DNS. This " @@ -182,6 +185,13 @@ public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { return provider == null ? null : provider.newNameResolver(targetUri, args); } + @Override + @Nullable + public NameResolver newNameResolver(io.grpc.Uri targetUri, NameResolver.Args args) { + NameResolverProvider provider = getProviderForScheme(targetUri.getScheme()); + return provider == null ? null : provider.newNameResolver(targetUri, args); + } + @Override public String getDefaultScheme() { return NameResolverRegistry.this.getDefaultScheme(); diff --git a/api/src/main/java/io/grpc/QueryParams.java b/api/src/main/java/io/grpc/QueryParams.java new file mode 100644 index 00000000000..31bc2e0e6da --- /dev/null +++ b/api/src/main/java/io/grpc/QueryParams.java @@ -0,0 +1,289 @@ +/* + * 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; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Splitter; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * A parser and mutable container class for {@code application/x-www-form-urlencoded}-style URL + * parameters as conceived by + * RFC 1866 Section 8.2.1. + * + *

For example, a URI like {@code "http://who?name=John+Doe&role=admin&role=user&active"} has: + * + *

    + *
  • A key {@code name} with value {@code John Doe} + *
  • A key {@code role} with value {@code admin} + *
  • A second key named {@code role} with value {@code user} + *
  • "Lone" key {@code active} without a value. + *
+ * + *

This class is meant to be used with {@link io.grpc.Uri}. For example: + * + *

{@code
+ * Uri uri = Uri.parse("http://who?name=John+Doe&role=admin&role=user&active");
+ * QueryParams params = QueryParams.fromRawQuery(uri.getRawQuery());
+ * params.asList().removeIf(e -> "role".equals(e.getKey()) && "admin".equals(e.getValue()));
+ *
+ * Uri modifiedUri = uri.toBuilder().setRawQuery(params.toRawQuery()).build();
+ * }
+ * + *

Note that the empty collection is encoded as a null raw query string, which means "absent" to + * {@link io.grpc.Uri.Builder#setRawQuery}. An empty string query component (""), on the other hand, + * is modeled as an instance of QueryParams containing a single lone (empty) key. It must be this + * way if we are to simultaneously 1) support lone keys, 2) have parse/toRawQuery round-trip + * transparency, and 3) never fail to parse a valid RFC 3986 query component. + * + *

This container and its {@link Entry} take the same position as {@link io.grpc.Uri} on + * equality: raw keys and values must match exactly to be equal. Most callers won't care about how + * keys and values are encoded on the wire and will work with the getters for cooked keys and values + * instead. + * + *

Instances are not safe for concurrent access by multiple threads, including by way of the + * {@link #asList()} view method. + */ +@Internal +public final class QueryParams { + + private static final String UTF_8 = "UTF-8"; + private final List entries = new ArrayList<>(); + + /** Creates a new, empty {@code QueryParams} instance. */ + public QueryParams() {} + + /** + * Parses a raw query string into a {@code QueryParams} instance. + * + *

The input is split on {@code '&'} and each parameter is parsed as either a key/value pair + * (if it contains an equals sign) or a "lone" key (if it does not). + * + *

No valid RFC 3986 query component will fail to parse. For example, {@code ===} is parsed as + * a single parameter with "" as the key and "==" as the value. {@code &&&} is parsed as three + * lone keys named "". And so on. If {@code rawQuery} is not a valid RFC 3986 query component, the + * behavior is undefined. But if you are starting with a {@link io.grpc.Uri}, passing the value + * returned by {@link io.grpc.Uri#getRawQuery()} is always well-defined and will never fail. + * + *

Calling {@link #toRawQuery()} on the returned object is guaranteed to return exactly {@code + * rawQuery}. + * + * @param rawQuery the raw query component to parse, or null to return an empty container + * @return a new instance of {@code QueryParams} representing the input + */ + public static QueryParams fromRawQuery(@Nullable String rawQuery) { + QueryParams params = new QueryParams(); + if (rawQuery != null) { + for (String part : Splitter.on('&').split(rawQuery)) { + int equalsIndex = part.indexOf('='); + if (equalsIndex == -1) { + params.entries.add(Entry.forRawLoneKey(part)); + } else { + String rawKey = part.substring(0, equalsIndex); + String rawValue = part.substring(equalsIndex + 1); + params.entries.add(Entry.forRawKeyValue(rawKey, rawValue)); + } + } + } + return params; + } + + /** + * Returns a mutable list view of the query parameters. + * + * @return the mutable list of entries + */ + public List asList() { + return entries; + } + + /** + * Returns the "raw" query string representation of these parameters, suitable for passing to the + * {@link io.grpc.Uri.Builder#setRawQuery} method. + * + * @return the raw query string + */ + @Nullable + public String toRawQuery() { + if (entries.isEmpty()) { + return null; + } + StringBuilder resultBuilder = new StringBuilder(); + boolean first = true; + for (Entry entry : entries) { + if (!first) { + resultBuilder.append('&'); + } + entry.appendToRawQueryStringBuilder(resultBuilder); + first = false; + } + return resultBuilder.toString(); + } + + @Override + public String toString() { + return entries.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof QueryParams)) { + return false; + } + QueryParams other = (QueryParams) o; + return entries.equals(other.entries); + } + + @Override + public int hashCode() { + return entries.hashCode(); + } + + /** A single query parameter entry. */ + public static final class Entry { + private final String rawKey; + @Nullable private final String rawValue; + private final String key; + @Nullable private final String value; + + private Entry(String rawKey, @Nullable String rawValue, String key, @Nullable String value) { + this.rawKey = checkNotNull(rawKey, "rawKey"); + this.rawValue = rawValue; + this.key = checkNotNull(key, "key"); + this.value = value; + } + + /** + * Returns the key. + * + *

Any characters that needed URL encoding have already been decoded. + */ + public String getKey() { + return key; + } + + /** + * Returns the value, or {@code null} if this is a "lone" key. + * + *

Any characters that needed URL encoding have already been decoded. + */ + @Nullable + public String getValue() { + return value; + } + + /** Returns {@code true} if this entry has a value, {@code false} if it is a "lone" key. */ + public boolean hasValue() { + return value != null; + } + + /** + * Creates a new key/value pair entry. + * + *

Both key and value can contain any character. They will be URL encoded for you if + * necessary. + */ + public static Entry forKeyValue(String key, String value) { + checkNotNull(key, "key"); + checkNotNull(value, "value"); + return new Entry(encode(key), encode(value), key, value); + } + + /** + * Creates a new query parameter with a "lone" key. + * + *

'key' can contain any character. It will be URL encoded for you later, as necessary. + * + * @param key the decoded key, must not be null + * @return a new {@code Entry} + */ + public static Entry forLoneKey(String key) { + checkNotNull(key, "key"); + return new Entry(encode(key), null, key, null); + } + + static Entry forRawKeyValue(String rawKey, String rawValue) { + checkNotNull(rawKey, "rawKey"); + checkNotNull(rawValue, "rawValue"); + return new Entry(rawKey, rawValue, decode(rawKey), decode(rawValue)); + } + + static Entry forRawLoneKey(String rawKey) { + checkNotNull(rawKey, "rawKey"); + return new Entry(rawKey, null, decode(rawKey), null); + } + + void appendToRawQueryStringBuilder(StringBuilder sb) { + sb.append(rawKey); + if (rawValue != null) { + sb.append('=').append(rawValue); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Entry)) { + return false; + } + Entry entry = (Entry) o; + return Objects.equals(rawKey, entry.rawKey) && Objects.equals(rawValue, entry.rawValue); + } + + @Override + public int hashCode() { + return Objects.hash(rawKey, rawValue); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + appendToRawQueryStringBuilder(sb); + return sb.toString(); + } + } + + private static String decode(String s) { + try { + // TODO: Use URLDecoder.decode(String, Charset) when available + return URLDecoder.decode(s, UTF_8); + } catch (UnsupportedEncodingException impossible) { + throw new AssertionError("UTF-8 is not supported", impossible); + } + } + + private static String encode(String s) { + try { + // TODO: Use URLEncoder.encode(String, Charset) when available + return URLEncoder.encode(s, UTF_8); + } catch (UnsupportedEncodingException impossible) { + throw new AssertionError("UTF-8 is not supported", impossible); + } + } +} diff --git a/api/src/main/java/io/grpc/Server.java b/api/src/main/java/io/grpc/Server.java index 97ea06a81c2..97c4d495b8a 100644 --- a/api/src/main/java/io/grpc/Server.java +++ b/api/src/main/java/io/grpc/Server.java @@ -21,13 +21,13 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; -import javax.annotation.concurrent.ThreadSafe; /** * Server for listening for and dispatching incoming calls. It is not expected to be implemented by * application code or interceptors. + * + *

This class is thread-safe. */ -@ThreadSafe public abstract class Server { /** diff --git a/api/src/main/java/io/grpc/ServerBuilder.java b/api/src/main/java/io/grpc/ServerBuilder.java index cd1cddbb93f..3effe593e57 100644 --- a/api/src/main/java/io/grpc/ServerBuilder.java +++ b/api/src/main/java/io/grpc/ServerBuilder.java @@ -435,6 +435,17 @@ public T setBinaryLog(BinaryLog binaryLog) { */ public abstract Server build(); + /** + * Adds a metric sink to the server. + * + * @param metricSink the metric sink to add. + * @return this + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12693") + public T addMetricSink(MetricSink metricSink) { + return thisT(); + } + /** * Returns the correctly typed version of the builder. */ diff --git a/api/src/main/java/io/grpc/ServerCallHandler.java b/api/src/main/java/io/grpc/ServerCallHandler.java index fdfa9997957..7d7d8217300 100644 --- a/api/src/main/java/io/grpc/ServerCallHandler.java +++ b/api/src/main/java/io/grpc/ServerCallHandler.java @@ -16,13 +16,12 @@ package io.grpc; -import javax.annotation.concurrent.ThreadSafe; - /** * Interface to initiate processing of incoming remote calls. Advanced applications and generated * code will implement this interface to allows {@link Server}s to invoke service methods. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface ServerCallHandler { /** * Starts asynchronous processing of an incoming call. diff --git a/api/src/main/java/io/grpc/ServerInterceptor.java b/api/src/main/java/io/grpc/ServerInterceptor.java index 9b2e76ef608..2944cf680fe 100644 --- a/api/src/main/java/io/grpc/ServerInterceptor.java +++ b/api/src/main/java/io/grpc/ServerInterceptor.java @@ -16,7 +16,6 @@ package io.grpc; -import javax.annotation.concurrent.ThreadSafe; /** * Interface for intercepting incoming calls before they are dispatched by @@ -34,8 +33,9 @@ * without completing the previous ones first. Refer to the * {@link io.grpc.ServerCall.Listener ServerCall.Listener} docs for more details regarding thread * safety of the returned listener. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface ServerInterceptor { /** * Intercept {@link ServerCall} dispatch by the {@code next} {@link ServerCallHandler}. General diff --git a/api/src/main/java/io/grpc/ServerRegistry.java b/api/src/main/java/io/grpc/ServerRegistry.java index 5b9c8c558e7..1ec7030b82b 100644 --- a/api/src/main/java/io/grpc/ServerRegistry.java +++ b/api/src/main/java/io/grpc/ServerRegistry.java @@ -24,6 +24,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.ServiceLoader; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.concurrent.ThreadSafe; @@ -93,8 +94,9 @@ public static synchronized ServerRegistry getDefaultRegistry() { if (instance == null) { List providerList = ServiceProviders.loadAll( ServerProvider.class, - getHardCodedClasses(), - ServerProvider.class.getClassLoader(), + ServiceLoader.load(ServerProvider.class, ServerProvider.class.getClassLoader()) + .iterator(), + ServerRegistry::getHardCodedClasses, new ServerPriorityAccessor()); instance = new ServerRegistry(); for (ServerProvider provider : providerList) { diff --git a/api/src/main/java/io/grpc/ServerStreamTracer.java b/api/src/main/java/io/grpc/ServerStreamTracer.java index d522610ab3a..81691642131 100644 --- a/api/src/main/java/io/grpc/ServerStreamTracer.java +++ b/api/src/main/java/io/grpc/ServerStreamTracer.java @@ -17,13 +17,13 @@ package io.grpc; import javax.annotation.Nullable; -import javax.annotation.concurrent.ThreadSafe; /** * Listens to events on a stream to collect metrics. + * + *

This class is thread-safe. */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/2861") -@ThreadSafe public abstract class ServerStreamTracer extends StreamTracer { /** * Called before the interceptors and the call handlers and make changes to the Context object diff --git a/api/src/main/java/io/grpc/ServiceProviders.java b/api/src/main/java/io/grpc/ServiceProviders.java index ac4b27d8783..861688be9fb 100644 --- a/api/src/main/java/io/grpc/ServiceProviders.java +++ b/api/src/main/java/io/grpc/ServiceProviders.java @@ -17,10 +17,13 @@ package io.grpc; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Supplier; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.Iterator; import java.util.List; +import java.util.ListIterator; import java.util.ServiceConfigurationError; import java.util.ServiceLoader; @@ -29,42 +32,44 @@ private ServiceProviders() { // do not instantiate } - /** - * If this is not Android, returns the highest priority implementation of the class via - * {@link ServiceLoader}. - * If this is Android, returns an instance of the highest priority class in {@code hardcoded}. - */ - public static T load( - Class klass, - Iterable> hardcoded, - ClassLoader cl, - PriorityAccessor priorityAccessor) { - List candidates = loadAll(klass, hardcoded, cl, priorityAccessor); - if (candidates.isEmpty()) { - return null; - } - return candidates.get(0); - } - /** * If this is not Android, returns all available implementations discovered via * {@link ServiceLoader}. * If this is Android, returns all available implementations in {@code hardcoded}. * The list is sorted in descending priority order. + * + *

{@code serviceLoader} should be created with {@code ServiceLoader.load(MyClass.class, + * MyClass.class.getClassLoader()).iterator()} in order to be detected by R8 so that R8 full mode + * will keep the constructors for the provider classes. */ public static List loadAll( Class klass, - Iterable> hardcoded, - ClassLoader cl, + Iterator serviceLoader, + Supplier>> hardcoded, final PriorityAccessor priorityAccessor) { - Iterable candidates; - if (isAndroid(cl)) { - candidates = getCandidatesViaHardCoded(klass, hardcoded); + Iterator candidates; + if (serviceLoader instanceof ListIterator) { + // A rewriting tool has replaced the ServiceLoader with a List of some sort (R8 uses + // ArrayList, AppReduce uses singletonList). We prefer to use such iterators on Android as + // they won't need reflection like the hard-coded list does. In addition, the provider + // instances will have already been created, so it seems we should use them. + // + // R8: https://r8.googlesource.com/r8/+/490bc53d9310d4cc2a5084c05df4aadaec8c885d/src/main/java/com/android/tools/r8/ir/optimize/ServiceLoaderRewriter.java + // AppReduce: service_loader_pass.cc + candidates = serviceLoader; + } else if (isAndroid(klass.getClassLoader())) { + // Avoid getResource() on Android, which must read from a zip which uses a lot of memory + candidates = getCandidatesViaHardCoded(klass, hardcoded.get()).iterator(); + } else if (!serviceLoader.hasNext()) { + // Attempt to load using the context class loader and ServiceLoader. + // This allows frameworks like http://aries.apache.org/modules/spi-fly.html to plug in. + candidates = ServiceLoader.load(klass).iterator(); } else { - candidates = getCandidatesViaServiceLoader(klass, cl); + candidates = serviceLoader; } List list = new ArrayList<>(); - for (T current: candidates) { + while (candidates.hasNext()) { + T current = candidates.next(); if (!priorityAccessor.isAvailable(current)) { continue; } @@ -101,15 +106,14 @@ static boolean isAndroid(ClassLoader cl) { } /** - * Loads service providers for the {@code klass} service using {@link ServiceLoader}. + * For testing only: Loads service providers for the {@code klass} service using {@link + * ServiceLoader}. Does not support spi-fly and related tricks. */ @VisibleForTesting public static Iterable getCandidatesViaServiceLoader(Class klass, ClassLoader cl) { Iterable i = ServiceLoader.load(klass, cl); - // Attempt to load using the context class loader and ServiceLoader. - // This allows frameworks like http://aries.apache.org/modules/spi-fly.html to plug in. if (!i.iterator().hasNext()) { - i = ServiceLoader.load(klass); + return null; } return i; } diff --git a/api/src/main/java/io/grpc/StatusException.java b/api/src/main/java/io/grpc/StatusException.java index 2a235c3aaaf..c0a67a375b2 100644 --- a/api/src/main/java/io/grpc/StatusException.java +++ b/api/src/main/java/io/grpc/StatusException.java @@ -65,6 +65,7 @@ public final Status getStatus() { * * @since 1.0.0 */ + @Nullable public final Metadata getTrailers() { return trailers; } diff --git a/api/src/main/java/io/grpc/StatusOr.java b/api/src/main/java/io/grpc/StatusOr.java index 0f1e9da3c75..b7dd68cfd7b 100644 --- a/api/src/main/java/io/grpc/StatusOr.java +++ b/api/src/main/java/io/grpc/StatusOr.java @@ -33,7 +33,7 @@ private StatusOr(Status status, T value) { } /** Construct from a value. */ - public static StatusOr fromValue(@Nullable T value) { + public static StatusOr fromValue(T value) { StatusOr result = new StatusOr(null, value); return result; } @@ -54,7 +54,7 @@ public boolean hasValue() { * Returns the value if set or throws exception if there is no value set. This method is meant * to be called after checking the return value of hasValue() first. */ - public @Nullable T getValue() { + public T getValue() { if (status != null) { throw new IllegalStateException("No value present."); } @@ -105,6 +105,7 @@ public String toString() { return stringHelper.toString(); } + @Nullable private final Status status; private final T value; } diff --git a/api/src/main/java/io/grpc/StreamTracer.java b/api/src/main/java/io/grpc/StreamTracer.java index 66b3de8be6b..251e6e2b49f 100644 --- a/api/src/main/java/io/grpc/StreamTracer.java +++ b/api/src/main/java/io/grpc/StreamTracer.java @@ -16,15 +16,14 @@ package io.grpc; -import javax.annotation.concurrent.ThreadSafe; - /** * Listens to events on a stream to collect metrics. * *

DO NOT MOCK: Use TestStreamTracer. Mocks are not thread-safe + * + *

This class is thread-safe. */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/2861") -@ThreadSafe public abstract class StreamTracer { /** * Stream is closed. This will be called exactly once. diff --git a/api/src/main/java/io/grpc/Uri.java b/api/src/main/java/io/grpc/Uri.java new file mode 100644 index 00000000000..a88bb6138d8 --- /dev/null +++ b/api/src/main/java/io/grpc/Uri.java @@ -0,0 +1,1184 @@ +/* + * 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; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.base.VerifyException; +import com.google.common.collect.ImmutableList; +import com.google.common.net.InetAddresses; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.net.InetAddress; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.MalformedInputException; +import java.nio.charset.StandardCharsets; +import java.util.BitSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * A not-quite-general-purpose representation of a Uniform Resource Identifier (URI), as defined by + * RFC 3986. + * + *

The URI

+ * + *

A URI identifies a resource by its name or location or both. The resource could be a file, + * service, or some other abstract entity. + * + *

Examples

+ * + *
    + *
  • http://admin@example.com:8080/controlpanel?filter=users#settings + *
  • ftp://[2001:db8::7]/docs/report.pdf + *
  • file:///My%20Computer/Documents/letter.doc + *
  • dns://8.8.8.8/storage.googleapis.com + *
  • mailto:John.Doe@example.com + *
  • tel:+1-206-555-1212 + *
  • urn:isbn:978-1492082798 + *
+ * + *

Limitations

+ * + *

This class aims to meet the needs of grpc-java itself and RPC related code that depend on it. + * It isn't quite general-purpose. It definitely would not be suitable for building an HTTP user + * agent or proxy server. In particular, it: + * + *

    + *
  • Can only represent a URI, not a "URI-reference" or "relative reference". In other words, a + * "scheme" is always required. + *
  • Has no knowledge of the particulars of any scheme, with respect to normalization and + * comparison. We don't know https://google.com is the same as + * https://google.com:443, that file:/// is the same as + * file://localhost, or that joe@example.com is the same as + * joe@EXAMPLE.COM. No one class can or should know everything about every scheme so + * all this is better handled at a higher layer. + *
  • Implements {@link #equals(Object)} as a char-by-char comparison. Expect false negatives. + *
  • Does not support "IPvFuture" literal addresses. + *
  • Does not reflect how web browsers parse user input or the URL Living Standard. + *
  • Does not support different character encodings. Assumes UTF-8 in several places. + *
+ * + *

Migrating from RFC 2396 and {@link java.net.URI}

+ * + *

Those migrating from {@link java.net.URI} and/or its primary specification in RFC 2396 should + * note some differences. + * + *

Uniform Hierarchical Syntax

+ * + *

RFC 3986 unifies the older ideas of "hierarchical" and "opaque" URIs into a single generic + * syntax. What RFC 2396 called an opaque "scheme-specific part" is always broken out by RFC 3986 + * into an authority and path hierarchy, followed by query and fragment components. Accordingly, + * this class has only getters for those components but no {@link + * java.net.URI#getSchemeSpecificPart()} analog. + * + *

The RFC 3986 definition of path is now more liberal to accommodate this: + * + *

    + *
  • Path doesn't have to start with a slash. For example, the path of + * urn:isbn:978-1492082798 is isbn:978-1492082798 even though it doesn't + * look much like a file system path. + *
  • The path can now be empty. So Android's + * intent:#Intent;action=MAIN;category=LAUNCHER;end is now a valid {@link Uri}. Even + * the scheme-only about: is now valid. + *
+ * + *

The uniform syntax always understands what follows a '?' to be a query string. For example, + * mailto:me@example.com?subject=foo now has a query component whereas RFC 2396 + * considered everything after the mailto: scheme to be opaque. + * + *

Same goes for fragment. data:image/png;...#xywh=0,0,10,10 now has a fragment + * whereas RFC 2396 considered everything after the scheme to be opaque. + * + *

Uniform Authority Syntax

+ * + *

RFC 2396 tried to guess if an authority was a "server" (host:port) or "registry-based" + * (arbitrary string) based on its contents. RFC 3986 expects every authority to look like + * [userinfo@]host[:port] and loosens the definition of a "host" to accommodate. Accordingly, this + * class has no equivalent to {@link java.net.URI#parseServerAuthority()} -- authority was parsed + * into its components and checked for validity when the {@link Uri} was created. + * + *

Other Specific Differences

+ * + *

RFC 2396 does not allow underscores in a host name, meaning {@link java.net.URI} switches to + * opaque mode when it sees one. {@link Uri} does allow underscores in host, to accommodate + * registries other than DNS. So http://my_site.com:8080/index.html now parses as a + * host, port and path rather than a single opaque scheme-specific part. + * + *

{@link Uri} strictly *requires* square brackets in the query string and fragment to be + * percent-encoded whereas RFC 2396 merely recommended doing so. + * + *

Other URx classes are "liberal in what they accept and strict in what they produce." {@link + * Uri#parse(String)} and {@link Uri#create(String)}, however, are strict in what they accept and + * transparent when asked to reproduce it via {@link Uri#toString()}. The former policy may be + * appropriate for parsing user input or web content, but this class is meant for gRPC clients, + * servers and plugins like name resolvers where human error at runtime is less likely and best + * detected early. {@link java.net.URI#create(String)} is similarly strict, which makes migration + * easy, except for the server/registry-based ambiguity addressed by {@link + * java.net.URI#parseServerAuthority()}. + * + *

{@link java.net.URI} and {@link Uri} both support IPv6 literals in square brackets as defined + * by RFC 2732. + * + *

{@link java.net.URI} supports IPv6 scope IDs but accepts and emits a non-standard syntax. + * {@link Uri} implements the newer RFC 6874, which percent encodes scope IDs and the % delimiter + * itself. RFC 9844 claims to obsolete RFC 6874 because web browsers would not support it. This + * class implements RFC 6874 anyway, mostly to avoid creating a barrier to migration away from + * {@link java.net.URI}. + * + *

Some URI components, e.g. scheme, are required while others may or may not be present, e.g. + * authority. {@link Uri} is careful to preserve the distinction between an absent string component + * (getter returns null) and one with an empty value (getter returns ""). {@link java.net.URI} makes + * this distinction too, *except* when it comes to the authority and host components: {@link + * java.net.URI#getAuthority()} and {@link java.net.URI#getHost()} return null when an authority is + * absent, e.g. file:/path as expected. But these methods surprisingly also return null + * when the authority is the empty string, e.g.file:///path. {@link Uri}'s getters + * correctly return null and "" in these cases, respectively, as one would expect. + */ +@Internal +public final class Uri { + // Components are stored percent-encoded, just as originally parsed for transparent parse/toString + // round-tripping. + private final String scheme; // != null since we don't support relative references. + @Nullable private final String userInfo; + @Nullable private final String host; + @Nullable private final String port; + private final String path; // In RFC 3986, path is always defined (but can be empty). + @Nullable private final String query; + @Nullable private final String fragment; + + private Uri(Builder builder) { + this.scheme = checkNotNull(builder.scheme, "scheme"); + this.userInfo = builder.userInfo; + this.host = builder.host; + this.port = builder.port; + this.path = builder.path; + this.query = builder.query; + this.fragment = builder.fragment; + + // Checks common to the parse() and Builder code paths. + if (hasAuthority()) { + if (!path.isEmpty() && !path.startsWith("/")) { + throw new IllegalArgumentException("Has authority -- Non-empty path must start with '/'"); + } + } else { + if (path.startsWith("//")) { + throw new IllegalArgumentException("No authority -- Path cannot start with '//'"); + } + } + } + + /** + * Parses a URI from its string form. + * + * @throws URISyntaxException if 's' is not a valid RFC 3986 URI. + */ + public static Uri parse(String s) throws URISyntaxException { + try { + return create(s); + } catch (IllegalArgumentException e) { + throw new URISyntaxException(s, e.getMessage()); + } + } + + /** + * Creates a URI from a string assumed to be valid. + * + *

Useful for defining URI constants in code. Not for user input. + * + * @throws IllegalArgumentException if 's' is not a valid RFC 3986 URI. + */ + public static Uri create(String s) { + Builder builder = new Builder(); + int i = 0; + final int n = s.length(); + + // 3.1. Scheme: Look for a ':' before '/', '?', or '#'. + int schemeColon = -1; + for (; i < n; ++i) { + char c = s.charAt(i); + if (c == ':') { + schemeColon = i; + break; + } else if (c == '/' || c == '?' || c == '#') { + break; + } + } + if (schemeColon < 0) { + throw new IllegalArgumentException("Missing required scheme."); + } + builder.setRawScheme(s.substring(0, schemeColon)); + + // 3.2. Authority. Look for '//' then keep scanning until '/', '?', or '#'. + i = schemeColon + 1; + if (i + 1 < n && s.charAt(i) == '/' && s.charAt(i + 1) == '/') { + // "//" just means we have an authority. Skip over it. + i += 2; + + int authorityStart = i; + for (; i < n; ++i) { + char c = s.charAt(i); + if (c == '/' || c == '?' || c == '#') { + break; + } + } + builder.setRawAuthority(s.substring(authorityStart, i)); + } + + // 3.3. Path: Whatever is left before '?' or '#'. + int pathStart = i; + for (; i < n; ++i) { + char c = s.charAt(i); + if (c == '?' || c == '#') { + break; + } + } + builder.setRawPath(s.substring(pathStart, i)); + + // 3.4. Query, if we stopped at '?'. + if (i < n && s.charAt(i) == '?') { + i++; // Skip '?' + int queryStart = i; + for (; i < n; ++i) { + char c = s.charAt(i); + if (c == '#') { + break; + } + } + builder.setRawQuery(s.substring(queryStart, i)); + } + + // 3.5. Fragment, if we stopped at '#'. + if (i < n && s.charAt(i) == '#') { + ++i; // Skip '#' + builder.setRawFragment(s.substring(i)); + } + + return builder.build(); + } + + private static int findPortStartColon(String authority, int hostStart) { + for (int i = authority.length() - 1; i >= hostStart; --i) { + char c = authority.charAt(i); + if (c == ':') { + return i; + } + if (c == ']') { + // Hit the end of IP-literal. Any further colon is inside it and couldn't indicate a port. + break; + } + if (!digitChars.get(c)) { + // Found a non-digit, non-colon, non-bracket. + // This means there is no valid port (e.g. host is "example.com") + break; + } + } + return -1; + } + + // Checks a raw path for validity and parses it into segments. Let 'out' be null to just validate. + private static void parseAssumedUtf8PathIntoSegments( + String path, ImmutableList.Builder out) { + // Skip the first slash so it doesn't count as an empty segment at the start. + // (e.g., "/a" -> ["a"], not ["", "a"]) + int start = path.startsWith("/") ? 1 : 0; + + for (int i = start; i < path.length(); ) { + int nextSlash = path.indexOf('/', i); + String segment; + if (nextSlash >= 0) { + // Typical segment case (e.g., "foo" in "/foo/bar"). + segment = path.substring(i, nextSlash); + i = nextSlash + 1; + } else { + // Final segment case (e.g., "bar" in "/foo/bar"). + segment = path.substring(i); + i = path.length(); + } + if (out != null) { + out.add(percentDecodeAssumedUtf8(segment)); + } else { + checkPercentEncodedArg(segment, "path segment", pChars); + } + } + + // RFC 3986 says a trailing slash creates a final empty segment. + // (e.g., "/foo/" -> ["foo", ""]) + if (path.endsWith("/") && out != null) { + out.add(""); + } + } + + /** Returns the scheme of this URI. */ + public String getScheme() { + return scheme; + } + + /** + * Returns the percent-decoded "Authority" component of this URI, or null if not present. + * + *

NB: This method's decoding is lossy -- It only exists for compatibility with {@link + * java.net.URI}. Prefer {@link #getRawAuthority()} or work instead with authority in terms of its + * individual components ({@link #getUserInfo()}, {@link #getHost()} and {@link #getPort()}). The + * problem with getAuthority() is that it returns the delimited concatenation of the percent- + * decoded userinfo, host and port components. But both userinfo and host can contain the '@' + * character, which becomes indistinguishable from the userinfo/host delimiter after decoding. For + * example, URIs scheme://x@y%40z and scheme://x%40y@z have different + * userinfo and host components but getAuthority() returns "x@y@z" for both of them. + * + *

NB: This method assumes the "host" component was encoded as UTF-8, as mandated by RFC 3986. + * This method also assumes the "user information" part of authority was encoded as UTF-8, + * although RFC 3986 doesn't specify an encoding. + * + *

Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in + * the output. Callers who want to detect and handle errors in some other way should call {@link + * #getRawAuthority()}, {@link #percentDecode(CharSequence)}, then decode the bytes for + * themselves. + */ + @Nullable + public String getAuthority() { + return percentDecodeAssumedUtf8(getRawAuthority()); + } + + private boolean hasAuthority() { + return host != null; + } + + /** + * Returns the "authority" component of this URI in its originally parsed, possibly + * percent-encoded form. + */ + @Nullable + public String getRawAuthority() { + if (hasAuthority()) { + StringBuilder sb = new StringBuilder(); + appendAuthority(sb); + return sb.toString(); + } + return null; + } + + private void appendAuthority(StringBuilder sb) { + if (userInfo != null) { + sb.append(userInfo).append('@'); + } + if (host != null) { + sb.append(host); + } + if (port != null) { + sb.append(':').append(port); + } + } + + /** + * Returns the percent-decoded "User Information" component of this URI, or null if not present. + * + *

NB: This method *assumes* this component was encoded as UTF-8, although RFC 3986 doesn't + * specify an encoding. + * + *

Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in + * the output. Callers who want to detect and handle errors in some other way should call {@link + * #getRawUserInfo()}, {@link #percentDecode(CharSequence)}, then decode the bytes for themselves. + */ + @Nullable + public String getUserInfo() { + return percentDecodeAssumedUtf8(userInfo); + } + + /** + * Returns the "User Information" component of this URI in its originally parsed, possibly + * percent-encoded form. + */ + @Nullable + public String getRawUserInfo() { + return userInfo; + } + + /** + * Returns the percent-decoded "host" component of this URI, or null if not present. + * + *

This method assumes the host was encoded as UTF-8, as mandated by RFC 3986. + * + *

Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in + * the output. Callers who want to detect and handle errors in some other way should call {@link + * #getRawHost()}, {@link #percentDecode(CharSequence)}, then decode the bytes for themselves. + */ + @Nullable + public String getHost() { + return percentDecodeAssumedUtf8(host); + } + + /** + * Returns the host component of this URI in its originally parsed, possibly percent-encoded form. + */ + @Nullable + public String getRawHost() { + return host; + } + + /** Returns the "port" component of this URI, or -1 if empty or not present. */ + public int getPort() { + return port != null && !port.isEmpty() ? Integer.parseInt(port) : -1; + } + + /** Returns the raw port component of this URI in its originally parsed form. */ + @Nullable + public String getRawPort() { + return port; + } + + /** + * Returns the (possibly empty) percent-decoded "path" component of this URI. + * + *

NB: This method *assumes* the path was encoded as UTF-8, although RFC 3986 doesn't specify + * an encoding. + * + *

Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in + * the output. Callers who want to detect and handle errors in some other way should call {@link + * #getRawPath()}, {@link #percentDecode(CharSequence)}, then decode the bytes for themselves. + * + *

NB: Prefer {@link #getPathSegments()} because this method's decoding is lossy. For example, + * consider these (different) URIs: + * + *

    + *
  • file:///home%2Ffolder/my%20file + *
  • file:///home/folder/my%20file + *
+ * + *

Calling getPath() on each returns the same string: /home/folder/my file. You + * can't tell whether the second '/' character is part of the first path segment or separates the + * first and second path segments. This method only exists to ease migration from {@link + * java.net.URI}. + */ + public String getPath() { + return percentDecodeAssumedUtf8(path); + } + + /** + * Returns this URI's path as a list of path segments not including the '/' segment delimiters. + * + *

Prefer this method over {@link #getPath()} because it preserves the distinction between + * segment separators and literal '/'s within a path segment. + * + *

A trailing '/' delimiter in the path results in the empty string as the last element in the + * returned list. For example, file://localhost/foo/bar/ has path segments + * ["foo", "bar", ""] + * + *

A leading '/' delimiter cannot be detected using this method. For example, both + * dns:example.com and dns:///example.com have the same list of path segments: + * ["example.com"]. Use {@link #isPathAbsolute()} or {@link #isPathRootless()} to + * distinguish these cases. + * + *

The returned list is immutable. + */ + public List getPathSegments() { + // Returned list must be immutable but we intentionally keep guava out of the public API. + ImmutableList.Builder segmentsBuilder = ImmutableList.builder(); + parseAssumedUtf8PathIntoSegments(path, segmentsBuilder); + return segmentsBuilder.build(); + } + + /** + * Returns true iff this URI's path component starts with a path segment (rather than the '/' + * segment delimiter). + * + *

The path of an RFC 3986 URI is either empty, absolute (starts with the '/' segment + * delimiter) or rootless (starts with a path segment). For example, tel:+1-206-555-1212 + * , mailto:me@example.com and urn:isbn:978-1492082798 all have + * rootless paths. mailto:%2Fdev%2Fnull@example.com is also rootless because its + * percent-encoded slashes are not segment delimiters but rather part of the first and only path + * segment. + * + *

Contrast rootless paths with absolute ones (see {@link #isPathAbsolute()}. + */ + public boolean isPathRootless() { + return !path.isEmpty() && !path.startsWith("/"); + } + + /** + * Returns true iff this URI's path component starts with the '/' segment delimiter (rather than a + * path segment). + * + *

The path of an RFC 3986 URI is either empty, absolute (starts with the '/' segment + * delimiter) or rootless (starts with a path segment). For example, file:///resume.txt + * , file:/resume.txt and file://localhost/ all have absolute + * paths while tel:+1-206-555-1212's path is not absolute. + * mailto:%2Fdev%2Fnull@example.com is also not absolute because its percent-encoded + * slashes are not segment delimiters but rather part of the first and only path segment. + * + *

Contrast absolute paths with rootless ones (see {@link #isPathRootless()}. + * + *

NB: The term "absolute" has two different meanings in RFC 3986 which are easily confused. + * This method tests for a property of this URI's path component. Contrast with {@link + * #isAbsolute()} which tests the URI itself for a different property. + */ + public boolean isPathAbsolute() { + return path.startsWith("/"); + } + + /** + * Returns the path component of this URI in its originally parsed, possibly percent-encoded form. + */ + public String getRawPath() { + return path; + } + + /** + * Returns the query component of this URI in its originally parsed, possibly percent-encoded + * form, without any leading '?' character, or null if not present. + * + *

The query component can only be read in its raw form. That’s because virtually everyone uses + * query as a container for structured data, with some additional layer of encoding not present in + * RFC-3986. Like 'application/x-www-form-urlencoded', which encodes key/value pairs like so: + * ?k1=v1&k2=v+2. The encoding of these containers always has characters that take on + * a special delimiter meaning when not percent-encoded and a literal meaning when they are (like + * '&', '=' and '+' above). Since it matters whether a character was percent encoded or not, + * offering a '#getQuery()' method that percent-decodes everything like we do for other components + * would be error-prone. + */ + @Nullable + public String getRawQuery() { + return query; + } + + /** + * Returns the percent-decoded "fragment" component of this URI, or null if not present. + * + *

NB: This method assumes the fragment was encoded as UTF-8, although RFC 3986 doesn't specify + * an encoding. + * + *

Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in + * the output. Callers who want to detect and handle errors in some other way should call {@link + * #getRawFragment()}, {@link #percentDecode(CharSequence)}, then decode the bytes for themselves. + * + *

NB: Choose carefully between this method and {@link #getRawFragment()}. Many URI schemes + * embed further structure inside the fragment that isn't part of the RFC 3986 generic syntax. For + * example, Android uses the fragment to encode the many fields of an Intent, like {@code + * intent:#Intent;S.key=val;end;}. And the URI of a JSON resource may use RFC 6901 in its fragment + * to point at a particular node, e.g. {@code + * file:/etc/config/service.json#/methodConfig/0/retryPolicy/maxBackoff}. + * + *

When percent-encoding is used to escape internal delimiters, like a literal ';' and '=' in + * an `intent:`, call {@link #getRawFragment()} to preserve that percent-encoding, or risk + * corruption. Conversely, use *this* method when percent-decoding is needed *before* any further + * interpretation, like with a JSON pointer, which must be percent-encoded in a URI fragment but + * uses a completely different method of escaping literal '/' characters. + */ + @Nullable + public String getFragment() { + return percentDecodeAssumedUtf8(fragment); + } + + /** + * Returns the fragment component of this URI in its original, possibly percent-encoded form, and + * without any leading '#' character. + * + *

NB: Choose carefully between this method and {@link #getFragment()}. See that Javadoc for + * details. + */ + @Nullable + public String getRawFragment() { + return fragment; + } + + /** + * {@inheritDoc} + * + *

If this URI was created by {@link #parse(String)} or {@link #create(String)}, then the + * returned string will match that original input exactly. + */ + @Override + public String toString() { + // https://datatracker.ietf.org/doc/html/rfc3986#section-5.3 + StringBuilder sb = new StringBuilder(); + sb.append(scheme).append(':'); + if (hasAuthority()) { + sb.append("//"); + appendAuthority(sb); + } + sb.append(path); + if (query != null) { + sb.append('?').append(query); + } + if (fragment != null) { + sb.append('#').append(fragment); + } + return sb.toString(); + } + + /** + * Returns true iff this URI has a scheme and an authority/path hierarchy, but no fragment. + * + *

All instances of {@link Uri} are RFC 3986 URIs, not "relative references", so this method is + * equivalent to {@code getFragment() == null}. It mostly exists for compatibility with {@link + * java.net.URI}. + */ + public boolean isAbsolute() { + return scheme != null && fragment == null; + } + + /** + * {@inheritDoc} + * + *

Two instances of {@link Uri} are equal if and only if they have the same string + * representation, which RFC 3986 calls "Simple String Comparison" (6.2.1). Callers with a higher + * layer expectation of equality (e.g. http://some%2Dhost:80/foo/./bar.txt ~= + * http://some-host/foo/bar.txt) will experience false negatives. + */ + @Override + public boolean equals(Object otherObj) { + if (!(otherObj instanceof Uri)) { + return false; + } + Uri other = (Uri) otherObj; + return Objects.equals(scheme, other.scheme) + && Objects.equals(userInfo, other.userInfo) + && Objects.equals(host, other.host) + && Objects.equals(port, other.port) + && Objects.equals(path, other.path) + && Objects.equals(query, other.query) + && Objects.equals(fragment, other.fragment); + } + + @Override + public int hashCode() { + return Objects.hash(scheme, userInfo, host, port, path, query, fragment); + } + + /** Returns a new Builder initialized with the fields of this URI. */ + public Builder toBuilder() { + return new Builder(this); + } + + /** Creates a new {@link Builder} with all fields uninitialized or set to their default values. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Builder for {@link Uri}. */ + public static final class Builder { + private String scheme; + private String path = ""; + private String query; + private String fragment; + private String userInfo; + private String host; + private String port; + + private Builder() {} + + Builder(Uri prototype) { + this.scheme = prototype.scheme; + this.userInfo = prototype.userInfo; + this.host = prototype.host; + this.port = prototype.port; + this.path = prototype.path; + this.query = prototype.query; + this.fragment = prototype.fragment; + } + + /** + * Sets the scheme, e.g. "https", "dns" or "xds". + * + *

This field is required. + * + * @return this, for fluent building + * @throws IllegalArgumentException if the scheme is invalid. + */ + @CanIgnoreReturnValue + public Builder setScheme(String scheme) { + return setRawScheme(scheme.toLowerCase(Locale.ROOT)); + } + + @CanIgnoreReturnValue + Builder setRawScheme(String scheme) { + if (scheme.isEmpty() || !alphaChars.get(scheme.charAt(0))) { + throw new IllegalArgumentException("Scheme must start with an alphabetic char"); + } + for (int i = 0; i < scheme.length(); i++) { + char c = scheme.charAt(i); + if (!schemeChars.get(c)) { + throw new IllegalArgumentException("Invalid character in scheme at index " + i); + } + } + this.scheme = scheme; + return this; + } + + /** + * Specifies the new URI's path component as a string of zero or more '/' delimited segments. + * + *

Path segments can consist of any string of codepoints. Codepoints that can't be encoded + * literally will be percent-encoded for you. + * + *

If a URI contains an authority component, then the path component must either be empty or + * begin with a slash ("/") character. If a URI does not contain an authority component, then + * the path cannot begin with two slash characters ("//"). + * + *

This method interprets all '/' characters in 'path' as segment delimiters. If any of your + * segments contain literal '/' characters, call {@link #setRawPath(String)} instead. + * + *

See RFC 3986 3.3 + * for more. + * + *

This field is required but can be empty (its default value). + * + * @param path the new path + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setPath(String path) { + checkArgument(path != null, "Path can be empty but not null"); + this.path = percentEncode(path, pCharsAndSlash); + return this; + } + + /** + * Specifies the new URI's path component as a string of zero or more '/' delimited segments. + * + *

Path segments can consist of any string of codepoints but the caller must first percent- + * encode anything other than RFC 3986's "pchar" character class using UTF-8. + * + *

If a URI contains an authority component, then the path component must either be empty or + * begin with a slash ("/") character. If a URI does not contain an authority component, then + * the path cannot begin with two slash characters ("//"). + * + *

This method interprets all '/' characters in 'path' as segment delimiters. If any of your + * segments contain literal '/' characters, you must percent-encode them. + * + *

See RFC 3986 3.3 + * for more. + * + *

This field is required but can be empty (its default value). + * + * @param path the new path, a string consisting of characters from "pchar" + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setRawPath(String path) { + checkArgument(path != null, "Path can be empty but not null"); + parseAssumedUtf8PathIntoSegments(path, null); + this.path = path; + return this; + } + + /** + * Specifies the query component of the new URI, possibly percent-encoded, exactly as it will + * appear in the string form of the built URI. + * + *

'query' must only contain codepoints from RFC 3986's "query" character class. Any other + * characters must be percent-encoded using UTF-8. Do not include the leading '?' delimiter. + * + *

The query component can only be provided in its raw form. That’s because virtually + * everyone uses query as a container for structured data, with some additional layer of + * encoding not present in RFC-3986. Like 'application/x-www-form-urlencoded', which encodes + * key/value pairs like so: ?k1=v1&k2=v+2. The encoding of these containers always + * has characters that take on a special delimiter meaning when not percent-encoded and a + * literal meaning when they are (like '&', '=' and '+' above). Since 'query' must have already + * been carefully percent-encoded externally, a '#setQuery(String)' method that percent-encodes + * an assumed-cooked string would be error-prone. + * + *

This field is optional. + * + * @param query the new query component, or null to clear this field + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setRawQuery(@Nullable String query) { + if (query != null) { + checkPercentEncodedArg(query, "query", queryChars); + } + this.query = query; + return this; + } + + /** + * Specifies the fragment component of the new URI (not including the leading '#'). + * + *

The fragment can contain any string of codepoints. Codepoints that can't be encoded + * literally will be percent-encoded for you as UTF-8. + * + *

NB: Choose carefully between this method and {@link #setRawFragment(String)}. Many URI + * schemes embed further structure in the fragment that isn't part of the RFC 3986 generic + * syntax. These schemes often use internal delimiters that must be carefully percent-encoded in + * ways that this method doesn't understand. See {@link #getFragment()} for an example. In that + * case, callers should percent-encode externally then call {@link #setRawFragment(String)} + * instead. + * + *

This field is optional. + * + * @param fragment the new fragment component, or null to clear this field + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setFragment(@Nullable String fragment) { + this.fragment = percentEncode(fragment, fragmentChars); + return this; + } + + /** + * Specifies the fragment component of the new URI, already percent-encoded, exactly as it will + * appear after the '#' delimiter in the string form of the built URI. + * + *

NB: Choose carefully between this method and {@link #setFragment(String)}. {@code + * fragment} must only contain codepoints from RFC 3986's "fragment" character class. Use + * percent-encoding and UTF-8 to represent anything else. In certain cases, you can use {@link + * #setFragment(String)} to have the fragment percent-encoded for you instead, but see that + * method's Javadoc for its limitations. + * + *

This field is optional. + * + * @param fragment the new fragment component, or null to clear this field + * @return this, for fluent building + * @throws IllegalArgumentException if 'fragment' contains forbidden characters + */ + @CanIgnoreReturnValue + public Builder setRawFragment(@Nullable String fragment) { + if (fragment != null) { + checkPercentEncodedArg(fragment, "fragment", fragmentChars); + } + this.fragment = fragment; + return this; + } + + /** + * Set the "user info" component of the new URI, e.g. "username:password", not including the + * trailing '@' character. + * + *

User info can contain any string of codepoints. Codepoints that can't be encoded literally + * will be percent-encoded for you as UTF-8. + * + *

This field is optional. + * + * @param userInfo the new "user info" component, or null to clear this field + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setUserInfo(@Nullable String userInfo) { + this.userInfo = percentEncode(userInfo, userInfoChars); + return this; + } + + @CanIgnoreReturnValue + Builder setRawUserInfo(String userInfo) { + checkPercentEncodedArg(userInfo, "userInfo", userInfoChars); + this.userInfo = userInfo; + return this; + } + + /** + * Specifies the "host" component of the new URI in its "registered name" form (usually DNS), + * e.g. "server.com". + * + *

The registered name can contain any string of codepoints. Codepoints that can't be encoded + * literally will be percent-encoded for you as UTF-8. + * + *

This field is optional. + * + * @param regName the new host component in "registered name" form, or null to clear this field + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setHost(@Nullable String regName) { + if (regName != null) { + regName = regName.toLowerCase(Locale.ROOT); + regName = percentEncode(regName, regNameChars); + } + this.host = regName; + return this; + } + + /** + * Specifies the "host" component of the new URI as an IP address. + * + *

This field is optional. + * + * @param addr the new "host" component in InetAddress form, or null to clear this field + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setHost(@Nullable InetAddress addr) { + this.host = addr != null ? toUriString(addr) : null; + return this; + } + + private static String toUriString(InetAddress addr) { + // InetAddresses.toUriString(addr) is almost enough but neglects RFC 6874 percent encoding. + String inetAddrStr = InetAddresses.toUriString(addr); + int percentIndex = inetAddrStr.indexOf('%'); + if (percentIndex < 0) { + return inetAddrStr; + } + + String scope = inetAddrStr.substring(percentIndex, inetAddrStr.length() - 1); + return inetAddrStr.substring(0, percentIndex) + percentEncode(scope, unreservedChars) + "]"; + } + + @CanIgnoreReturnValue + Builder setRawHost(String host) { + if (host.startsWith("[") && host.endsWith("]")) { + // IP-literal: Guava's isUriInetAddress() is almost enough but it doesn't check the scope. + int percentIndex = host.indexOf('%'); + if (percentIndex > 0) { + String scope = host.substring(percentIndex, host.length() - 1); + checkPercentEncodedArg(scope, "scope", unreservedChars); + } + } + // IP-literal validation is complicated so we delegate it to Guava. We use this particular + // method of InetAddresses because it doesn't try to match interfaces on the local machine. + // (The validity of a URI should be the same no matter which machine does the parsing.) + // TODO(jdcormie): IPFuture + if (!InetAddresses.isUriInetAddress(host)) { + // Must be a "registered name". + checkPercentEncodedArg(host, "host", regNameChars); + } + this.host = host; + return this; + } + + /** + * Specifies the "port" component of the new URI, e.g. "8080". + * + *

The port can be any non-negative integer. A negative value represents "no port". + * + *

This field is optional. + * + * @param port the new "port" component, or -1 to clear this field + * @return this, for fluent building + */ + @CanIgnoreReturnValue + public Builder setPort(int port) { + this.port = port < 0 ? null : Integer.toString(port); + return this; + } + + @CanIgnoreReturnValue + Builder setRawPort(String port) { + if (port != null && !port.isEmpty()) { + try { + Integer.parseInt(port); // Result unused. + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port", e); + } + } + this.port = port; + return this; + } + + /** + * Specifies the userinfo, host and port URI components all at once using a single string. + * + *

This setter is "raw" in the sense that special characters in userinfo and host must be + * passed in percent-encoded. See RFC 3986 3.2 for the set + * of characters allowed in each component of an authority. + * + *

There's no "cooked" method to set authority like for other URI components because + * authority is a *compound* URI component whose userinfo, host and port components are + * delimited with special characters '@' and ':'. But the first two of those components can + * themselves contain these delimiters so we need percent-encoding to parse them unambiguously. + * + * @param authority an RFC 3986 authority string that will be used to set userinfo, host and + * port, or null to clear all three of those components + */ + @CanIgnoreReturnValue + public Builder setRawAuthority(@Nullable String authority) { + if (authority == null) { + setUserInfo(null); + setHost((String) null); + setPort(-1); + } else { + // UserInfo. Easy because '@' cannot appear unencoded inside userinfo or host. + int userInfoEnd = authority.indexOf('@'); + if (userInfoEnd >= 0) { + setRawUserInfo(authority.substring(0, userInfoEnd)); + } else { + setUserInfo(null); + } + + // Host/Port. + int hostStart = userInfoEnd >= 0 ? userInfoEnd + 1 : 0; + int portStartColon = findPortStartColon(authority, hostStart); + if (portStartColon < 0) { + setRawHost(authority.substring(hostStart)); + setPort(-1); + } else { + setRawHost(authority.substring(hostStart, portStartColon)); + setRawPort(authority.substring(portStartColon + 1)); + } + } + return this; + } + + /** Builds a new instance of {@link Uri} as specified by the setters. */ + public Uri build() { + checkState(scheme != null, "Missing required scheme."); + if (host == null) { + checkState(port == null, "Cannot set port without host."); + checkState(userInfo == null, "Cannot set userInfo without host."); + } + return new Uri(this); + } + } + + /** + * Decodes a string of characters in the range [U+0000, U+007F] to bytes. + * + *

Each percent-encoded sequence (e.g. "%F0" or "%2a", as defined by RFC 3986 2.1) is decoded + * to the octet it encodes. Other characters are decoded to their code point's single byte value. + * A literal % character must be encoded as %25. + * + * @throws IllegalArgumentException if 's' contains characters out of range or invalid percent + * encoding sequences. + */ + public static ByteBuffer percentDecode(CharSequence s) { + // This is large enough because each input character needs *at most* one byte of output. + ByteBuffer outBuf = ByteBuffer.allocate(s.length()); + percentDecode(s, "input", null, outBuf); + outBuf.flip(); + return outBuf; + } + + private static void percentDecode( + CharSequence s, String what, BitSet allowedChars, ByteBuffer outBuf) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%') { + if (i + 2 >= s.length()) { + throw new IllegalArgumentException( + "Invalid percent-encoding at index " + i + " of " + what + ": " + s); + } + int h1 = Character.digit(s.charAt(i + 1), 16); + int h2 = Character.digit(s.charAt(i + 2), 16); + if (h1 == -1 || h2 == -1) { + throw new IllegalArgumentException( + "Invalid hex digit in " + what + " at index " + i + " of: " + s); + } + if (outBuf != null) { + outBuf.put((byte) (h1 << 4 | h2)); + } + i += 2; + } else if (allowedChars == null || allowedChars.get(c)) { + if (outBuf != null) { + outBuf.put((byte) c); + } + } else { + throw new IllegalArgumentException("Invalid character in " + what + " at index " + i); + } + } + } + + @Nullable + private static String percentDecodeAssumedUtf8(@Nullable String s) { + if (s == null || s.indexOf('%') == -1) { + return s; + } + + ByteBuffer utf8Bytes = percentDecode(s); + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .decode(utf8Bytes) + .toString(); + } catch (CharacterCodingException e) { + throw new VerifyException(e); // Should not happen in REPLACE mode. + } + } + + @Nullable + private static String percentEncode(String s, BitSet allowedCodePoints) { + if (s == null) { + return null; + } + CharsetEncoder encoder = + StandardCharsets.UTF_8 + .newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + ByteBuffer utf8Bytes; + try { + utf8Bytes = encoder.encode(CharBuffer.wrap(s)); + } catch (MalformedInputException e) { + throw new IllegalArgumentException("Malformed input", e); // Must be a broken surrogate pair. + } catch (CharacterCodingException e) { + throw new VerifyException(e); // Should not happen when encoding to UTF-8. + } + + StringBuilder sb = new StringBuilder(); + while (utf8Bytes.hasRemaining()) { + int b = 0xff & utf8Bytes.get(); + if (allowedCodePoints.get(b)) { + sb.append((char) b); + } else { + sb.append('%'); + sb.append(hexDigitsByVal[(b & 0xF0) >> 4]); + sb.append(hexDigitsByVal[b & 0x0F]); + } + } + return sb.toString(); + } + + private static void checkPercentEncodedArg(String s, String what, BitSet allowedChars) { + percentDecode(s, what, allowedChars, null); + } + + // See UriTest for how these were computed from the ABNF constants in RFC 3986. + static final BitSet digitChars = BitSet.valueOf(new long[] {0x3ff000000000000L}); + static final BitSet alphaChars = BitSet.valueOf(new long[] {0L, 0x7fffffe07fffffeL}); + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + static final BitSet schemeChars = + BitSet.valueOf(new long[] {0x3ff680000000000L, 0x7fffffe07fffffeL}); + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + static final BitSet unreservedChars = + BitSet.valueOf(new long[] {0x3ff600000000000L, 0x47fffffe87fffffeL}); + // gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + static final BitSet genDelimsChars = + BitSet.valueOf(new long[] {0x8400800800000000L, 0x28000001L}); + // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + static final BitSet subDelimsChars = BitSet.valueOf(new long[] {0x28001fd200000000L}); + // reserved = gen-delims / sub-delims + static final BitSet reservedChars = BitSet.valueOf(new long[] {0xac009fda00000000L, 0x28000001L}); + // reg-name = *( unreserved / pct-encoded / sub-delims ) + static final BitSet regNameChars = + BitSet.valueOf(new long[] {0x2bff7fd200000000L, 0x47fffffe87fffffeL}); + // userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) + static final BitSet userInfoChars = + BitSet.valueOf(new long[] {0x2fff7fd200000000L, 0x47fffffe87fffffeL}); + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + static final BitSet pChars = + BitSet.valueOf(new long[] {0x2fff7fd200000000L, 0x47fffffe87ffffffL}); + static final BitSet pCharsAndSlash = + BitSet.valueOf(new long[] {0x2fffffd200000000L, 0x47fffffe87ffffffL}); + // query = *( pchar / "/" / "?" ) + static final BitSet queryChars = + BitSet.valueOf(new long[] {0xafffffd200000000L, 0x47fffffe87ffffffL}); + // fragment = *( pchar / "/" / "?" ) + static final BitSet fragmentChars = queryChars; + + private static final char[] hexDigitsByVal = "0123456789ABCDEF".toCharArray(); +} diff --git a/api/src/test/java/io/grpc/HttpConnectProxiedSocketAddressTest.java b/api/src/test/java/io/grpc/HttpConnectProxiedSocketAddressTest.java new file mode 100644 index 00000000000..6620a7d413a --- /dev/null +++ b/api/src/test/java/io/grpc/HttpConnectProxiedSocketAddressTest.java @@ -0,0 +1,248 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +import com.google.common.testing.EqualsTester; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HttpConnectProxiedSocketAddressTest { + + private final InetSocketAddress proxyAddress = + new InetSocketAddress(InetAddress.getLoopbackAddress(), 8080); + private final InetSocketAddress targetAddress = + InetSocketAddress.createUnresolved("example.com", 443); + + @Test + public void buildWithAllFields() { + Map headers = new HashMap<>(); + headers.put("X-Custom-Header", "custom-value"); + headers.put("Proxy-Authorization", "Bearer token"); + + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers) + .setUsername("user") + .setPassword("pass") + .build(); + + assertThat(address.getProxyAddress()).isEqualTo(proxyAddress); + assertThat(address.getTargetAddress()).isEqualTo(targetAddress); + assertThat(address.getHeaders()).hasSize(2); + assertThat(address.getHeaders()).containsEntry("X-Custom-Header", "custom-value"); + assertThat(address.getHeaders()).containsEntry("Proxy-Authorization", "Bearer token"); + assertThat(address.getUsername()).isEqualTo("user"); + assertThat(address.getPassword()).isEqualTo("pass"); + } + + @Test + public void buildWithoutOptionalFields() { + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .build(); + + assertThat(address.getProxyAddress()).isEqualTo(proxyAddress); + assertThat(address.getTargetAddress()).isEqualTo(targetAddress); + assertThat(address.getHeaders()).isEmpty(); + assertThat(address.getUsername()).isNull(); + assertThat(address.getPassword()).isNull(); + } + + @Test + public void buildWithEmptyHeaders() { + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(Collections.emptyMap()) + .build(); + + assertThat(address.getHeaders()).isEmpty(); + } + + @Test + public void headersAreImmutable() { + Map headers = new HashMap<>(); + headers.put("key1", "value1"); + + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers) + .build(); + + headers.put("key2", "value2"); + + assertThat(address.getHeaders()).hasSize(1); + assertThat(address.getHeaders()).containsEntry("key1", "value1"); + assertThat(address.getHeaders()).doesNotContainKey("key2"); + } + + @Test + public void returnedHeadersAreUnmodifiable() { + Map headers = new HashMap<>(); + headers.put("key", "value"); + + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers) + .build(); + + assertThrows(UnsupportedOperationException.class, + () -> address.getHeaders().put("newKey", "newValue")); + } + + @Test + public void nullHeadersThrowsException() { + assertThrows(NullPointerException.class, + () -> HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(null) + .build()); + } + + @Test + public void equalsAndHashCode() { + Map headers1 = new HashMap<>(); + headers1.put("header", "value"); + + Map headers2 = new HashMap<>(); + headers2.put("header", "value"); + + Map differentHeaders = new HashMap<>(); + differentHeaders.put("different", "header"); + + new EqualsTester() + .addEqualityGroup( + HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers1) + .setUsername("user") + .setPassword("pass") + .build(), + HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers2) + .setUsername("user") + .setPassword("pass") + .build()) + .addEqualityGroup( + HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(differentHeaders) + .setUsername("user") + .setPassword("pass") + .build()) + .addEqualityGroup( + HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .build()) + .testEquals(); + } + + @Test + public void toStringContainsHeaders() { + Map headers = new HashMap<>(); + headers.put("X-Test", "test-value"); + + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers) + .setUsername("user") + .setPassword("secret") + .build(); + + String toString = address.toString(); + assertThat(toString).contains("headers"); + assertThat(toString).contains("X-Test"); + assertThat(toString).contains("hasPassword=true"); + assertThat(toString).doesNotContain("secret"); + } + + @Test + public void toStringWithoutPassword() { + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .build(); + + String toString = address.toString(); + assertThat(toString).contains("hasPassword=false"); + } + + @Test + public void hashCodeDependsOnHeaders() { + Map headers1 = new HashMap<>(); + headers1.put("header", "value1"); + + Map headers2 = new HashMap<>(); + headers2.put("header", "value2"); + + HttpConnectProxiedSocketAddress address1 = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers1) + .build(); + + HttpConnectProxiedSocketAddress address2 = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers2) + .build(); + + assertNotEquals(address1.hashCode(), address2.hashCode()); + } + + @Test + public void multipleHeadersSupported() { + Map headers = new HashMap<>(); + headers.put("X-Header-1", "value1"); + headers.put("X-Header-2", "value2"); + headers.put("X-Header-3", "value3"); + + HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder() + .setProxyAddress(proxyAddress) + .setTargetAddress(targetAddress) + .setHeaders(headers) + .build(); + + assertThat(address.getHeaders()).hasSize(3); + assertThat(address.getHeaders()).containsEntry("X-Header-1", "value1"); + assertThat(address.getHeaders()).containsEntry("X-Header-2", "value2"); + assertThat(address.getHeaders()).containsEntry("X-Header-3", "value3"); + } +} + diff --git a/api/src/test/java/io/grpc/LoadBalancerRegistryTest.java b/api/src/test/java/io/grpc/LoadBalancerRegistryTest.java index 5b348b7adab..690db6622e0 100644 --- a/api/src/test/java/io/grpc/LoadBalancerRegistryTest.java +++ b/api/src/test/java/io/grpc/LoadBalancerRegistryTest.java @@ -40,7 +40,7 @@ public void getClassesViaHardcoded_classesPresent() throws Exception { @Test public void stockProviders() { LoadBalancerRegistry defaultRegistry = LoadBalancerRegistry.getDefaultRegistry(); - assertThat(defaultRegistry.providers()).hasSize(3); + assertThat(defaultRegistry.providers()).hasSize(4); LoadBalancerProvider pickFirst = defaultRegistry.getProvider("pick_first"); assertThat(pickFirst).isInstanceOf(PickFirstLoadBalancerProvider.class); @@ -56,6 +56,12 @@ public void stockProviders() { assertThat(outlierDetection.getClass().getName()).isEqualTo( "io.grpc.util.OutlierDetectionLoadBalancerProvider"); assertThat(roundRobin.getPriority()).isEqualTo(5); + + LoadBalancerProvider randomSubsetting = defaultRegistry.getProvider( + "random_subsetting_experimental"); + assertThat(randomSubsetting.getClass().getName()).isEqualTo( + "io.grpc.util.RandomSubsettingLoadBalancerProvider"); + assertThat(randomSubsetting.getPriority()).isEqualTo(5); } @Test diff --git a/api/src/test/java/io/grpc/LoadBalancerTest.java b/api/src/test/java/io/grpc/LoadBalancerTest.java index 5e9e5cbe816..22fdc220081 100644 --- a/api/src/test/java/io/grpc/LoadBalancerTest.java +++ b/api/src/test/java/io/grpc/LoadBalancerTest.java @@ -64,6 +64,26 @@ public void pickResult_withSubchannelAndTracer() { assertThat(result.isDrop()).isFalse(); } + @Test + public void pickResult_withSubchannelReplacement() { + PickResult result = PickResult.withSubchannel(subchannel, tracerFactory) + .copyWithSubchannel(subchannel2); + assertThat(result.getSubchannel()).isSameInstanceAs(subchannel2); + assertThat(result.getStatus()).isSameInstanceAs(Status.OK); + assertThat(result.getStreamTracerFactory()).isSameInstanceAs(tracerFactory); + assertThat(result.isDrop()).isFalse(); + } + + @Test + public void pickResult_withStreamTracerFactory() { + PickResult result = PickResult.withSubchannel(subchannel) + .copyWithStreamTracerFactory(tracerFactory); + assertThat(result.getSubchannel()).isSameInstanceAs(subchannel); + assertThat(result.getStatus()).isSameInstanceAs(Status.OK); + assertThat(result.getStreamTracerFactory()).isSameInstanceAs(tracerFactory); + assertThat(result.isDrop()).isFalse(); + } + @Test public void pickResult_withNoResult() { PickResult result = PickResult.withNoResult(); diff --git a/api/src/test/java/io/grpc/ManagedChannelRegistryTest.java b/api/src/test/java/io/grpc/ManagedChannelRegistryTest.java index 30de2477d77..7b02459290e 100644 --- a/api/src/test/java/io/grpc/ManagedChannelRegistryTest.java +++ b/api/src/test/java/io/grpc/ManagedChannelRegistryTest.java @@ -20,17 +20,23 @@ import static org.junit.Assert.fail; import com.google.common.collect.ImmutableSet; +import io.grpc.FlagResetRule; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; /** Unit tests for {@link ManagedChannelRegistry}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class ManagedChannelRegistryTest { private String target = "testing123"; private ChannelCredentials creds = new ChannelCredentials() { @@ -40,6 +46,20 @@ public ChannelCredentials withoutBearerTokens() { } }; + @Rule public final FlagResetRule flagResetRule = new FlagResetRule(); + + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + + @Before + public void setUp() { + flagResetRule.setFlagForTest(FeatureFlags::setRfc3986UrisEnabled, enableRfc3986UrisParam); + } + @Test public void register_unavailableProviderThrows() { ManagedChannelRegistry reg = new ManagedChannelRegistry(); @@ -223,6 +243,53 @@ public NewChannelBuilderResult newChannelBuilder( mcb); } + @Test + public void newChannelBuilder_propagatesRegistry() { + final NameResolverRegistry nameResolverRegistry = new NameResolverRegistry(); + class SocketAddress1 extends SocketAddress { + } + + ManagedChannelRegistry registry = new ManagedChannelRegistry(); + class MockChannelBuilder extends ForwardingChannelBuilder2 { + @Override + public ManagedChannelBuilder delegate() { + throw new UnsupportedOperationException(); + } + } + + final ManagedChannelBuilder mcb = new MockChannelBuilder(); + registry.register(new BaseProvider(true, 4) { + @Override + protected Collection> getSupportedSocketAddressTypes() { + return Collections.singleton(SocketAddress1.class); + } + + @Override + public NewChannelBuilderResult newChannelBuilder( + String passedTarget, ChannelCredentials passedCreds, + NameResolverRegistry passedRegistry, NameResolverProvider passedProvider) { + assertThat(passedRegistry).isSameInstanceAs(nameResolverRegistry); + return NewChannelBuilderResult.channelBuilder(mcb); + } + }); + + // ManagedChannelRegistry.newChannelBuilder(NameResolverRegistry, String, ChannelCredentials) + // gets the scheme from target. Then it gets NameResolverProvider from registry for that scheme. + // Then it gets producedSocketAddressTypes from that provider. + // Then it finds a ManagedChannelProvider that supports those types. + // So we need a registered NameResolverProvider for the scheme. + nameResolverRegistry.register(new BaseNameResolverProvider(true, 5, "sc1") { + @Override + public Collection> getProducedSocketAddressTypes() { + return Collections.singleton(SocketAddress1.class); + } + }); + + assertThat( + registry.newChannelBuilder(nameResolverRegistry, "sc1:" + target, creds)).isSameInstanceAs( + mcb); + } + @Test public void newChannelBuilder_unsupportedSocketAddressTypes() { NameResolverRegistry nameResolverRegistry = new NameResolverRegistry(); diff --git a/api/src/test/java/io/grpc/NameResolverRegistryTest.java b/api/src/test/java/io/grpc/NameResolverRegistryTest.java index 2fd23e3a974..76976c3b59b 100644 --- a/api/src/test/java/io/grpc/NameResolverRegistryTest.java +++ b/api/src/test/java/io/grpc/NameResolverRegistryTest.java @@ -33,7 +33,8 @@ /** Unit tests for {@link NameResolverRegistry}. */ @RunWith(JUnit4.class) public class NameResolverRegistryTest { - private final URI uri = URI.create("dns:///localhost"); + private final URI javaNetUri = URI.create("dns:///localhost"); + private final Uri ioGrpcUri = Uri.create("dns:///localhost"); private final NameResolver.Args args = NameResolver.Args.newBuilder() .setDefaultPort(8080) .setProxyDetector(mock(ProxyDetector.class)) @@ -96,43 +97,80 @@ public void getDefaultScheme_noProvider() { } @Test - public void newNameResolver_providerReturnsNull() { + public void newNameResolver_providerReturnsNull_ioGrpcUri() { NameResolverRegistry registry = new NameResolverRegistry(); registry.register( - new BaseProvider(true, 5, "noScheme") { + new BaseProvider(true, 5, ioGrpcUri.getScheme()) { @Override - public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) { - assertThat(passedUri).isSameInstanceAs(uri); + public NameResolver newNameResolver(Uri passedUri, NameResolver.Args passedArgs) { + assertThat(passedUri).isSameInstanceAs(ioGrpcUri); assertThat(passedArgs).isSameInstanceAs(args); return null; } }); - assertThat(registry.asFactory().newNameResolver(uri, args)).isNull(); - assertThat(registry.asFactory().getDefaultScheme()).isEqualTo("noScheme"); + assertThat(registry.asFactory().newNameResolver(ioGrpcUri, args)).isNull(); + assertThat(registry.asFactory().getDefaultScheme()).isEqualTo(ioGrpcUri.getScheme()); } @Test - public void newNameResolver_providerReturnsNonNull() { + public void newNameResolver_providerReturnsNull_javaNetUri() { NameResolverRegistry registry = new NameResolverRegistry(); - registry.register(new BaseProvider(true, 5, uri.getScheme()) { - @Override - public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) { - return null; - } - }); - final NameResolver nr = new NameResolver() { - @Override public String getServiceAuthority() { - throw new UnsupportedOperationException(); - } + registry.register( + new BaseProvider(true, 5, javaNetUri.getScheme()) { + @Override + public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) { + assertThat(passedUri).isSameInstanceAs(javaNetUri); + assertThat(passedArgs).isSameInstanceAs(args); + return null; + } + }); + assertThat(registry.asFactory().newNameResolver(javaNetUri, args)).isNull(); + assertThat(registry.asFactory().getDefaultScheme()).isEqualTo(javaNetUri.getScheme()); + } - @Override public void start(Listener2 listener) { - throw new UnsupportedOperationException(); - } + @Test + public void newNameResolver_providerReturnsNonNull_ioGrpcUri() { + NameResolverRegistry registry = new NameResolverRegistry(); + Uri uri = ioGrpcUri; + registry.register( + new BaseProvider(true, 5, uri.getScheme()) { + @Override + public NameResolver newNameResolver(Uri passedUri, NameResolver.Args passedArgs) { + return null; + } + }); + final NameResolver nr = new DummyNameResolver(); + registry.register( + new BaseProvider(true, 4, uri.getScheme()) { + @Override + public NameResolver newNameResolver(Uri passedUri, NameResolver.Args passedArgs) { + return nr; + } + }); + registry.register( + new BaseProvider(true, 3, uri.getScheme()) { + @Override + public NameResolver newNameResolver(Uri passedUri, NameResolver.Args passedArgs) { + fail("Should not be called"); + throw new AssertionError(); + } + }); + assertThat(registry.asFactory().newNameResolver(uri, args)).isNull(); + assertThat(registry.asFactory().getDefaultScheme()).isEqualTo(uri.getScheme()); + } - @Override public void shutdown() { - throw new UnsupportedOperationException(); - } - }; + @Test + public void newNameResolver_providerReturnsNonNull_javaNetUri() { + NameResolverRegistry registry = new NameResolverRegistry(); + URI uri = javaNetUri; + registry.register( + new BaseProvider(true, 5, uri.getScheme()) { + @Override + public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) { + return null; + } + }); + final NameResolver nr = new DummyNameResolver(); registry.register( new BaseProvider(true, 4, uri.getScheme()) { @Override @@ -153,27 +191,45 @@ public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) } @Test - public void newNameResolver_multipleScheme() { + public void newNameResolver_multipleScheme_ioGrpcUri() { NameResolverRegistry registry = new NameResolverRegistry(); - registry.register(new BaseProvider(true, 5, uri.getScheme()) { - @Override - public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) { - return null; - } - }); - final NameResolver nr = new NameResolver() { - @Override public String getServiceAuthority() { - throw new UnsupportedOperationException(); - } + Uri uri = ioGrpcUri; + registry.register( + new BaseProvider(true, 5, uri.getScheme()) { + @Override + public NameResolver newNameResolver(Uri passedUri, NameResolver.Args passedArgs) { + return null; + } + }); + final NameResolver nr = new DummyNameResolver(); + registry.register( + new BaseProvider(true, 4, "other") { + @Override + public NameResolver newNameResolver(Uri passedUri, NameResolver.Args passedArgs) { + return nr; + } + }); - @Override public void start(Listener2 listener) { - throw new UnsupportedOperationException(); - } + assertThat(registry.asFactory().newNameResolver(uri, args)).isNull(); + assertThat(registry.asFactory().newNameResolver(Uri.create("other:///0.0.0.0:80"), args)) + .isSameInstanceAs(nr); + assertThat(registry.asFactory().newNameResolver(Uri.create("OTHER:///0.0.0.0:80"), args)) + .isSameInstanceAs(nr); + assertThat(registry.asFactory().getDefaultScheme()).isEqualTo("dns"); + } - @Override public void shutdown() { - throw new UnsupportedOperationException(); - } - }; + @Test + public void newNameResolver_multipleScheme_javaNetUri() { + NameResolverRegistry registry = new NameResolverRegistry(); + URI uri = javaNetUri; + registry.register( + new BaseProvider(true, 5, uri.getScheme()) { + @Override + public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) { + return null; + } + }); + final NameResolver nr = new DummyNameResolver(); registry.register( new BaseProvider(true, 4, "other") { @Override @@ -186,16 +242,17 @@ public NameResolver newNameResolver(URI passedUri, NameResolver.Args passedArgs) assertThat(registry.asFactory().newNameResolver(URI.create("/0.0.0.0:80"), args)).isNull(); assertThat(registry.asFactory().newNameResolver(URI.create("///0.0.0.0:80"), args)).isNull(); assertThat(registry.asFactory().newNameResolver(URI.create("other:///0.0.0.0:80"), args)) - .isSameInstanceAs(nr); + .isSameInstanceAs(nr); assertThat(registry.asFactory().newNameResolver(URI.create("OTHER:///0.0.0.0:80"), args)) - .isSameInstanceAs(nr); + .isSameInstanceAs(nr); assertThat(registry.asFactory().getDefaultScheme()).isEqualTo("dns"); } @Test public void newNameResolver_noProvider() { NameResolver.Factory factory = new NameResolverRegistry().asFactory(); - assertThat(factory.newNameResolver(uri, args)).isNull(); + assertThat(factory.newNameResolver(javaNetUri, args)).isNull(); + assertThat(factory.newNameResolver(ioGrpcUri, args)).isNull(); assertThat(factory.getDefaultScheme()).isEqualTo("unknown"); } @@ -261,9 +318,31 @@ public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { throw new UnsupportedOperationException(); } + @Override + public NameResolver newNameResolver(Uri targetUri, NameResolver.Args args) { + throw new UnsupportedOperationException(); + } + @Override public String getDefaultScheme() { return scheme == null ? "scheme" + getClass().getSimpleName() : scheme; } } + + private static class DummyNameResolver extends NameResolver { + @Override + public String getServiceAuthority() { + throw new UnsupportedOperationException(); + } + + @Override + public void start(Listener2 listener) { + throw new UnsupportedOperationException(); + } + + @Override + public void shutdown() { + throw new UnsupportedOperationException(); + } + } } diff --git a/api/src/test/java/io/grpc/NameResolverTest.java b/api/src/test/java/io/grpc/NameResolverTest.java index ae8c080bd5c..82abe5c7505 100644 --- a/api/src/test/java/io/grpc/NameResolverTest.java +++ b/api/src/test/java/io/grpc/NameResolverTest.java @@ -192,6 +192,56 @@ public void resolutionResult_hashCode() { Objects.hashCode(StatusOr.fromValue(ADDRESSES), ATTRIBUTES, CONFIG)); } + @Test + public void startOnOldListener_resolverReportsError() { + final boolean[] onErrorCalled = new boolean[1]; + final Status[] receivedError = new Status[1]; + + NameResolver resolver = new NameResolver() { + @Override + public String getServiceAuthority() { + return "example.com"; + } + + @Override + public void shutdown() { + } + + @Override + public void start(Listener2 listener2) { + ResolutionResult errorResult = ResolutionResult.newBuilder() + .setAddressesOrError(StatusOr.fromStatus( + Status.UNAVAILABLE + .withDescription("DNS resolution failed with UNAVAILABLE"))) + .build(); + + listener2.onResult(errorResult); + } + }; + + NameResolver.Listener listener = new NameResolver.Listener() { + @Override + public void onAddresses( + List servers, + Attributes attributes) { + throw new AssertionError("Called onAddresses on error"); + } + + @Override + public void onError(Status error) { + onErrorCalled[0] = true; + receivedError[0] = error; + } + }; + + resolver.start(listener); + + assertThat(onErrorCalled[0]).isTrue(); + assertThat(receivedError[0].getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(receivedError[0].getDescription()).isEqualTo( + "DNS resolution failed with UNAVAILABLE"); + } + private static class FakeSocketAddress extends SocketAddress { final String name; diff --git a/api/src/test/java/io/grpc/QueryParamsTest.java b/api/src/test/java/io/grpc/QueryParamsTest.java new file mode 100644 index 00000000000..2def165a170 --- /dev/null +++ b/api/src/test/java/io/grpc/QueryParamsTest.java @@ -0,0 +1,274 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import io.grpc.QueryParams.Entry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link QueryParams}. */ +@RunWith(JUnit4.class) +public class QueryParamsTest { + + @Test + public void emptyInstance() { + QueryParams params = new QueryParams(); + assertThat(params.asList()).isEmpty(); + assertThat(params.toRawQuery()).isNull(); + } + + @Test + public void parseNull_yieldsEmptyInstance() { + QueryParams params = QueryParams.fromRawQuery(null); + assertThat(params.asList()).isEmpty(); + assertThat(params.toRawQuery()).isNull(); + } + + @Test + public void parseEmptyString_yieldsSingleLoneKey() { + QueryParams params = QueryParams.fromRawQuery(""); + assertThat(params.toRawQuery()).isEmpty(); + assertThat(params.asList()).isNotEmpty(); + Entry entry = params.asList().get(0); + assertThat(entry).isNotNull(); + assertThat(entry.getKey()).isEmpty(); + assertThat(entry.hasValue()).isFalse(); + assertThat(entry.getValue()).isNull(); + } + + @Test + public void parseNormalPairs() { + QueryParams params = QueryParams.fromRawQuery("a=b&c=d"); + assertThat(params.toRawQuery()).isEqualTo("a=b&c=d"); + + QueryParams.Entry a = params.asList().get(0); + assertThat(a.getKey()).isEqualTo("a"); + assertThat(a.hasValue()).isTrue(); + assertThat(a.getValue()).isEqualTo("b"); + + QueryParams.Entry c = params.asList().get(1); + assertThat(c.getKey()).isEqualTo("c"); + assertThat(c.getValue()).isEqualTo("d"); + } + + @Test + public void parseLoneKey() { + QueryParams params = QueryParams.fromRawQuery("a&b"); + assertThat(params.toRawQuery()).isEqualTo("a&b"); + + QueryParams.Entry a = params.asList().get(0); + assertThat(a.getKey()).isEqualTo("a"); + assertThat(a.hasValue()).isFalse(); + + QueryParams.Entry b = params.asList().get(1); + assertThat(b.getKey()).isEqualTo("b"); + assertThat(b.hasValue()).isFalse(); + } + + @Test + public void parseEmptyKeysAndValues() { + QueryParams params = QueryParams.fromRawQuery("=&="); + assertThat(params.toRawQuery()).isEqualTo("=&="); + + assertThat(params.asList()).hasSize(2); + assertThat(params.asList().get(0).getKey()).isEmpty(); + assertThat(params.asList().get(0).hasValue()).isTrue(); + assertThat(params.asList().get(0).getValue()).isEmpty(); + assertThat(params.asList().get(1).getKey()).isEmpty(); + assertThat(params.asList().get(1).hasValue()).isTrue(); + assertThat(params.asList().get(1).getValue()).isEmpty(); + } + + @Test + public void roundTripPreservesEncodingOfSpaces() { + // Spaces can be encoded as + or %20. + QueryParams params = QueryParams.fromRawQuery("a+b=c%20d"); + assertThat(params.asList().get(0).getKey()).isEqualTo("a b"); + assertThat(params.asList().get(0).getValue()).isEqualTo("c d"); + assertThat(params.toRawQuery()).isEqualTo("a+b=c%20d"); + } + + @Test + public void roundTripPreservesCaseOfHexDigits() { + // Percent encoding can use upper or lower case. + QueryParams params = QueryParams.fromRawQuery("%4A%4a=%4B%4b"); + assertThat(params.asList().get(0).getKey()).isEqualTo("JJ"); + assertThat(params.asList().get(0).getValue()).isEqualTo("KK"); + assertThat(params.toRawQuery()).isEqualTo("%4A%4a=%4B%4b"); + } + + @Test + public void asListMethod() { + QueryParams params = new QueryParams(); + params.asList().add(QueryParams.Entry.forKeyValue("a b", "c d")); + params.asList().add(QueryParams.Entry.forLoneKey("e f")); + + // URLEncoder encodes spaces as + + assertThat(params.toRawQuery()).isEqualTo("a+b=c+d&e+f"); + } + + @Test + public void parseInvalidPercentEncodingThrows() { + assertThrows(IllegalArgumentException.class, () -> QueryParams.fromRawQuery("a=%GH")); + } + + @Test + public void parseInvalidKeyValueEncodingSucceeds() { + QueryParams params = QueryParams.fromRawQuery("===="); + assertThat(params.asList()) + .containsExactly(Entry.forRawKeyValue("", "===")) + .inOrder(); + assertThat(params.toRawQuery()).isEqualTo("===="); + } + + @Test + public void uriIntegration_canBuild() { + QueryParams params = new QueryParams(); + params.asList().add(Entry.forKeyValue("a", "b")); + params.asList().add(Entry.forKeyValue("c", "d")); + + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("example.com") + .setRawQuery(params.toRawQuery()) + .build(); + + assertThat(uri.toString()).isEqualTo("http://example.com?a=b&c=d"); + assertThat(uri.getRawQuery()).isEqualTo("a=b&c=d"); + } + + @Test + public void uriIntegration_canBuildEmpty() { + QueryParams params = new QueryParams(); + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("example.com") + .setRawQuery(params.toRawQuery()) + .build(); + + assertThat(uri.toString()).isEqualTo("http://example.com"); + assertThat(uri.getRawQuery()).isNull(); + } + + @Test + public void uriIntegration_canParse() { + Uri uri = Uri.create("http://example.com?a=b&c=d&e"); + QueryParams params = QueryParams.fromRawQuery(uri.getRawQuery()); + + assertThat(params.asList()) + .containsExactly( + Entry.forKeyValue("a", "b"), Entry.forKeyValue("c", "d"), Entry.forLoneKey("e")) + .inOrder(); + } + + @Test + public void keysAndValuesWithCharactersNeedingUrlEncoding() { + QueryParams params = new QueryParams(); + params.asList().add(Entry.forKeyValue("a=b", "c&d")); + params.asList().add(Entry.forKeyValue("e+f", "g h")); + + assertThat(params.toRawQuery()).isEqualTo("a%3Db=c%26d&e%2Bf=g+h"); + + QueryParams roundTripped = QueryParams.fromRawQuery(params.toRawQuery()); + assertThat(roundTripped).isEqualTo(params); + } + + @Test + public void keysAndValuesWithCodePointsOutsideAsciiRange() { + QueryParams params = new QueryParams(); + params.asList().add(Entry.forKeyValue("€", "𐐷")); + + assertThat(params.toRawQuery()).isEqualTo("%E2%82%AC=%F0%90%90%B7"); + + QueryParams roundTripped = QueryParams.fromRawQuery(params.toRawQuery()); + assertThat(roundTripped).isEqualTo(params); + } + + @Test + public void toStringMethod() { + QueryParams params = new QueryParams(); + assertThat(params.toString()).isEqualTo("[]"); + + params.asList().add(Entry.forKeyValue("a", "b")); + assertThat(params.toString()).isEqualTo("[a=b]"); + + params.asList().add(Entry.forLoneKey("c")); + assertThat(params.toString()).isEqualTo("[a=b, c]"); + + params.asList().add(Entry.forKeyValue("d=e", "f&g")); + assertThat(params.toString()).isEqualTo("[a=b, c, d%3De=f%26g]"); + } + + @Test + public void entryProperties() { + Entry keyValue = Entry.forKeyValue("key", "val"); + assertThat(keyValue.getKey()).isEqualTo("key"); + assertThat(keyValue.getValue()).isEqualTo("val"); + assertThat(keyValue.hasValue()).isTrue(); + + Entry loneKey = Entry.forLoneKey("key"); + assertThat(loneKey.getKey()).isEqualTo("key"); + assertThat(loneKey.getValue()).isNull(); + assertThat(loneKey.hasValue()).isFalse(); + } + + @Test + public void equalsAndHashCode_container() { + QueryParams params1 = new QueryParams(); + QueryParams params2 = new QueryParams(); + + // Empty instances are equal + assertThat(params1).isEqualTo(params2); + assertThat(params1.hashCode()).isEqualTo(params2.hashCode()); + + params1.asList().add(Entry.forKeyValue("a", "b")); + params1.asList().add(Entry.forLoneKey("c")); + + params2.asList().add(Entry.forKeyValue("a", "b")); + params2.asList().add(Entry.forLoneKey("c")); + + // Identical parameters in identical order are equal + assertThat(params1).isEqualTo(params2); + assertThat(params1.hashCode()).isEqualTo(params2.hashCode()); + + // Order matters. + QueryParams params3 = new QueryParams(); + params3.asList().add(Entry.forLoneKey("c")); + params3.asList().add(Entry.forKeyValue("a", "b")); + assertThat(params1).isNotEqualTo(params3); + } + + @Test + public void equalsAndHashCode_entry() { + // Raw matches are equal. + assertThat(Entry.forRawKeyValue("a+b", "c")).isEqualTo(Entry.forRawKeyValue("a+b", "c")); + assertThat(Entry.forRawKeyValue("a+b", "c").hashCode()) + .isEqualTo(Entry.forRawKeyValue("a+b", "c").hashCode()); + + // Spaces encoding matters. + and %20 are not equal. + assertThat(Entry.forRawKeyValue("a+b", "c")).isNotEqualTo(Entry.forRawKeyValue("a%20b", "c")); + + // Case of hex digits matter: %4A vs %4a are not equal raw keys. + assertThat(Entry.forRawKeyValue("a", "%4A")).isNotEqualTo(Entry.forRawKeyValue("a", "%4a")); + } +} diff --git a/api/src/test/java/io/grpc/ServiceProvidersTest.java b/api/src/test/java/io/grpc/ServiceProvidersTest.java index 7d4388a5bb9..f971ed42646 100644 --- a/api/src/test/java/io/grpc/ServiceProvidersTest.java +++ b/api/src/test/java/io/grpc/ServiceProvidersTest.java @@ -16,6 +16,7 @@ package io.grpc; +import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -23,12 +24,15 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; import io.grpc.InternalServiceProviders.PriorityAccessor; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -36,7 +40,6 @@ /** Unit tests for {@link ServiceProviders}. */ @RunWith(JUnit4.class) public class ServiceProvidersTest { - private static final List> NO_HARDCODED = Collections.emptyList(); private static final PriorityAccessor ACCESSOR = new PriorityAccessor() { @Override @@ -51,6 +54,19 @@ public int getPriority(ServiceProvidersTestAbstractProvider provider) { }; private final String serviceFile = "META-INF/services/io.grpc.ServiceProvidersTestAbstractProvider"; + private boolean failingHardCodedAccessed; + private final Supplier>> failingHardCoded = new Supplier>>() { + @Override + public Iterable> get() { + failingHardCodedAccessed = true; + throw new AssertionError(); + } + }; + + @After + public void tearDown() { + assertThat(failingHardCodedAccessed).isFalse(); + } @Test public void contextClassLoaderProvider() { @@ -69,8 +85,8 @@ public void contextClassLoaderProvider() { Thread.currentThread().setContextClassLoader(rcll); assertEquals( Available7Provider.class, - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR).getClass()); + load(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR) + .getClass()); } finally { Thread.currentThread().setContextClassLoader(ccl); } @@ -85,8 +101,7 @@ public void noProvider() { serviceFile, "io/grpc/ServiceProvidersTestAbstractProvider-doesNotExist.txt"); Thread.currentThread().setContextClassLoader(cl); - assertNull(ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR)); + assertNull(load(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR)); } finally { Thread.currentThread().setContextClassLoader(ccl); } @@ -98,11 +113,11 @@ public void multipleProvider() throws Exception { "io/grpc/ServiceProvidersTestAbstractProvider-multipleProvider.txt"); assertSame( Available7Provider.class, - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR).getClass()); + load(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR) + .getClass()); - List providers = ServiceProviders.loadAll( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR); + List providers = loadAll( + ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR); assertEquals(3, providers.size()); assertEquals(Available7Provider.class, providers.get(0).getClass()); assertEquals(Available5Provider.class, providers.get(1).getClass()); @@ -116,8 +131,8 @@ public void unavailableProvider() { "io/grpc/ServiceProvidersTestAbstractProvider-unavailableProvider.txt"); assertEquals( Available7Provider.class, - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR).getClass()); + load(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR) + .getClass()); } @Test @@ -125,8 +140,7 @@ public void unknownClassProvider() { ClassLoader cl = new ReplacingClassLoader(getClass().getClassLoader(), serviceFile, "io/grpc/ServiceProvidersTestAbstractProvider-unknownClassProvider.txt"); try { - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR); + loadAll(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR); fail("Exception expected"); } catch (ServiceConfigurationError e) { // noop @@ -140,8 +154,7 @@ public void exceptionSurfacedToCaller_failAtInit() { try { // Even though there is a working provider, if any providers fail then we should fail // completely to avoid returning something unexpected. - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR); + loadAll(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR); fail("Expected exception"); } catch (ServiceConfigurationError expected) { // noop @@ -154,8 +167,7 @@ public void exceptionSurfacedToCaller_failAtPriority() { "io/grpc/ServiceProvidersTestAbstractProvider-failAtPriorityProvider.txt"); try { // The exception should be surfaced to the caller - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR); + loadAll(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR); fail("Expected exception"); } catch (FailAtPriorityProvider.PriorityException expected) { // noop @@ -168,8 +180,7 @@ public void exceptionSurfacedToCaller_failAtAvailable() { "io/grpc/ServiceProvidersTestAbstractProvider-failAtAvailableProvider.txt"); try { // The exception should be surfaced to the caller - ServiceProviders.load( - ServiceProvidersTestAbstractProvider.class, NO_HARDCODED, cl, ACCESSOR); + loadAll(ServiceProvidersTestAbstractProvider.class, failingHardCoded, cl, ACCESSOR); fail("Expected exception"); } catch (FailAtAvailableProvider.AvailableException expected) { // noop @@ -244,6 +255,30 @@ class RandomClass {} assertFalse(candidates.iterator().hasNext()); } + private static T load( + Class klass, + Supplier>> hardCoded, + ClassLoader cl, + PriorityAccessor priorityAccessor) { + List candidates = loadAll(klass, hardCoded, cl, priorityAccessor); + if (candidates.isEmpty()) { + return null; + } + return candidates.get(0); + } + + private static List loadAll( + Class klass, + Supplier>> hardCoded, + ClassLoader classLoader, + PriorityAccessor priorityAccessor) { + return ServiceProviders.loadAll( + klass, + ServiceLoader.load(klass, classLoader).iterator(), + hardCoded, + priorityAccessor); + } + private static class BaseProvider extends ServiceProvidersTestAbstractProvider { private final boolean isAvailable; private final int priority; diff --git a/api/src/test/java/io/grpc/UriTest.java b/api/src/test/java/io/grpc/UriTest.java new file mode 100644 index 00000000000..0de7ef0fc10 --- /dev/null +++ b/api/src/test/java/io/grpc/UriTest.java @@ -0,0 +1,828 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeNoException; + +import com.google.common.net.InetAddresses; +import com.google.common.testing.EqualsTester; +import java.net.Inet6Address; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.BitSet; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class UriTest { + + @Test + public void parse_allComponents() throws URISyntaxException { + Uri uri = Uri.parse("scheme://user@host:0443/path?query#fragment"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isEqualTo("user@host:0443"); + assertThat(uri.getUserInfo()).isEqualTo("user"); + assertThat(uri.getPort()).isEqualTo(443); + assertThat(uri.getRawPort()).isEqualTo("0443"); + assertThat(uri.getPath()).isEqualTo("/path"); + assertThat(uri.getRawQuery()).isEqualTo("query"); + assertThat(uri.getFragment()).isEqualTo("fragment"); + assertThat(uri.toString()).isEqualTo("scheme://user@host:0443/path?query#fragment"); + assertThat(uri.isAbsolute()).isFalse(); // Has a fragment. + assertThat(uri.isPathAbsolute()).isTrue(); + assertThat(uri.isPathRootless()).isFalse(); + } + + @Test + public void parse_noAuthority() throws URISyntaxException { + Uri uri = Uri.parse("scheme:/path?query#fragment"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isNull(); + assertThat(uri.getPath()).isEqualTo("/path"); + assertThat(uri.getRawQuery()).isEqualTo("query"); + assertThat(uri.getFragment()).isEqualTo("fragment"); + assertThat(uri.toString()).isEqualTo("scheme:/path?query#fragment"); + assertThat(uri.isAbsolute()).isFalse(); // Has a fragment. + } + + @Test + public void parse_ipv6Literal_withPort() throws URISyntaxException { + Uri uri = Uri.parse("scheme://[2001:db8::7]:012345"); + assertThat(uri.getAuthority()).isEqualTo("[2001:db8::7]:012345"); + assertThat(uri.getRawHost()).isEqualTo("[2001:db8::7]"); + assertThat(uri.getHost()).isEqualTo("[2001:db8::7]"); + assertThat(uri.getRawPort()).isEqualTo("012345"); + assertThat(uri.getPort()).isEqualTo(12345); + } + + @Test + public void parse_ipv6Literal_noPort() throws URISyntaxException { + Uri uri = Uri.parse("scheme://[2001:db8::7]"); + assertThat(uri.getAuthority()).isEqualTo("[2001:db8::7]"); + assertThat(uri.getRawHost()).isEqualTo("[2001:db8::7]"); + assertThat(uri.getHost()).isEqualTo("[2001:db8::7]"); + assertThat(uri.getRawPort()).isNull(); + assertThat(uri.getPort()).isLessThan(0); + } + + @Test + public void parse_ipv6ScopedLiteral() throws URISyntaxException { + Uri uri = Uri.parse("http://[fe80::1%25eth0]"); + assertThat(uri.getRawHost()).isEqualTo("[fe80::1%25eth0]"); + assertThat(uri.getHost()).isEqualTo("[fe80::1%eth0]"); + } + + @Test + public void parse_ipv6ScopedPercentEncodedLiteral() throws URISyntaxException { + Uri uri = Uri.parse("http://[fe80::1%25foo-bar%2Fblah]"); + assertThat(uri.getRawHost()).isEqualTo("[fe80::1%25foo-bar%2Fblah]"); + assertThat(uri.getHost()).isEqualTo("[fe80::1%foo-bar/blah]"); + } + + @Test + public void parse_noQuery() throws URISyntaxException { + Uri uri = Uri.parse("scheme://authority/path#fragment"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isEqualTo("authority"); + assertThat(uri.getPath()).isEqualTo("/path"); + assertThat(uri.getRawQuery()).isNull(); + assertThat(uri.getFragment()).isEqualTo("fragment"); + assertThat(uri.toString()).isEqualTo("scheme://authority/path#fragment"); + } + + @Test + public void parse_noFragment() throws URISyntaxException { + Uri uri = Uri.parse("scheme://authority/path?query"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isEqualTo("authority"); + assertThat(uri.getPath()).isEqualTo("/path"); + assertThat(uri.getRawQuery()).isEqualTo("query"); + assertThat(uri.getFragment()).isNull(); + assertThat(uri.toString()).isEqualTo("scheme://authority/path?query"); + assertThat(uri.isAbsolute()).isTrue(); + } + + @Test + public void parse_emptyPathWithAuthority() throws URISyntaxException { + Uri uri = Uri.parse("scheme://authority"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isEqualTo("authority"); + assertThat(uri.getPath()).isEmpty(); + assertThat(uri.getRawQuery()).isNull(); + assertThat(uri.getFragment()).isNull(); + assertThat(uri.toString()).isEqualTo("scheme://authority"); + assertThat(uri.isAbsolute()).isTrue(); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isFalse(); + } + + @Test + public void parse_rootless() throws URISyntaxException { + Uri uri = Uri.parse("mailto:ceo@company.com?subject=raise"); + assertThat(uri.getScheme()).isEqualTo("mailto"); + assertThat(uri.getAuthority()).isNull(); + assertThat(uri.getPath()).isEqualTo("ceo@company.com"); + assertThat(uri.getRawQuery()).isEqualTo("subject=raise"); + assertThat(uri.getFragment()).isNull(); + assertThat(uri.toString()).isEqualTo("mailto:ceo@company.com?subject=raise"); + assertThat(uri.isAbsolute()).isTrue(); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isTrue(); + } + + @Test + public void parse_emptyPath() throws URISyntaxException { + Uri uri = Uri.parse("scheme:"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isNull(); + assertThat(uri.getPath()).isEmpty(); + assertThat(uri.getRawQuery()).isNull(); + assertThat(uri.getFragment()).isNull(); + assertThat(uri.toString()).isEqualTo("scheme:"); + assertThat(uri.isAbsolute()).isTrue(); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isFalse(); + } + + @Test + public void parse_emptyQuery() { + Uri uri = Uri.create("scheme:?"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getRawQuery()).isEmpty(); + } + + @Test + public void parse_emptyFragment() { + Uri uri = Uri.create("scheme:#"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getFragment()).isEmpty(); + } + + @Test + public void parse_emptyUserInfo() { + Uri uri = Uri.create("scheme://@host"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isEqualTo("@host"); + assertThat(uri.getHost()).isEqualTo("host"); + assertThat(uri.getUserInfo()).isEmpty(); + assertThat(uri.toString()).isEqualTo("scheme://@host"); + } + + @Test + public void parse_emptyPort() { + Uri uri = Uri.create("scheme://host:"); + assertThat(uri.getScheme()).isEqualTo("scheme"); + assertThat(uri.getAuthority()).isEqualTo("host:"); + assertThat(uri.getRawAuthority()).isEqualTo("host:"); + assertThat(uri.getHost()).isEqualTo("host"); + assertThat(uri.getPort()).isEqualTo(-1); + assertThat(uri.getRawPort()).isEqualTo(""); + assertThat(uri.toString()).isEqualTo("scheme://host:"); + } + + @Test + public void parse_invalidScheme_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("1scheme://authority/path")); + assertThat(e).hasMessageThat().contains("Scheme must start with an alphabetic char"); + + e = assertThrows(URISyntaxException.class, () -> Uri.parse(":path")); + assertThat(e).hasMessageThat().contains("Scheme must start with an alphabetic char"); + } + + @Test + public void parse_unTerminatedScheme_throws() { + URISyntaxException e = assertThrows(URISyntaxException.class, () -> Uri.parse("scheme/")); + assertThat(e).hasMessageThat().contains("Missing required scheme"); + + e = assertThrows(URISyntaxException.class, () -> Uri.parse("scheme?")); + assertThat(e).hasMessageThat().contains("Missing required scheme"); + + e = assertThrows(URISyntaxException.class, () -> Uri.parse("scheme#")); + assertThat(e).hasMessageThat().contains("Missing required scheme"); + } + + @Test + public void parse_invalidCharactersInScheme_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("schem e://authority/path")); + assertThat(e).hasMessageThat().contains("Invalid character in scheme"); + } + + @Test + public void parse_unTerminatedAuthority_throws() { + Uri uri = Uri.create("s://auth/"); + assertThat(uri.getAuthority()).isEqualTo("auth"); + uri = Uri.create("s://auth?"); + assertThat(uri.getAuthority()).isEqualTo("auth"); + uri = Uri.create("s://auth#"); + assertThat(uri.getAuthority()).isEqualTo("auth"); + } + + @Test + public void parse_invalidCharactersInUserinfo_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("scheme://u ser@host/path")); + assertThat(e).hasMessageThat().contains("Invalid character in userInfo"); + } + + @Test + public void parse_invalidBackslashInUserinfo_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("http://other.com\\@intended.com")); + assertThat(e).hasMessageThat().contains("Invalid character in userInfo"); + } + + @Test + public void parse_invalidCharactersInHost_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("scheme://h ost/path")); + assertThat(e).hasMessageThat().contains("Invalid character in host"); + } + + @Test + public void parse_invalidBackslashInHost_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("http://other.com\\.intended.com")); + assertThat(e).hasMessageThat().contains("Invalid character in host"); + } + + @Test + public void parse_invalidBackslashScope_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("http://[::1%25foo\\bar]")); + assertThat(e).hasMessageThat().contains("Invalid character in scope"); + } + + @Test + public void parse_invalidCharactersInPort_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("scheme://user@host:8 0/path")); + assertThat(e).hasMessageThat().contains("Invalid character"); + } + + @Test + public void parse_nonAsciiCharacterInPath_throws() throws URISyntaxException { + URISyntaxException e = assertThrows(URISyntaxException.class, () -> Uri.parse("foo:bär")); + assertThat(e).hasMessageThat().contains("Invalid character in path"); + } + + @Test + public void parse_invalidCharactersInPath_throws() { + URISyntaxException e = assertThrows(URISyntaxException.class, () -> Uri.parse("scheme:/p ath")); + assertThat(e).hasMessageThat().contains("Invalid character in path"); + } + + @Test + public void parse_invalidCharactersInQuery_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("scheme://user@host/p?q[]uery")); + assertThat(e).hasMessageThat().contains("Invalid character in query"); + } + + @Test + public void parse_invalidCharactersInFragment_throws() { + URISyntaxException e = + assertThrows(URISyntaxException.class, () -> Uri.parse("scheme://user@host/path#f[]rag")); + assertThat(e).hasMessageThat().contains("Invalid character in fragment"); + } + + @Test + public void parse_nonAsciiCharacterInFragment_throws() throws URISyntaxException { + URISyntaxException e = assertThrows(URISyntaxException.class, () -> Uri.parse("foo:#bär")); + assertThat(e).hasMessageThat().contains("Invalid character in fragment"); + } + + @Test + public void parse_decoding() throws URISyntaxException { + Uri uri = Uri.parse("s://user%2Ename:pass%2Eword@a%2db:1234/p%20ath?q%20uery#f%20ragment"); + assertThat(uri.getAuthority()).isEqualTo("user.name:pass.word@a-b:1234"); + assertThat(uri.getRawAuthority()).isEqualTo("user%2Ename:pass%2Eword@a%2db:1234"); + assertThat(uri.getUserInfo()).isEqualTo("user.name:pass.word"); + assertThat(uri.getRawUserInfo()).isEqualTo("user%2Ename:pass%2Eword"); + assertThat(uri.getHost()).isEqualTo("a-b"); + assertThat(uri.getRawHost()).isEqualTo("a%2db"); + assertThat(uri.getPort()).isEqualTo(1234); + assertThat(uri.getPath()).isEqualTo("/p ath"); + assertThat(uri.getRawPath()).isEqualTo("/p%20ath"); + assertThat(uri.getRawQuery()).isEqualTo("q%20uery"); + assertThat(uri.getFragment()).isEqualTo("f ragment"); + assertThat(uri.getRawFragment()).isEqualTo("f%20ragment"); + } + + @Test + public void parse_decodingNonAscii() throws URISyntaxException { + Uri uri = Uri.parse("s://a/%E2%82%AC"); + assertThat(uri.getPath()).isEqualTo("/€"); + } + + @Test + public void parse_decodingPercent() throws URISyntaxException { + Uri uri = Uri.parse("s://a/p%2520ath#f%25ragment"); + assertThat(uri.getPath()).isEqualTo("/p%20ath"); + assertThat(uri.getFragment()).isEqualTo("f%ragment"); + } + + @Test + public void parse_invalidPercentEncoding_throws() { + URISyntaxException e = assertThrows(URISyntaxException.class, () -> Uri.parse("s://a/p%2")); + assertThat(e).hasMessageThat().contains("Invalid"); + + e = assertThrows(URISyntaxException.class, () -> Uri.parse("s://a/p%2G")); + assertThat(e).hasMessageThat().contains("Invalid"); + } + + @Test + public void parse_emptyAuthority() { + Uri uri = Uri.create("file:///foo/bar"); + assertThat(uri.getAuthority()).isEmpty(); + assertThat(uri.getHost()).isEmpty(); + assertThat(uri.getUserInfo()).isNull(); + assertThat(uri.getPort()).isEqualTo(-1); + assertThat(uri.getPath()).isEqualTo("/foo/bar"); + } + + @Test + public void parse_pathSegments_empty() throws URISyntaxException { + Uri uri = Uri.create("scheme:"); + assertThat(uri.getPathSegments()).isEmpty(); + } + + @Test + public void parse_pathSegments_root() throws URISyntaxException { + Uri uri = Uri.create("scheme:/"); + assertThat(uri.getPathSegments()).containsExactly(""); + } + + @Test + public void parse_onePathSegment() throws URISyntaxException { + Uri uri = Uri.create("file:/foo"); + assertThat(uri.getPathSegments()).containsExactly("foo"); + } + + @Test + public void parse_onePathSegment_trailingSlash() throws URISyntaxException { + Uri uri = Uri.create("file:/foo/"); + assertThat(uri.getPathSegments()).containsExactly("foo", ""); + } + + @Test + public void parse_onePathSegment_rootless() throws URISyntaxException { + Uri uri = Uri.create("dns:www.example.com"); + assertThat(uri.getPathSegments()).containsExactly("www.example.com"); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isTrue(); + } + + @Test + public void parse_twoPathSegments() throws URISyntaxException { + Uri uri = Uri.create("file:/foo/bar"); + assertThat(uri.getPathSegments()).containsExactly("foo", "bar"); + } + + @Test + public void parse_twoPathSegments_rootless() throws URISyntaxException { + Uri uri = Uri.create("file:foo/bar"); + assertThat(uri.getPathSegments()).containsExactly("foo", "bar"); + } + + @Test + public void parse_percentEncodedPathSegment_rootless() throws URISyntaxException { + Uri uri = Uri.create("mailto:%2Fdev%2Fnull@example.com"); + assertThat(uri.getPathSegments()).containsExactly("/dev/null@example.com"); + assertThat(uri.isPathAbsolute()).isFalse(); + assertThat(uri.isPathRootless()).isTrue(); + } + + @Test + public void toString_percentEncoding() throws URISyntaxException { + Uri uri = + Uri.newBuilder() + .setScheme("s") + .setHost("a b") + .setPath("/p ath") + .setRawQuery("q%20uery") + .setFragment("f ragment") + .build(); + assertThat(uri.toString()).isEqualTo("s://a%20b/p%20ath?q%20uery#f%20ragment"); + } + + @Test + public void parse_transparentRoundTrip_ipLiteral() { + Uri uri = Uri.create("http://[2001:dB8::7]:080/%4a%4B%2f%2F?%4c%4D#%4e%4F").toBuilder().build(); + assertThat(uri.toString()).isEqualTo("http://[2001:dB8::7]:080/%4a%4B%2f%2F?%4c%4D#%4e%4F"); + + // IPv6 host has non-canonical :: zeros and mixed case hex digits. + assertThat(uri.getRawHost()).isEqualTo("[2001:dB8::7]"); + assertThat(uri.getHost()).isEqualTo("[2001:dB8::7]"); + assertThat(uri.getRawPort()).isEqualTo("080"); // Leading zeros. + assertThat(uri.getPort()).isEqualTo(80); + // Unnecessary and mixed case percent encodings. + assertThat(uri.getRawPath()).isEqualTo("/%4a%4B%2f%2F"); + assertThat(uri.getPathSegments()).containsExactly("JK//"); + assertThat(uri.getRawQuery()).isEqualTo("%4c%4D"); + assertThat(uri.getRawFragment()).isEqualTo("%4e%4F"); + assertThat(uri.getFragment()).isEqualTo("NO"); + } + + @Test + public void parse_transparentRoundTrip_regName() { + Uri uri = Uri.create("http://aB%4A%4b:080/%4a%4B%2f%2F?%4c%4D#%4e%4F").toBuilder().build(); + assertThat(uri.toString()).isEqualTo("http://aB%4A%4b:080/%4a%4B%2f%2F?%4c%4D#%4e%4F"); + + // Mixed case literal chars and hex digits. + assertThat(uri.getRawHost()).isEqualTo("aB%4A%4b"); + assertThat(uri.getHost()).isEqualTo("aBJK"); + assertThat(uri.getRawPort()).isEqualTo("080"); // Leading zeros. + assertThat(uri.getPort()).isEqualTo(80); + // Unnecessary and mixed case percent encodings. + assertThat(uri.getRawPath()).isEqualTo("/%4a%4B%2f%2F"); + assertThat(uri.getPathSegments()).containsExactly("JK//"); + assertThat(uri.getRawQuery()).isEqualTo("%4c%4D"); + assertThat(uri.getRawFragment()).isEqualTo("%4e%4F"); + assertThat(uri.getFragment()).isEqualTo("NO"); + } + + @Test + public void builder_numericPort() throws URISyntaxException { + Uri uri = Uri.newBuilder().setScheme("scheme").setHost("host").setPort(80).build(); + assertThat(uri.toString()).isEqualTo("scheme://host:80"); + } + + @Test + public void builder_ipv6Literal() throws URISyntaxException { + Uri uri = + Uri.newBuilder() + .setScheme("scheme") + .setHost(InetAddresses.forString("2001:4860:4860::8844")) + .build(); + assertThat(uri.toString()).isEqualTo("scheme://[2001:4860:4860::8844]"); + } + + @Test + public void builder_ipv6ScopedLiteral_numeric() throws UnknownHostException { + Uri uri = + Uri.newBuilder() + .setScheme("http") + // Create an address with a numeric scope_id, which should always be valid. + .setHost( + Inet6Address.getByAddress(null, InetAddresses.forString("fe80::1").getAddress(), 1)) + .build(); + + // We expect the scope ID to be percent encoded. + assertThat(uri.getRawHost()).isEqualTo("[fe80::1%251]"); + assertThat(uri.getHost()).isEqualTo("[fe80::1%1]"); + } + + @Test + public void builder_ipv6ScopedLiteral_named() throws UnknownHostException { + // Unfortunately, there's no Java API to create an Inet6Address with an arbitrary interface- + // scoped name. There's actually no way to hermetically create an Inet6Address with a scope name + // at all! The following address/interface is likely to be present on Linux test runners. + Inet6Address address; + try { + address = (Inet6Address) InetAddresses.forString("::1%lo"); + } catch (IllegalArgumentException e) { + assumeNoException(e); + return; // Not reached. + } + Uri uri = Uri.newBuilder().setScheme("http").setHost(address).build(); + + // We expect the scope ID to be percent encoded. + assertThat(uri.getRawHost()).isEqualTo("[::1%25lo]"); + assertThat(uri.getHost()).isEqualTo("[::1%lo]"); + } + + @Test + public void builder_ipv6PercentEncodedScopedLiteral() { + Uri uri = Uri.newBuilder().setScheme("http").setRawHost("[fe80::1%25foo%2Dbar%2Fblah]").build(); + assertThat(uri.getRawHost()).isEqualTo("[fe80::1%25foo%2Dbar%2Fblah]"); + assertThat(uri.getHost()).isEqualTo("[fe80::1%foo-bar/blah]"); + } + + @Test + public void builder_encodingWithAllowedReservedChars() throws URISyntaxException { + Uri uri = + Uri.newBuilder() + .setScheme("s") + .setUserInfo("u@") + .setHost("a[]") + .setPath("/p:/@") + .setRawQuery("q/?") + .setFragment("f/?") + .build(); + assertThat(uri.toString()).isEqualTo("s://u%40@a%5B%5D/p:/@?q/?#f/?"); + } + + @Test + public void builder_percentEncodingNonAscii() throws URISyntaxException { + Uri uri = Uri.newBuilder().setScheme("s").setHost("a").setPath("/€").build(); + assertThat(uri.toString()).isEqualTo("s://a/%E2%82%AC"); + } + + @Test + public void builder_percentEncodingLoneHighSurrogate_throws() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> Uri.newBuilder().setPath("\uD83D")); // Lone high surrogate. + assertThat(e.getMessage()).contains("Malformed input"); + } + + @Test + public void builder_hasAuthority_pathStartsWithSlash_throws() throws URISyntaxException { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> Uri.newBuilder().setScheme("s").setHost("a").setPath("path").build()); + assertThat(e.getMessage()).contains("Non-empty path must start with '/'"); + } + + @Test + public void builder_noAuthority_pathStartsWithDoubleSlash_throws() throws URISyntaxException { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> Uri.newBuilder().setScheme("s").setPath("//path").build()); + assertThat(e.getMessage()).contains("Path cannot start with '//'"); + } + + @Test + public void builder_noScheme_throws() { + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> Uri.newBuilder().build()); + assertThat(e.getMessage()).contains("Missing required scheme"); + } + + @Test + public void builder_noHost_hasUserInfo_throws() { + IllegalStateException e = + assertThrows( + IllegalStateException.class, + () -> Uri.newBuilder().setScheme("scheme").setUserInfo("user").build()); + assertThat(e.getMessage()).contains("Cannot set userInfo without host"); + } + + @Test + public void builder_noHost_hasPort_throws() { + IllegalStateException e = + assertThrows( + IllegalStateException.class, + () -> Uri.newBuilder().setScheme("scheme").setPort(1234).build()); + assertThat(e.getMessage()).contains("Cannot set port without host"); + } + + @Test + public void builder_normalizesCaseWhereAppropriate() { + Uri uri = + Uri.newBuilder() + .setScheme("hTtP") // #section-3.1 says producers (Builder) should normalize to lower. + .setHost("aBc") // #section-3.2.2 says producers (Builder) should normalize to lower. + .setPath("/CdE") // #section-6.2.2.1 says the rest are assumed to be case-sensitive + .setRawQuery("fGh") + .setFragment("IjK") + .build(); + assertThat(uri.toString()).isEqualTo("http://abc/CdE?fGh#IjK"); + } + + @Test + public void builder_normalizesIpv6Literal() { + Uri uri = + Uri.newBuilder().setScheme("scheme").setHost(InetAddresses.forString("ABCD::EFAB")).build(); + assertThat(uri.toString()).isEqualTo("scheme://[abcd::efab]"); + } + + @Test + public void builder_canClearAllOptionalFields() { + Uri uri = + Uri.create("http://user@host:80/path?query#fragment").toBuilder() + .setHost((String) null) + .setPath("") + .setUserInfo(null) + .setPort(-1) + .setRawQuery(null) + .setFragment(null) + .build(); + assertThat(uri.toString()).isEqualTo("http:"); + } + + @Test + public void builder_setRawQuery() { + Uri uri = Uri.newBuilder().setScheme("http").setHost("host").setRawQuery("%61=b&c=%64").build(); + assertThat(uri.getRawQuery()).isEqualTo("%61=b&c=%64"); + assertThat(uri.toString()).isEqualTo("http://host?%61=b&c=%64"); + } + + @Test + public void builder_setRawQuery_null() { + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("host") + .setRawQuery("a=b") + .setRawQuery(null) + .build(); + assertThat(uri.getRawQuery()).isNull(); + assertThat(uri.toString()).isEqualTo("http://host"); + } + + @Test + public void builder_setRawFragment() { + Uri uri = Uri.newBuilder().setScheme("http").setHost("host").setRawFragment("a%20b").build(); + assertThat(uri.getRawFragment()).isEqualTo("a%20b"); + assertThat(uri.getFragment()).isEqualTo("a b"); + assertThat(uri.toString()).isEqualTo("http://host#a%20b"); + } + + @Test + public void builder_setRawFragment_null() { + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("host") + .setRawFragment("a%20b") + .setRawFragment(null) + .build(); + assertThat(uri.getRawFragment()).isNull(); + assertThat(uri.getFragment()).isNull(); + assertThat(uri.toString()).isEqualTo("http://host"); + } + + @Test + public void builder_setRawFragment_invalidCharacters_throws() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> Uri.newBuilder().setRawFragment("f[]rag")); + assertThat(e).hasMessageThat().contains("Invalid character in fragment"); + } + + @Test + public void builder_setRawFragment_invalidPercentEncoding_throws() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> Uri.newBuilder().setRawFragment("f%XXragment")); + assertThat(e).hasMessageThat().contains("Invalid"); + } + + @Test + public void builder_canClearAuthorityComponents() { + Uri uri = Uri.create("s://user@host:80/path").toBuilder().setRawAuthority(null).build(); + assertThat(uri.toString()).isEqualTo("s:/path"); + } + + @Test + public void builder_canSetEmptyAuthority() { + Uri uri = Uri.create("s://user@host:80/path").toBuilder().setRawAuthority("").build(); + assertThat(uri.toString()).isEqualTo("s:///path"); + } + + @Test + public void builder_canSetRawAuthority() { + Uri uri = Uri.newBuilder().setScheme("http").setRawAuthority("user@host:1234").build(); + assertThat(uri.getUserInfo()).isEqualTo("user"); + assertThat(uri.getHost()).isEqualTo("host"); + assertThat(uri.getPort()).isEqualTo(1234); + } + + @Test + public void builder_setRawAuthorityPercentDecodes() { + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setRawAuthority("user:user%40user@host%40host%3Ahost") + .build(); + assertThat(uri.getUserInfo()).isEqualTo("user:user@user"); + assertThat(uri.getHost()).isEqualTo("host@host:host"); + assertThat(uri.getPort()).isEqualTo(-1); + } + + @Test + public void builder_setRawAuthorityReplacesAllComponents() { + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setUserInfo("user") + .setHost("host") + .setPort(1234) + .setRawAuthority("other") + .build(); + assertThat(uri.getUserInfo()).isNull(); + assertThat(uri.getHost()).isEqualTo("other"); + assertThat(uri.getPort()).isEqualTo(-1); + } + + @Test + public void toString_percentEncodingMultiChar() throws URISyntaxException { + Uri uri = + Uri.newBuilder() + .setScheme("s") + .setHost("a") + .setPath("/emojis/😊/icon.png") // Smile requires two chars to express in a java String. + .build(); + assertThat(uri.toString()).isEqualTo("s://a/emojis/%F0%9F%98%8A/icon.png"); + } + + @Test + public void toString_percentEncodingLiteralPercent() throws URISyntaxException { + Uri uri = + Uri.newBuilder() + .setScheme("s") + .setHost("a") + .setPath("/p%20ath") + .setRawQuery("q%25uery") + .setFragment("f%ragment") + .build(); + assertThat(uri.toString()).isEqualTo("s://a/p%2520ath?q%25uery#f%25ragment"); + } + + @Test + public void equalsAndHashCode() { + new EqualsTester() + .addEqualityGroup( + Uri.create("scheme://authority/path?query#fragment"), + Uri.create("scheme://authority/path?query#fragment")) + .addEqualityGroup(Uri.create("scheme://authority/path")) + .addEqualityGroup(Uri.create("scheme://authority/path?query")) + .addEqualityGroup(Uri.create("scheme:/path")) + .addEqualityGroup(Uri.create("scheme:/path?query")) + .addEqualityGroup(Uri.create("scheme:/path#fragment")) + .addEqualityGroup(Uri.create("scheme:path")) + .addEqualityGroup(Uri.create("scheme:path?query")) + .addEqualityGroup(Uri.create("scheme:path#fragment")) + .addEqualityGroup(Uri.create("scheme:")) + .testEquals(); + } + + @Test + public void isAbsolute() { + assertThat(Uri.create("scheme://authority/path").isAbsolute()).isTrue(); + assertThat(Uri.create("scheme://authority/path?query").isAbsolute()).isTrue(); + assertThat(Uri.create("scheme://authority/path#fragment").isAbsolute()).isFalse(); + assertThat(Uri.create("scheme://authority/path?query#fragment").isAbsolute()).isFalse(); + } + + @Test + public void serializedCharacterClasses_matchComputed() { + assertThat(Uri.digitChars).isEqualTo(bitSetOfRange('0', '9')); + assertThat(Uri.alphaChars).isEqualTo(or(bitSetOfRange('A', 'Z'), bitSetOfRange('a', 'z'))); + assertThat(Uri.schemeChars) + .isEqualTo(or(Uri.digitChars, Uri.alphaChars, bitSetOf('+', '-', '.'))); + assertThat(Uri.unreservedChars) + .isEqualTo(or(Uri.alphaChars, Uri.digitChars, bitSetOf('-', '.', '_', '~'))); + assertThat(Uri.genDelimsChars).isEqualTo(bitSetOf(':', '/', '?', '#', '[', ']', '@')); + assertThat(Uri.subDelimsChars) + .isEqualTo(bitSetOf('!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=')); + assertThat(Uri.reservedChars).isEqualTo(or(Uri.genDelimsChars, Uri.subDelimsChars)); + assertThat(Uri.regNameChars).isEqualTo(or(Uri.unreservedChars, Uri.subDelimsChars)); + assertThat(Uri.userInfoChars) + .isEqualTo(or(Uri.unreservedChars, Uri.subDelimsChars, bitSetOf(':'))); + assertThat(Uri.pChars) + .isEqualTo(or(Uri.unreservedChars, Uri.subDelimsChars, bitSetOf(':', '@'))); + assertThat(Uri.pCharsAndSlash).isEqualTo(or(Uri.pChars, bitSetOf('/'))); + assertThat(Uri.queryChars).isEqualTo(or(Uri.pChars, bitSetOf('/', '?'))); + assertThat(Uri.fragmentChars).isEqualTo(or(Uri.pChars, bitSetOf('/', '?'))); + } + + private static BitSet bitSetOfRange(char from, char to) { + BitSet bitset = new BitSet(); + for (char c = from; c <= to; c++) { + bitset.set(c); + } + return bitset; + } + + private static BitSet bitSetOf(char... chars) { + BitSet bitset = new BitSet(); + for (char c : chars) { + bitset.set(c); + } + return bitset; + } + + private static BitSet or(BitSet... bitsets) { + BitSet bitset = new BitSet(); + for (BitSet bs : bitsets) { + bitset.or(bs); + } + return bitset; + } +} diff --git a/api/src/testFixtures/java/io/grpc/FlagResetRule.java b/api/src/testFixtures/java/io/grpc/FlagResetRule.java new file mode 100644 index 00000000000..08ce7ce82f2 --- /dev/null +++ b/api/src/testFixtures/java/io/grpc/FlagResetRule.java @@ -0,0 +1,96 @@ +/* + * 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; + +import java.util.ArrayDeque; +import java.util.Deque; +import javax.annotation.Nullable; +import org.junit.rules.ExternalResource; + +/** + * A {@link org.junit.rules.TestRule} that lets you set one or more feature flags just for + * the duration of the current test case. + * + *

Flags and other global variables must be reset to ensure no state leaks across tests. + */ +public final class FlagResetRule extends ExternalResource { + + /** A functional interface representing a standard gRPC feature flag setter. */ + public interface SetterMethod { + /** Sets a flag for testing and returns its previous value. */ + T set(T val); + } + + private final Deque toRunAfter = new ArrayDeque<>(); + + /** + * Sets a global feature flag to 'value' using 'setter' and arranges for its previous value to be + * unconditionally restored when the test completes. + */ + public void setFlagForTest(SetterMethod setter, T value) { + final T oldValue = setter.set(value); + toRunAfter.push(() -> setter.set(oldValue)); + } + + /** + * Sets java system property 'key' to 'value' and arranges for its previous value to be + * unconditionally restored when the test completes. + */ + public void setSystemPropertyForTest(String key, String value) { + String oldValue = System.setProperty(key, value); + restoreSystemPropertyAfterTest(key, oldValue); + } + + /** + * Clears java system property 'key' and arranges for its previous value to be unconditionally + * restored when the test completes. + */ + public void clearSystemPropertyForTest(String key) { + String oldValue = System.clearProperty(key); + restoreSystemPropertyAfterTest(key, oldValue); + } + + private void restoreSystemPropertyAfterTest(String key, @Nullable String oldValue) { + toRunAfter.push( + () -> { + if (oldValue == null) { + System.clearProperty(key); + } else { + System.setProperty(key, oldValue); + } + }); + } + + @Override + protected void after() { + RuntimeException toThrow = null; + while (!toRunAfter.isEmpty()) { + try { + toRunAfter.pop().run(); + } catch (RuntimeException e) { + if (toThrow == null) { + toThrow = e; + } else { + toThrow.addSuppressed(e); + } + } + } + if (toThrow != null) { + throw toThrow; + } + } +} diff --git a/api/src/testFixtures/java/io/grpc/StatusMatcher.java b/api/src/testFixtures/java/io/grpc/StatusMatcher.java index f464b2d709d..08e9fffb013 100644 --- a/api/src/testFixtures/java/io/grpc/StatusMatcher.java +++ b/api/src/testFixtures/java/io/grpc/StatusMatcher.java @@ -26,7 +26,7 @@ */ public final class StatusMatcher implements ArgumentMatcher { public static StatusMatcher statusHasCode(ArgumentMatcher codeMatcher) { - return new StatusMatcher(codeMatcher, null); + return new StatusMatcher(codeMatcher, null, null); } public static StatusMatcher statusHasCode(Status.Code code) { @@ -35,17 +35,20 @@ public static StatusMatcher statusHasCode(Status.Code code) { private final ArgumentMatcher codeMatcher; private final ArgumentMatcher descriptionMatcher; + private final ArgumentMatcher causeMatcher; private StatusMatcher( ArgumentMatcher codeMatcher, - ArgumentMatcher descriptionMatcher) { + ArgumentMatcher descriptionMatcher, + ArgumentMatcher causeMatcher) { this.codeMatcher = checkNotNull(codeMatcher, "codeMatcher"); this.descriptionMatcher = descriptionMatcher; + this.causeMatcher = causeMatcher; } public StatusMatcher andDescription(ArgumentMatcher descriptionMatcher) { checkState(this.descriptionMatcher == null, "Already has a description matcher"); - return new StatusMatcher(codeMatcher, descriptionMatcher); + return new StatusMatcher(codeMatcher, descriptionMatcher, causeMatcher); } public StatusMatcher andDescription(String description) { @@ -56,11 +59,21 @@ public StatusMatcher andDescriptionContains(String substring) { return andDescription(new StringContainsMatcher(substring)); } + public StatusMatcher andCause(ArgumentMatcher causeMatcher) { + checkState(this.causeMatcher == null, "Already has a cause matcher"); + return new StatusMatcher(codeMatcher, descriptionMatcher, causeMatcher); + } + + public StatusMatcher andCause(Throwable cause) { + return andCause(new EqualsMatcher<>(cause)); + } + @Override public boolean matches(Status status) { return status != null && codeMatcher.matches(status.getCode()) - && (descriptionMatcher == null || descriptionMatcher.matches(status.getDescription())); + && (descriptionMatcher == null || descriptionMatcher.matches(status.getDescription())) + && (causeMatcher == null || causeMatcher.matches(status.getCause())); } @Override @@ -72,6 +85,10 @@ public String toString() { sb.append(", description="); sb.append(descriptionMatcher); } + if (causeMatcher != null) { + sb.append(", cause="); + sb.append(causeMatcher); + } sb.append("}"); return sb.toString(); } diff --git a/api/src/testFixtures/java/io/grpc/StatusSubject.java b/api/src/testFixtures/java/io/grpc/StatusSubject.java new file mode 100644 index 00000000000..0b00df96140 --- /dev/null +++ b/api/src/testFixtures/java/io/grpc/StatusSubject.java @@ -0,0 +1,68 @@ +/* + * 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; + +import static com.google.common.truth.Fact.fact; + +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.Subject; +import javax.annotation.Nullable; + +/** Propositions for {@link Status} subjects. */ +public final class StatusSubject extends Subject { + + private static final Subject.Factory statusFactory = new Factory(); + + public static Subject.Factory status() { + return statusFactory; + } + + private final Status actual; + + private StatusSubject(FailureMetadata metadata, @Nullable Status subject) { + super(metadata, subject); + this.actual = subject; + } + + /** Fails if the subject is not OK. */ + public void isOk() { + if (actual == null) { + failWithActual("expected to be OK but was", "null"); + } else if (!actual.isOk()) { + failWithoutActual( + fact("expected to be OK but was", actual.getCode()), + fact("description", actual.getDescription()), + fact("cause", actual.getCause())); + } + } + + /** Fails if the subject does not have the given code. */ + public void hasCode(Status.Code expectedCode) { + if (actual == null) { + failWithActual("expected to have code " + expectedCode + " but was", "null"); + } else { + check("getCode()").that(actual.getCode()).isEqualTo(expectedCode); + } + } + + private static final class Factory implements Subject.Factory { + @Override + public StatusSubject createSubject(FailureMetadata metadata, @Nullable Status that) { + return new StatusSubject(metadata, that); + } + } +} diff --git a/authz/src/main/java/io/grpc/authz/AuthorizationPolicyTranslator.java b/authz/src/main/java/io/grpc/authz/AuthorizationPolicyTranslator.java index ed7e018412c..183ae2c3f55 100644 --- a/authz/src/main/java/io/grpc/authz/AuthorizationPolicyTranslator.java +++ b/authz/src/main/java/io/grpc/authz/AuthorizationPolicyTranslator.java @@ -156,19 +156,19 @@ private static Map parseRules( } /** - * Translates a gRPC authorization policy in JSON string to Envoy RBAC policies. - * On success, will return one of the following - - * 1. One allow RBAC policy or, - * 2. Two RBAC policies, deny policy followed by allow policy. - * If the policy cannot be parsed or is invalid, an exception will be thrown. - */ + * Translates a gRPC authorization policy in JSON string to Envoy RBAC policies. + * On success, will return one of the following - + * 1. One allow RBAC policy or, + * 2. Two RBAC policies, deny policy followed by allow policy. + * If the policy cannot be parsed or is invalid, an exception will be thrown. + */ public static List translate(String authorizationPolicy) throws IllegalArgumentException, IOException { Object jsonObject = JsonParser.parse(authorizationPolicy); if (!(jsonObject instanceof Map)) { throw new IllegalArgumentException( - "Authorization policy should be a JSON object. Found: " - + (jsonObject == null ? null : jsonObject.getClass())); + "Authorization policy should be a JSON object. Found: " + + (jsonObject == null ? null : jsonObject.getClass())); } @SuppressWarnings("unchecked") Map json = (Map)jsonObject; diff --git a/benchmarks/src/jmh/java/io/grpc/benchmarks/TransportBenchmark.java b/benchmarks/src/jmh/java/io/grpc/benchmarks/TransportBenchmark.java index d0de1571a59..8c0ef187fbb 100644 --- a/benchmarks/src/jmh/java/io/grpc/benchmarks/TransportBenchmark.java +++ b/benchmarks/src/jmh/java/io/grpc/benchmarks/TransportBenchmark.java @@ -41,7 +41,6 @@ import io.grpc.okhttp.OkHttpChannelBuilder; import io.grpc.stub.StreamObserver; import io.netty.channel.Channel; -import io.netty.channel.DefaultEventLoopGroup; import io.netty.channel.EventLoopGroup; import io.netty.channel.ServerChannel; import io.netty.channel.local.LocalAddress; @@ -103,7 +102,8 @@ public void setUp() throws Exception { case NETTY_LOCAL: { String name = "bench" + Math.random(); LocalAddress address = new LocalAddress(name); - EventLoopGroup group = new DefaultEventLoopGroup(); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup group = new io.netty.channel.DefaultEventLoopGroup(); serverBuilder = NettyServerBuilder.forAddress(address, serverCreds) .bossEventLoopGroup(group) .workerEventLoopGroup(group) diff --git a/benchmarks/src/jmh/java/io/grpc/benchmarks/netty/AbstractBenchmark.java b/benchmarks/src/jmh/java/io/grpc/benchmarks/netty/AbstractBenchmark.java index 6d8a9ec8a8e..ffda287764d 100644 --- a/benchmarks/src/jmh/java/io/grpc/benchmarks/netty/AbstractBenchmark.java +++ b/benchmarks/src/jmh/java/io/grpc/benchmarks/netty/AbstractBenchmark.java @@ -41,7 +41,6 @@ import io.netty.channel.local.LocalAddress; import io.netty.channel.local.LocalChannel; import io.netty.channel.local.LocalServerChannel; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.util.concurrent.DefaultThreadFactory; @@ -229,8 +228,14 @@ public void setup(ExecutorType clientExecutor, // Always use a different worker group from the client. ThreadFactory serverThreadFactory = new DefaultThreadFactory("STF pool", true /* daemon */); - serverBuilder.workerEventLoopGroup(new NioEventLoopGroup(0, serverThreadFactory)); - serverBuilder.bossEventLoopGroup(new NioEventLoopGroup(1, serverThreadFactory)); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup workerGroup = + new io.netty.channel.nio.NioEventLoopGroup(0, serverThreadFactory); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup bossGroup = + new io.netty.channel.nio.NioEventLoopGroup(1, serverThreadFactory); + serverBuilder.workerEventLoopGroup(workerGroup); + serverBuilder.bossEventLoopGroup(bossGroup); // Always set connection and stream window size to same value serverBuilder.flowControlWindow(windowSize.bytes()); @@ -388,8 +393,11 @@ public void onReady() { ThreadFactory clientThreadFactory = new DefaultThreadFactory("CTF pool", true /* daemon */); for (int i = 0; i < channelCount; i++) { // Use a dedicated event-loop for each channel + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup elg = + new io.netty.channel.nio.NioEventLoopGroup(1, clientThreadFactory); channels[i] = channelBuilder - .eventLoopGroup(new NioEventLoopGroup(1, clientThreadFactory)) + .eventLoopGroup(elg) .build(); } } diff --git a/benchmarks/src/main/java/io/grpc/benchmarks/Utils.java b/benchmarks/src/main/java/io/grpc/benchmarks/Utils.java index c4ba99e1639..9d678761133 100644 --- a/benchmarks/src/main/java/io/grpc/benchmarks/Utils.java +++ b/benchmarks/src/main/java/io/grpc/benchmarks/Utils.java @@ -35,9 +35,7 @@ import io.grpc.okhttp.OkHttpChannelBuilder; import io.grpc.testing.TlsTesting; import io.netty.channel.epoll.EpollDomainSocketChannel; -import io.netty.channel.epoll.EpollEventLoopGroup; import io.netty.channel.epoll.EpollSocketChannel; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.channel.unix.DomainSocketAddress; import io.netty.util.concurrent.DefaultThreadFactory; @@ -128,22 +126,30 @@ private static NettyChannelBuilder configureNetty( DefaultThreadFactory tf = new DefaultThreadFactory("client-elg-", true /*daemon */); switch (transport) { case NETTY_NIO: + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup elg = new io.netty.channel.nio.NioEventLoopGroup(0, tf); builder - .eventLoopGroup(new NioEventLoopGroup(0, tf)) + .eventLoopGroup(elg) .channelType(NioSocketChannel.class, InetSocketAddress.class); break; case NETTY_EPOLL: // These classes only work on Linux. + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup epollElg = + new io.netty.channel.epoll.EpollEventLoopGroup(0, tf); builder - .eventLoopGroup(new EpollEventLoopGroup(0, tf)) + .eventLoopGroup(epollElg) .channelType(EpollSocketChannel.class, InetSocketAddress.class); break; case NETTY_UNIX_DOMAIN_SOCKET: // These classes only work on Linux. + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup unixElg = + new io.netty.channel.epoll.EpollEventLoopGroup(0, tf); builder - .eventLoopGroup(new EpollEventLoopGroup(0, tf)) + .eventLoopGroup(unixElg) .channelType(EpollDomainSocketChannel.class, DomainSocketAddress.class); break; diff --git a/benchmarks/src/main/java/io/grpc/benchmarks/driver/LoadWorker.java b/benchmarks/src/main/java/io/grpc/benchmarks/driver/LoadWorker.java index 63fbef0cabe..75b51b3b2e5 100644 --- a/benchmarks/src/main/java/io/grpc/benchmarks/driver/LoadWorker.java +++ b/benchmarks/src/main/java/io/grpc/benchmarks/driver/LoadWorker.java @@ -27,7 +27,6 @@ import io.grpc.benchmarks.proto.WorkerServiceGrpc; import io.grpc.netty.NettyServerBuilder; import io.grpc.stub.StreamObserver; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import java.util.logging.Level; import java.util.logging.Logger; @@ -45,11 +44,13 @@ public class LoadWorker { LoadWorker(int driverPort, int serverPort) throws Exception { this.serverPort = serverPort; - NioEventLoopGroup singleThreadGroup = new NioEventLoopGroup(1, - new ThreadFactoryBuilder() - .setDaemon(true) - .setNameFormat("load-worker-%d") - .build()); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup singleThreadGroup = + new io.netty.channel.nio.NioEventLoopGroup(1, + new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("load-worker-%d") + .build()); this.driverServer = NettyServerBuilder.forPort(driverPort, InsecureServerCredentials.create()) .directExecutor() .channelType(NioServerSocketChannel.class) diff --git a/benchmarks/src/main/java/io/grpc/benchmarks/qps/AsyncServer.java b/benchmarks/src/main/java/io/grpc/benchmarks/qps/AsyncServer.java index 7f61e15f783..9c7455ac45b 100644 --- a/benchmarks/src/main/java/io/grpc/benchmarks/qps/AsyncServer.java +++ b/benchmarks/src/main/java/io/grpc/benchmarks/qps/AsyncServer.java @@ -29,7 +29,6 @@ import io.grpc.testing.TlsTesting; import io.netty.channel.EventLoopGroup; import io.netty.channel.ServerChannel; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.concurrent.DefaultThreadFactory; import java.io.IOException; @@ -94,8 +93,12 @@ static Server newServer(ServerConfiguration config) throws IOException { ThreadFactory tf = new DefaultThreadFactory("server-elg-", true /*daemon */); switch (config.transport) { case NETTY_NIO: { - boss = new NioEventLoopGroup(1, tf); - worker = new NioEventLoopGroup(0, tf); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup nioBoss = new io.netty.channel.nio.NioEventLoopGroup(1, tf); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup nioWorker = new io.netty.channel.nio.NioEventLoopGroup(0, tf); + boss = nioBoss; + worker = nioWorker; channelType = NioServerSocketChannel.class; break; } diff --git a/binder/BUILD.bazel b/binder/BUILD.bazel new file mode 100644 index 00000000000..a0b8e9353e9 --- /dev/null +++ b/binder/BUILD.bazel @@ -0,0 +1,29 @@ +load("@rules_android//rules:rules.bzl", "android_library") +load("@rules_jvm_external//:defs.bzl", "artifact") + +licenses(["notice"]) + +android_library( + name = "binder", + srcs = glob([ + "src/main/java/**/*.java", + ]), + manifest = "src/main/AndroidManifest.xml", + custom_package = "io.grpc.binder", + # TODO(jdcormie): Figure out how to make this public without forcing + # existing non-android users to configure rules_android and Google's maven. + visibility = ["//:__subpackages__"], + exports = ["@grpc_android_maven//:androidx_annotation_annotation"], + deps = [ + "//api", + "//core:internal", + # Resolve android deps from the isolated grpc_android_maven repository. + "@grpc_android_maven//:androidx_annotation_annotation", + "@grpc_android_maven//:androidx_annotation_annotation_jvm", + "@grpc_android_maven//:androidx_core_core", + "@grpc_android_maven//:androidx_lifecycle_lifecycle_common", + artifact("com.google.code.findbugs:jsr305"), + artifact("com.google.errorprone:error_prone_annotations"), + artifact("com.google.guava:guava"), + ], +) diff --git a/binder/build.gradle b/binder/build.gradle index d1302ddfed0..7e7d4810e98 100644 --- a/binder/build.gradle +++ b/binder/build.gradle @@ -13,14 +13,20 @@ android { targetCompatibility 1.8 } defaultConfig { - minSdkVersion 22 + minSdkVersion 23 targetSdkVersion 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - multiDexEnabled = true } lintOptions { abortOnError = false } + buildTypes { + debug { + testCoverageEnabled true // For robolectric unit tests. + enableUnitTestCoverage true // For tests that run on an emulator. + } + } + publishing { singleVariant('release') { withSourcesJar() @@ -55,6 +61,7 @@ dependencies { testImplementation project(':grpc-testing') testImplementation project(':grpc-inprocess') testImplementation testFixtures(project(':grpc-core')) + testImplementation testFixtures(project(':grpc-api')) androidTestAnnotationProcessor libraries.auto.value androidTestImplementation project(':grpc-testing') @@ -134,3 +141,31 @@ afterEvaluate { components.release.withVariantsFromConfiguration(configurations.releaseTestFixturesVariantReleaseApiPublication) { skip() } components.release.withVariantsFromConfiguration(configurations.releaseTestFixturesVariantReleaseRuntimePublication) { skip() } } + +tasks.withType(Test) { + // Robolectric modifies classes in memory at runtime, so they lack a java.security.CodeSource + // URL to their on-disk location. By default, JaCoCo ignores classes without this property. + // Overriding this allows Robolectric tests to be instrumented. + jacoco.includeNoLocationClasses = true + // Don't instrument certain JDK internals protected from modification by JEP 403's "strong + // encapsulation." Avoids IllegalAccessError, InvalidClassException and similar at runtime. + jacoco.excludes = ["jdk.internal.**"] +} + +// Android projects don't automatically get a coverage report task. We must +// register one manually here and wire it up to AGP's test tasks. +tasks.register("jacocoTestReport", JacocoReport) { + dependsOn "testDebugUnitTest" + + reports { + // For codecov.io and coveralls. + xml.required = true + // Use the same output location as the other subprojects. + html.outputLocation = layout.buildDirectory.dir("reports/jacoco/test/html") + } + + sourceDirectories.from = android.sourceSets.main.java.srcDirs + classDirectories.from = fileTree(dir: layout.buildDirectory.dir("intermediates/javac/debug/classes"), + excludes: ['**/R.class', '**/R$*.class', '**/BuildConfig.class', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*']) + executionData.from = tasks.named("testDebugUnitTest").map { it.jacoco.destinationFile } +} diff --git a/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java b/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java index e3a8c58bf88..4e3cfcf0d05 100644 --- a/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java +++ b/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java @@ -97,6 +97,7 @@ public final class BinderChannelSmokeTest { .setType(MethodDescriptor.MethodType.BIDI_STREAMING) .build(); + AndroidComponentAddress serverAddress; ManagedChannel channel; AtomicReference headersCapture = new AtomicReference<>(); AtomicReference clientUidCapture = new AtomicReference<>(); @@ -134,7 +135,7 @@ public void setUp() throws Exception { TestUtils.recordRequestHeadersInterceptor(headersCapture), PeerUids.newPeerIdentifyingServerInterceptor()); - AndroidComponentAddress serverAddress = HostServices.allocateService(appContext); + serverAddress = HostServices.allocateService(appContext); HostServices.configureService( serverAddress, HostServices.serviceParamsBuilder() @@ -149,13 +150,15 @@ public void setUp() throws Exception { .build()) .build()); - channel = - BinderChannelBuilder.forAddress(serverAddress, appContext) + channel = newBinderChannelBuilder().build(); + } + + BinderChannelBuilder newBinderChannelBuilder() { + return BinderChannelBuilder.forAddress(serverAddress, appContext) .inboundParcelablePolicy( - InboundParcelablePolicy.newBuilder() - .setAcceptParcelableMetadataValues(true) - .build()) - .build(); + InboundParcelablePolicy.newBuilder() + .setAcceptParcelableMetadataValues(true) + .build()); } @After @@ -185,6 +188,18 @@ public void testBasicCall() throws Exception { assertThat(doCall("Hello").get()).isEqualTo("Hello"); } + @Test + public void testBasicCallWithLegacyAuthStrategy() throws Exception { + channel = newBinderChannelBuilder().useLegacyAuthStrategy().build(); + assertThat(doCall("Hello").get()).isEqualTo("Hello"); + } + + @Test + public void testBasicCallWithV2AuthStrategy() throws Exception { + channel = newBinderChannelBuilder().useV2AuthStrategy().build(); + assertThat(doCall("Hello").get()).isEqualTo("Hello"); + } + @Test public void testPeerUidIsRecorded() throws Exception { assertThat(doCall("Hello").get()).isEqualTo("Hello"); diff --git a/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java b/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java index 0038a054854..aa3fb573ab5 100644 --- a/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java +++ b/binder/src/androidTest/java/io/grpc/binder/internal/BinderClientTransportTest.java @@ -41,6 +41,7 @@ import io.grpc.binder.BinderServerBuilder; import io.grpc.binder.HostServices; import io.grpc.binder.SecurityPolicy; +import io.grpc.binder.internal.FakeDeadBinder; import io.grpc.binder.internal.OneWayBinderProxies.BlackHoleOneWayBinderProxy; import io.grpc.binder.internal.OneWayBinderProxies.BlockingBinderDecorator; import io.grpc.binder.internal.OneWayBinderProxies.ThrowingOneWayBinderProxy; @@ -48,6 +49,7 @@ import io.grpc.internal.ClientStream; import io.grpc.internal.ClientStreamListener; import io.grpc.internal.ClientTransportFactory.ClientTransportOptions; +import io.grpc.internal.DisconnectError; import io.grpc.internal.FixedObjectPool; import io.grpc.internal.ManagedClientTransport; import io.grpc.internal.ObjectPool; @@ -358,6 +360,20 @@ public void testTxnFailurePostSetup() throws Exception { assertThat(streamStatus.getCause()).isSameInstanceAs(doe); } + @Test + public void testServerBinderDeadOnArrival() throws Exception { + BlockingBinderDecorator decorator = new BlockingBinderDecorator<>(); + transport = new BinderClientTransportBuilder().setBinderDecorator(decorator).build(); + transport.start(transportListener).run(); + decorator.putNextResult(decorator.takeNextRequest()); // Server's "Endpoint" Binder. + OneWayBinderProxy unusedServerBinder = decorator.takeNextRequest(); + decorator.putNextResult( + OneWayBinderProxy.wrap(new FakeDeadBinder(), offloadServicePool.getObject())); + Status clientStatus = transportListener.awaitShutdown(); + assertThat(clientStatus.getCode()).isEqualTo(Code.UNAVAILABLE); + assertThat(clientStatus.getDescription()).contains("Failed to observe outgoing binder"); + } + @Test public void testBlackHoleEndpointConnectTimeout() throws Exception { BlockingBinderDecorator decorator = new BlockingBinderDecorator<>(); @@ -514,7 +530,7 @@ private static final class TestTransportListener implements ManagedClientTranspo private final SettableFuture isTerminated = SettableFuture.create(); @Override - public void transportShutdown(Status shutdownStatus) { + public void transportShutdown(Status shutdownStatus, DisconnectError disconnectError) { if (!this.shutdownStatus.set(shutdownStatus)) { throw new IllegalStateException("transportShutdown() already called"); } diff --git a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java index 18928339fbd..a241634dd22 100644 --- a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java +++ b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java @@ -20,8 +20,6 @@ import static com.google.common.base.Preconditions.checkState; import android.content.Context; -import android.os.UserHandle; -import androidx.annotation.RequiresApi; import com.google.errorprone.annotations.DoNotCall; import io.grpc.ExperimentalApi; import io.grpc.ForwardingChannelBuilder; @@ -235,32 +233,6 @@ public BinderChannelBuilder securityPolicy(SecurityPolicy securityPolicy) { return this; } - /** - * Specifies the {@link UserHandle} to be searched for the remote Android Service by default. - * - *

Used only as a fallback if the direct or resolved {@link AndroidComponentAddress} doesn't - * specify a {@link UserHandle}. If neither the Channel nor the {@link AndroidComponentAddress} - * specifies a target user, the {@link UserHandle} of the current process will be used. - * - *

Connecting to a server in a different Android user is uncommon and can only be done by a - * "system app" client with special permissions. See {@link - * AndroidComponentAddress.Builder#setTargetUser(UserHandle)} for details. - * - * @deprecated This method's name is misleading because it implies an impersonated client identity - * when it's actually specifying part of the server's location. It's also no longer necessary - * since the target user is part of {@link AndroidComponentAddress}. Prefer to specify target - * user in the address instead, either directly or via a {@link io.grpc.NameResolverProvider}. - * @param targetUserHandle the target user to bind into. - * @return this - */ - @ExperimentalApi("https://github.com/grpc/grpc-java/issues/10173") - @RequiresApi(30) - @Deprecated - public BinderChannelBuilder bindAsUser(UserHandle targetUserHandle) { - transportFactoryBuilder.setDefaultTargetUserHandle(targetUserHandle); - return this; - } - /** Sets the policy for inbound parcelable objects. */ public BinderChannelBuilder inboundParcelablePolicy( InboundParcelablePolicy inboundParcelablePolicy) { @@ -308,6 +280,67 @@ public BinderChannelBuilder preAuthorizeServers(boolean preAuthorize) { return this; } + /** + * Specifies how and when to authorize a server against this Channel's {@link SecurityPolicy}. + * + *

This method selects the original "legacy" authorization strategy, which is no longer + * preferred for two reasons: First, the legacy strategy considers the UID of the server *process* + * we connect to. This is problematic for services using the `android:isolatedProcess` attribute, + * which runs them under a different "ephemeral" UID. This UID lacks all the privileges of the + * hosting app -- any non-trivial SecurityPolicy would fail to authorize it. Second, the legacy + * authorization strategy performs SecurityPolicy checks later in the connection handshake, which + * means the calling UID must be rechecked on every subsequent RPC. For these reasons, prefer + * {@link #useV2AuthStrategy} instead. + * + *

The server does not know which authorization strategy a client is using. Both strategies + * work with all versions of the grpc-binder server. + * + *

Callers need not specify an authorization strategy, but the default is unspecified and will + * eventually become {@link #useV2AuthStrategy()}. Clients that require the legacy strategy should + * configure it explicitly using this method. Eventually, however, legacy support will be + * deprecated and removed. + * + * @return this + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12397") + public BinderChannelBuilder useLegacyAuthStrategy() { + transportFactoryBuilder.setUseLegacyAuthStrategy(true); + return this; + } + + /** + * Specifies how and when to authorize a server against this Channel's {@link SecurityPolicy}. + * + *

This method selects the v2 authorization strategy. It improves on the original strategy + * ({@link #useLegacyAuthStrategy}), by considering the UID of the server *app* we connect to, + * rather than the server *process*. This allows clients to connect to services configured with + * the `android:isolatedProcess` attribute, which run with the same authority as the hosting app, + * but under a different "ephemeral" UID that any non-trivial SecurityPolicy would fail to + * authorize. + * + *

Furthermore, the v2 authorization strategy performs SecurityPolicy checks earlier in the + * connection handshake, which allows subsequent RPCs over that connection to proceed securely + * without further UID checks. For these reasons, clients should prefer the v2 strategy. + * + *

The server does not know which authorization strategy a client is using. Both strategies + * work with all versions of the grpc-binder server. + * + *

Callers need not specify an authorization strategy, but the default is unspecified and can + * change over time. Clients that require the v2 strategy should configure it explicitly using + * this method. Eventually, this strategy will become the default and legacy support will be + * removed. + * + *

If moving to the new authorization strategy causes a robolectric test to fail, ensure your + * fake Service component is registered with `ShadowPackageManager` using `addOrUpdateService()`. + * + * @return this + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12397") + public BinderChannelBuilder useV2AuthStrategy() { + transportFactoryBuilder.setUseLegacyAuthStrategy(false); + return this; + } + @Override public BinderChannelBuilder idleTimeout(long value, TimeUnit unit) { checkState( diff --git a/binder/src/main/java/io/grpc/binder/BinderServerBuilder.java b/binder/src/main/java/io/grpc/binder/BinderServerBuilder.java index c926c853472..5f0885883a5 100644 --- a/binder/src/main/java/io/grpc/binder/BinderServerBuilder.java +++ b/binder/src/main/java/io/grpc/binder/BinderServerBuilder.java @@ -68,7 +68,7 @@ private BinderServerBuilder( serverImplBuilder = new ServerImplBuilder( - streamTracerFactories -> { + (streamTracerFactories, metricRecorder) -> { internalBuilder.setStreamTracerFactories(streamTracerFactories); BinderServer server = internalBuilder.build(); BinderInternal.setIBinder(binderReceiver, server.getHostBinder()); diff --git a/binder/src/main/java/io/grpc/binder/internal/Bindable.java b/binder/src/main/java/io/grpc/binder/internal/Bindable.java index ae0c7284faf..59a2502de2b 100644 --- a/binder/src/main/java/io/grpc/binder/internal/Bindable.java +++ b/binder/src/main/java/io/grpc/binder/internal/Bindable.java @@ -54,8 +54,11 @@ interface Observer { * before giving them a chance to run. However, note that the identity/existence of the resolved * Service can change between the time this method returns and the time you actually bind/connect * to it. For example, suppose the target package gets uninstalled or upgraded right after this - * method returns. In {@link Observer#onBound}, you should verify that the server you resolved is - * the same one you connected to. + * method returns. + * + *

Compare with {@link #getConnectedServiceInfo()}, which can only be called after {@link + * Observer#onBound(IBinder)} but can be used to learn about the service you actually connected + * to. */ @AnyThread ServiceInfo resolve() throws StatusException; @@ -68,6 +71,21 @@ interface Observer { @AnyThread void bind(); + /** + * Asks PackageManager for details about the remote Service we *actually* connected to. + * + *

Can only be called after {@link Observer#onBound}. + * + *

Compare with {@link #resolve()}, which reports which service would be selected as of now but + * *without* connecting. + * + * @throws StatusException UNIMPLEMENTED if the connected service isn't found (an {@link + * Observer#onUnbound} callback has likely already happened or is on its way!) + * @throws IllegalStateException if {@link Observer#onBound} has not "happened-before" this call + */ + @AnyThread + ServiceInfo getConnectedServiceInfo() throws StatusException; + /** * Unbind from the remote service if connected. * diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java index 144ad56eec3..58e7d7e2b31 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java @@ -25,6 +25,8 @@ import android.os.IBinder; import android.os.Parcel; import android.os.Process; +import androidx.annotation.BinderThread; +import androidx.annotation.MainThread; import com.google.common.base.Ticker; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; @@ -54,6 +56,7 @@ import io.grpc.internal.GrpcUtil; import io.grpc.internal.ManagedClientTransport; import io.grpc.internal.ObjectPool; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.StatsTraceContext; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledFuture; @@ -72,6 +75,9 @@ public final class BinderClientTransport extends BinderTransport private final SecurityPolicy securityPolicy; private final Bindable serviceBinding; + @GuardedBy("this") + private final ClientHandshake handshake; + /** Number of ongoing calls which keep this transport "in-use". */ private final AtomicInteger numInUseStreams; @@ -87,14 +93,6 @@ public final class BinderClientTransport extends BinderTransport @GuardedBy("this") private ScheduledFuture readyTimeoutFuture; // != null iff timeout scheduled. - @GuardedBy("this") - @Nullable - private ListenableFuture authResultFuture; // null before we check auth. - - @GuardedBy("this") - @Nullable - private ListenableFuture preAuthResultFuture; // null before we pre-auth. - /** * Constructs a new transport instance. * @@ -122,18 +120,17 @@ public BinderClientTransport( Boolean preAuthServerOverride = options.getEagAttributes().get(PRE_AUTH_SERVER_OVERRIDE); this.preAuthorizeServer = preAuthServerOverride != null ? preAuthServerOverride : factory.preAuthorizeServers; + this.handshake = + factory.useLegacyAuthStrategy ? new LegacyClientHandshake() : new V2ClientHandshake(); numInUseStreams = new AtomicInteger(); pingTracker = new PingTracker(Ticker.systemTicker(), (id) -> sendPing(id)); - serviceBinding = new ServiceBinding( factory.mainThreadExecutor, factory.sourceContext, factory.channelCredentials, targetAddress.asBindIntent(), - targetAddress.getTargetUser() != null - ? targetAddress.getTargetUser() - : factory.defaultTargetUserHandle, + targetAddress.getTargetUser(), factory.bindServiceFlags.toInteger(), this); } @@ -146,7 +143,7 @@ void releaseExecutors() { @Override public synchronized void onBound(IBinder binder) { - sendSetupTransaction(binderDecorator.decorate(OneWayBinderProxy.wrap(binder, offloadExecutor))); + handshake.onBound(binderDecorator.decorate(OneWayBinderProxy.wrap(binder, offloadExecutor))); } @Override @@ -158,31 +155,33 @@ public synchronized void onUnbound(Status reason) { @Override public synchronized Runnable start(Listener clientTransportListener) { this.clientTransportListener = checkNotNull(clientTransportListener); - return () -> { - synchronized (BinderClientTransport.this) { - if (inState(TransportState.NOT_STARTED)) { - setState(TransportState.SETUP); - try { - if (preAuthorizeServer) { - preAuthorize(serviceBinding.resolve()); - } else { - serviceBinding.bind(); - } - } catch (StatusException e) { - shutdownInternal(e.getStatus(), true); - return; - } - if (readyTimeoutMillis >= 0) { - readyTimeoutFuture = - getScheduledExecutorService() - .schedule( - BinderClientTransport.this::onReadyTimeout, - readyTimeoutMillis, - MILLISECONDS); - } - } + return this::postStartRunnable; + } + + private synchronized void postStartRunnable() { + if (!inState(TransportState.NOT_STARTED)) { + return; + } + + setState(TransportState.SETUP); + + try { + if (preAuthorizeServer) { + preAuthorize(serviceBinding.resolve()); + } else { + serviceBinding.bind(); } - }; + } catch (StatusException e) { + shutdownInternal(e.getStatus(), true); + return; + } + + if (readyTimeoutMillis >= 0) { + readyTimeoutFuture = + getScheduledExecutorService() + .schedule( + BinderClientTransport.this::onReadyTimeout, readyTimeoutMillis, MILLISECONDS); + } } @GuardedBy("this") @@ -195,7 +194,8 @@ private void preAuthorize(ServiceInfo serviceInfo) { // unauthorized server a chance to run, but the connection will still fail by SecurityPolicy // check later in handshake. Pre-auth remains effective at mitigating abuse because malware // can't typically control the exact timing of its installation. - preAuthResultFuture = checkServerAuthorizationAsync(serviceInfo.applicationInfo.uid); + ListenableFuture preAuthResultFuture = + register(checkServerAuthorizationAsync(serviceInfo.applicationInfo.uid)); Futures.addCallback( preAuthResultFuture, new FutureCallback() { @@ -213,13 +213,16 @@ public void onFailure(Throwable t) { } private synchronized void handlePreAuthResult(Status authorization) { - if (inState(TransportState.SETUP)) { - if (!authorization.isOk()) { - shutdownInternal(authorization, true); - } else { - serviceBinding.bind(); - } + if (!inState(TransportState.SETUP)) { + return; + } + + if (!authorization.isOk()) { + shutdownInternal(authorization, true); + return; } + + serviceBinding.bind(); } private synchronized void onReadyTimeout() { @@ -261,22 +264,22 @@ public synchronized ClientStream newStream( Status failure = Status.INTERNAL.withDescription("Clashing call IDs"); shutdownInternal(failure, true); return newFailingClientStream(failure, attributes, headers, tracers); + } + + if (inbound.countsForInUse() && numInUseStreams.getAndIncrement() == 0) { + clientTransportListener.transportInUse(true); + } + Outbound.ClientOutbound outbound = + new Outbound.ClientOutbound(this, callId, method, headers, statsTraceContext); + if (method.getType().clientSendsOneMessage()) { + return new SingleMessageClientStream(inbound, outbound, attributes); } else { - if (inbound.countsForInUse() && numInUseStreams.getAndIncrement() == 0) { - clientTransportListener.transportInUse(true); - } - Outbound.ClientOutbound outbound = - new Outbound.ClientOutbound(this, callId, method, headers, statsTraceContext); - if (method.getType().clientSendsOneMessage()) { - return new SingleMessageClientStream(inbound, outbound, attributes); - } else { - return new MultiMessageClientStream(inbound, outbound, attributes); - } + return new MultiMessageClientStream(inbound, outbound, attributes); } } @Override - protected void unregisterInbound(Inbound inbound) { + protected void unregisterInbound(Inbound inbound) { if (inbound.countsForInUse() && numInUseStreams.decrementAndGet() == 0) { clientTransportListener.transportInUse(false); } @@ -303,7 +306,7 @@ public synchronized void shutdownNow(Status reason) { @Override @GuardedBy("this") void notifyShutdown(Status status) { - clientTransportListener.transportShutdown(status); + clientTransportListener.transportShutdown(status, SimpleDisconnectError.UNKNOWN); } @Override @@ -316,12 +319,6 @@ void notifyTerminated() { readyTimeoutFuture.cancel(false); readyTimeoutFuture = null; } - if (preAuthResultFuture != null) { - preAuthResultFuture.cancel(false); // No effect if already complete. - } - if (authResultFuture != null) { - authResultFuture.cancel(false); // No effect if already complete. - } serviceBinding.unbind(); clientTransportListener.transportTerminated(); } @@ -329,35 +326,47 @@ void notifyTerminated() { @Override @GuardedBy("this") protected void handleSetupTransport(Parcel parcel) { - int remoteUid = Binder.getCallingUid(); - if (inState(TransportState.SETUP)) { - int version = parcel.readInt(); - IBinder binder = parcel.readStrongBinder(); - if (version != WIRE_FORMAT_VERSION) { - shutdownInternal(Status.UNAVAILABLE.withDescription("Wire format version mismatch"), true); - } else if (binder == null) { - shutdownInternal( - Status.UNAVAILABLE.withDescription("Malformed SETUP_TRANSPORT data"), true); - } else { - restrictIncomingBinderToCallsFrom(remoteUid); - attributes = setSecurityAttrs(attributes, remoteUid); - authResultFuture = checkServerAuthorizationAsync(remoteUid); - Futures.addCallback( - authResultFuture, - new FutureCallback() { - @Override - public void onSuccess(Status result) { - handleAuthResult(binder, result); - } + if (!inState(TransportState.SETUP)) { + return; + } - @Override - public void onFailure(Throwable t) { - handleAuthResult(t); - } - }, - offloadExecutor); - } + int version = parcel.readInt(); + if (version != WIRE_FORMAT_VERSION) { + shutdownInternal(Status.UNAVAILABLE.withDescription("Wire format version mismatch"), true); + return; + } + + IBinder binder = parcel.readStrongBinder(); + if (binder == null) { + shutdownInternal(Status.UNAVAILABLE.withDescription("Malformed SETUP_TRANSPORT data"), true); + return; + } + + if (!setOutgoingBinder(OneWayBinderProxy.wrap(binder, offloadExecutor))) { + shutdownInternal( + Status.UNAVAILABLE.withDescription("Failed to observe outgoing binder"), true); + return; } + handshake.handleSetupTransport(); + } + + @GuardedBy("this") + private void checkServerAuthorization(int remoteUid) { + ListenableFuture authResultFuture = register(checkServerAuthorizationAsync(remoteUid)); + Futures.addCallback( + authResultFuture, + new FutureCallback() { + @Override + public void onSuccess(Status result) { + handleAuthResult(result); + } + + @Override + public void onFailure(Throwable t) { + handleAuthResult(t); + } + }, + offloadExecutor); } private ListenableFuture checkServerAuthorizationAsync(int remoteUid) { @@ -366,26 +375,76 @@ private ListenableFuture checkServerAuthorizationAsync(int remoteUid) { : Futures.submit(() -> securityPolicy.checkAuthorization(remoteUid), offloadExecutor); } - private synchronized void handleAuthResult(IBinder binder, Status authorization) { - if (inState(TransportState.SETUP)) { - if (!authorization.isOk()) { - shutdownInternal(authorization, true); - } else if (!setOutgoingBinder(OneWayBinderProxy.wrap(binder, offloadExecutor))) { - shutdownInternal( - Status.UNAVAILABLE.withDescription("Failed to observe outgoing binder"), true); - } else { - // Check state again, since a failure inside setOutgoingBinder (or a callback it - // triggers), could have shut us down. - if (!isShutdown()) { - setState(TransportState.READY); - attributes = clientTransportListener.filterTransport(attributes); - clientTransportListener.transportReady(); - if (readyTimeoutFuture != null) { - readyTimeoutFuture.cancel(false); - readyTimeoutFuture = null; - } - } + private synchronized void handleAuthResult(Status authorization) { + if (!inState(TransportState.SETUP)) { + return; + } + + if (!authorization.isOk()) { + shutdownInternal(authorization, true); + return; + } + handshake.onServerAuthorizationOk(); + } + + private final class V2ClientHandshake implements ClientHandshake { + + private OneWayBinderProxy endpointBinder; + + @Override + @GuardedBy("BinderClientTransport.this") // By way of @GuardedBy("this") `handshake` member. + public void onBound(OneWayBinderProxy endpointBinder) { + this.endpointBinder = endpointBinder; + Futures.addCallback( + Futures.submit(serviceBinding::getConnectedServiceInfo, offloadExecutor), + new FutureCallback() { + @Override + public void onSuccess(ServiceInfo result) { + synchronized (BinderClientTransport.this) { + onConnectedServiceInfo(result); + } + } + + @Override + public void onFailure(Throwable t) { + synchronized (BinderClientTransport.this) { + shutdownInternal(Status.fromThrowable(t), true); + } + } + }, + offloadExecutor); + } + + @GuardedBy("BinderClientTransport.this") + private void onConnectedServiceInfo(ServiceInfo serviceInfo) { + if (!inState(TransportState.SETUP)) { + return; } + attributes = setSecurityAttrs(attributes, serviceInfo.applicationInfo.uid); + checkServerAuthorization(serviceInfo.applicationInfo.uid); + } + + @Override + @GuardedBy("BinderClientTransport.this") + public void onServerAuthorizationOk() { + sendSetupTransaction(endpointBinder); + } + + @Override + @GuardedBy("BinderClientTransport.this") // By way of @GuardedBy("this") `handshake` member. + public void handleSetupTransport() { + onHandshakeComplete(); + } + } + + @GuardedBy("this") + private void onHandshakeComplete() { + setState(TransportState.READY); + attributes = clientTransportListener.filterTransport(attributes); + clientTransportListener.transportReady(); + if (readyTimeoutFuture != null) { + readyTimeoutFuture.cancel(false); + readyTimeoutFuture = null; } } @@ -400,6 +459,52 @@ protected void handlePingResponse(Parcel parcel) { pingTracker.onPingResponse(parcel.readInt()); } + /** + * An abstract implementation of the client's connection handshake. + * + *

Supports a clean migration away from the legacy approach, one client at a time. + */ + private interface ClientHandshake { + /** + * Notifies the implementation that the binding has succeeded and we are now connected to the + * server's "endpoint" which can be reached at 'endpointBinder'. + */ + @MainThread + void onBound(OneWayBinderProxy endpointBinder); + + /** Notifies the implementation that we've received a valid SETUP_TRANSPORT transaction. */ + @BinderThread + void handleSetupTransport(); + + /** Notifies the implementation that the SecurityPolicy check of the server succeeded. */ + void onServerAuthorizationOk(); + } + + private final class LegacyClientHandshake implements ClientHandshake { + @Override + @MainThread + @GuardedBy("BinderClientTransport.this") // By way of @GuardedBy("this") `handshake` member. + public void onBound(OneWayBinderProxy binder) { + sendSetupTransaction(binder); + } + + @Override + @BinderThread + @GuardedBy("BinderClientTransport.this") // By way of @GuardedBy("this") `handshake` member. + public void handleSetupTransport() { + int remoteUid = Binder.getCallingUid(); + restrictIncomingBinderToCallsFrom(remoteUid); + attributes = setSecurityAttrs(attributes, remoteUid); + checkServerAuthorization(remoteUid); + } + + @Override + @GuardedBy("BinderClientTransport.this") // By way of @GuardedBy("this") `handshake` member. + public void onServerAuthorizationOk() { + onHandshakeComplete(); + } + } + private static ClientStream newFailingClientStream( Status failure, Attributes attributes, Metadata headers, ClientStreamTracer[] tracers) { StatsTraceContext statsTraceContext = diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java index 3f51452c90c..459e064ad9b 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransportFactory.java @@ -18,7 +18,6 @@ import static com.google.common.base.Preconditions.checkNotNull; import android.content.Context; -import android.os.UserHandle; import androidx.core.content.ContextCompat; import io.grpc.ChannelCredentials; import io.grpc.ChannelLogger; @@ -39,7 +38,6 @@ import java.util.Collections; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; -import javax.annotation.Nullable; /** Creates new binder transports. */ @Internal @@ -50,12 +48,12 @@ public final class BinderClientTransportFactory implements ClientTransportFactor final ObjectPool scheduledExecutorPool; final ObjectPool offloadExecutorPool; final SecurityPolicy securityPolicy; - @Nullable final UserHandle defaultTargetUserHandle; final BindServiceFlags bindServiceFlags; final InboundParcelablePolicy inboundParcelablePolicy; final OneWayBinderProxy.Decorator binderDecorator; final long readyTimeoutMillis; final boolean preAuthorizeServers; // TODO(jdcormie): Default to true. + final boolean useLegacyAuthStrategy; ScheduledExecutorService executorService; Executor offloadExecutor; @@ -71,12 +69,12 @@ private BinderClientTransportFactory(Builder builder) { scheduledExecutorPool = checkNotNull(builder.scheduledExecutorPool); offloadExecutorPool = checkNotNull(builder.offloadExecutorPool); securityPolicy = checkNotNull(builder.securityPolicy); - defaultTargetUserHandle = builder.defaultTargetUserHandle; bindServiceFlags = checkNotNull(builder.bindServiceFlags); inboundParcelablePolicy = checkNotNull(builder.inboundParcelablePolicy); binderDecorator = checkNotNull(builder.binderDecorator); readyTimeoutMillis = builder.readyTimeoutMillis; preAuthorizeServers = builder.preAuthorizeServers; + useLegacyAuthStrategy = builder.useLegacyAuthStrategy; executorService = scheduledExecutorPool.getObject(); offloadExecutor = offloadExecutorPool.getObject(); @@ -125,12 +123,12 @@ public static final class Builder implements ClientTransportFactoryBuilder { ObjectPool scheduledExecutorPool = SharedResourcePool.forResource(GrpcUtil.TIMER_SERVICE); SecurityPolicy securityPolicy = SecurityPolicies.internalOnly(); - @Nullable UserHandle defaultTargetUserHandle; BindServiceFlags bindServiceFlags = BindServiceFlags.DEFAULTS; InboundParcelablePolicy inboundParcelablePolicy = InboundParcelablePolicy.DEFAULT; OneWayBinderProxy.Decorator binderDecorator = OneWayBinderProxy.IDENTITY_DECORATOR; long readyTimeoutMillis = 60_000; boolean preAuthorizeServers; + boolean useLegacyAuthStrategy = true; // TODO(jdcormie): Default to false. @Override public BinderClientTransportFactory buildClientTransportFactory() { @@ -172,11 +170,6 @@ public Builder setSecurityPolicy(SecurityPolicy securityPolicy) { return this; } - public Builder setDefaultTargetUserHandle(@Nullable UserHandle defaultTargetUserHandle) { - this.defaultTargetUserHandle = defaultTargetUserHandle; - return this; - } - public Builder setBindServiceFlags(BindServiceFlags bindServiceFlags) { this.bindServiceFlags = checkNotNull(bindServiceFlags, "bindServiceFlags"); return this; @@ -229,5 +222,11 @@ public Builder setPreAuthorizeServers(boolean preAuthorizeServers) { this.preAuthorizeServers = preAuthorizeServers; return this; } + + /** Specifies which version of the client handshake to use. */ + public Builder setUseLegacyAuthStrategy(boolean useLegacyAuthStrategy) { + this.useLegacyAuthStrategy = useLegacyAuthStrategy; + return this; + } } } diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderServer.java b/binder/src/main/java/io/grpc/binder/internal/BinderServer.java index fca8e3d88e1..f913775fcbe 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderServer.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderServer.java @@ -70,6 +70,7 @@ public final class BinderServer implements InternalServer, LeakSafeOneWayBinder. private final LeakSafeOneWayBinder hostServiceBinder; private final BinderTransportSecurity.ServerPolicyChecker serverPolicyChecker; private final InboundParcelablePolicy inboundParcelablePolicy; + private final OneWayBinderProxy.Decorator clientBinderDecorator; @GuardedBy("this") private ServerListener listener; @@ -92,6 +93,7 @@ private BinderServer(Builder builder) { ImmutableList.copyOf(checkNotNull(builder.streamTracerFactories, "streamTracerFactories")); this.serverPolicyChecker = BinderInternal.createPolicyChecker(builder.serverSecurityPolicy); this.inboundParcelablePolicy = builder.inboundParcelablePolicy; + this.clientBinderDecorator = builder.clientBinderDecorator; hostServiceBinder = new LeakSafeOneWayBinder(this); } @@ -179,13 +181,13 @@ public synchronized boolean handleTransaction(int code, Parcel parcel) { checkNotNull(executor, "Not started?")); // Create a new transport and let our listener know about it. BinderServerTransport transport = - new BinderServerTransport( + BinderServerTransport.create( executorServicePool, attrsBuilder.build(), streamTracerFactories, - OneWayBinderProxy.IDENTITY_DECORATOR, + clientBinderDecorator, callbackBinder); - transport.setServerTransportListener(listener.transportCreated(transport)); + transport.start(listener.transportCreated(transport)); return true; } } @@ -225,6 +227,7 @@ public static class Builder { SharedResourcePool.forResource(GrpcUtil.TIMER_SERVICE); ServerSecurityPolicy serverSecurityPolicy = SecurityPolicies.serverInternalOnly(); InboundParcelablePolicy inboundParcelablePolicy = InboundParcelablePolicy.DEFAULT; + OneWayBinderProxy.Decorator clientBinderDecorator = OneWayBinderProxy.IDENTITY_DECORATOR; public BinderServer build() { return new BinderServer(this); @@ -295,5 +298,19 @@ public Builder setInboundParcelablePolicy(InboundParcelablePolicy inboundParcela checkNotNull(inboundParcelablePolicy, "inboundParcelablePolicy"); return this; } + + /** + * Sets the {@link OneWayBinderProxy.Decorator} to be applied to this server's "client Binders". + * + *

Tests can use this to capture post-setup transactions from server to client. The specified + * decorator will be applied every time a client connects. The decorated result will be used for + * all subsequent transactions to this client from the new ServerTransport. + * + *

Optional, {@link OneWayBinderProxy#IDENTITY_DECORATOR} is the default. + */ + public Builder setClientBinderDecorator(OneWayBinderProxy.Decorator clientBinderDecorator) { + this.clientBinderDecorator = checkNotNull(clientBinderDecorator); + return this; + } } } diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java index 1c345249735..784d833bdf5 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java @@ -38,54 +38,87 @@ public final class BinderServerTransport extends BinderTransport implements ServerTransport { private final List streamTracerFactories; - @Nullable private ServerTransportListener serverTransportListener; + + @GuardedBy("this") + private final SimplePromise listenerPromise = new SimplePromise<>(); + + private BinderServerTransport( + ObjectPool executorServicePool, + Attributes attributes, + List streamTracerFactories, + OneWayBinderProxy.Decorator binderDecorator) { + super(executorServicePool, attributes, binderDecorator, buildLogId(attributes)); + this.streamTracerFactories = streamTracerFactories; + } /** * Constructs a new transport instance. * * @param binderDecorator used to decorate 'callbackBinder', for fault injection. */ - public BinderServerTransport( + public static BinderServerTransport create( ObjectPool executorServicePool, Attributes attributes, List streamTracerFactories, OneWayBinderProxy.Decorator binderDecorator, IBinder callbackBinder) { - super(executorServicePool, attributes, binderDecorator, buildLogId(attributes)); - this.streamTracerFactories = streamTracerFactories; + BinderServerTransport transport = + new BinderServerTransport( + executorServicePool, attributes, streamTracerFactories, binderDecorator); // TODO(jdcormie): Plumb in the Server's executor() and use it here instead. - setOutgoingBinder(OneWayBinderProxy.wrap(callbackBinder, getScheduledExecutorService())); + // No need to handle failure here because if 'callbackBinder' is already dead, we'll notice it + // again in start() when we send the first transaction. + synchronized (transport) { + transport.setOutgoingBinder( + OneWayBinderProxy.wrap(callbackBinder, transport.getScheduledExecutorService())); + } + return transport; } - public synchronized void setServerTransportListener( - ServerTransportListener serverTransportListener) { - this.serverTransportListener = serverTransportListener; + /** + * Initializes this transport instance. + * + *

Must be called exactly once, even if {@link #shutdown} or {@link #shutdownNow} was called + * first. + * + * @param serverTransportListener where this transport will report events + */ + public synchronized void start(ServerTransportListener serverTransportListener) { + this.listenerPromise.set(serverTransportListener); + if (isShutdown()) { + // It's unlikely, but we could be shutdown externally between construction and start(). One + // possible cause is an extremely short handshake timeout. + return; + } + + sendSetupTransaction(); + + // Check we're not shutdown again, since a failure inside sendSetupTransaction (or a callback + // it triggers), could have shut us down. if (isShutdown()) { - setState(TransportState.SHUTDOWN_TERMINATED); - notifyTerminated(); - releaseExecutors(); - } else { - sendSetupTransaction(); - // Check we're not shutdown again, since a failure inside sendSetupTransaction (or a callback - // it triggers), could have shut us down. - if (!isShutdown()) { - setState(TransportState.READY); - attributes = serverTransportListener.transportReady(attributes); - } + return; } + + setState(TransportState.READY); + attributes = serverTransportListener.transportReady(attributes); } StatsTraceContext createStatsTraceContext(String methodName, Metadata headers) { return StatsTraceContext.newServerContext(streamTracerFactories, methodName, headers); } + /** + * Reports a new ServerStream requested by the remote client. + * + *

Precondition: {@link #start(ServerTransportListener)} must already have been called. + */ synchronized Status startStream(ServerStream stream, String methodName, Metadata headers) { if (isShutdown()) { return Status.UNAVAILABLE.withDescription("transport is shutdown"); - } else { - serverTransportListener.streamCreated(stream, methodName, headers); - return Status.OK; } + + listenerPromise.get().streamCreated(stream, methodName, headers); + return Status.OK; } @Override @@ -97,9 +130,7 @@ void notifyShutdown(Status status) { @Override @GuardedBy("this") void notifyTerminated() { - if (serverTransportListener != null) { - serverTransportListener.transportTerminated(); - } + listenerPromise.runWhenSet(ServerTransportListener::transportTerminated); } @Override @@ -115,7 +146,7 @@ public synchronized void shutdownNow(Status reason) { @Override @Nullable @GuardedBy("this") - protected Inbound createInbound(int callId) { + protected Inbound createInbound(int callId) { return new Inbound.ServerInbound(this, attributes, callId); } diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java index 904b7e83001..2b7aa97bfd9 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java +++ b/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java @@ -45,14 +45,15 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; -import javax.annotation.concurrent.ThreadSafe; /** * Base class for binder-based gRPC transport. @@ -74,8 +75,9 @@ * *

IMPORTANT: This implementation must comply with this published wire format. * https://github.com/grpc/proposal/blob/master/L73-java-binderchannel/wireformat.md + * + *

This class is thread-safe. */ -@ThreadSafe public abstract class BinderTransport implements IBinder.DeathRecipient { private static final Logger logger = Logger.getLogger(BinderTransport.class.getName()); @@ -157,15 +159,19 @@ protected enum TransportState { private final ObjectPool executorServicePool; private final ScheduledExecutorService scheduledExecutorService; private final InternalLogId logId; + @GuardedBy("this") private final LeakSafeOneWayBinder incomingBinder; - protected final ConcurrentHashMap> ongoingCalls; + protected final ConcurrentHashMap> ongoingCalls; protected final OneWayBinderProxy.Decorator binderDecorator; @GuardedBy("this") private final LinkedHashSet callIdsToNotifyWhenReady = new LinkedHashSet<>(); + @GuardedBy("this") + private final List> ownedFutures = new ArrayList<>(); // To cancel upon terminate. + @GuardedBy("this") protected Attributes attributes; @@ -249,6 +255,13 @@ void releaseExecutors() { executorServicePool.returnObject(scheduledExecutorService); } + // Registers the specified future for eventual safe cancellation upon shutdown/terminate. + @GuardedBy("this") + protected final > T register(T future) { + ownedFutures.add(future); + return future; + } + @GuardedBy("this") boolean inState(TransportState transportState) { return this.transportState == transportState; @@ -265,6 +278,14 @@ final void setState(TransportState newState) { transportState = newState; } + /** + * Sets the binder to use for sending subsequent transactions to our peer. + * + *

Subclasses should call this as early as possible but not from a constructor. + * + *

Returns true for success, false if the process hosting 'binder' is already dead. Callers are + * responsible for handling this. + */ @GuardedBy("this") protected boolean setOutgoingBinder(OneWayBinderProxy binder) { binder = binderDecorator.decorate(binder); @@ -297,15 +318,23 @@ final void shutdownInternal(Status shutdownStatus, boolean forceTerminate) { incomingBinder.detach(); setState(TransportState.SHUTDOWN_TERMINATED); sendShutdownTransaction(); - ArrayList> calls = new ArrayList<>(ongoingCalls.values()); + ArrayList> calls = new ArrayList<>(ongoingCalls.values()); ongoingCalls.clear(); + ArrayList> futuresToCancel = new ArrayList<>(ownedFutures); + ownedFutures.clear(); scheduledExecutorService.execute( () -> { - for (Inbound inbound : calls) { + for (Inbound inbound : calls) { synchronized (inbound) { inbound.closeAbnormal(shutdownStatus); } } + + for (Future future : futuresToCancel) { + // Not holding any locks here just in case some listener runs on a direct Executor. + future.cancel(false); // No effect if already isDone(). + } + synchronized (this) { notifyTerminated(); } @@ -363,7 +392,7 @@ protected synchronized void sendPing(int id) throws StatusException { } } - protected void unregisterInbound(Inbound inbound) { + protected void unregisterInbound(Inbound inbound) { unregisterCall(inbound.callId); } @@ -452,13 +481,13 @@ private boolean handleTransactionInternal(int code, Parcel parcel) { } } else { int size = parcel.dataSize(); - Inbound inbound = ongoingCalls.get(code); + Inbound inbound = ongoingCalls.get(code); if (inbound == null) { synchronized (this) { if (!isShutdown()) { inbound = createInbound(code); if (inbound != null) { - Inbound existing = ongoingCalls.put(code, inbound); + Inbound existing = ongoingCalls.put(code, inbound); // Can't happen as only one invocation of handleTransaction() is running at a time. Verify.verify(existing == null, "impossible appearance of %s", existing); } @@ -490,7 +519,7 @@ protected void restrictIncomingBinderToCallsFrom(int allowedCallingUid) { @Nullable @GuardedBy("this") - protected Inbound createInbound(int callId) { + protected Inbound createInbound(int callId) { return null; } @@ -537,7 +566,7 @@ final void handleAcknowledgedBytes(long numBytes) { Iterator i = callIdsToNotifyWhenReady.iterator(); while (isReady() && i.hasNext()) { - Inbound inbound = ongoingCalls.get(i.next()); + Inbound inbound = ongoingCalls.get(i.next()); i.remove(); if (inbound != null) { // Calls can be removed out from under us. inbound.onTransportReady(); @@ -569,7 +598,7 @@ private static void checkTransition(TransportState current, TransportState next) } @VisibleForTesting - Map> getOngoingCalls() { + Map> getOngoingCalls() { return ongoingCalls; } diff --git a/binder/src/main/java/io/grpc/binder/internal/BlockPool.java b/binder/src/main/java/io/grpc/binder/internal/BlockPool.java index 3c58abdd80b..985e465ab4b 100644 --- a/binder/src/main/java/io/grpc/binder/internal/BlockPool.java +++ b/binder/src/main/java/io/grpc/binder/internal/BlockPool.java @@ -40,7 +40,7 @@ final class BlockPool { * The size of each standard block. (Currently 16k) The block size must be at least as large as * the maximum header list size. */ - private static final int BLOCK_SIZE = Math.max(16 * 1024, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE); + static final int BLOCK_SIZE = Math.max(16 * 1024, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE); /** * Maximum number of blocks to keep around. (Max 128k). This limit is a judgement call. 128k is diff --git a/binder/src/main/java/io/grpc/binder/internal/Inbound.java b/binder/src/main/java/io/grpc/binder/internal/Inbound.java index 9b9dfeef5ce..83fc8273d6f 100644 --- a/binder/src/main/java/io/grpc/binder/internal/Inbound.java +++ b/binder/src/main/java/io/grpc/binder/internal/Inbound.java @@ -42,9 +42,10 @@ * *

Out-of-order messages are reassembled into their correct order. */ -abstract class Inbound implements StreamListener.MessageProducer { +abstract class Inbound + implements StreamListener.MessageProducer { - protected final BinderTransport transport; + protected final T transport; protected final Attributes attributes; final int callId; @@ -145,7 +146,7 @@ enum State { @GuardedBy("this") private boolean producingMessages; - private Inbound(BinderTransport transport, Attributes attributes, int callId) { + private Inbound(T transport, Attributes attributes, int callId) { this.transport = transport; this.attributes = attributes; this.callId = callId; @@ -399,6 +400,13 @@ private void handleMessageData(int flags, int index, Parcel parcel) throws Statu numBytes = parcel.dataPosition() - startPos; } else { numBytes = parcel.readInt(); + if (numBytes > parcel.dataAvail()) { + throw Status.INTERNAL + .withDescription( + "Message size is larger than remaining parcel size: " + + numBytes + " > " + parcel.dataAvail()) + .asException(); + } block = BlockPool.acquireBlock(numBytes); if (numBytes > 0) { parcel.readByteArray(block); @@ -551,7 +559,7 @@ public synchronized String toString() { // ====================================== // Client-side inbound transactions. - static final class ClientInbound extends Inbound { + static final class ClientInbound extends Inbound { private final boolean countsForInUse; @@ -564,7 +572,10 @@ static final class ClientInbound extends Inbound { private Metadata trailers; ClientInbound( - BinderTransport transport, Attributes attributes, int callId, boolean countsForInUse) { + BinderClientTransport transport, + Attributes attributes, + int callId, + boolean countsForInUse) { super(transport, attributes, callId); this.countsForInUse = countsForInUse; } @@ -608,13 +619,9 @@ protected void deliverCloseAbnormal(Status status) { // ====================================== // Server-side inbound transactions. - static final class ServerInbound extends Inbound { - - private final BinderServerTransport serverTransport; - + static final class ServerInbound extends Inbound { ServerInbound(BinderServerTransport transport, Attributes attributes, int callId) { super(transport, attributes, callId); - this.serverTransport = transport; } @GuardedBy("this") @@ -623,17 +630,16 @@ protected void handlePrefix(int flags, Parcel parcel) throws StatusException { String methodName = parcel.readString(); Metadata headers = MetadataHelper.readMetadata(parcel, attributes); - StatsTraceContext statsTraceContext = - serverTransport.createStatsTraceContext(methodName, headers); + StatsTraceContext statsTraceContext = transport.createStatsTraceContext(methodName, headers); Outbound.ServerOutbound outbound = - new Outbound.ServerOutbound(serverTransport, callId, statsTraceContext); + new Outbound.ServerOutbound(transport, callId, statsTraceContext); ServerStream stream; if ((flags & TransactionUtils.FLAG_EXPECT_SINGLE_MESSAGE) != 0) { stream = new SingleMessageServerStream(this, outbound, attributes); } else { stream = new MultiMessageServerStream(this, outbound, attributes); } - Status status = serverTransport.startStream(stream, methodName, headers); + Status status = transport.startStream(stream, methodName, headers); if (status.isOk()) { checkNotNull(listener); // Is it ok to assume this will happen synchronously? if (transport.isReady()) { diff --git a/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java b/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java index 67859045dba..5a3c9fcc986 100644 --- a/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java +++ b/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java @@ -20,6 +20,7 @@ import android.content.Intent; import com.google.common.collect.ImmutableSet; import io.grpc.NameResolver; +import io.grpc.Uri; import io.grpc.NameResolver.Args; import io.grpc.NameResolverProvider; import io.grpc.binder.AndroidComponentAddress; @@ -46,7 +47,17 @@ public String getDefaultScheme() { @Override public NameResolver newNameResolver(URI targetUri, final Args args) { if (Objects.equals(targetUri.getScheme(), ANDROID_INTENT_SCHEME)) { - return new IntentNameResolver(parseUriArg(targetUri), args); + return new IntentNameResolver(parseUriArg(targetUri.toString()), args); + } else { + return null; + } + } + + @Nullable + @Override + public NameResolver newNameResolver(Uri targetUri, final Args args) { + if (Objects.equals(targetUri.getScheme(), ANDROID_INTENT_SCHEME)) { + return new IntentNameResolver(parseUriArg(targetUri.toString()), args); } else { return null; } @@ -67,9 +78,9 @@ public ImmutableSet> getProducedSocketAddressType return ImmutableSet.of(AndroidComponentAddress.class); } - private static Intent parseUriArg(URI targetUri) { + private static Intent parseUriArg(String targetUri) { try { - return Intent.parseUri(targetUri.toString(), URI_INTENT_SCHEME); + return Intent.parseUri(targetUri, URI_INTENT_SCHEME); } catch (URISyntaxException e) { throw new IllegalArgumentException(e); } diff --git a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java index 3351736108e..4b6bf7d06fb 100644 --- a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java +++ b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java @@ -102,6 +102,9 @@ public String methodName() { private State reportedState; // Only used on the main thread. + @GuardedBy("this") + private ComponentName connectedServiceName; + @AnyThread ServiceBinding( Executor mainThreadExecutor, @@ -305,6 +308,26 @@ private void clearReferences() { sourceContext = null; } + @AnyThread + @Override + public ServiceInfo getConnectedServiceInfo() throws StatusException { + try { + return getContextForTargetUser("cross-user v2 handshake") + .getPackageManager() + .getServiceInfo(getConnectedServiceName(), /* flags= */ 0); + } catch (PackageManager.NameNotFoundException e) { + throw Status.UNIMPLEMENTED + .withCause(e) + .withDescription("connected remote service was uninstalled/disabled during handshake") + .asException(); + } + } + + private synchronized ComponentName getConnectedServiceName() { + checkState(connectedServiceName != null, "onBound() not yet called!"); + return connectedServiceName; + } + @Override @MainThread public void onServiceConnected(ComponentName className, IBinder binder) { @@ -312,6 +335,7 @@ public void onServiceConnected(ComponentName className, IBinder binder) { synchronized (this) { if (state == State.BINDING) { state = State.BOUND; + connectedServiceName = className; bound = true; } } diff --git a/binder/src/main/java/io/grpc/binder/internal/SimplePromise.java b/binder/src/main/java/io/grpc/binder/internal/SimplePromise.java new file mode 100644 index 00000000000..c7d227fbf64 --- /dev/null +++ b/binder/src/main/java/io/grpc/binder/internal/SimplePromise.java @@ -0,0 +1,97 @@ +/* + * 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.binder.internal; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import java.util.ArrayList; +import java.util.List; + +/** + * Placeholder for an object that will be provided later. + * + *

Similar to {@link com.google.common.util.concurrent.SettableFuture}, except it cannot fail or + * be cancelled. Most importantly, this class guarantees that {@link Listener}s run one-at-a-time + * and in the same order that they were scheduled. This conveniently matches the expectations of + * most listener interfaces in the io.grpc universe. + * + *

Not safe for concurrent use by multiple threads. Thread-compatible for callers that provide + * synchronization externally. + */ +public class SimplePromise { + private T value; + private List> pendingListeners; // Allocated lazily in the hopes it's never needed. + + /** + * Provides the promised object and runs any pending listeners. + * + * @throws IllegalStateException if this method has already been called + * @throws RuntimeException if some pending listener threw when we tried to run it + */ + public void set(T value) { + checkNotNull(value, "value"); + checkState(this.value == null, "Already set!"); + this.value = value; + if (pendingListeners != null) { + for (Listener listener : pendingListeners) { + listener.notify(value); + } + pendingListeners = null; + } + } + + /** + * Returns the promised object, under the assumption that it's already been set. + * + *

Compared to {@link #runWhenSet(Listener)}, this method may be a more efficient way to access + * the promised value in the case where you somehow know externally that {@link #set(T)} has + * "happened-before" this call. + * + * @throws IllegalStateException if {@link #set(T)} has not yet been called + */ + public T get() { + checkState(value != null, "Not yet set!"); + return value; + } + + /** + * Runs the given listener when this promise is fulfilled, or immediately if already fulfilled. + * + * @throws RuntimeException if already fulfilled and 'listener' threw when we tried to run it + */ + public void runWhenSet(Listener listener) { + if (value != null) { + listener.notify(value); + } else { + if (pendingListeners == null) { + pendingListeners = new ArrayList<>(); + } + pendingListeners.add(listener); + } + } + + /** + * An object that wants to get notified when a SimplePromise has been fulfilled. + */ + public interface Listener { + /** + * Indicates that the associated SimplePromise has been fulfilled with the given `value`. + */ + void notify(T value); + } +} diff --git a/binder/src/test/java/io/grpc/binder/internal/BinderServerTransportTest.java b/binder/src/test/java/io/grpc/binder/internal/BinderServerTransportTest.java index e7e73e6d4b0..d261ce43c8c 100644 --- a/binder/src/test/java/io/grpc/binder/internal/BinderServerTransportTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/BinderServerTransportTest.java @@ -20,17 +20,21 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; import static org.robolectric.Shadows.shadowOf; import android.os.IBinder; import android.os.Looper; import android.os.Parcel; +import android.os.RemoteException; import com.google.common.collect.ImmutableList; import io.grpc.Attributes; +import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.internal.FixedObjectPool; import io.grpc.internal.MockServerTransportListener; +import io.grpc.internal.ObjectPool; +import java.util.List; import java.util.concurrent.ScheduledExecutorService; import org.junit.Before; import org.junit.Rule; @@ -60,26 +64,106 @@ public final class BinderServerTransportTest { @Before public void setUp() throws Exception { - transport = - new BinderServerTransport( - new FixedObjectPool<>(executorService), - Attributes.EMPTY, - ImmutableList.of(), - OneWayBinderProxy.IDENTITY_DECORATOR, - mockBinder); transportListener = new MockServerTransportListener(transport); } + // Provide defaults so that we can "include only relevant details in tests." + BinderServerTransportBuilder newBinderServerTransportBuilder() { + return new BinderServerTransportBuilder() + .setExecutorServicePool(new FixedObjectPool<>(executorService)) + .setAttributes(Attributes.EMPTY) + .setStreamTracerFactories(ImmutableList.of()) + .setBinderDecorator(OneWayBinderProxy.IDENTITY_DECORATOR) + .setCallbackBinder(mockBinder); + } + @Test - public void testSetupTransactionFailureCausesMultipleShutdowns_b153460678() throws Exception { + public void testSetupTransactionFailureReportsMultipleTerminations_b153460678() throws Exception { // Make the binder fail the setup transaction. - when(mockBinder.transact(anyInt(), any(Parcel.class), isNull(), anyInt())).thenReturn(false); - transport.setServerTransportListener(transportListener); + doThrow(new RemoteException()) + .when(mockBinder) + .transact(anyInt(), any(Parcel.class), isNull(), anyInt()); + transport = newBinderServerTransportBuilder().setCallbackBinder(mockBinder).build(); + shadowOf(Looper.getMainLooper()).idle(); + transport.start(transportListener); + + // Now shut it down externally *before* executing Runnables scheduled on the executor. + transport.shutdownNow(Status.UNKNOWN.withDescription("reasons")); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(transportListener.isTerminated()).isTrue(); + } + + @Test + public void testClientBinderIsDeadOnArrival() throws Exception { + transport = newBinderServerTransportBuilder() + .setCallbackBinder(new FakeDeadBinder()) + .build(); + transport.start(transportListener); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(transportListener.isTerminated()).isTrue(); + } + + @Test + public void testStartAfterShutdownAndIdle() throws Exception { + transport = newBinderServerTransportBuilder().build(); + transport.shutdownNow(Status.UNKNOWN.withDescription("reasons")); + shadowOf(Looper.getMainLooper()).idle(); + transport.start(transportListener); + shadowOf(Looper.getMainLooper()).idle(); - // Now shut it down. + assertThat(transportListener.isTerminated()).isTrue(); + } + + @Test + public void testStartAfterShutdownNoIdle() throws Exception { + transport = newBinderServerTransportBuilder().build(); transport.shutdownNow(Status.UNKNOWN.withDescription("reasons")); + transport.start(transportListener); shadowOf(Looper.getMainLooper()).idle(); assertThat(transportListener.isTerminated()).isTrue(); } + + static class BinderServerTransportBuilder { + ObjectPool executorServicePool; + Attributes attributes; + List streamTracerFactories; + OneWayBinderProxy.Decorator binderDecorator; + IBinder callbackBinder; + + public BinderServerTransport build() { + return BinderServerTransport.create( + executorServicePool, attributes, streamTracerFactories, binderDecorator, callbackBinder); + } + + public BinderServerTransportBuilder setExecutorServicePool( + ObjectPool executorServicePool) { + this.executorServicePool = executorServicePool; + return this; + } + + public BinderServerTransportBuilder setAttributes(Attributes attributes) { + this.attributes = attributes; + return this; + } + + public BinderServerTransportBuilder setStreamTracerFactories( + List streamTracerFactories) { + this.streamTracerFactories = streamTracerFactories; + return this; + } + + public BinderServerTransportBuilder setBinderDecorator( + OneWayBinderProxy.Decorator binderDecorator) { + this.binderDecorator = binderDecorator; + return this; + } + + public BinderServerTransportBuilder setCallbackBinder(IBinder callbackBinder) { + this.callbackBinder = callbackBinder; + return this; + } + } } diff --git a/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java b/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java index aa75ba84b09..2809a72fee1 100644 --- a/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java @@ -30,6 +30,7 @@ import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.NameResolverProvider; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.binder.ApiConstants; import java.net.URI; import org.junit.Before; @@ -70,18 +71,32 @@ public void testProviderScheme_returnsIntentScheme() throws Exception { @Test public void testNoResolverForUnknownScheme_returnsNull() throws Exception { - assertThat(provider.newNameResolver(new URI("random://uri"), args)).isNull(); + assertThat(provider.newNameResolver(Uri.create("random://uri"), args)).isNull(); } @Test public void testResolutionWithBadUri_throwsIllegalArg() throws Exception { assertThrows( IllegalArgumentException.class, - () -> provider.newNameResolver(new URI("intent:xxx#Intent;e.x=1;end;"), args)); + () -> provider.newNameResolver(Uri.create("intent:xxx#Intent;e.x=1;end;"), args)); } @Test public void testResolverForIntentScheme_returnsResolver() throws Exception { + Uri uri = Uri.create("intent:#Intent;action=action;end"); + NameResolver resolver = provider.newNameResolver(uri, args); + assertThat(resolver).isNotNull(); + assertThat(resolver.getServiceAuthority()).isEqualTo("localhost"); + syncContext.execute(() -> resolver.start(mockListener)); + shadowOf(getMainLooper()).idle(); + verify(mockListener).onResult2(resultCaptor.capture()); + assertThat(resultCaptor.getValue().getAddressesOrError()).isNotNull(); + syncContext.execute(resolver::shutdown); + shadowOf(getMainLooper()).idle(); + } + + @Test + public void testResolverForIntentScheme_returnsResolver_javaNetUri() throws Exception { URI uri = new URI("intent://authority/path#Intent;action=action;scheme=scheme;end"); NameResolver resolver = provider.newNameResolver(uri, args); assertThat(resolver).isNotNull(); diff --git a/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java b/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java index d3d73f0e9eb..63c47bf4f19 100644 --- a/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java @@ -18,13 +18,17 @@ import static android.os.IBinder.FLAG_ONEWAY; import static android.os.Process.myUid; +import static com.google.common.truth.Truth.assertAbout; import static com.google.common.truth.Truth.assertThat; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static io.grpc.StatusSubject.status; import static io.grpc.binder.internal.BinderTransport.REMOTE_UID; import static io.grpc.binder.internal.BinderTransport.SETUP_TRANSPORT; import static io.grpc.binder.internal.BinderTransport.SHUTDOWN_TRANSPORT; import static io.grpc.binder.internal.BinderTransport.WIRE_FORMAT_VERSION; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assume.assumeTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -43,26 +47,35 @@ import androidx.test.core.content.pm.ApplicationInfoBuilder; import androidx.test.core.content.pm.PackageInfoBuilder; import com.google.common.collect.ImmutableList; +import com.google.common.truth.TruthJUnit; import io.grpc.Attributes; +import io.grpc.CallOptions; import io.grpc.InternalChannelz.SocketStats; +import io.grpc.Metadata; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.binder.AndroidComponentAddress; import io.grpc.binder.ApiConstants; import io.grpc.binder.AsyncSecurityPolicy; import io.grpc.binder.SecurityPolicies; +import io.grpc.binder.internal.OneWayBinderProxies.*; import io.grpc.binder.internal.SettableAsyncSecurityPolicy.AuthRequest; import io.grpc.internal.AbstractTransportTest; +import io.grpc.internal.ClientStream; +import io.grpc.internal.ClientStreamListenerBase; import io.grpc.internal.ClientTransport; import io.grpc.internal.ClientTransportFactory.ClientTransportOptions; import io.grpc.internal.ConnectionClientTransport; +import io.grpc.internal.DisconnectError; import io.grpc.internal.GrpcUtil; import io.grpc.internal.InternalServer; import io.grpc.internal.ManagedClientTransport; import io.grpc.internal.MockServerTransportListener; import io.grpc.internal.ObjectPool; import io.grpc.internal.SharedResourcePool; +import java.io.InputStream; import java.util.List; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import org.junit.Before; @@ -98,6 +111,9 @@ @LooperMode(Mode.INSTRUMENTATION_TEST) public final class RobolectricBinderTransportTest extends AbstractTransportTest { + static final int SERVER_APP_UID = 11111; + static final int EPHEMERAL_SERVER_UID = 22222; // UID of isolated server process. + private final Application application = ApplicationProvider.getApplicationContext(); private final ObjectPool executorServicePool = SharedResourcePool.forResource(GrpcUtil.TIMER_SERVICE); @@ -110,20 +126,29 @@ public final class RobolectricBinderTransportTest extends AbstractTransportTest @Mock AsyncSecurityPolicy mockClientSecurityPolicy; - @Captor - ArgumentCaptor statusCaptor; + @Captor ArgumentCaptor statusCaptor; ApplicationInfo serverAppInfo; PackageInfo serverPkgInfo; ServiceInfo serviceInfo; private int nextServerAddress; - - @Parameter public boolean preAuthServersParam; - - @Parameters(name = "preAuthServersParam={0}") - public static ImmutableList data() { - return ImmutableList.of(true, false); + private BlockingBinderDecorator blockingDecorator = + new BlockingBinderDecorator<>(); + + @Parameter(value = 0) + public boolean preAuthServersParam; + + @Parameter(value = 1) + public boolean useLegacyAuthStrategy; + + @Parameters(name = "preAuthServersParam={0};useLegacyAuthStrategy={1}") + public static ImmutableList data() { + return ImmutableList.of( + new Object[] {false, false}, + new Object[] {false, true}, + new Object[] {true, false}, + new Object[] {true, true}); } @Override @@ -153,27 +178,34 @@ public void requestRealisticBindServiceBehavior() { shadowOf(application).setUnbindServiceCallsOnServiceDisconnected(false); } - @Override - protected InternalServer newServer(List streamTracerFactories) { + BinderServer.Builder newServerBuilder() { AndroidComponentAddress listenAddr = AndroidComponentAddress.forBindIntent( new Intent() .setClassName(serviceInfo.packageName, serviceInfo.name) .setAction("io.grpc.action.BIND." + nextServerAddress++)); - BinderServer binderServer = - new BinderServer.Builder() - .setListenAddress(listenAddr) - .setExecutorPool(serverExecutorPool) - .setExecutorServicePool(executorServicePool) - .setStreamTracerFactories(streamTracerFactories) - .build(); + return new BinderServer.Builder() + .setListenAddress(listenAddr) + .setExecutorPool(serverExecutorPool) + .setExecutorServicePool(executorServicePool) + .setStreamTracerFactories(List.of()); + } + void registerServerWithRobolectric(BinderServer server) { + AndroidComponentAddress listenAddr = (AndroidComponentAddress) server.getListenSocketAddress(); shadowOf(application.getPackageManager()).addServiceIfNotPresent(listenAddr.getComponent()); shadowOf(application) .setComponentNameAndServiceForBindServiceForIntent( - listenAddr.asBindIntent(), listenAddr.getComponent(), binderServer.getHostBinder()); - return binderServer; + listenAddr.asBindIntent(), listenAddr.getComponent(), server.getHostBinder()); + } + + @Override + protected InternalServer newServer(List streamTracerFactories) { + BinderServer server = + newServerBuilder().setStreamTracerFactories(streamTracerFactories).build(); + registerServerWithRobolectric(server); + return server; } @Override @@ -189,6 +221,7 @@ protected InternalServer newServer( BinderClientTransportFactory.Builder newClientTransportFactoryBuilder() { return new BinderClientTransportFactory.Builder() .setPreAuthorizeServers(preAuthServersParam) + .setUseLegacyAuthStrategy(useLegacyAuthStrategy) .setSourceContext(application) .setScheduledExecutorPool(executorServicePool) .setOffloadExecutorPool(offloadExecutorPool); @@ -223,9 +256,9 @@ public void clientAuthorizesServerUidsInOrder() throws Exception { // lets us fake value this *globally*. So the ShadowBinder#setCallingUid() here unrealistically // affects the server's view of the client's uid too. For now this doesn't matter because this // test never exercises server SecurityPolicy. - ShadowBinder.setCallingUid(11111); // UID of the server *process*. + ShadowBinder.setCallingUid(EPHEMERAL_SERVER_UID); - serverPkgInfo.applicationInfo.uid = 22222; // UID of the server *app*, which can be different. + serverPkgInfo.applicationInfo.uid = SERVER_APP_UID; shadowOf(application.getPackageManager()).installPackage(serverPkgInfo); shadowOf(application.getPackageManager()).addOrUpdateService(serviceInfo); server = newServer(ImmutableList.of()); @@ -243,13 +276,17 @@ public void clientAuthorizesServerUidsInOrder() throws Exception { if (preAuthServersParam) { AuthRequest preAuthRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_MS, MILLISECONDS); - assertThat(preAuthRequest.uid).isEqualTo(22222); + assertThat(preAuthRequest.uid).isEqualTo(SERVER_APP_UID); verify(mockClientTransportListener, never()).transportReady(); preAuthRequest.setResult(Status.OK); } AuthRequest authRequest = securityPolicy.takeNextAuthRequest(TIMEOUT_MS, MILLISECONDS); - assertThat(authRequest.uid).isEqualTo(11111); + if (useLegacyAuthStrategy) { + assertThat(authRequest.uid).isEqualTo(EPHEMERAL_SERVER_UID); + } else { + assertThat(authRequest.uid).isEqualTo(SERVER_APP_UID); + } verify(mockClientTransportListener, never()).transportReady(); authRequest.setResult(Status.OK); @@ -320,6 +357,10 @@ public void clientIgnoresDuplicateSetupTransaction() throws Exception { @Test public void clientIgnoresTransactionFromNonServerUids() throws Exception { server.start(serverListener); + + // This test is not applicable to the new auth strategy which keeps the client Binder a secret. + assumeTrue(useLegacyAuthStrategy); + client = newClientTransport(server); startTransport(client, mockClientTransportListener); @@ -336,7 +377,7 @@ public void clientIgnoresTransactionFromNonServerUids() throws Exception { sendShutdownTransportTransactionAsUid(client, serverUid); verify(mockClientTransportListener, timeout(TIMEOUT_MS)) - .transportShutdown(statusCaptor.capture()); + .transportShutdown(statusCaptor.capture(), any(DisconnectError.class)); assertThat(statusCaptor.getValue().getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(statusCaptor.getValue().getDescription()).contains("shutdown"); } @@ -353,6 +394,32 @@ static void sendShutdownTransportTransactionAsUid(ClientTransport client, int se } } + @Test + public void clientReportsAuthzErrorToServer() throws Exception { + server.start(serverListener); + client = + newClientTransportBuilder() + .setFactory( + newClientTransportFactoryBuilder() + .setSecurityPolicy(SecurityPolicies.permissionDenied("test")) + .buildClientTransportFactory()) + .build(); + runIfNotNull(client.start(mockClientTransportListener)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)) + .transportShutdown(statusCaptor.capture(), any(DisconnectError.class)); + assertThat(statusCaptor.getValue().getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + + // Client doesn't tell the server in this case by design -- we don't even want to start it! + TruthJUnit.assume().that(preAuthServersParam).isFalse(); + // Similar story here. The client won't send a setup transaction to an unauthorized server. + TruthJUnit.assume().that(useLegacyAuthStrategy).isTrue(); + + MockServerTransportListener serverTransportListener = + serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS); + serverTransportListener.waitForTermination(TIMEOUT_MS, MILLISECONDS); + assertThat(serverTransportListener.isTerminated()).isTrue(); + } + @Test @Override // We don't quite pass the official/abstract version of this test yet because @@ -384,4 +451,248 @@ public void flowControlPushBack() {} @Ignore("See BinderTransportTest#serverAlreadyListening") @Override public void serverAlreadyListening() {} + + @Test + public void singleTxnMsgsDeliveredToServerOutOfOrder() throws Exception { + server.start(serverListener); + client = + newClientTransportBuilder() + .setFactory( + newClientTransportFactoryBuilder() + .setBinderDecorator(blockingDecorator) + .buildClientTransportFactory()) + .build(); + runIfNotNull(client.start(mockClientTransportListener)); + blockingDecorator.putNextResult(takeNextBinder(blockingDecorator)); // Endpoint binder. + QueueingOneWayBinderProxy queueingServerProxy = + new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator)); // Server binder. + blockingDecorator.putNextResult(queueingServerProxy); + + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady(); + + ClientStream stream = + client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers); + ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase(); + stream.start(clientStreamListener); + stream.writeMessage(methodDescriptor.streamRequest("one")); + stream.writeMessage(methodDescriptor.streamRequest("two")); + stream.halfClose(); + + // Expect one transaction for headers, one for each message, and one for half-close. + QueueingOneWayBinderProxy.Transaction txHeaders = takeNextTransaction(queueingServerProxy); + QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingServerProxy); + QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingServerProxy); + QueueingOneWayBinderProxy.Transaction txHalfClose = takeNextTransaction(queueingServerProxy); + + // Deliver messages out of order! + queueingServerProxy.deliver(txHeaders); + queueingServerProxy.deliver(tx2); + queueingServerProxy.deliver(tx1); + queueingServerProxy.deliver(txHalfClose); + + MockServerTransportListener serverTransportListener = + serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS); + MockServerTransportListener.StreamCreation serverStreamCreation = + serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS); + serverStreamCreation.stream.request(2); + + // Expect the server to deliver the messages in the order they were originally sent. + InputStream msg1 = takeNextMessage(serverStreamCreation.listener.messageQueue); + assertThat(methodDescriptor.parseResponse(msg1)).isEqualTo("one"); + + InputStream msg2 = takeNextMessage(serverStreamCreation.listener.messageQueue); + assertThat(methodDescriptor.parseResponse(msg2)).isEqualTo("two"); + + assertThat(serverStreamCreation.listener.awaitHalfClosed(TIMEOUT_MS, MILLISECONDS)).isTrue(); + serverStreamCreation.stream.close(Status.OK, new Metadata()); + + assertAbout(status()).that(clientStreamListener.awaitClose(TIMEOUT_MS, MILLISECONDS)).isOk(); + assertAbout(status()) + .that(serverStreamCreation.listener.awaitClose(TIMEOUT_MS, MILLISECONDS)) + .isOk(); + } + + @Test + public void msgFragmentsDeliveredToServerOutOfOrder() throws Exception { + server.start(serverListener); + client = + newClientTransportBuilder() + .setFactory( + newClientTransportFactoryBuilder() + .setBinderDecorator(blockingDecorator) + .buildClientTransportFactory()) + .build(); + runIfNotNull(client.start(mockClientTransportListener)); + blockingDecorator.putNextResult(takeNextBinder(blockingDecorator)); // Endpoint binder. + QueueingOneWayBinderProxy queueingServerProxy = + new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator)); // Server binder. + blockingDecorator.putNextResult(queueingServerProxy); + + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady(); + + ClientStream stream = + client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers); + ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase(); + stream.start(clientStreamListener); + + String largeMessage = newStringOfLength(BlockPool.BLOCK_SIZE + 1); + stream.writeMessage(methodDescriptor.streamRequest(largeMessage)); + stream.halfClose(); + + // Expect the client to split largeMessage into two transactions, plus headers and half-close. + QueueingOneWayBinderProxy.Transaction txHeaders = takeNextTransaction(queueingServerProxy); + QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingServerProxy); + QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingServerProxy); + QueueingOneWayBinderProxy.Transaction txHalfClose = takeNextTransaction(queueingServerProxy); + + // Deliver fragments out of order! + queueingServerProxy.deliver(txHeaders); + queueingServerProxy.deliver(tx2); + queueingServerProxy.deliver(tx1); + queueingServerProxy.deliver(txHalfClose); + + // Verify that the server reassembles the transactions correctly. + MockServerTransportListener serverTransportListener = + serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS); + MockServerTransportListener.StreamCreation serverStreamCreation = + serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS); + serverStreamCreation.stream.request(1); + InputStream msg = takeNextMessage(serverStreamCreation.listener.messageQueue); + assertThat(methodDescriptor.parseResponse(msg)).isEqualTo(largeMessage); + + assertThat(serverStreamCreation.listener.awaitHalfClosed(TIMEOUT_MS, MILLISECONDS)).isTrue(); + serverStreamCreation.stream.close(Status.OK, new Metadata()); + + assertAbout(status()).that(clientStreamListener.awaitClose(TIMEOUT_MS, MILLISECONDS)).isOk(); + assertAbout(status()) + .that(serverStreamCreation.listener.awaitClose(TIMEOUT_MS, MILLISECONDS)) + .isOk(); + } + + @Test + public void singleTxnMsgsDeliveredToClientOutOfOrder() throws Exception { + server = newServerBuilder().setClientBinderDecorator(blockingDecorator).build(); + registerServerWithRobolectric((BinderServer) server); + server.start(serverListener); + + client = newClientTransport(server); + runIfNotNull(client.start(mockClientTransportListener)); + + QueueingOneWayBinderProxy queueingClientProxy = + new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator)); + blockingDecorator.putNextResult(queueingClientProxy); + + // Deliver the setup transaction without interference. + queueingClientProxy.deliver(takeNextTransaction(queueingClientProxy)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady(); + + ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase(); + ClientStream stream = + client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers); + stream.start(clientStreamListener); + stream.halfClose(); + stream.request(2); + + MockServerTransportListener serverTransportListener = + serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS); + MockServerTransportListener.StreamCreation serverStreamCreation = + serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS); + + serverStreamCreation.stream.writeMessage(methodDescriptor.streamResponse("one")); + serverStreamCreation.stream.writeMessage(methodDescriptor.streamResponse("two")); + serverStreamCreation.stream.close(Status.OK, new Metadata()); + + // Expect one transaction from the server for each message. + QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingClientProxy); + QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingClientProxy); + QueueingOneWayBinderProxy.Transaction txClose = takeNextTransaction(queueingClientProxy); + + // Deliver messages to the client out of order! + queueingClientProxy.deliver(tx2); + queueingClientProxy.deliver(tx1); + queueingClientProxy.deliver(txClose); + + // Client should deliver messages to the application in the order sent. + InputStream msg1 = takeNextMessage(clientStreamListener.messageQueue); + assertThat(methodDescriptor.parseResponse(msg1)).isEqualTo("one"); + InputStream msg2 = takeNextMessage(clientStreamListener.messageQueue); + assertThat(methodDescriptor.parseResponse(msg2)).isEqualTo("two"); + + assertAbout(status()).that(clientStreamListener.awaitClose(TIMEOUT_MS, MILLISECONDS)).isOk(); + assertAbout(status()) + .that(serverStreamCreation.listener.awaitClose(TIMEOUT_MS, MILLISECONDS)) + .isOk(); + } + + @Test + public void msgFragmentsDeliveredToClientOutOfOrder() throws Exception { + server = newServerBuilder().setClientBinderDecorator(blockingDecorator).build(); + registerServerWithRobolectric((BinderServer) server); + server.start(serverListener); + + client = newClientTransport(server); + runIfNotNull(client.start(mockClientTransportListener)); + + QueueingOneWayBinderProxy queueingClientProxy = + new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator)); + blockingDecorator.putNextResult(queueingClientProxy); + + // Deliver the setup transaction without interference. + queueingClientProxy.deliver(takeNextTransaction(queueingClientProxy)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady(); + + ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase(); + ClientStream stream = + client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers); + stream.start(clientStreamListener); + stream.request(1); + + MockServerTransportListener serverTransportListener = + serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS); + MockServerTransportListener.StreamCreation serverStreamCreation = + serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS); + + String largeMessage = newStringOfLength(BlockPool.BLOCK_SIZE + 1); + serverStreamCreation.stream.writeMessage(methodDescriptor.streamResponse(largeMessage)); + serverStreamCreation.stream.flush(); + + // Expect the client to split largeMessage into two transactions. + QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingClientProxy); + QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingClientProxy); + + // Deliver them to the client out of order! + queueingClientProxy.deliver(tx2); + queueingClientProxy.deliver(tx1); + + // Client should reassemble the message correctly. + InputStream msg = takeNextMessage(clientStreamListener.messageQueue); + assertThat(methodDescriptor.parseResponse(msg)).isEqualTo(largeMessage); + } + + private static OneWayBinderProxy takeNextBinder( + BlockingBinderDecorator decorator) throws InterruptedException { + OneWayBinderProxy proxy = decorator.takeNextRequest(TIMEOUT_MS, MILLISECONDS); + assertThat(proxy).isNotNull(); + return proxy; + } + + private static QueueingOneWayBinderProxy.Transaction takeNextTransaction( + QueueingOneWayBinderProxy proxy) throws InterruptedException { + QueueingOneWayBinderProxy.Transaction tx = proxy.pollNextTransaction(TIMEOUT_MS, MILLISECONDS); + assertThat(tx).isNotNull(); + return tx; + } + + private static InputStream takeNextMessage(BlockingQueue messageQueue) + throws InterruptedException { + InputStream msg = messageQueue.poll(TIMEOUT_MS, MILLISECONDS); + assertThat(msg).isNotNull(); + return msg; + } + + private static String newStringOfLength(int numChars) { + char[] chars = new char[numChars]; + java.util.Arrays.fill(chars, 'x'); + return new String(chars); + } } diff --git a/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java b/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java index 0875881dcc5..0f57b6f8a30 100644 --- a/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java @@ -52,7 +52,6 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowApplication; -import org.robolectric.shadows.ShadowDevicePolicyManager; @RunWith(RobolectricTestRunner.class) public final class ServiceBindingTest { @@ -116,6 +115,32 @@ public void testBind() throws Exception { assertThat(binding.isSourceContextCleared()).isFalse(); } + @Test + public void testGetConnectedServiceInfo() throws Exception { + binding = newBuilder().setTargetComponent(serviceComponent).build(); + binding.bind(); + shadowOf(getMainLooper()).idle(); + + assertThat(observer.gotBoundEvent).isTrue(); + + ServiceInfo serviceInfo = binding.getConnectedServiceInfo(); + assertThat(serviceInfo.name).isEqualTo(serviceComponent.getClassName()); + assertThat(serviceInfo.packageName).isEqualTo(serviceComponent.getPackageName()); + } + + @Test + public void testGetConnectedServiceInfoThrows() throws Exception { + binding = newBuilder().setTargetComponent(serviceComponent).build(); + binding.bind(); + shadowOf(getMainLooper()).idle(); + + assertThat(observer.gotBoundEvent).isTrue(); + shadowOf(appContext.getPackageManager()).removeService(serviceComponent); + + StatusException se = assertThrows(StatusException.class, binding::getConnectedServiceInfo); + assertThat(se.getStatus().getCode()).isEqualTo(Code.UNIMPLEMENTED); + } + @Test public void testBindingIntent() throws Exception { shadowApplication.setComponentNameAndServiceForBindService(null, null); @@ -365,12 +390,12 @@ public void testBindServiceAsUser_returnsErrorWhenSdkBelowR() { @Test @Config(sdk = 28) public void testDevicePolicyBlind_returnsErrorWhenSdkBelowR() { - String deviceAdminClassName = "DevicePolicyAdmin"; - ComponentName adminComponent = new ComponentName(appContext, deviceAdminClassName); - allowBindDeviceAdminForUser(appContext, adminComponent, 10); + ComponentName adminComponent = new ComponentName(appContext, "DevicePolicyAdmin"); + UserHandle user10 = generateUserHandle(/* userId= */ 10); + allowBindDeviceAdminForUser(appContext, adminComponent, user10); binding = newBuilder() - .setTargetUserHandle(UserHandle.getUserHandleForUid(10)) + .setTargetUserHandle(user10) .setChannelCredentials(BinderChannelCredentials.forDevicePolicyAdmin(adminComponent)) .build(); binding.bind(); @@ -384,13 +409,13 @@ public void testDevicePolicyBlind_returnsErrorWhenSdkBelowR() { @Test @Config(sdk = 30) public void testBindWithDeviceAdmin() throws Exception { - String deviceAdminClassName = "DevicePolicyAdmin"; - ComponentName adminComponent = new ComponentName(appContext, deviceAdminClassName); - allowBindDeviceAdminForUser(appContext, adminComponent, /* userId= */ 0); + ComponentName adminComponent = new ComponentName(appContext, "DevicePolicyAdmin"); + UserHandle user0 = generateUserHandle(/* userId= */ 0); + allowBindDeviceAdminForUser(appContext, adminComponent, user0); binding = newBuilder() - .setTargetUserHandle(UserHandle.getUserHandleForUid(/* uid= */ 0)) - .setTargetUserHandle(generateUserHandle(/* userId= */ 0)) + .setTargetUserHandle(user0) + .setTargetComponent(serviceComponent) .setChannelCredentials(BinderChannelCredentials.forDevicePolicyAdmin(adminComponent)) .build(); shadowOf(getMainLooper()).idle(); @@ -403,6 +428,10 @@ public void testBindWithDeviceAdmin() throws Exception { assertThat(observer.binder).isSameInstanceAs(mockBinder); assertThat(observer.gotUnboundEvent).isFalse(); assertThat(binding.isSourceContextCleared()).isFalse(); + + ServiceInfo serviceInfo = binding.getConnectedServiceInfo(); + assertThat(serviceInfo.name).isEqualTo(serviceComponent.getClassName()); + assertThat(serviceInfo.packageName).isEqualTo(serviceComponent.getPackageName()); } private void assertNoLockHeld() { @@ -418,15 +447,10 @@ private void assertNoLockHeld() { } private static void allowBindDeviceAdminForUser( - Context context, ComponentName admin, int userId) { - ShadowDevicePolicyManager devicePolicyManager = - shadowOf(context.getSystemService(DevicePolicyManager.class)); - devicePolicyManager.setDeviceOwner(admin); - devicePolicyManager.setBindDeviceAdminTargetUsers( - Arrays.asList(UserHandle.getUserHandleForUid(userId))); - shadowOf((DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE)); - devicePolicyManager.setDeviceOwner(admin); - devicePolicyManager.setBindDeviceAdminTargetUsers(Arrays.asList(generateUserHandle(userId))); + Context context, ComponentName admin, UserHandle user) { + DevicePolicyManager devicePolicyManager = context.getSystemService(DevicePolicyManager.class); + shadowOf(devicePolicyManager).setBindDeviceAdminTargetUsers(Arrays.asList(user)); + shadowOf(devicePolicyManager).setDeviceOwner(admin); } /** Generate UserHandles the hard way. */ diff --git a/binder/src/test/java/io/grpc/binder/internal/SimplePromiseTest.java b/binder/src/test/java/io/grpc/binder/internal/SimplePromiseTest.java new file mode 100644 index 00000000000..6486ff5e8a1 --- /dev/null +++ b/binder/src/test/java/io/grpc/binder/internal/SimplePromiseTest.java @@ -0,0 +1,143 @@ +/* + * 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.binder.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import io.grpc.binder.internal.SimplePromise.Listener; +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.InOrder; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public final class SimplePromiseTest { + + private static final String FULFILLED_VALUE = "a fulfilled value"; + + @Mock private Listener mockListener1; + @Mock private Listener mockListener2; + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private SimplePromise promise = new SimplePromise<>(); + + @Before + public void setUp() { + } + + @Test + public void get_beforeFulfilled_throws() { + IllegalStateException e = assertThrows(IllegalStateException.class, () -> promise.get()); + assertThat(e).hasMessageThat().isEqualTo("Not yet set!"); + } + + @Test + public void get_afterFulfilled_returnsValue() { + promise.set(FULFILLED_VALUE); + assertThat(promise.get()).isEqualTo(FULFILLED_VALUE); + } + + @Test + public void set_withNull_throws() { + assertThrows(NullPointerException.class, () -> promise.set(null)); + } + + @Test + public void set_calledTwice_throws() { + promise.set(FULFILLED_VALUE); + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> promise.set("another value")); + assertThat(e).hasMessageThat().isEqualTo("Already set!"); + } + + @Test + public void runWhenSet_beforeFulfill_listenerIsNotifiedUponSet() { + promise.runWhenSet(mockListener1); + + // Should not have been called yet. + verify(mockListener1, never()).notify(FULFILLED_VALUE); + + promise.set(FULFILLED_VALUE); + + // Now it should be called. + verify(mockListener1, times(1)).notify(FULFILLED_VALUE); + } + + @Test + public void runWhenSet_afterSet_listenerIsNotifiedImmediately() { + promise.set(FULFILLED_VALUE); + promise.runWhenSet(mockListener1); + + // Should have been called immediately. + verify(mockListener1, times(1)).notify(FULFILLED_VALUE); + } + + @Test + public void multipleListeners_addedBeforeSet_allNotifiedInOrder() { + promise.runWhenSet(mockListener1); + promise.runWhenSet(mockListener2); + + promise.set(FULFILLED_VALUE); + + InOrder inOrder = inOrder(mockListener1, mockListener2); + inOrder.verify(mockListener1).notify(FULFILLED_VALUE); + inOrder.verify(mockListener2).notify(FULFILLED_VALUE); + } + + @Test + public void listenerThrows_duringSet_propagatesException() { + // A listener that will throw when notified. + Listener throwingListener = + (value) -> { + throw new UnsupportedOperationException("Listener failed"); + }; + + promise.runWhenSet(throwingListener); + + // Fulfilling the promise should now throw the exception from the listener. + UnsupportedOperationException e = + assertThrows(UnsupportedOperationException.class, () -> promise.set(FULFILLED_VALUE)); + assertThat(e).hasMessageThat().isEqualTo("Listener failed"); + } + + @Test + public void listenerThrows_whenAddedAfterSet_propagatesException() { + promise.set(FULFILLED_VALUE); + + // A listener that will throw when notified. + Listener throwingListener = + (value) -> { + throw new UnsupportedOperationException("Listener failed"); + }; + + // Running the listener should throw immediately because the promise is already fulfilled. + UnsupportedOperationException e = + assertThrows( + UnsupportedOperationException.class, () -> promise.runWhenSet(throwingListener)); + assertThat(e).hasMessageThat().isEqualTo("Listener failed"); + } +} diff --git a/binder/src/testFixtures/java/io/grpc/binder/internal/FakeDeadBinder.java b/binder/src/testFixtures/java/io/grpc/binder/internal/FakeDeadBinder.java new file mode 100644 index 00000000000..5bce7498c4b --- /dev/null +++ b/binder/src/testFixtures/java/io/grpc/binder/internal/FakeDeadBinder.java @@ -0,0 +1,74 @@ +/* + * 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.binder.internal; + +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.IInterface; +import android.os.Parcel; +import android.os.RemoteException; +import java.io.FileDescriptor; + +/** An {@link IBinder} that behaves as if its hosting process has died, for testing. */ +public class FakeDeadBinder implements IBinder { + @Override + public boolean isBinderAlive() { + return false; + } + + @Override + public IInterface queryLocalInterface(String descriptor) { + return null; + } + + @Override + public String getInterfaceDescriptor() throws RemoteException { + throw new DeadObjectException(); + } + + @Override + public boolean pingBinder() { + return false; + } + + @Override + public void dump(FileDescriptor fd, String[] args) throws RemoteException { + throw new DeadObjectException(); + } + + @Override + public void dumpAsync(FileDescriptor fd, String[] args) throws RemoteException { + throw new DeadObjectException(); + } + + @Override + public boolean transact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + throw new DeadObjectException(); + } + + @Override + public void linkToDeath(DeathRecipient r, int flags) throws RemoteException { + throw new DeadObjectException(); + } + + @Override + public boolean unlinkToDeath(DeathRecipient deathRecipient, int flags) { + // No need to check whether 'deathRecipient' was ever actually passed to linkToDeath(): Per our + // API contract, if "the IBinder has already died" we never throw and always return false. + return false; + } +} diff --git a/binder/src/androidTest/java/io/grpc/binder/internal/OneWayBinderProxies.java b/binder/src/testFixtures/java/io/grpc/binder/internal/OneWayBinderProxies.java similarity index 67% rename from binder/src/androidTest/java/io/grpc/binder/internal/OneWayBinderProxies.java rename to binder/src/testFixtures/java/io/grpc/binder/internal/OneWayBinderProxies.java index 4abdb2c03dd..c7eee06e73a 100644 --- a/binder/src/androidTest/java/io/grpc/binder/internal/OneWayBinderProxies.java +++ b/binder/src/testFixtures/java/io/grpc/binder/internal/OneWayBinderProxies.java @@ -18,6 +18,7 @@ import android.os.RemoteException; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; /** A collection of {@link OneWayBinderProxy}-related test helpers. */ @@ -42,6 +43,18 @@ public OneWayBinderProxy takeNextRequest() throws InterruptedException { return requests.take(); } + /** + * Returns the next {@link OneWayBinderProxy} that needs decorating, blocking for up to the + * specified timeout if it hasn't yet been provided to {@link #decorate}. + * + *

Follow this with a call to {@link #putNextResult(OneWayBinderProxy)} to provide the result + * of {@link #decorate} and unblock the waiting caller. + */ + public OneWayBinderProxy takeNextRequest(long timeout, TimeUnit unit) + throws InterruptedException { + return requests.poll(timeout, unit); + } + /** Provides the next value to return from {@link #decorate}. */ public void putNextResult(T next) throws InterruptedException { results.put(next); @@ -119,6 +132,49 @@ public void transact(int code, ParcelHolder data) throws RemoteException { } } + /** A {@link OneWayBinderProxy} that queues transactions for a test to deliver manually later. */ + public static final class QueueingOneWayBinderProxy extends OneWayBinderProxy { + public static final class Transaction { + public final int code; + private final ParcelHolder parcel; + + public Transaction(int code, ParcelHolder parcel) { + this.code = code; + this.parcel = parcel; + } + } + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final OneWayBinderProxy wrapped; + + public QueueingOneWayBinderProxy(OneWayBinderProxy wrapped) { + super(wrapped.getDelegate()); + this.wrapped = wrapped; + } + + @Override + public void transact(int code, ParcelHolder data) throws RemoteException { + queue.add(new Transaction(code, new ParcelHolder(data.release()))); + } + + /** + * Returns the next transaction that was queued in order, waiting up to the specified timeout. + */ + public Transaction pollNextTransaction(long timeout, TimeUnit unit) + throws InterruptedException { + return queue.poll(timeout, unit); + } + + /** + * Delivers a previously queued transaction to its original destination. + * + * @throws IllegalStateException if transaction was already delivered once before + */ + public void deliver(Transaction transaction) throws RemoteException { + wrapped.transact(transaction.code, transaction.parcel); + } + } + // Cannot be instantiated. private OneWayBinderProxies() {} ; diff --git a/build.gradle b/build.gradle index f80b27a4476..077098fd20e 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ subprojects { apply plugin: "net.ltgt.errorprone" group = "io.grpc" - version = "1.77.0-SNAPSHOT" // CURRENT_GRPC_VERSION + version = "1.83.0-SNAPSHOT" // CURRENT_GRPC_VERSION repositories { maven { // The google mirror is less flaky than mavenCentral() @@ -43,6 +43,7 @@ subprojects { ignoreGradleMetadataRedirection() } } + maven { url 'https://central.sonatype.com/repository/maven-snapshots/' } } tasks.withType(JavaCompile).configureEach { @@ -197,6 +198,25 @@ subprojects { } } + plugins.withId("com.android.base") { + android { + lint { + abortOnError true + if (rootProject.hasProperty('failOnWarnings') && rootProject.failOnWarnings.toBoolean()) { + warningsAsErrors true + } + } + } + tasks.withType(JavaCompile).configureEach { + it.options.compilerArgs += [ + "-Xlint:all" + ] + if (rootProject.hasProperty('failOnWarnings') && rootProject.failOnWarnings.toBoolean()) { + it.options.compilerArgs += ["-Werror"] + } + } + } + plugins.withId("java") { dependencies { testImplementation libraries.junit, @@ -514,5 +534,5 @@ configurations { } } -tasks.register('checkForUpdates', CheckForUpdatesTask, project.configurations.checkForUpdates, "libs") +tasks.register('checkForUpdates', CheckForUpdatesTask, project.configurations.checkForUpdates, "libs", layout.projectDirectory.file("gradle/libs.versions.toml")) diff --git a/buildSrc/src/main/java/io/grpc/gradle/CheckForUpdatesTask.java b/buildSrc/src/main/java/io/grpc/gradle/CheckForUpdatesTask.java index 9d0156a1b72..b7c28dbbb2d 100644 --- a/buildSrc/src/main/java/io/grpc/gradle/CheckForUpdatesTask.java +++ b/buildSrc/src/main/java/io/grpc/gradle/CheckForUpdatesTask.java @@ -16,11 +16,15 @@ package io.grpc.gradle; +import java.io.IOException; +import java.nio.file.Files; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import javax.inject.Inject; import org.gradle.api.DefaultTask; import org.gradle.api.artifacts.Configuration; @@ -32,6 +36,7 @@ import org.gradle.api.artifacts.result.ResolvedComponentResult; import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.artifacts.result.UnresolvedDependencyResult; +import org.gradle.api.file.RegularFile; import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Nested; @@ -45,7 +50,23 @@ public abstract class CheckForUpdatesTask extends DefaultTask { private final Set libraries; @Inject - public CheckForUpdatesTask(Configuration updateConf, String catalog) { + public CheckForUpdatesTask(Configuration updateConf, String catalog, RegularFile commentFile) + throws IOException { + // Check for overrides to the default version selection ('+'), using comments of the form: + // # checkForUpdates: library-name:1.2.+ + List fileComments = Files.lines(commentFile.getAsFile().toPath()) + .filter(l -> l.matches("# *checkForUpdates:.*")) + .map(l -> l.replaceFirst("# *checkForUpdates:", "").trim()) + .collect(Collectors.toList()); + Map aliasToVersionSelector = new HashMap<>(2*fileComments.size()); + for (String comment : fileComments) { + String[] parts = comment.split(":", 2); + String name = parts[0].replaceAll("[_-]", "."); + if (aliasToVersionSelector.put(name, parts[1]) != null) { + throw new RuntimeException("Duplicate checkForUpdates comment for library: " + name); + } + } + updateConf.setVisible(false); updateConf.setTransitive(false); VersionCatalog versionCatalog = getProject().getExtensions().getByType(VersionCatalogsExtension.class).named(catalog); @@ -59,8 +80,12 @@ public CheckForUpdatesTask(Configuration updateConf, String catalog) { oldConf.getDependencies().add(oldDep); Configuration newConf = updateConf.copy(); + String versionSelector = aliasToVersionSelector.remove(name); + if (versionSelector == null) { + versionSelector = "+"; + } Dependency newDep = getProject().getDependencies().create( - depMap(dep.getGroup(), dep.getName(), "+", "pom")); + depMap(dep.getGroup(), dep.getName(), versionSelector, "pom")); newConf.getDependencies().add(newDep); libraries.add(new Library( @@ -68,6 +93,10 @@ public CheckForUpdatesTask(Configuration updateConf, String catalog) { oldConf.getIncoming().getResolutionResult().getRootComponent(), newConf.getIncoming().getResolutionResult().getRootComponent())); } + if (!aliasToVersionSelector.isEmpty()) { + throw new RuntimeException( + "Unused checkForUpdates comments: " + aliasToVersionSelector.keySet()); + } this.libraries = Collections.unmodifiableSet(libraries); } @@ -96,10 +125,16 @@ public void checkForUpdates() { "- Current version of libs.%s not resolved", name)); continue; } + DependencyResult newResult = lib.getNewResult().get().getDependencies().iterator().next(); + if (newResult instanceof UnresolvedDependencyResult) { + System.out.println(String.format( + "- New version of libs.%s not resolved", name)); + continue; + } ModuleVersionIdentifier oldId = ((ResolvedDependencyResult) oldResult).getSelected().getModuleVersion(); - ModuleVersionIdentifier newId = ((ResolvedDependencyResult) lib.getNewResult().get() - .getDependencies().iterator().next()).getSelected().getModuleVersion(); + ModuleVersionIdentifier newId = + ((ResolvedDependencyResult) newResult).getSelected().getModuleVersion(); if (oldId != newId) { System.out.println(String.format( "libs.%s = %s %s -> %s", diff --git a/buildscripts/build_docker.sh b/buildscripts/build_docker.sh index b34f9a6e542..fa75c07c1eb 100755 --- a/buildscripts/build_docker.sh +++ b/buildscripts/build_docker.sh @@ -4,5 +4,4 @@ set -eu -o pipefail readonly buildscripts_dir="$(dirname "$(readlink -f "$0")")" docker build -t grpc-java-artifacts-x86 "$buildscripts_dir"/grpc-java-artifacts docker build -t grpc-java-artifacts-multiarch -f "$buildscripts_dir"/grpc-java-artifacts/Dockerfile.multiarch.base "$buildscripts_dir"/grpc-java-artifacts -docker build -t grpc-java-artifacts-ubuntu2004 -f "$buildscripts_dir"/grpc-java-artifacts/Dockerfile.ubuntu2004.base "$buildscripts_dir"/grpc-java-artifacts diff --git a/buildscripts/cloudbuild-testing.yaml b/buildscripts/cloudbuild-testing.yaml new file mode 100644 index 00000000000..623b85b6882 --- /dev/null +++ b/buildscripts/cloudbuild-testing.yaml @@ -0,0 +1,64 @@ +substitutions: + _GAE_SERVICE_ACCOUNT: appengine-testing-java@grpc-testing.iam.gserviceaccount.com +options: + env: + - BUILD_ID=$BUILD_ID + - KOKORO_GAE_SERVICE=java-gae-interop-test + - DUMMY_DEFAULT_VERSION=dummy-default + - GRADLE_OPTS=-Dorg.gradle.jvmargs='-Xmx1g' + - GRADLE_FLAGS=-PskipCodegen=true -PskipAndroid=true + logging: CLOUD_LOGGING_ONLY + machineType: E2_HIGHCPU_8 + +steps: +- id: clean-stale-deploys + name: gcr.io/cloud-builders/gcloud + allowFailure: true + script: | + #!/usr/bin/env bash + set -e + echo "Cleaning out stale deploys from previous runs, it is ok if this part fails" + # If the test fails, the deployment is leaked. + # Delete all versions whose name is not 'dummy-default' and is older than 1 hour. + # This expression is an ISO8601 relative date: + # https://cloud.google.com/sdk/gcloud/reference/topic/datetimes + (gcloud app versions list --format="get(version.id)" \ + --filter="service=$KOKORO_GAE_SERVICE AND NOT version : '$DUMMY_DEFAULT_VERSION' AND version.createTime<'-p1h'" \ + | xargs -i gcloud app services delete "$KOKORO_GAE_SERVICE" --version {} --quiet) || true + +- name: gcr.io/cloud-builders/docker + args: ['build', '-t', 'gae-build', 'buildscripts/gae-build/'] + +- id: build + name: gae-build + script: | + #!/usr/bin/env bash + exec ./gradlew $GRADLE_FLAGS :grpc-gae-interop-testing-jdk8:appengineStage + +- id: deploy + name: gcr.io/cloud-builders/gcloud + args: + - app + - deploy + - gae-interop-testing/gae-jdk8/build/staged-app/app.yaml + - --service-account=$_GAE_SERVICE_ACCOUNT + - --no-promote + - --no-stop-previous-version + - --version=cb-$BUILD_ID + +- id: runInteropTestRemote + name: eclipse-temurin:17-jdk + env: + - PROJECT_ID=$PROJECT_ID + script: | + #!/usr/bin/env bash + exec ./gradlew $GRADLE_FLAGS --stacktrace -PgaeDeployVersion="cb-$BUILD_ID" \ + -PgaeProjectId="$PROJECT_ID" :grpc-gae-interop-testing-jdk8:runInteropTestRemote + +- id: cleanup + name: gcr.io/cloud-builders/gcloud + script: | + #!/usr/bin/env bash + set -e + echo "Performing cleanup now." + gcloud app services delete "$KOKORO_GAE_SERVICE" --version "cb-$BUILD_ID" --quiet diff --git a/buildscripts/gae-build/Dockerfile b/buildscripts/gae-build/Dockerfile new file mode 100644 index 00000000000..7e68b270801 --- /dev/null +++ b/buildscripts/gae-build/Dockerfile @@ -0,0 +1,10 @@ +FROM eclipse-temurin:17-jdk + +# The AppEngine Gradle plugin downloads and runs its own gcloud to get the .jar +# to link against, so we need Python even if we use gcloud deploy directly +# instead of using the plugin. +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends python3 && \ + rm -rf /var/lib/apt/lists/* diff --git a/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base b/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base index da2c46904ca..b2f5625afe3 100644 --- a/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base +++ b/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 +FROM ubuntu:20.04 RUN export DEBIAN_FRONTEND=noninteractive && \ apt-get update && \ @@ -9,7 +9,8 @@ RUN export DEBIAN_FRONTEND=noninteractive && \ curl \ g++-aarch64-linux-gnu \ g++-powerpc64le-linux-gnu \ - openjdk-8-jdk \ + g++-s390x-linux-gnu \ + openjdk-11-jdk \ pkg-config \ && \ rm -rf /var/lib/apt/lists/* diff --git a/buildscripts/grpc-java-artifacts/Dockerfile.ubuntu2004.base b/buildscripts/grpc-java-artifacts/Dockerfile.ubuntu2004.base deleted file mode 100644 index e987fb3e684..00000000000 --- a/buildscripts/grpc-java-artifacts/Dockerfile.ubuntu2004.base +++ /dev/null @@ -1,19 +0,0 @@ -FROM ubuntu:20.04 - -RUN export DEBIAN_FRONTEND=noninteractive && \ - apt-get update && \ - apt-get upgrade -y && \ - apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - curl \ - g++-s390x-linux-gnu \ - openjdk-8-jdk \ - pkg-config \ - && \ - rm -rf /var/lib/apt/lists/* - -RUN curl -Ls https://github.com/Kitware/CMake/releases/download/v3.26.3/cmake-3.26.3-linux-x86_64.tar.gz | \ - tar xz -C /var/local -ENV PATH /var/local/cmake-3.26.3-linux-x86_64/bin:$PATH - diff --git a/buildscripts/kokoro/android.sh b/buildscripts/kokoro/android.sh index 5af55b2f551..f9dc3842f7c 100755 --- a/buildscripts/kokoro/android.sh +++ b/buildscripts/kokoro/android.sh @@ -19,7 +19,9 @@ cat <> gradle.properties org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m EOF -export ANDROID_HOME=/tmp/Android/Sdk +# https://bazel.build/versions/9.1.0/docs/sandboxing gives each worker its own +# isolated and ephemeral /tmp. Install the Android SDK anywhere but there. +export ANDROID_HOME=$HOME/Android/Sdk mkdir -p "${ANDROID_HOME}/cmdline-tools" curl -Ls -o cmdline.zip \ "https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip" @@ -27,6 +29,27 @@ unzip -qd "${ANDROID_HOME}/cmdline-tools" cmdline.zip rm cmdline.zip mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" (yes || true) | "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses + +# Bazel/android_rules requires build-tools at least version 35.0.0 and, unlike +# gradle, won't download anything for itself. +# TODO(jdcormie): Keep this version in sync with AGP's default and any +# `buildToolsVersion` config. +"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install "build-tools;35.0.1" + +# TODO(jdcormie): Use the same SDK version as build.gradle's compileSdkVersion. +"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install "platforms;android-34" + +# Bazelisk takes care of installing Bazel. +mkdir -p /tmp/bazelisk +curl -Ls -o /tmp/bazelisk/bazelisk https://github.com/bazelbuild/bazelisk/releases/download/v1.19.0/bazelisk-linux-amd64 +chmod +x /tmp/bazelisk/bazelisk +export PATH=/tmp/bazelisk:$PATH + +export USE_BAZEL_VERSION=9.1.0 +bazelisk build \ + //android \ + //binder + curl -Ls https://github.com/Kitware/CMake/releases/download/v3.26.3/cmake-3.26.3-linux-x86_64.tar.gz | \ tar xz -C /tmp export PATH=/tmp/cmake-3.26.3-linux-x86_64/bin:$PATH @@ -132,15 +155,18 @@ fi # Update the statuses with the deltas +set +x gsutil cp gs://grpc-testing-secrets/github_credentials/oauth_token.txt ~/ desc="New DEX reference count: $(printf "%'d" "$new_dex_count") (delta: $(printf "%'d" "$dex_count_delta"))" +echo "Setting status: $desc" curl -f -s -X POST -H "Content-Type: application/json" \ -H "Authorization: token $(cat ~/oauth_token.txt | tr -d '\n')" \ -d '{"state": "success", "context": "android/dex_diff", "description": "'"${desc}"'"}' \ "https://api.github.com/repos/grpc/grpc-java/statuses/${KOKORO_GITHUB_PULL_REQUEST_COMMIT}" desc="New APK size in bytes: $(printf "%'d" "$new_apk_size") (delta: $(printf "%'d" "$apk_size_delta"))" +echo "Setting status: $desc" curl -f -s -X POST -H "Content-Type: application/json" \ -H "Authorization: token $(cat ~/oauth_token.txt | tr -d '\n')" \ -d '{"state": "success", "context": "android/apk_diff", "description": "'"${desc}"'"}' \ diff --git a/buildscripts/kokoro/gae-interop.sh b/buildscripts/kokoro/gae-interop.sh deleted file mode 100755 index c4ce56cac52..00000000000 --- a/buildscripts/kokoro/gae-interop.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -set -exu -o pipefail -if [[ -f /VERSION ]]; then - cat /VERSION -fi - -KOKORO_GAE_SERVICE="java-gae-interop-test" - -# We deploy as different versions of a single service, this way any stale -# lingering deploys can be easily cleaned up by purging all running versions -# of this service. -KOKORO_GAE_APP_VERSION=$(hostname) - -# A dummy version that can be the recipient of all traffic, so that the kokoro test version can be -# set to 0 traffic. This is a requirement in order to delete it. -DUMMY_DEFAULT_VERSION='dummy-default' - -function cleanup() { - echo "Performing cleanup now." - gcloud app services delete $KOKORO_GAE_SERVICE --version $KOKORO_GAE_APP_VERSION --quiet -} -trap cleanup SIGHUP SIGINT SIGTERM EXIT - -readonly GRPC_JAVA_DIR="$(cd "$(dirname "$0")"/../.. && pwd)" -cd "$GRPC_JAVA_DIR" - -## -## Deploy the dummy 'default' version of the service -## -GRADLE_FLAGS="--stacktrace -DgaeStopPreviousVersion=false -PskipCodegen=true -PskipAndroid=true" -export GRADLE_OPTS="-Dorg.gradle.jvmargs='-Xmx1g'" - -# Deploy the dummy 'default' version. We only require that it exists when cleanup() is called. -# It ok if we race with another run and fail here, because the end result is idempotent. -set +e -if ! gcloud app versions describe "$DUMMY_DEFAULT_VERSION" --service="$KOKORO_GAE_SERVICE"; then - ./gradlew $GRADLE_FLAGS -DgaeDeployVersion="$DUMMY_DEFAULT_VERSION" -DgaePromote=true :grpc-gae-interop-testing-jdk8:appengineDeploy -else - echo "default version already exists: $DUMMY_DEFAULT_VERSION" -fi -set -e - -# Deploy and test the real app (jdk8) -./gradlew $GRADLE_FLAGS -DgaeDeployVersion="$KOKORO_GAE_APP_VERSION" :grpc-gae-interop-testing-jdk8:runInteropTestRemote - -set +e -echo "Cleaning out stale deploys from previous runs, it is ok if this part fails" - -# Sometimes the trap based cleanup fails. -# Delete all versions whose name is not 'dummy-default' and is older than 1 hour. -# This expression is an ISO8601 relative date: -# https://cloud.google.com/sdk/gcloud/reference/topic/datetimes -gcloud app versions list --format="get(version.id)" --filter="service=$KOKORO_GAE_SERVICE AND NOT version : 'dummy-default' AND version.createTime<'-p1h'" | xargs -i gcloud app services delete "$KOKORO_GAE_SERVICE" --version {} --quiet -exit 0 diff --git a/buildscripts/kokoro/linux_artifacts.sh b/buildscripts/kokoro/linux_artifacts.sh index 49d9932dfa0..778826b2fc2 100755 --- a/buildscripts/kokoro/linux_artifacts.sh +++ b/buildscripts/kokoro/linux_artifacts.sh @@ -17,7 +17,5 @@ trap spongify_logs EXIT SKIP_TESTS=true ARCH=aarch_64 /grpc-java/buildscripts/kokoro/unix.sh "$GRPC_JAVA_DIR"/buildscripts/run_in_docker.sh grpc-java-artifacts-multiarch env \ SKIP_TESTS=true ARCH=ppcle_64 /grpc-java/buildscripts/kokoro/unix.sh -# Use a newer GCC version. GCC 7 in multiarch has a bug: -# internal compiler error: output_operand: invalid %-code -"$GRPC_JAVA_DIR"/buildscripts/run_in_docker.sh grpc-java-artifacts-ubuntu2004 env \ +"$GRPC_JAVA_DIR"/buildscripts/run_in_docker.sh grpc-java-artifacts-multiarch env \ SKIP_TESTS=true ARCH=s390_64 /grpc-java/buildscripts/kokoro/unix.sh diff --git a/buildscripts/kokoro/macos.cfg b/buildscripts/kokoro/macos.cfg index a58691a7102..4c79743692e 100644 --- a/buildscripts/kokoro/macos.cfg +++ b/buildscripts/kokoro/macos.cfg @@ -2,7 +2,7 @@ # Location of the continuous shell script in repository. build_file: "grpc-java/buildscripts/kokoro/macos.sh" -timeout_mins: 45 +timeout_mins: 60 # We always build mvn artifacts. action { diff --git a/buildscripts/kokoro/macos.sh b/buildscripts/kokoro/macos.sh index 018d15dd2f9..58b042fc7d4 100755 --- a/buildscripts/kokoro/macos.sh +++ b/buildscripts/kokoro/macos.sh @@ -1,5 +1,6 @@ #!/bin/bash set -veux -o pipefail +CMAKE_VERSION=3.31.10 if [[ -f /VERSION ]]; then cat /VERSION @@ -7,6 +8,13 @@ fi readonly GRPC_JAVA_DIR="$(cd "$(dirname "$0")"/../.. && pwd)" +DOWNLOAD_DIR=/tmp/source +mkdir -p ${DOWNLOAD_DIR} +curl -Ls https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-macos-universal.tar.gz | tar xz -C ${DOWNLOAD_DIR} + +curl -Ls https://archive.apache.org/dist/maven/maven-3/3.8.8/binaries/apache-maven-3.8.8-bin.tar.gz | + tar xz -C "${DOWNLOAD_DIR}" + # We had problems with random tests timing out because it took seconds to do # trivial (ns) operations. The Kokoro Mac machines have 2 cores with 4 logical # threads, so Gradle should be using 4 workers by default. @@ -15,7 +23,7 @@ export GRADLE_FLAGS="${GRADLE_FLAGS:-} --max-workers=2" . "$GRPC_JAVA_DIR"/buildscripts/kokoro/kokoro.sh trap spongify_logs EXIT -export -n JAVA_HOME -export PATH="$(/usr/libexec/java_home -v"1.8.0")/bin:${PATH}" +export PATH="/Library/Java/JavaVirtualMachines/jdk-11-latest/Contents/Home/bin:${DOWNLOAD_DIR}/cmake-${CMAKE_VERSION}-macos-universal/CMake.app/Contents/bin:${DOWNLOAD_DIR}/apache-maven-3.8.8/bin:${PATH}" +unset JAVA_HOME -"$GRPC_JAVA_DIR"/buildscripts/kokoro/unix.sh +ARCH=aarch_64 "$GRPC_JAVA_DIR"/buildscripts/kokoro/unix.sh diff --git a/buildscripts/kokoro/psm-regional_td.cfg b/buildscripts/kokoro/psm-regional_td.cfg new file mode 100644 index 00000000000..60c7258e2da --- /dev/null +++ b/buildscripts/kokoro/psm-regional_td.cfg @@ -0,0 +1,17 @@ +# Config file for internal CI + +# Location of the continuous shell script in repository. +build_file: "grpc-java/buildscripts/kokoro/psm-interop-test-java.sh" +timeout_mins: 30 + +action { + define_artifacts { + regex: "artifacts/**/*sponge_log.xml" + regex: "artifacts/**/*.log" + strip_prefix: "artifacts" + } +} +env_vars { + key: "PSM_TEST_SUITE" + value: "regional_td" +} diff --git a/buildscripts/kokoro/unix.sh b/buildscripts/kokoro/unix.sh index 455d9c07199..693768a0270 100755 --- a/buildscripts/kokoro/unix.sh +++ b/buildscripts/kokoro/unix.sh @@ -38,6 +38,7 @@ ARCH="$ARCH" buildscripts/make_dependencies.sh # Set properties via flags, do not pollute gradle.properties GRADLE_FLAGS="${GRADLE_FLAGS:-}" +GRADLE_FLAGS+=" --stacktrace" GRADLE_FLAGS+=" -PtargetArch=$ARCH" # For universal binaries on macOS, signal Gradle to use universal flags. diff --git a/buildscripts/kokoro/upload_artifacts.sh b/buildscripts/kokoro/upload_artifacts.sh index 39e27eff522..85b267e2329 100644 --- a/buildscripts/kokoro/upload_artifacts.sh +++ b/buildscripts/kokoro/upload_artifacts.sh @@ -41,9 +41,9 @@ LOCAL_OTHER_ARTIFACTS="$KOKORO_GFILE_DIR"/github/grpc-java/artifacts/ [[ "$(find "$LOCAL_MVN_ARTIFACTS" -type f -iname 'protoc-gen-grpc-java-*-linux-s390_64.exe' | wc -l)" != '0' ]] # from macos job: -[[ "$(find "$LOCAL_MVN_ARTIFACTS" -type f -iname 'protoc-gen-grpc-java-*-osx-x86_64.exe' | wc -l)" != '0' ]] -# copy all x86 artifacts to aarch until native artifacts are built -find "$LOCAL_MVN_ARTIFACTS" -type f -iname 'protoc-gen-grpc-java-*-osx-x86_64.exe*' -exec bash -c 'cp "${0}" "${0/x86/aarch}"' {} \; +[[ "$(find "$LOCAL_MVN_ARTIFACTS" -type f -iname 'protoc-gen-grpc-java-*-osx-aarch_64.exe' | wc -l)" != '0' ]] +# copy the universal binaries to x86, as they work on both architectures +find "$LOCAL_MVN_ARTIFACTS" -type f -iname 'protoc-gen-grpc-java-*-osx-aarch_64.exe*' -exec bash -c 'cp "${0}" "${0/aarch/x86}"' {} \; # from windows job: [[ "$(find "$LOCAL_MVN_ARTIFACTS" -type f -iname 'protoc-gen-grpc-java-*-windows-x86_64.exe' | wc -l)" != '0' ]] diff --git a/buildscripts/kokoro/windows32.bat b/buildscripts/kokoro/windows32.bat index f87899d0ab8..90c74a57f1a 100644 --- a/buildscripts/kokoro/windows32.bat +++ b/buildscripts/kokoro/windows32.bat @@ -25,7 +25,7 @@ cd "%WORKSPACE%" SET TARGET_ARCH=x86_32 SET FAIL_ON_WARNINGS=true -SET PROTOBUF_VER=22.5 +SET PROTOBUF_VER=35.1 SET PKG_CONFIG_PATH=%ESCWORKSPACE%\\grpc-java-helper32\\protobuf-%PROTOBUF_VER%\\build\\protobuf-%PROTOBUF_VER%\\lib\\pkgconfig SET VC_PROTOBUF_LIBS=/LIBPATH:%ESCWORKSPACE%\\grpc-java-helper32\\protobuf-%PROTOBUF_VER%\\build\\protobuf-%PROTOBUF_VER%\\lib SET VC_PROTOBUF_INCLUDE=%ESCWORKSPACE%\\grpc-java-helper32\\protobuf-%PROTOBUF_VER%\\build\\protobuf-%PROTOBUF_VER%\\include @@ -66,14 +66,16 @@ for /f "tokens=*" %%a in ('pkg-config --libs protobuf') do ( set lib=!lib:~2! @rem remove spaces set lib=!lib: =! - @rem Because protobuf is specified as libprotobuf and elsewhere - if !lib! NEQ protobuf ( + set libprefix=!lib:~0,4! + if !libprefix!==absl ( set lib=!lib!.lib - if "!libs_list!"=="" ( - set libs_list=!lib! - ) else ( - set libs_list=!libs_list!,!lib! - ) + ) else ( + set lib=lib!lib!.lib + ) + if "!libs_list!"=="" ( + set libs_list=!lib! + ) else ( + set libs_list=!libs_list!,!lib! ) ) ) diff --git a/buildscripts/kokoro/windows64.bat b/buildscripts/kokoro/windows64.bat index 0a99f47dd3d..dba07807d03 100644 --- a/buildscripts/kokoro/windows64.bat +++ b/buildscripts/kokoro/windows64.bat @@ -24,7 +24,7 @@ cd "%WORKSPACE%" SET TARGET_ARCH=x86_64 SET FAIL_ON_WARNINGS=true -SET PROTOBUF_VER=22.5 +SET PROTOBUF_VER=35.1 SET PKG_CONFIG_PATH=%ESCWORKSPACE%\\grpc-java-helper64\\protobuf-%PROTOBUF_VER%\\build\\protobuf-%PROTOBUF_VER%\\lib\\pkgconfig SET VC_PROTOBUF_LIBS=/LIBPATH:%ESCWORKSPACE%\\grpc-java-helper64\\protobuf-%PROTOBUF_VER%\\build\\protobuf-%PROTOBUF_VER%\\lib SET VC_PROTOBUF_INCLUDE=%ESCWORKSPACE%\\grpc-java-helper64\\protobuf-%PROTOBUF_VER%\\build\\protobuf-%PROTOBUF_VER%\\include @@ -50,14 +50,16 @@ for /f "tokens=*" %%a in ('pkg-config --libs protobuf') do ( set lib=!lib:~2! @rem remove spaces set lib=!lib: =! - @rem Because protobuf is specified as libprotobuf and elsewhere - if !lib! NEQ protobuf ( + set libprefix=!lib:~0,4! + if !libprefix!==absl ( set lib=!lib!.lib - if "!libs_list!"=="" ( - set libs_list=!lib! - ) else ( - set libs_list=!libs_list!,!lib! - ) + ) else ( + set lib=lib!lib!.lib + ) + if "!libs_list!"=="" ( + set libs_list=!lib! + ) else ( + set libs_list=!libs_list!,!lib! ) ) ) diff --git a/buildscripts/make_dependencies.bat b/buildscripts/make_dependencies.bat index dce08ef7624..4ff775347a5 100644 --- a/buildscripts/make_dependencies.bat +++ b/buildscripts/make_dependencies.bat @@ -1,8 +1,8 @@ choco install -y pkgconfiglite choco install -y openjdk --version=17.0 set PATH=%PATH%;"c:\Program Files\OpenJDK\jdk-17\bin" -set PROTOBUF_VER=22.5 -set ABSL_VERSION=20230125.4 +set PROTOBUF_VER=35.1 +set ABSL_VERSION=20250127.1 set CMAKE_NAME=cmake-3.26.3-windows-x86_64 if not exist "protobuf-%PROTOBUF_VER%\build\Release\" ( @@ -30,7 +30,6 @@ del protobuf.zip powershell -command "$ProgressPreference = 'SilentlyContinue'; $ErrorActionPreference = 'stop'; & { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 ; iwr https://github.com/abseil/abseil-cpp/archive/refs/tags/%ABSL_VERSION%.zip -OutFile absl.zip }" || exit /b 1 powershell -command "$ErrorActionPreference = 'stop'; & { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('absl.zip', '.') }" || exit /b 1 del absl.zip -rmdir protobuf-%PROTOBUF_VER%\third_party\abseil-cpp move abseil-cpp-%ABSL_VERSION% protobuf-%PROTOBUF_VER%\third_party\abseil-cpp mkdir protobuf-%PROTOBUF_VER%\build pushd protobuf-%PROTOBUF_VER%\build @@ -51,7 +50,7 @@ for /f "tokens=4 delims=\" %%a in ("%VCINSTALLDIR%") do ( for /f "tokens=1 delims=." %%a in ("%VisualStudioVersion%") do ( SET visual_studio_major_version=%%a ) -cmake -Dprotobuf_BUILD_TESTS=OFF -DCMAKE_INSTALL_PREFIX=%cd%\protobuf-%PROTOBUF_VER% -DCMAKE_PREFIX_PATH=%cd%\protobuf-%PROTOBUF_VER% -G "Visual Studio %visual_studio_major_version% %VC_YEAR%" %CMAKE_VSARCH% .. || exit /b 1 +cmake -DCMAKE_CXX_STANDARD=17 -DABSL_MSVC_STATIC_RUNTIME=ON -Dprotobuf_BUILD_TESTS=OFF -DCMAKE_INSTALL_PREFIX=%cd%\protobuf-%PROTOBUF_VER% -DCMAKE_PREFIX_PATH=%cd%\protobuf-%PROTOBUF_VER% -G "Visual Studio %visual_studio_major_version% %VC_YEAR%" %CMAKE_VSARCH% .. || exit /b 1 cmake --build . --config Release --target install || exit /b 1 popd goto :eof diff --git a/buildscripts/make_dependencies.sh b/buildscripts/make_dependencies.sh index 73cb54b7b68..600e2d40a1b 100755 --- a/buildscripts/make_dependencies.sh +++ b/buildscripts/make_dependencies.sh @@ -3,54 +3,33 @@ # Build protoc set -evux -o pipefail -PROTOBUF_VERSION=22.5 -ABSL_VERSION=20230125.4 -CMAKE_VERSION=3.26.3 +PROTOBUF_VERSION=35.1 +ABSL_VERSION=20250127.1 # ARCH is x86_64 bit unless otherwise specified. ARCH="${ARCH:-x86_64}" DOWNLOAD_DIR=/tmp/source INSTALL_DIR="/tmp/protobuf-cache/$PROTOBUF_VERSION/$(uname -s)-$ARCH" BUILDSCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" -mkdir -p $DOWNLOAD_DIR -cd "$DOWNLOAD_DIR" - -# Start with a sane default -NUM_CPU=4 -if [[ $(uname) == 'Linux' ]]; then - NUM_CPU=$(nproc) -fi -if [[ $(uname) == 'Darwin' ]]; then - NUM_CPU=$(sysctl -n hw.ncpu) -fi -# Make protoc -# Can't check for presence of directory as cache auto-creates it. -if [ -f ${INSTALL_DIR}/bin/protoc ]; then - echo "Not building protobuf. Already built" -# TODO(ejona): swap to `brew install --devel protobuf` once it is up-to-date -else - if [[ ! -d "protobuf-${PROTOBUF_VERSION}" ]]; then - curl -Ls "https://github.com/google/protobuf/releases/download/v${PROTOBUF_VERSION}/protobuf-${PROTOBUF_VERSION}.tar.gz" | tar xz - curl -Ls "https://github.com/abseil/abseil-cpp/archive/refs/tags/${ABSL_VERSION}.tar.gz" | tar xz - rmdir "protobuf-$PROTOBUF_VERSION/third_party/abseil-cpp" - mv "abseil-cpp-$ABSL_VERSION" "protobuf-$PROTOBUF_VERSION/third_party/abseil-cpp" +function build_and_install() { + if [[ "$1" == "abseil" ]]; then + TESTS_OFF_ARG=ABSL_BUILD_TEST_HELPERS + else + TESTS_OFF_ARG=protobuf_BUILD_TESTS fi - # the same source dir is used for 32 and 64 bit builds, so we need to clean stale data first - rm -rf "$DOWNLOAD_DIR/protobuf-${PROTOBUF_VERSION}/build" - mkdir "$DOWNLOAD_DIR/protobuf-${PROTOBUF_VERSION}/build" - pushd "$DOWNLOAD_DIR/protobuf-${PROTOBUF_VERSION}/build" - # install here so we don't need sudo if [[ "$(uname -s)" == "Darwin" ]]; then cmake .. \ - -DCMAKE_CXX_STANDARD=14 -Dprotobuf_BUILD_TESTS=OFF -DBUILD_SHARED_LIBS=OFF \ - -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" -DABSL_INTERNAL_AT_LEAST_CXX17=0 \ + -DCMAKE_CXX_STANDARD=17 -D${TESTS_OFF_ARG}=OFF -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_PREFIX_PATH="$INSTALL_DIR" \ -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ -B. || exit 1 elif [[ "$ARCH" == x86* ]]; then CFLAGS=-m${ARCH#*_} CXXFLAGS=-m${ARCH#*_} cmake .. \ - -DCMAKE_CXX_STANDARD=14 -Dprotobuf_BUILD_TESTS=OFF -DBUILD_SHARED_LIBS=OFF \ - -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" -DABSL_INTERNAL_AT_LEAST_CXX17=0 \ + -DCMAKE_CXX_STANDARD=17 -D${TESTS_OFF_ARG}=OFF -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_PREFIX_PATH="$INSTALL_DIR" \ -B. || exit 1 else if [[ "$ARCH" == aarch_64 ]]; then @@ -66,17 +45,56 @@ else exit 1 fi cmake .. \ - -DCMAKE_CXX_STANDARD=14 -Dprotobuf_BUILD_TESTS=OFF -DBUILD_SHARED_LIBS=OFF \ - -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" -DABSL_INTERNAL_AT_LEAST_CXX17=0 \ + -DCMAKE_CXX_STANDARD=17 -D${TESTS_OFF_ARG}=OFF -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_PREFIX_PATH="$INSTALL_DIR" \ -Dcrosscompile_ARCH="$GCC_ARCH" \ -DCMAKE_TOOLCHAIN_FILE=$BUILDSCRIPTS_DIR/toolchain.cmake \ -B. || exit 1 fi export CMAKE_BUILD_PARALLEL_LEVEL="$NUM_CPU" cmake --build . || exit 1 + # install here so we don't need sudo cmake --install . || exit 1 - [ -d "$INSTALL_DIR/lib64" ] && mv "$INSTALL_DIR/lib64" "$INSTALL_DIR/lib" +} + +mkdir -p $DOWNLOAD_DIR +cd "$DOWNLOAD_DIR" + +# Start with a sane default +NUM_CPU=4 +if [[ $(uname) == 'Linux' ]]; then + NUM_CPU=$(nproc) +fi +if [[ $(uname) == 'Darwin' ]]; then + NUM_CPU=$(sysctl -n hw.ncpu) +fi +export CMAKE_BUILD_PARALLEL_LEVEL="$NUM_CPU" + +# Make protoc +# Can't check for presence of directory as cache auto-creates it. +if [ -f ${INSTALL_DIR}/bin/protoc ]; then + echo "Not building protobuf. Already built" +# TODO(ejona): swap to `brew install --devel protobuf` once it is up-to-date +else + if [[ ! -d "protobuf-${PROTOBUF_VERSION}" ]]; then + curl -Ls "https://github.com/google/protobuf/releases/download/v${PROTOBUF_VERSION}/protobuf-${PROTOBUF_VERSION}.tar.gz" | tar xz + curl -Ls "https://github.com/abseil/abseil-cpp/archive/refs/tags/${ABSL_VERSION}.tar.gz" | tar xz + fi + # the same source dir is used for 32 and 64 bit builds, so we need to clean stale data first + rm -rf "$DOWNLOAD_DIR/abseil-cpp-${ABSL_VERSION}/build" + mkdir "$DOWNLOAD_DIR/abseil-cpp-${ABSL_VERSION}/build" + pushd "$DOWNLOAD_DIR/abseil-cpp-${ABSL_VERSION}/build" + build_and_install "abseil" + popd + + rm -rf "$DOWNLOAD_DIR/protobuf-${PROTOBUF_VERSION}/build" + mkdir "$DOWNLOAD_DIR/protobuf-${PROTOBUF_VERSION}/build" + pushd "$DOWNLOAD_DIR/protobuf-${PROTOBUF_VERSION}/build" + build_and_install "protobuf" popd + + [ -d "$INSTALL_DIR/lib64" ] && mv "$INSTALL_DIR/lib64" "$INSTALL_DIR/lib" fi # If /tmp/protobuf exists then we just assume it's a symlink created by us. @@ -94,3 +112,4 @@ export CXXFLAGS="$(PKG_CONFIG_PATH=/tmp/protobuf/lib/pkgconfig pkg-config --cfla export LIBRARY_PATH=/tmp/protobuf/lib export LD_LIBRARY_PATH=/tmp/protobuf/lib EOF + diff --git a/compiler/BUILD.bazel b/compiler/BUILD.bazel index 30ff3ce5dff..a9ffe77a55a 100644 --- a/compiler/BUILD.bazel +++ b/compiler/BUILD.bazel @@ -1,5 +1,5 @@ -load("@rules_java//java:defs.bzl", "java_library") load("@rules_cc//cc:defs.bzl", "cc_binary") +load("@rules_java//java:defs.bzl", "java_library") load("@rules_jvm_external//:defs.bzl", "artifact") load("//:java_grpc_library.bzl", "java_rpc_toolchain") @@ -13,6 +13,7 @@ cc_binary( ], visibility = ["//visibility:public"], deps = [ + "@abseil-cpp//absl/strings", "@com_google_protobuf//:protoc_lib", ], ) diff --git a/compiler/build.gradle b/compiler/build.gradle index c5acccebae7..f970f629e19 100644 --- a/compiler/build.gradle +++ b/compiler/build.gradle @@ -76,6 +76,7 @@ model { aarch_64 { architecture "aarch_64" } s390_64 { architecture "s390_64" } loongarch_64 { architecture "loongarch_64" } + riscv_64 { architecture "riscv_64" } } components { @@ -84,6 +85,7 @@ model { 'x86_32', 'x86_64', 'ppcle_64', + 'riscv_64', 'aarch_64', 's390_64', 'loongarch_64' @@ -100,7 +102,7 @@ model { all { if (toolChain in Gcc || toolChain in Clang) { cppCompiler.define("GRPC_VERSION", version) - cppCompiler.args "--std=c++14" + cppCompiler.args "--std=c++17" addEnvArgs("CXXFLAGS", cppCompiler.args) addEnvArgs("CPPFLAGS", cppCompiler.args) if (project.hasProperty('buildUniversal') && @@ -130,7 +132,7 @@ model { } else if (toolChain in VisualCpp) { usingVisualCpp = true cppCompiler.define("GRPC_VERSION", version) - cppCompiler.args "/EHsc", "/MT" + cppCompiler.args "/EHsc", "/MT", "/std:c++17" if (rootProject.hasProperty('vcProtobufInclude')) { cppCompiler.args "/I${rootProject.vcProtobufInclude}" } diff --git a/compiler/src/java_plugin/cpp/java_generator.cpp b/compiler/src/java_plugin/cpp/java_generator.cpp index 659d7ccca47..d0f8cdd13d5 100644 --- a/compiler/src/java_plugin/cpp/java_generator.cpp +++ b/compiler/src/java_plugin/cpp/java_generator.cpp @@ -46,6 +46,7 @@ #include #include #include +#include "absl/strings/escaping.h" #include #include #include @@ -213,7 +214,7 @@ static inline std::string MethodIdFieldName(const MethodDescriptor* method) { } static inline std::string MessageFullJavaName(const Descriptor* desc) { - return protobuf::compiler::java::ClassName(desc); + return protobuf::compiler::java::QualifiedClassName(desc); } // TODO(nmittler): Remove once protobuf includes javadoc methods in distribution. @@ -1050,7 +1051,7 @@ static void PrintGetServiceDescriptorMethod(const ServiceDescriptor* service, (*vars)["proto_base_descriptor_supplier"] = service_name + "BaseDescriptorSupplier"; (*vars)["proto_file_descriptor_supplier"] = service_name + "FileDescriptorSupplier"; (*vars)["proto_method_descriptor_supplier"] = service_name + "MethodDescriptorSupplier"; - (*vars)["proto_class_name"] = protobuf::compiler::java::ClassName(service->file()); + (*vars)["proto_class_name"] = protobuf::compiler::java::QualifiedClassName(service->file()); p->Print( *vars, "private static abstract class $proto_base_descriptor_supplier$\n" @@ -1206,7 +1207,7 @@ static void PrintService(const ServiceDescriptor* service, bool disable_version, GeneratedAnnotation generated_annotation) { (*vars)["service_name"] = service->name(); - (*vars)["file_name"] = service->file()->name(); + (*vars)["file_name"] = absl::Utf8SafeCEscape(service->file()->name()); (*vars)["service_class_name"] = ServiceClassName(service); (*vars)["grpc_version"] = ""; #ifdef GRPC_VERSION @@ -1384,7 +1385,7 @@ void GenerateService(const ServiceDescriptor* service, } std::string ServiceJavaPackage(const FileDescriptor* file) { - std::string result = protobuf::compiler::java::ClassName(file); + std::string result = protobuf::compiler::java::QualifiedClassName(file); size_t last_dot_pos = result.find_last_of('.'); if (last_dot_pos != std::string::npos) { result.resize(last_dot_pos); diff --git a/compiler/src/java_plugin/cpp/java_plugin.cpp b/compiler/src/java_plugin/cpp/java_plugin.cpp index a595a6a6896..4b02d6e9884 100644 --- a/compiler/src/java_plugin/cpp/java_plugin.cpp +++ b/compiler/src/java_plugin/cpp/java_plugin.cpp @@ -58,7 +58,11 @@ class JavaGrpcGenerator : public protobuf::compiler::CodeGenerator { return protobuf::Edition::EDITION_PROTO2; } protobuf::Edition GetMaximumEdition() const override { +#if GOOGLE_PROTOBUF_VERSION >= 6032000 + return protobuf::Edition::EDITION_2024; +#else return protobuf::Edition::EDITION_2023; +#endif } std::vector GetFeatureExtensions() const override { diff --git a/compiler/src/test/golden/TestDeprecatedService.java.txt b/compiler/src/test/golden/TestDeprecatedService.java.txt index f42922c76b2..ccdadf0a24f 100644 --- a/compiler/src/test/golden/TestDeprecatedService.java.txt +++ b/compiler/src/test/golden/TestDeprecatedService.java.txt @@ -8,7 +8,7 @@ import static io.grpc.MethodDescriptor.generateFullMethodName; * */ @javax.annotation.Generated( - value = "by gRPC proto compiler (version 1.77.0-SNAPSHOT)", + value = "by gRPC proto compiler (version 1.83.0-SNAPSHOT)", comments = "Source: grpc/testing/compiler/test.proto") @io.grpc.stub.annotations.GrpcGenerated @java.lang.Deprecated diff --git a/compiler/src/test/golden/TestService.java.txt b/compiler/src/test/golden/TestService.java.txt index 2e8d702a877..8024c06299f 100644 --- a/compiler/src/test/golden/TestService.java.txt +++ b/compiler/src/test/golden/TestService.java.txt @@ -8,7 +8,7 @@ import static io.grpc.MethodDescriptor.generateFullMethodName; * */ @javax.annotation.Generated( - value = "by gRPC proto compiler (version 1.77.0-SNAPSHOT)", + value = "by gRPC proto compiler (version 1.83.0-SNAPSHOT)", comments = "Source: grpc/testing/compiler/test.proto") @io.grpc.stub.annotations.GrpcGenerated public final class TestServiceGrpc { diff --git a/context/BUILD.bazel b/context/BUILD.bazel index fad8e2ef881..0a51dca24a9 100644 --- a/context/BUILD.bazel +++ b/context/BUILD.bazel @@ -1,4 +1,5 @@ load("@rules_java//java:defs.bzl", "java_library") + java_library( name = "context", visibility = ["//visibility:public"], diff --git a/core/src/main/java/io/grpc/internal/AbstractClientStream.java b/core/src/main/java/io/grpc/internal/AbstractClientStream.java index 14fd5888147..bce1820b482 100644 --- a/core/src/main/java/io/grpc/internal/AbstractClientStream.java +++ b/core/src/main/java/io/grpc/internal/AbstractClientStream.java @@ -43,9 +43,9 @@ import javax.annotation.Nullable; /** - * The abstract base class for {@link ClientStream} implementations. Extending classes only need to - * implement {@link #transportState()} and {@link #abstractClientStreamSink()}. Must only be called - * from the sending application thread. + * The abstract base class for {@link ClientStream} implementations. + * + *

Must only be called from the sending application thread. */ public abstract class AbstractClientStream extends AbstractStream implements ClientStream, MessageFramer.Sink { diff --git a/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java b/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java index a257637de22..dcefa8f8351 100644 --- a/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java +++ b/core/src/main/java/io/grpc/internal/AutoConfiguredLoadBalancerFactory.java @@ -19,17 +19,15 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.MoreObjects; import io.grpc.ChannelLogger.ChannelLogLevel; import io.grpc.ConnectivityState; import io.grpc.ConnectivityStateInfo; import io.grpc.LoadBalancer; +import io.grpc.LoadBalancer.FixedResultPicker; import io.grpc.LoadBalancer.Helper; import io.grpc.LoadBalancer.PickResult; -import io.grpc.LoadBalancer.PickSubchannelArgs; import io.grpc.LoadBalancer.ResolvedAddresses; import io.grpc.LoadBalancer.Subchannel; -import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.LoadBalancerProvider; import io.grpc.LoadBalancerRegistry; import io.grpc.NameResolver.ConfigOrError; @@ -40,10 +38,10 @@ import java.util.Map; import javax.annotation.Nullable; -public final class AutoConfiguredLoadBalancerFactory { +public final class AutoConfiguredLoadBalancerFactory extends LoadBalancerProvider { private final LoadBalancerRegistry registry; - private final String defaultPolicy; + private final LoadBalancerProvider defaultProvider; public AutoConfiguredLoadBalancerFactory(String defaultPolicy) { this(LoadBalancerRegistry.getDefaultRegistry(), defaultPolicy); @@ -52,47 +50,34 @@ public AutoConfiguredLoadBalancerFactory(String defaultPolicy) { @VisibleForTesting AutoConfiguredLoadBalancerFactory(LoadBalancerRegistry registry, String defaultPolicy) { this.registry = checkNotNull(registry, "registry"); - this.defaultPolicy = checkNotNull(defaultPolicy, "defaultPolicy"); + LoadBalancerProvider provider = + registry.getProvider(checkNotNull(defaultPolicy, "defaultPolicy")); + if (provider == null) { + Status status = Status.INTERNAL.withDescription("Could not find policy '" + defaultPolicy + + "'. Make sure its implementation is either registered to LoadBalancerRegistry or" + + " included in META-INF/services/io.grpc.LoadBalancerProvider from your jar files."); + provider = new FixedPickerLoadBalancerProvider( + ConnectivityState.TRANSIENT_FAILURE, + new LoadBalancer.FixedResultPicker(PickResult.withError(status)), + status); + } + this.defaultProvider = provider; } + @Override public AutoConfiguredLoadBalancer newLoadBalancer(Helper helper) { return new AutoConfiguredLoadBalancer(helper); } - private static final class NoopLoadBalancer extends LoadBalancer { - - @Override - @Deprecated - @SuppressWarnings("InlineMeSuggester") - public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { - } - - @Override - public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { - return Status.OK; - } - - @Override - public void handleNameResolutionError(Status error) {} - - @Override - public void shutdown() {} - } - @VisibleForTesting - public final class AutoConfiguredLoadBalancer { + public final class AutoConfiguredLoadBalancer extends LoadBalancer { private final Helper helper; private LoadBalancer delegate; private LoadBalancerProvider delegateProvider; AutoConfiguredLoadBalancer(Helper helper) { this.helper = helper; - delegateProvider = registry.getProvider(defaultPolicy); - if (delegateProvider == null) { - throw new IllegalStateException("Could not find policy '" + defaultPolicy - + "'. Make sure its implementation is either registered to LoadBalancerRegistry or" - + " included in META-INF/services/io.grpc.LoadBalancerProvider from your jar files."); - } + this.delegateProvider = defaultProvider; delegate = delegateProvider.newLoadBalancer(helper); } @@ -100,29 +85,20 @@ public final class AutoConfiguredLoadBalancer { * Returns non-OK status if the delegate rejects the resolvedAddresses (e.g. if it does not * support an empty list). */ - Status tryAcceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { + @Override + public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { PolicySelection policySelection = (PolicySelection) resolvedAddresses.getLoadBalancingPolicyConfig(); if (policySelection == null) { - LoadBalancerProvider defaultProvider; - try { - defaultProvider = getProviderOrThrow(defaultPolicy, "using default policy"); - } catch (PolicyException e) { - Status s = Status.INTERNAL.withDescription(e.getMessage()); - helper.updateBalancingState(ConnectivityState.TRANSIENT_FAILURE, new FailingPicker(s)); - delegate.shutdown(); - delegateProvider = null; - delegate = new NoopLoadBalancer(); - return Status.OK; - } policySelection = new PolicySelection(defaultProvider, /* config= */ null); } if (delegateProvider == null || !policySelection.provider.getPolicyName().equals(delegateProvider.getPolicyName())) { - helper.updateBalancingState(ConnectivityState.CONNECTING, new EmptyPicker()); + helper.updateBalancingState( + ConnectivityState.CONNECTING, new FixedResultPicker(PickResult.withNoResult())); delegate.shutdown(); delegateProvider = policySelection.provider; LoadBalancer old = delegate; @@ -145,20 +121,24 @@ Status tryAcceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { .build()); } - void handleNameResolutionError(Status error) { + @Override + public void handleNameResolutionError(Status error) { getDelegate().handleNameResolutionError(error); } + @Override @Deprecated - void handleSubchannelState(Subchannel subchannel, ConnectivityStateInfo stateInfo) { + public void handleSubchannelState(Subchannel subchannel, ConnectivityStateInfo stateInfo) { getDelegate().handleSubchannelState(subchannel, stateInfo); } - void requestConnection() { + @Override + public void requestConnection() { getDelegate().requestConnection(); } - void shutdown() { + @Override + public void shutdown() { delegate.shutdown(); delegate = null; } @@ -179,16 +159,6 @@ LoadBalancerProvider getDelegateProvider() { } } - private LoadBalancerProvider getProviderOrThrow(String policy, String choiceReason) - throws PolicyException { - LoadBalancerProvider provider = registry.getProvider(policy); - if (provider == null) { - throw new PolicyException( - "Trying to load '" + policy + "' because " + choiceReason + ", but it's unavailable"); - } - return provider; - } - /** * Parses first available LoadBalancer policy from service config. Available LoadBalancer should * be registered to {@link LoadBalancerRegistry}. If the first available LoadBalancer policy is @@ -209,8 +179,11 @@ private LoadBalancerProvider getProviderOrThrow(String policy, String choiceReas * * @return the parsed {@link PolicySelection}, or {@code null} if no selection could be made. */ + // TODO(ejona): The Provider API doesn't allow null, but ScParser can handle this and it will need + // tweaking to ManagedChannelImpl.defaultServiceConfig to fix. @Nullable - ConfigOrError parseLoadBalancerPolicy(Map serviceConfig) { + @Override + public ConfigOrError parseLoadBalancingPolicyConfig(Map serviceConfig) { try { List loadBalancerConfigs = null; if (serviceConfig != null) { @@ -228,38 +201,18 @@ ConfigOrError parseLoadBalancerPolicy(Map serviceConfig) { } } - @VisibleForTesting - static final class PolicyException extends Exception { - private static final long serialVersionUID = 1L; - - private PolicyException(String msg) { - super(msg); - } + @Override + public boolean isAvailable() { + return true; } - private static final class EmptyPicker extends SubchannelPicker { - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return PickResult.withNoResult(); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(EmptyPicker.class).toString(); - } + @Override + public int getPriority() { + return 5; } - private static final class FailingPicker extends SubchannelPicker { - private final Status failure; - - FailingPicker(Status failure) { - this.failure = failure; - } - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return PickResult.withError(failure); - } + @Override + public String getPolicyName() { + return "auto_configured_internal"; } } diff --git a/core/src/main/java/io/grpc/internal/CertificateUtils.java b/core/src/main/java/io/grpc/internal/CertificateUtils.java index 91d17de93cb..130a435bb1a 100644 --- a/core/src/main/java/io/grpc/internal/CertificateUtils.java +++ b/core/src/main/java/io/grpc/internal/CertificateUtils.java @@ -26,14 +26,29 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Collection; +import java.util.List; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; import javax.security.auth.x500.X500Principal; /** * Contains certificate/key PEM file utility method(s) for internal usage. */ public final class CertificateUtils { + private static final Class x509ExtendedTrustManagerClass; + + static { + Class x509ExtendedTrustManagerClass1; + try { + x509ExtendedTrustManagerClass1 = Class.forName("javax.net.ssl.X509ExtendedTrustManager"); + } catch (ClassNotFoundException e) { + x509ExtendedTrustManagerClass1 = null; + // Will disallow per-rpc authority override via call option. + } + x509ExtendedTrustManagerClass = x509ExtendedTrustManagerClass1; + } + /** * Creates X509TrustManagers using the provided CA certs. */ @@ -71,6 +86,17 @@ public static TrustManager[] createTrustManager(InputStream rootCerts) return trustManagerFactory.getTrustManagers(); } + public static X509TrustManager getX509ExtendedTrustManager(List trustManagers) { + if (x509ExtendedTrustManagerClass != null) { + for (TrustManager trustManager : trustManagers) { + if (x509ExtendedTrustManagerClass.isInstance(trustManager)) { + return (X509TrustManager) trustManager; + } + } + } + return null; + } + private static X509Certificate[] getX509Certificates(InputStream inputStream) throws CertificateException { CertificateFactory factory = CertificateFactory.getInstance("X.509"); diff --git a/core/src/main/java/io/grpc/internal/ClientTransport.java b/core/src/main/java/io/grpc/internal/ClientTransport.java index fd0f30b8bf1..3e2c2aea247 100644 --- a/core/src/main/java/io/grpc/internal/ClientTransport.java +++ b/core/src/main/java/io/grpc/internal/ClientTransport.java @@ -24,15 +24,15 @@ import io.grpc.MethodDescriptor; import io.grpc.Status; import java.util.concurrent.Executor; -import javax.annotation.concurrent.ThreadSafe; /** * The client-side transport typically encapsulating a single connection to a remote * server. However, streams created before the client has discovered any server address may * eventually be issued on different connections. All methods on the transport and its callbacks * are expected to execute quickly. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface ClientTransport extends InternalInstrumented { /** diff --git a/core/src/main/java/io/grpc/internal/ClientTransportFactory.java b/core/src/main/java/io/grpc/internal/ClientTransportFactory.java index 6c10ced4652..6023fb14aa9 100644 --- a/core/src/main/java/io/grpc/internal/ClientTransportFactory.java +++ b/core/src/main/java/io/grpc/internal/ClientTransportFactory.java @@ -24,6 +24,7 @@ import io.grpc.ChannelCredentials; import io.grpc.ChannelLogger; import io.grpc.HttpConnectProxiedSocketAddress; +import io.grpc.MetricRecorder; import java.io.Closeable; import java.net.SocketAddress; import java.util.Collection; @@ -91,6 +92,8 @@ final class ClientTransportOptions { private Attributes eagAttributes = Attributes.EMPTY; @Nullable private String userAgent; @Nullable private HttpConnectProxiedSocketAddress connectProxiedSocketAddr; + private MetricRecorder metricRecorder = new MetricRecorder() { + }; public ChannelLogger getChannelLogger() { return channelLogger; @@ -101,6 +104,15 @@ public ClientTransportOptions setChannelLogger(ChannelLogger channelLogger) { return this; } + public MetricRecorder getMetricRecorder() { + return metricRecorder; + } + + public ClientTransportOptions setMetricRecorder(MetricRecorder metricRecorder) { + this.metricRecorder = Preconditions.checkNotNull(metricRecorder, "metricRecorder"); + return this; + } + public String getAuthority() { return authority; } diff --git a/core/src/main/java/io/grpc/internal/CompositeReadableBuffer.java b/core/src/main/java/io/grpc/internal/CompositeReadableBuffer.java index 4407eb8a2a2..6cedb2caee9 100644 --- a/core/src/main/java/io/grpc/internal/CompositeReadableBuffer.java +++ b/core/src/main/java/io/grpc/internal/CompositeReadableBuffer.java @@ -18,12 +18,10 @@ import java.io.IOException; import java.io.OutputStream; -import java.nio.Buffer; import java.nio.ByteBuffer; import java.nio.InvalidMarkException; import java.util.ArrayDeque; import java.util.Deque; -import java.util.Queue; import javax.annotation.Nullable; /** @@ -39,7 +37,6 @@ public class CompositeReadableBuffer extends AbstractReadableBuffer { private final Deque readableBuffers; private Deque rewindableBuffers; private int readableBytes; - private final Queue buffers = new ArrayDeque(2); private boolean marked; public CompositeReadableBuffer(int initialCapacity) { @@ -122,20 +119,6 @@ public int read(ReadableBuffer buffer, int length, byte[] dest, int offset) { } }; - private static final NoThrowReadOperation BYTE_BUF_OP = - new NoThrowReadOperation() { - @Override - public int read(ReadableBuffer buffer, int length, ByteBuffer dest, int unused) { - // Change the limit so that only lengthToCopy bytes are available. - int prevLimit = dest.limit(); - ((Buffer) dest).limit(dest.position() + length); - // Write the bytes and restore the original limit. - buffer.readBytes(dest); - ((Buffer) dest).limit(prevLimit); - return 0; - } - }; - private static final ReadOperation STREAM_OP = new ReadOperation() { @Override @@ -151,41 +134,11 @@ public void readBytes(byte[] dest, int destOffset, int length) { executeNoThrow(BYTE_ARRAY_OP, length, dest, destOffset); } - @Override - public void readBytes(ByteBuffer dest) { - executeNoThrow(BYTE_BUF_OP, dest.remaining(), dest, 0); - } - @Override public void readBytes(OutputStream dest, int length) throws IOException { execute(STREAM_OP, length, dest, 0); } - /** - * Reads {@code length} bytes from this buffer and writes them to the destination buffer. - * Increments the read position by {@code length}. If the required bytes are not readable, throws - * {@link IndexOutOfBoundsException}. - * - * @param dest the destination buffer to receive the bytes. - * @param length the number of bytes to be copied. - * @throws IndexOutOfBoundsException if required bytes are not readable - */ - public void readBytes(CompositeReadableBuffer dest, int length) { - checkReadable(length); - readableBytes -= length; - - while (length > 0) { - ReadableBuffer buffer = buffers.peek(); - if (buffer.readableBytes() > length) { - dest.addBuffer(buffer.readBytes(length)); - length = 0; - } else { - dest.addBuffer(buffers.poll()); - length -= buffer.readableBytes(); - } - } - } - @Override public ReadableBuffer readBytes(int length) { if (length <= 0) { diff --git a/core/src/main/java/io/grpc/internal/ConnectionClientTransport.java b/core/src/main/java/io/grpc/internal/ConnectionClientTransport.java index 8385316d608..6199d9dad4d 100644 --- a/core/src/main/java/io/grpc/internal/ConnectionClientTransport.java +++ b/core/src/main/java/io/grpc/internal/ConnectionClientTransport.java @@ -17,12 +17,12 @@ package io.grpc.internal; import io.grpc.Attributes; -import javax.annotation.concurrent.ThreadSafe; /** * A {@link ManagedClientTransport} that is based on a connection. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface ConnectionClientTransport extends ManagedClientTransport { /** * Returns a set of attributes, which may vary depending on the state of the transport. The keys diff --git a/core/src/main/java/io/grpc/internal/DelayedClientCall.java b/core/src/main/java/io/grpc/internal/DelayedClientCall.java index 253237c3c7d..db057e7728e 100644 --- a/core/src/main/java/io/grpc/internal/DelayedClientCall.java +++ b/core/src/main/java/io/grpc/internal/DelayedClientCall.java @@ -49,6 +49,9 @@ */ public class DelayedClientCall extends ClientCall { private static final Logger logger = Logger.getLogger(DelayedClientCall.class.getName()); + + /** A string describing what this call is waiting on. */ + private final String bufferContext; /** * A timer to monitor the initial deadline. The timer must be cancelled on transition to the real * call. @@ -64,6 +67,8 @@ public class DelayedClientCall extends ClientCall { * order, but also used if an error occurs before {@code realCall} is set. */ private Listener listener; + // No need to synchronize; start() synchronization provides a happens-before + private Metadata startHeaders; // Must hold {@code this} lock when setting. private ClientCall realCall; @GuardedBy("this") @@ -74,7 +79,11 @@ public class DelayedClientCall extends ClientCall { private DelayedListener delayedListener; protected DelayedClientCall( - Executor callExecutor, ScheduledExecutorService scheduler, @Nullable Deadline deadline) { + String bufferContext, + Executor callExecutor, + ScheduledExecutorService scheduler, + @Nullable Deadline deadline) { + this.bufferContext = checkNotNull(bufferContext, "bufferContext"); this.callExecutor = checkNotNull(callExecutor, "callExecutor"); checkNotNull(scheduler, "scheduler"); context = Context.current(); @@ -141,7 +150,8 @@ public void run() { } buf.append(seconds); buf.append(String.format(Locale.US, ".%09d", nanos)); - buf.append("s"); + buf.append("s waiting for "); + buf.append(bufferContext); cancel( Status.DEADLINE_EXCEEDED.withDescription(buf.toString()), // We should not cancel the call if the realCall is set because there could be a @@ -161,13 +171,23 @@ public void run() { */ // When this method returns, passThrough is guaranteed to be true public final Runnable setCall(ClientCall call) { + Listener savedDelayedListener; synchronized (this) { // If realCall != null, then either setCall() or cancel() has been called. if (realCall != null) { return null; } setRealCall(checkNotNull(call, "call")); + // start() not yet called + if (delayedListener == null) { + assert pendingRunnables.isEmpty(); + pendingRunnables = null; + passThrough = true; + return null; + } + savedDelayedListener = this.delayedListener; } + internalStart(savedDelayedListener); return new ContextRunnable(context) { @Override public void runInContext() { @@ -176,8 +196,15 @@ public void runInContext() { }; } + private void internalStart(Listener listener) { + Metadata savedStartHeaders = this.startHeaders; + this.startHeaders = null; + context.run(() -> realCall.start(listener, savedStartHeaders)); + } + @Override public final void start(Listener listener, final Metadata headers) { + checkNotNull(headers, "headers"); checkState(this.listener == null, "already started"); Status savedError; boolean savedPassThrough; @@ -187,7 +214,8 @@ public final void start(Listener listener, final Metadata headers) { savedError = error; savedPassThrough = passThrough; if (!savedPassThrough) { - listener = delayedListener = new DelayedListener<>(listener); + listener = delayedListener = new DelayedListener<>(this, listener); + startHeaders = headers; } } if (savedError != null) { @@ -196,15 +224,7 @@ public final void start(Listener listener, final Metadata headers) { } if (savedPassThrough) { realCall.start(listener, headers); - } else { - final Listener finalListener = listener; - delayOrExecute(new Runnable() { - @Override - public void run() { - realCall.start(finalListener, headers); - } - }); - } + } // else realCall.start() will be called by setCall } // When this method returns, passThrough is guaranteed to be true @@ -253,6 +273,7 @@ public void run() { if (listenerToClose != null) { callExecutor.execute(new CloseListenerRunnable(listenerToClose, status)); } + internalStart(listenerToClose); // listener instance doesn't matter drainPendingCalls(); } callCancelled(); @@ -432,15 +453,33 @@ public void runInContext() { } private static final class DelayedListener extends Listener { + private final DelayedClientCall call; private final Listener realListener; private volatile boolean passThrough; + private volatile Status exceptionStatus; @GuardedBy("this") private List pendingCallbacks = new ArrayList<>(); - public DelayedListener(Listener listener) { + public DelayedListener(DelayedClientCall call, Listener listener) { + this.call = call; this.realListener = listener; } + /** + * Cancels call and schedules onClose() notification. May only be called from within a + * DelayedListener callback dispatch (either queued drain or passThrough). Visibility of the + * write to {@code exceptionStatus} does not rely on a single callback executor; it is a + * {@code volatile} field, and callback queuing/pass-through transitions are coordinated by + * this listener's synchronization so subsequent callbacks observe the updated status. + */ + private void exceptionThrown(Throwable t, String description) { + // onClose() must be delivered exactly once and last. Other callbacks may already be queued + // ahead of realCall's eventual onClose, so we can't call onClose() here. We set the status + // and overwrite the onClose() details when it arrives. + exceptionStatus = Status.CANCELLED.withCause(t).withDescription(description); + call.cancel(description, t); + } + private void delayOrExecute(Runnable runnable) { synchronized (this) { if (!passThrough) { @@ -454,37 +493,75 @@ private void delayOrExecute(Runnable runnable) { @Override public void onHeaders(final Metadata headers) { if (passThrough) { - realListener.onHeaders(headers); + deliverHeaders(headers); } else { delayOrExecute(new Runnable() { @Override public void run() { - realListener.onHeaders(headers); + deliverHeaders(headers); } }); } } + private void deliverHeaders(Metadata headers) { + if (exceptionStatus != null) { + return; + } + try { + realListener.onHeaders(headers); + } catch (Throwable t) { + exceptionThrown(t, "Failed to read headers"); + } + } + @Override public void onMessage(final RespT message) { if (passThrough) { - realListener.onMessage(message); + deliverMessage(message); } else { delayOrExecute(new Runnable() { @Override public void run() { - realListener.onMessage(message); + deliverMessage(message); } }); } } + private void deliverMessage(RespT message) { + if (exceptionStatus != null) { + return; + } + try { + realListener.onMessage(message); + } catch (Throwable t) { + exceptionThrown(t, "Failed to read message."); + } + } + @Override public void onClose(final Status status, final Metadata trailers) { delayOrExecute(new Runnable() { @Override public void run() { - realListener.onClose(status, trailers); + Status effectiveStatus = status; + Metadata effectiveTrailers = trailers; + if (exceptionStatus != null) { + // Ideally status matches exceptionStatus, since exceptionStatus was used to cancel + // the call. However, cancel() may reconstruct a new Status instance, and the cancel + // is racy so this onClose may have already been queued when the cancellation + // occurred. Since other callbacks throw away data if exceptionStatus != null, it is + // semantically essential that we _not_ use a status provided by the server. + effectiveStatus = exceptionStatus; + // Replace trailers to prevent mixing sources of status and trailers. + effectiveTrailers = new Metadata(); + } + try { + realListener.onClose(effectiveStatus, effectiveTrailers); + } catch (RuntimeException ex) { + logger.log(Level.WARNING, "Exception thrown by onClose() in ClientCall", ex); + } } }); } @@ -492,17 +569,28 @@ public void run() { @Override public void onReady() { if (passThrough) { - realListener.onReady(); + deliverOnReady(); } else { delayOrExecute(new Runnable() { @Override public void run() { - realListener.onReady(); + deliverOnReady(); } }); } } + private void deliverOnReady() { + if (exceptionStatus != null) { + return; + } + try { + realListener.onReady(); + } catch (Throwable t) { + exceptionThrown(t, "Failed to call onReady."); + } + } + void drainPendingCallbacks() { assert !passThrough; List toRun = new ArrayList<>(); @@ -522,7 +610,6 @@ void drainPendingCallbacks() { } for (Runnable runnable : toRun) { // Avoid calling listener while lock is held to prevent deadlocks. - // TODO(ejona): exception handling runnable.run(); } toRun.clear(); diff --git a/core/src/main/java/io/grpc/internal/DelayedClientTransport.java b/core/src/main/java/io/grpc/internal/DelayedClientTransport.java index eccd8fadc8c..5569e1eecf8 100644 --- a/core/src/main/java/io/grpc/internal/DelayedClientTransport.java +++ b/core/src/main/java/io/grpc/internal/DelayedClientTransport.java @@ -201,8 +201,8 @@ public ListenableFuture getStats() { } /** - * Prevents creating any new streams. Buffered streams are not failed and may still proceed - * when {@link #reprocess} is called. The delayed transport will be terminated when there is no + * Prevents creating any new streams. Buffered streams are not failed and may still proceed + * when {@link #reprocess} is called. The delayed transport will be terminated when there is no * more buffered streams. */ @Override @@ -215,7 +215,7 @@ public final void shutdown(final Status status) { syncContext.executeLater(new Runnable() { @Override public void run() { - listener.transportShutdown(status); + listener.transportShutdown(status, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); } }); if (!hasPendingStreams() && reportTransportTerminated != null) { @@ -363,6 +363,7 @@ private class PendingStream extends DelayedStream { private volatile Status lastPickStatus; private PendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers) { + super("connecting_and_lb"); this.args = args; this.tracers = tracers; } diff --git a/core/src/main/java/io/grpc/internal/DelayedStream.java b/core/src/main/java/io/grpc/internal/DelayedStream.java index 2ca4630d6a1..a2b1e963ac5 100644 --- a/core/src/main/java/io/grpc/internal/DelayedStream.java +++ b/core/src/main/java/io/grpc/internal/DelayedStream.java @@ -42,6 +42,7 @@ * necessary. */ class DelayedStream implements ClientStream { + private final String bufferContext; /** {@code true} once realStream is valid and all pending calls have been drained. */ private volatile boolean passThrough; /** @@ -64,6 +65,14 @@ class DelayedStream implements ClientStream { // No need to synchronize; start() synchronization provides a happens-before private List preStartPendingCalls = new ArrayList<>(); + /** + * Create a delayed stream with debug context {@code bufferContext}. The context is what this + * stream is delayed by (e.g., "connecting", "call_credentials"). + */ + public DelayedStream(String bufferContext) { + this.bufferContext = checkNotNull(bufferContext, "bufferContext"); + } + @Override public void setMaxInboundMessageSize(final int maxSize) { checkState(listener == null, "May only be called before start"); @@ -104,11 +113,13 @@ public void appendTimeoutInsight(InsightBuilder insight) { return; } if (realStream != null) { - insight.appendKeyValue("buffered_nanos", streamSetTimeNanos - startTimeNanos); + insight.appendKeyValue( + bufferContext + "_delay", "" + (streamSetTimeNanos - startTimeNanos) + "ns"); realStream.appendTimeoutInsight(insight); } else { - insight.appendKeyValue("buffered_nanos", System.nanoTime() - startTimeNanos); - insight.append("waiting_for_connection"); + insight.appendKeyValue( + bufferContext + "_delay", "" + (System.nanoTime() - startTimeNanos) + "ns"); + insight.append("was_still_waiting"); } } } diff --git a/core/src/main/java/io/grpc/internal/DisconnectError.java b/core/src/main/java/io/grpc/internal/DisconnectError.java new file mode 100644 index 00000000000..771024f106e --- /dev/null +++ b/core/src/main/java/io/grpc/internal/DisconnectError.java @@ -0,0 +1,34 @@ +/* + * 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.internal; + +import javax.annotation.concurrent.Immutable; + +/** + * Represents the reason for a subchannel disconnection. + * Implementations are either the SimpleDisconnectError enum or the GoAwayDisconnectError class for + * dynamic ones. + */ +@Immutable +public interface DisconnectError { + /** + * Returns the string representation suitable for use as an error tag. + * + * @return The formatted error tag string. + */ + String toErrorString(); +} diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolver.java b/core/src/main/java/io/grpc/internal/DnsNameResolver.java index 6f1cf4cd900..1c1d95ed616 100644 --- a/core/src/main/java/io/grpc/internal/DnsNameResolver.java +++ b/core/src/main/java/io/grpc/internal/DnsNameResolver.java @@ -23,10 +23,8 @@ import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.base.Stopwatch; -import com.google.common.base.Throwables; import com.google.common.base.Verify; import com.google.common.base.VerifyException; -import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.NameResolver; import io.grpc.ProxiedSocketAddress; @@ -101,7 +99,7 @@ public class DnsNameResolver extends NameResolver { * not installed, the ttl value is {@code null} which falls back to {@link * #DEFAULT_NETWORK_CACHE_TTL_SECONDS gRPC default value}. * - *

For android, gRPC doesn't attempt to cache; this property value will be ignored. + *

For android, gRPC uses a fixed value; this property value will be ignored. */ @VisibleForTesting static final String NETWORKADDRESS_CACHE_TTL_PROPERTY = "networkaddress.cache.ttl"; @@ -211,20 +209,8 @@ public void refresh() { resolve(); } - private List resolveAddresses() { - List addresses; - Exception addressesException = null; - try { - addresses = addressResolver.resolveAddress(host); - } catch (Exception e) { - addressesException = e; - Throwables.throwIfUnchecked(e); - throw new RuntimeException(e); - } finally { - if (addressesException != null) { - logger.log(Level.FINE, "Address resolution failure", addressesException); - } - } + private List resolveAddresses() throws Exception { + List addresses = addressResolver.resolveAddress(host); // Each address forms an EAG List servers = new ArrayList<>(addresses.size()); for (InetAddress inetAddr : addresses) { @@ -275,21 +261,19 @@ private EquivalentAddressGroup detectProxy() throws IOException { /** * Main logic of name resolution. */ - protected InternalResolutionResult doResolve(boolean forceTxt) { - InternalResolutionResult result = new InternalResolutionResult(); + protected ResolutionResult doResolve() { + ResolutionResult.Builder resultBuilder = ResolutionResult.newBuilder(); try { - result.addresses = resolveAddresses(); + resultBuilder.setAddressesOrError(StatusOr.fromValue(resolveAddresses())); } catch (Exception e) { - if (!forceTxt) { - result.error = - Status.UNAVAILABLE.withDescription("Unable to resolve host " + host).withCause(e); - return result; - } + logger.log(Level.FINE, "Address resolution failure", e); + resultBuilder.setAddressesOrError(StatusOr.fromStatus( + Status.UNAVAILABLE.withDescription("Unable to resolve host " + host).withCause(e))); } if (enableTxt) { - result.config = resolveServiceConfig(); + resultBuilder.setServiceConfig(resolveServiceConfig()); } - return result; + return resultBuilder.build(); } private final class Resolve implements Runnable { @@ -304,38 +288,22 @@ public void run() { if (logger.isLoggable(Level.FINER)) { logger.finer("Attempting DNS resolution of " + host); } - InternalResolutionResult result = null; + ResolutionResult result = null; try { EquivalentAddressGroup proxiedAddr = detectProxy(); - ResolutionResult.Builder resolutionResultBuilder = ResolutionResult.newBuilder(); if (proxiedAddr != null) { if (logger.isLoggable(Level.FINER)) { logger.finer("Using proxy address " + proxiedAddr); } - resolutionResultBuilder.setAddressesOrError( - StatusOr.fromValue(Collections.singletonList(proxiedAddr))); + result = ResolutionResult.newBuilder() + .setAddressesOrError(StatusOr.fromValue(Collections.singletonList(proxiedAddr))) + .build(); } else { - result = doResolve(false); - if (result.error != null) { - InternalResolutionResult finalResult = result; - syncContext.execute(() -> - savedListener.onResult2(ResolutionResult.newBuilder() - .setAddressesOrError(StatusOr.fromStatus(finalResult.error)) - .build())); - return; - } - if (result.addresses != null) { - resolutionResultBuilder.setAddressesOrError(StatusOr.fromValue(result.addresses)); - } - if (result.config != null) { - resolutionResultBuilder.setServiceConfig(result.config); - } - if (result.attributes != null) { - resolutionResultBuilder.setAttributes(result.attributes); - } + result = doResolve(); } + ResolutionResult savedResult = result; syncContext.execute(() -> { - savedListener.onResult2(resolutionResultBuilder.build()); + savedListener.onResult2(savedResult); }); } catch (IOException e) { syncContext.execute(() -> @@ -345,7 +313,7 @@ public void run() { Status.UNAVAILABLE.withDescription( "Unable to resolve host " + host).withCause(e))).build())); } finally { - final boolean succeed = result != null && result.error == null; + final boolean succeed = result != null && result.getAddressesOrError().hasValue(); syncContext.execute(new Runnable() { @Override public void run() { @@ -463,12 +431,14 @@ private static final List getHostnamesFromChoice(Map serviceC /** * Returns value of network address cache ttl property if not Android environment. For android, - * DnsNameResolver does not cache the dns lookup result. + * DnsNameResolver uses a fixed value. */ private static long getNetworkAddressCacheTtlNanos(boolean isAndroid) { if (isAndroid) { - // on Android, ignore dns cache. - return 0; + // On Android, use fixed value. If the network used changes this value shouldn't matter, as + // channel.enterIdle() should be called and this name resolver instance will be discarded. The + // new name resolver instance will then re-request. + return TimeUnit.SECONDS.toNanos(DEFAULT_NETWORK_CACHE_TTL_SECONDS); } String cacheTtlPropertyValue = System.getProperty(NETWORKADDRESS_CACHE_TTL_PROPERTY); @@ -490,7 +460,7 @@ private static long getNetworkAddressCacheTtlNanos(boolean isAndroid) { * Determines if a given Service Config choice applies, and if so, returns it. * * @see - * Service Config in DNS + * Service Config in DNS * @param choice The service config choice. * @return The service config object or {@code null} if this choice does not apply. */ @@ -545,18 +515,6 @@ private static long getNetworkAddressCacheTtlNanos(boolean isAndroid) { return sc; } - /** - * Used as a DNS-based name resolver's internal representation of resolution result. - */ - protected static final class InternalResolutionResult { - private Status error; - private List addresses; - private ConfigOrError config; - public Attributes attributes; - - private InternalResolutionResult() {} - } - /** * Describes a parsed SRV record. */ diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java b/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java index c977fbb0cca..14b56f1a12a 100644 --- a/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java +++ b/core/src/main/java/io/grpc/internal/DnsNameResolverProvider.java @@ -21,25 +21,31 @@ import io.grpc.InternalServiceProviders; import io.grpc.NameResolver; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; import java.util.Collection; import java.util.Collections; +import java.util.List; /** * A provider for {@link DnsNameResolver}. * *

It resolves a target URI whose scheme is {@code "dns"}. The (optional) authority of the target - * URI is reserved for the address of alternative DNS server (not implemented yet). The path of the - * target URI, excluding the leading slash {@code '/'}, is treated as the host name and the optional - * port to be resolved by DNS. Example target URIs: + * URI is reserved for the address of alternative DNS server (not implemented yet). The target URI + * must be hierarchical and have exactly one path segment which will be interpreted as an RFC 2396 + * "server-based" authority and used as the "service authority" of the resulting {@link + * NameResolver}. The "host" part of this authority is the name to be resolved by DNS. The "port" + * part of this authority (if present) will become the port number for all {@link InetSocketAddress} + * produced by this resolver. For example: * *

    *
  • {@code "dns:///foo.googleapis.com:8080"} (using default DNS)
  • *
  • {@code "dns://8.8.8.8/foo.googleapis.com:8080"} (using alternative DNS (not implemented * yet))
  • - *
  • {@code "dns:///foo.googleapis.com"} (without port)
  • + *
  • {@code "dns:///foo.googleapis.com"} (output addresses will have port {@link + * NameResolver.Args#getDefaultPort()})
  • *
*/ public final class DnsNameResolverProvider extends NameResolverProvider { @@ -51,6 +57,7 @@ public final class DnsNameResolverProvider extends NameResolverProvider { @Override public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + // TODO(jdcormie): Remove once RFC 3986 migration is complete. if (SCHEME.equals(targetUri.getScheme())) { String targetPath = Preconditions.checkNotNull(targetUri.getPath(), "targetPath"); Preconditions.checkArgument(targetPath.startsWith("/"), @@ -68,6 +75,25 @@ public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { } } + @Override + public NameResolver newNameResolver(Uri targetUri, final NameResolver.Args args) { + if (SCHEME.equals(targetUri.getScheme())) { + List pathSegments = targetUri.getPathSegments(); + Preconditions.checkArgument(!pathSegments.isEmpty(), + "expected 1 path segment in target %s but found %s", targetUri, pathSegments); + String domainNameToResolve = pathSegments.get(0); + return new DnsNameResolver( + targetUri.getAuthority(), + domainNameToResolve, + args, + GrpcUtil.SHARED_CHANNEL_EXECUTOR, + Stopwatch.createUnstarted(), + IS_ANDROID); + } else { + return null; + } + } + @Override public String getDefaultScheme() { return SCHEME; diff --git a/core/src/main/java/io/grpc/internal/FixedPickerLoadBalancerProvider.java b/core/src/main/java/io/grpc/internal/FixedPickerLoadBalancerProvider.java new file mode 100644 index 00000000000..a632948bdb9 --- /dev/null +++ b/core/src/main/java/io/grpc/internal/FixedPickerLoadBalancerProvider.java @@ -0,0 +1,80 @@ +/* + * 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.internal; + +import static java.util.Objects.requireNonNull; + +import io.grpc.ConnectivityState; +import io.grpc.LoadBalancer; +import io.grpc.LoadBalancerProvider; +import io.grpc.Status; + +/** A LB provider whose LB always uses the same picker. */ +final class FixedPickerLoadBalancerProvider extends LoadBalancerProvider { + private final ConnectivityState state; + private final LoadBalancer.SubchannelPicker picker; + private final Status acceptAddressesStatus; + + public FixedPickerLoadBalancerProvider( + ConnectivityState state, LoadBalancer.SubchannelPicker picker, Status acceptAddressesStatus) { + this.state = requireNonNull(state, "state"); + this.picker = requireNonNull(picker, "picker"); + this.acceptAddressesStatus = requireNonNull(acceptAddressesStatus, "acceptAddressesStatus"); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public int getPriority() { + return 5; + } + + @Override + public String getPolicyName() { + return "fixed_picker_lb_internal"; + } + + @Override + public LoadBalancer newLoadBalancer(LoadBalancer.Helper helper) { + return new FixedPickerLoadBalancer(helper); + } + + private final class FixedPickerLoadBalancer extends LoadBalancer { + private final Helper helper; + + public FixedPickerLoadBalancer(Helper helper) { + this.helper = requireNonNull(helper, "helper"); + } + + @Override + public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { + helper.updateBalancingState(state, picker); + return acceptAddressesStatus; + } + + @Override + public void handleNameResolutionError(Status error) { + helper.updateBalancingState(state, picker); + } + + @Override + public void shutdown() {} + } +} diff --git a/core/src/main/java/io/grpc/internal/ForwardingReadableBuffer.java b/core/src/main/java/io/grpc/internal/ForwardingReadableBuffer.java index 06d04b6de2d..7e690309647 100644 --- a/core/src/main/java/io/grpc/internal/ForwardingReadableBuffer.java +++ b/core/src/main/java/io/grpc/internal/ForwardingReadableBuffer.java @@ -67,11 +67,6 @@ public void readBytes(byte[] dest, int destOffset, int length) { buf.readBytes(dest, destOffset, length); } - @Override - public void readBytes(ByteBuffer dest) { - buf.readBytes(dest); - } - @Override public void readBytes(OutputStream dest, int length) throws IOException { buf.readBytes(dest, length); diff --git a/core/src/main/java/io/grpc/internal/GoAwayDisconnectError.java b/core/src/main/java/io/grpc/internal/GoAwayDisconnectError.java new file mode 100644 index 00000000000..20c8c709932 --- /dev/null +++ b/core/src/main/java/io/grpc/internal/GoAwayDisconnectError.java @@ -0,0 +1,64 @@ +/* + * 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.internal; + + +import javax.annotation.concurrent.Immutable; + +/** + * Represents a dynamic disconnection due to an HTTP/2 GOAWAY frame. + * This class is immutable and holds the specific error code from the frame. + */ +@Immutable +public final class GoAwayDisconnectError implements DisconnectError { + private static final String ERROR_TAG = "GOAWAY"; + private final GrpcUtil.Http2Error errorCode; + + /** + * Creates a GoAway reason. + * + * @param errorCode The specific, non-null HTTP/2 error code (e.g., "NO_ERROR"). + */ + public GoAwayDisconnectError(GrpcUtil.Http2Error errorCode) { + if (errorCode == null) { + throw new NullPointerException("Http2Error cannot be null for GOAWAY"); + } + this.errorCode = errorCode; + } + + @Override + public String toErrorString() { + return ERROR_TAG + " " + errorCode.name(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + GoAwayDisconnectError goAwayDisconnectError = (GoAwayDisconnectError) o; + return errorCode == goAwayDisconnectError.errorCode; + } + + @Override + public int hashCode() { + return errorCode.hashCode(); + } +} diff --git a/core/src/main/java/io/grpc/internal/GrpcUtil.java b/core/src/main/java/io/grpc/internal/GrpcUtil.java index a512fff6af2..1871a422927 100644 --- a/core/src/main/java/io/grpc/internal/GrpcUtil.java +++ b/core/src/main/java/io/grpc/internal/GrpcUtil.java @@ -24,7 +24,6 @@ import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.base.Stopwatch; -import com.google.common.base.Strings; import com.google.common.base.Supplier; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; @@ -32,6 +31,7 @@ import io.grpc.ClientStreamTracer; import io.grpc.ClientStreamTracer.StreamInfo; import io.grpc.InternalChannelz.SocketStats; +import io.grpc.InternalFeatureFlags; import io.grpc.InternalLogId; import io.grpc.InternalMetadata; import io.grpc.InternalMetadata.TrustedAsciiMarshaller; @@ -219,7 +219,7 @@ public byte[] parseAsciiString(byte[] serialized) { public static final Splitter ACCEPT_ENCODING_SPLITTER = Splitter.on(',').trimResults(); - public static final String IMPLEMENTATION_VERSION = "1.77.0-SNAPSHOT"; // CURRENT_GRPC_VERSION + public static final String IMPLEMENTATION_VERSION = "1.83.0-SNAPSHOT"; // CURRENT_GRPC_VERSION /** * The default timeout in nanos for a keepalive ping request. @@ -241,6 +241,12 @@ public byte[] parseAsciiString(byte[] serialized) { */ public static final long DEFAULT_SERVER_KEEPALIVE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(20L); + /** + * The default minimum time between client keepalive pings permitted by server. + */ + public static final long DEFAULT_SERVER_PERMIT_KEEPALIVE_TIME_NANOS + = TimeUnit.MINUTES.toNanos(5L); + /** * The magic keepalive time value that disables keepalive. */ @@ -821,6 +827,31 @@ public static Status replaceInappropriateControlPlaneStatus(Status status) { + status.getDescription()).withCause(status.getCause()) : status; } + /** + * Returns a "clean" representation of a status code and description (not cause) like + * "UNAVAILABLE: The description". Should be similar to Status.formatThrowableMessage(). + */ + public static String statusToPrettyString(Status status) { + if (status.getDescription() == null) { + return status.getCode().toString(); + } else { + return status.getCode() + ": " + status.getDescription(); + } + } + + /** + * Create a status with contextual information, propagating details from a non-null status that + * contributed to the failure. For example, if UNAVAILABLE, "Couldn't load bar", and status + * "FAILED_PRECONDITION: Foo missing" were passed as arguments, then this method would produce the + * status "UNAVAILABLE: Couldn't load bar: FAILED_PRECONDITION: Foo missing" with a cause if the + * passed status had a cause. + */ + public static Status statusWithDetails(Status.Code code, String description, Status causeStatus) { + return code.toStatus() + .withDescription(description + ": " + statusToPrettyString(causeStatus)) + .withCause(causeStatus.getCause()); + } + /** * Checks whether the given item exists in the iterable. This is copied from Guava Collect's * {@code Iterables.contains()} because Guava Collect is not Android-friendly thus core can't @@ -933,18 +964,7 @@ public static String encodeAuthority(String authority) { } public static boolean getFlag(String envVarName, boolean enableByDefault) { - String envVar = System.getenv(envVarName); - if (envVar == null) { - envVar = System.getProperty(envVarName); - } - if (envVar != null) { - envVar = envVar.trim(); - } - if (enableByDefault) { - return Strings.isNullOrEmpty(envVar) || Boolean.parseBoolean(envVar); - } else { - return !Strings.isNullOrEmpty(envVar) && Boolean.parseBoolean(envVar); - } + return InternalFeatureFlags.getFlag(envVarName, enableByDefault); } diff --git a/core/src/main/java/io/grpc/internal/Http2ClientStreamTransportState.java b/core/src/main/java/io/grpc/internal/Http2ClientStreamTransportState.java index 5560a1abb6d..7124f2fc88a 100644 --- a/core/src/main/java/io/grpc/internal/Http2ClientStreamTransportState.java +++ b/core/src/main/java/io/grpc/internal/Http2ClientStreamTransportState.java @@ -223,8 +223,11 @@ private Status validateInitialMetadata(Metadata headers) { } String contentType = headers.get(GrpcUtil.CONTENT_TYPE_KEY); if (!GrpcUtil.isGrpcContentType(contentType)) { - return GrpcUtil.httpStatusToGrpcStatus(httpStatus) - .augmentDescription("invalid content-type: " + contentType); + Status status = GrpcUtil.httpStatusToGrpcStatus(httpStatus); + if (contentType == null) { + return status.augmentDescription("missing content-type in response headers"); + } + return status.augmentDescription("invalid content-type: " + contentType); } return null; } diff --git a/core/src/main/java/io/grpc/internal/InternalServer.java b/core/src/main/java/io/grpc/internal/InternalServer.java index a6079081233..8449f352b17 100644 --- a/core/src/main/java/io/grpc/internal/InternalServer.java +++ b/core/src/main/java/io/grpc/internal/InternalServer.java @@ -22,13 +22,13 @@ import java.net.SocketAddress; import java.util.List; import javax.annotation.Nullable; -import javax.annotation.concurrent.ThreadSafe; /** * An object that accepts new incoming connections on one or more listening socket addresses. * This would commonly encapsulate a bound socket that {@code accept()}s new connections. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface InternalServer { /** * Starts transport. Implementations must not call {@code listener} until after {@code start()} diff --git a/core/src/main/java/io/grpc/internal/InternalSubchannel.java b/core/src/main/java/io/grpc/internal/InternalSubchannel.java index 649843c5c03..00a66b1c1df 100644 --- a/core/src/main/java/io/grpc/internal/InternalSubchannel.java +++ b/core/src/main/java/io/grpc/internal/InternalSubchannel.java @@ -42,6 +42,7 @@ import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.InternalChannelz; import io.grpc.InternalChannelz.ChannelStats; +import io.grpc.InternalEquivalentAddressGroup; import io.grpc.InternalInstrumented; import io.grpc.InternalLogId; import io.grpc.InternalWithLogId; @@ -80,6 +81,7 @@ final class InternalSubchannel implements InternalInstrumented, Tr private final InternalChannelz channelz; private final CallTracer callsTracer; private final ChannelTracer channelTracer; + private final MetricRecorder metricRecorder; private final ChannelLogger channelLogger; private final boolean reconnectDisabled; @@ -191,6 +193,7 @@ protected void handleNotInUse() { this.scheduledExecutor = scheduledExecutor; this.connectingTimer = stopwatchSupplier.get(); this.syncContext = syncContext; + this.metricRecorder = metricRecorder; this.callback = callback; this.channelz = channelz; this.callsTracer = callsTracer; @@ -265,6 +268,7 @@ private void startNewTransport() { .setAuthority(eagChannelAuthority != null ? eagChannelAuthority : authority) .setEagAttributes(currentEagAttributes) .setUserAgent(userAgent) + .setMetricRecorder(metricRecorder) .setHttpConnectProxiedSocketAddress(proxiedAddr); TransportLogger transportLogger = new TransportLogger(); // In case the transport logs in the constructor, use the subchannel logId @@ -326,7 +330,7 @@ public void run() { } /** - * Immediately attempt to reconnect if the current state is TRANSIENT_FAILURE. Otherwise this + * Immediately attempt to reconnect if the current state is TRANSIENT_FAILURE. Otherwise, this * method has no effect. */ void resetConnectBackoff() { @@ -603,8 +607,8 @@ public void run() { connectedAddressAttributes = addressIndex.getCurrentEagAttributes(); gotoNonErrorState(READY); subchannelMetrics.recordConnectionAttemptSucceeded(/* target= */ target, - /* backendService= */ getAttributeOrDefault( - addressIndex.getCurrentEagAttributes(), NameResolver.ATTR_BACKEND_SERVICE), + /* backendService= */ getBackendServiceOrDefault( + addressIndex.getCurrentEagAttributes()), /* locality= */ getAttributeOrDefault(addressIndex.getCurrentEagAttributes(), EquivalentAddressGroup.ATTR_LOCALITY_NAME), /* securityLevel= */ extractSecurityLevel(addressIndex.getCurrentEagAttributes() @@ -620,7 +624,7 @@ public void transportInUse(boolean inUse) { } @Override - public void transportShutdown(final Status s) { + public void transportShutdown(final Status s, final DisconnectError disconnectError) { channelLogger.log( ChannelLogLevel.INFO, "{0} SHUTDOWN with {1}", transport.getLogId(), printShortStatus(s)); shutdownInitiated = true; @@ -635,18 +639,17 @@ public void run() { addressIndex.reset(); gotoNonErrorState(IDLE); subchannelMetrics.recordDisconnection(/* target= */ target, - /* backendService= */ getAttributeOrDefault(addressIndex.getCurrentEagAttributes(), - NameResolver.ATTR_BACKEND_SERVICE), + /* backendService= */ getBackendServiceOrDefault( + addressIndex.getCurrentEagAttributes()), /* locality= */ getAttributeOrDefault(addressIndex.getCurrentEagAttributes(), EquivalentAddressGroup.ATTR_LOCALITY_NAME), - /* disconnectError= */ SubchannelMetrics.DisconnectError.UNKNOWN - .getErrorString(null), + /* disconnectError= */ disconnectError.toErrorString(), /* securityLevel= */ extractSecurityLevel(addressIndex.getCurrentEagAttributes() .get(GrpcAttributes.ATTR_SECURITY_LEVEL))); } else if (pendingTransport == transport) { subchannelMetrics.recordConnectionAttemptFailed(/* target= */ target, - /* backendService= */getAttributeOrDefault(addressIndex.getCurrentEagAttributes(), - NameResolver.ATTR_BACKEND_SERVICE), + /* backendService= */ getBackendServiceOrDefault( + addressIndex.getCurrentEagAttributes()), /* locality= */ getAttributeOrDefault(addressIndex.getCurrentEagAttributes(), EquivalentAddressGroup.ATTR_LOCALITY_NAME)); Preconditions.checkState(state.getState() == CONNECTING, @@ -709,6 +712,14 @@ private String getAttributeOrDefault(Attributes attributes, Attributes.Key obj, String key) { String.format("value '%s' for key '%s' in '%s' is not a number", value, key, obj)); } - /** - * Gets a number from an object for the given key. If the key is not present, this returns null. - * If the value does not represent a float, throws an exception. - */ - @Nullable - public static Float getNumberAsFloat(Map obj, String key) { - assert key != null; - if (!obj.containsKey(key)) { - return null; - } - Object value = obj.get(key); - if (value instanceof Float) { - return (Float) value; - } - if (value instanceof String) { - try { - return Float.parseFloat((String) value); - } catch (NumberFormatException e) { - throw new IllegalArgumentException( - String.format("string value '%s' for key '%s' cannot be parsed as a float", value, - key)); - } - } - throw new IllegalArgumentException( - String.format("value %s for key '%s' is not a float", value, key)); - } - /** * Gets a number from an object for the given key, casted to an integer. If the key is not * present, this returns null. If the value does not represent an integer, throws an exception. @@ -361,7 +334,8 @@ private static int parseNanos(String value) throws ParseException { /** * Copy of {@link com.google.protobuf.util.Durations#normalizedDuration}. */ - @SuppressWarnings("NarrowingCompoundAssignment") + // Math.addExact() requires Android API level 24 + @SuppressWarnings({"NarrowingCompoundAssignment", "InlineMeInliner"}) private static long normalizedDuration(long seconds, int nanos) { if (nanos <= -NANOS_PER_SECOND || nanos >= NANOS_PER_SECOND) { seconds = checkedAdd(seconds, nanos / NANOS_PER_SECOND); diff --git a/core/src/main/java/io/grpc/internal/KeepAliveManager.java b/core/src/main/java/io/grpc/internal/KeepAliveManager.java index d831a096087..1937da6f467 100644 --- a/core/src/main/java/io/grpc/internal/KeepAliveManager.java +++ b/core/src/main/java/io/grpc/internal/KeepAliveManager.java @@ -262,9 +262,25 @@ public interface KeepAlivePinger { * Default client side {@link KeepAlivePinger}. */ public static final class ClientKeepAlivePinger implements KeepAlivePinger { - private final ConnectionClientTransport transport; - public ClientKeepAlivePinger(ConnectionClientTransport transport) { + + /** + * A {@link ClientTransport} that has life-cycle management. + * + *

This interface is thread-safe. + */ + public interface TransportWithDisconnectReason extends ClientTransport { + + /** + * Initiates a forceful shutdown in which preexisting and new calls are closed. Existing calls + * should be closed with the provided {@code reason} and {@code disconnectError}. + */ + void shutdownNow(Status reason, DisconnectError disconnectError); + } + + private final TransportWithDisconnectReason transport; + + public ClientKeepAlivePinger(TransportWithDisconnectReason transport) { this.transport = transport; } @@ -277,7 +293,8 @@ public void onSuccess(long roundTripTimeNanos) {} @Override public void onFailure(Status cause) { transport.shutdownNow(Status.UNAVAILABLE.withDescription( - "Keepalive failed. The connection is likely gone")); + "Keepalive failed. The connection is likely gone"), + SimpleDisconnectError.CONNECTION_TIMED_OUT); } }, MoreExecutors.directExecutor()); } @@ -285,8 +302,8 @@ public void onFailure(Status cause) { @Override public void onPingTimeout() { transport.shutdownNow(Status.UNAVAILABLE.withDescription( - "Keepalive failed. The connection is likely gone")); + "Keepalive failed. The connection is likely gone"), + SimpleDisconnectError.CONNECTION_TIMED_OUT); } } } - diff --git a/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java b/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java index 849e4b8e45c..1c23b1fa69d 100644 --- a/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java +++ b/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java @@ -69,6 +69,7 @@ import io.grpc.LoadBalancer.ResolvedAddresses; import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.LoadBalancer.SubchannelStateListener; +import io.grpc.LoadBalancerProvider; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.Metadata; @@ -85,7 +86,6 @@ import io.grpc.StatusOr; import io.grpc.SynchronizationContext; import io.grpc.SynchronizationContext.ScheduledHandle; -import io.grpc.internal.AutoConfiguredLoadBalancerFactory.AutoConfiguredLoadBalancer; import io.grpc.internal.ClientCallImpl.ClientStreamProvider; import io.grpc.internal.ClientTransportFactory.SwapChannelCredentialsResult; import io.grpc.internal.ManagedChannelImplBuilder.ClientTransportFactoryBuilder; @@ -95,6 +95,7 @@ import io.grpc.internal.RetriableStream.ChannelBufferMeter; import io.grpc.internal.RetriableStream.Throttle; import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -159,19 +160,17 @@ public Result selectConfig(PickSubchannelArgs args) { @Nullable private final String authorityOverride; private final NameResolverRegistry nameResolverRegistry; - private final URI targetUri; + private final UriWrapper targetUri; private final NameResolverProvider nameResolverProvider; private final NameResolver.Args nameResolverArgs; - private final AutoConfiguredLoadBalancerFactory loadBalancerFactory; + private final LoadBalancerProvider loadBalancerFactory; private final ClientTransportFactory originalTransportFactory; @Nullable private final ChannelCredentials originalChannelCreds; private final ClientTransportFactory transportFactory; - private final ClientTransportFactory oobTransportFactory; private final RestrictedScheduledExecutor scheduledExecutor; private final Executor executor; private final ObjectPool executorPool; - private final ObjectPool balancerRpcExecutorPool; private final ExecutorHolder balancerRpcExecutorHolder; private final ExecutorHolder offloadExecutorHolder; private final TimeProvider timeProvider; @@ -240,9 +239,6 @@ public void uncaughtException(Thread t, Throwable e) { private Collection> pendingCalls; private final Object pendingCallsInUseObject = new Object(); - // Must be mutated from syncContext - private final Set oobChannels = new HashSet<>(1, .75f); - // reprocess() must be run from syncContext private final DelayedClientTransport delayedTransport; private final UncommittedRetriableStreamsRegistry uncommittedRetriableStreamsRegistry @@ -312,9 +308,6 @@ private void maybeShutdownNowSubchannels() { for (InternalSubchannel subchannel : subchannels) { subchannel.shutdownNow(SHUTDOWN_NOW_STATUS); } - for (OobChannel oobChannel : oobChannels) { - oobChannel.getInternalSubchannel().shutdownNow(SHUTDOWN_NOW_STATUS); - } } } @@ -334,7 +327,6 @@ public void run() { builder.setTarget(target).setState(channelStateManager.getState()); List children = new ArrayList<>(); children.addAll(subchannels); - children.addAll(oobChannels); builder.setSubchannels(children); ret.set(builder.build()); } @@ -546,7 +538,7 @@ ClientStream newSubstream( ManagedChannelImpl( ManagedChannelImplBuilder builder, ClientTransportFactory clientTransportFactory, - URI targetUri, + UriWrapper targetUri, NameResolverProvider nameResolverProvider, BackoffPolicy.Provider backoffPolicyProvider, ObjectPool balancerRpcExecutorPool, @@ -564,8 +556,6 @@ ClientStream newSubstream( new ExecutorHolder(checkNotNull(builder.offloadExecutorPool, "offloadExecutorPool")); this.transportFactory = new CallCredentialsApplyingTransportFactory( clientTransportFactory, builder.callCredentials, this.offloadExecutorHolder); - this.oobTransportFactory = new CallCredentialsApplyingTransportFactory( - clientTransportFactory, null, this.offloadExecutorHolder); this.scheduledExecutor = new RestrictedScheduledExecutor(transportFactory.getScheduledExecutorService()); maxTraceEvents = builder.maxTraceEvents; @@ -604,8 +594,8 @@ ClientStream newSubstream( this.nameResolverArgs = nameResolverArgsBuilder.build(); this.nameResolver = getNameResolver( targetUri, authorityOverride, nameResolverProvider, nameResolverArgs); - this.balancerRpcExecutorPool = checkNotNull(balancerRpcExecutorPool, "balancerRpcExecutorPool"); - this.balancerRpcExecutorHolder = new ExecutorHolder(balancerRpcExecutorPool); + this.balancerRpcExecutorHolder = new ExecutorHolder( + checkNotNull(balancerRpcExecutorPool, "balancerRpcExecutorPool")); this.delayedTransport = new DelayedClientTransport(this.executor, this.syncContext); this.delayedTransport.start(delayedTransportListener); this.backoffPolicyProvider = backoffPolicyProvider; @@ -677,9 +667,9 @@ public CallTracer create() { @VisibleForTesting static NameResolver getNameResolver( - URI targetUri, @Nullable final String overrideAuthority, + UriWrapper targetUri, @Nullable final String overrideAuthority, NameResolverProvider provider, NameResolver.Args nameResolverArgs) { - NameResolver resolver = provider.newNameResolver(targetUri, nameResolverArgs); + NameResolver resolver = targetUri.newNameResolver(provider, nameResolverArgs); if (resolver == null) { throw new IllegalArgumentException("cannot create a NameResolver for " + targetUri); } @@ -996,9 +986,12 @@ private final class PendingCall extends DelayedClientCall method, CallOptions callOptions) { - super(getCallExecutor(callOptions), scheduledExecutor, callOptions.getDeadline()); + PendingCall(Context context, MethodDescriptor method, CallOptions callOptions) { + super( + "name_resolver", + getCallExecutor(callOptions), + scheduledExecutor, + callOptions.getDeadline()); this.context = context; this.method = method; this.callOptions = callOptions; @@ -1187,7 +1180,7 @@ private void maybeTerminateChannel() { if (terminated) { return; } - if (shutdown.get() && subchannels.isEmpty() && oobChannels.isEmpty()) { + if (shutdown.get() && subchannels.isEmpty()) { channelLogger.log(ChannelLogLevel.INFO, "Terminated"); channelz.removeRootChannel(this); executorPool.returnObject(executor); @@ -1201,13 +1194,6 @@ private void maybeTerminateChannel() { } } - // Must be called from syncContext - private void handleInternalSubchannelState(ConnectivityStateInfo newState) { - if (newState.getState() == TRANSIENT_FAILURE || newState.getState() == IDLE) { - refreshNameResolution(); - } - } - @Override public ConnectivityState getState(boolean requestConnection) { ConnectivityState savedChannelState = channelStateManager.getState(); @@ -1253,9 +1239,6 @@ public void run() { for (InternalSubchannel subchannel : subchannels) { subchannel.resetConnectBackoff(); } - for (OobChannel oobChannel : oobChannels) { - oobChannel.resetConnectBackoff(); - } } } @@ -1362,7 +1345,7 @@ void remove(RetriableStream retriableStream) { } private final class LbHelperImpl extends LoadBalancer.Helper { - AutoConfiguredLoadBalancer lb; + LoadBalancer lb; @Override public AbstractSubchannel createSubchannel(CreateSubchannelArgs args) { @@ -1413,86 +1396,28 @@ public ManagedChannel createOobChannel(EquivalentAddressGroup addressGroup, Stri @Override public ManagedChannel createOobChannel(List addressGroup, String authority) { - // TODO(ejona): can we be even stricter? Like terminating? - checkState(!terminated, "Channel is terminated"); - long oobChannelCreationTime = timeProvider.currentTimeNanos(); - InternalLogId oobLogId = InternalLogId.allocate("OobChannel", /*details=*/ null); - InternalLogId subchannelLogId = - InternalLogId.allocate("Subchannel-OOB", /*details=*/ authority); - ChannelTracer oobChannelTracer = - new ChannelTracer( - oobLogId, maxTraceEvents, oobChannelCreationTime, - "OobChannel for " + addressGroup); - final OobChannel oobChannel = new OobChannel( - authority, balancerRpcExecutorPool, oobTransportFactory.getScheduledExecutorService(), - syncContext, callTracerFactory.create(), oobChannelTracer, channelz, timeProvider); - channelTracer.reportEvent(new ChannelTrace.Event.Builder() - .setDescription("Child OobChannel created") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(oobChannelCreationTime) - .setChannelRef(oobChannel) - .build()); - ChannelTracer subchannelTracer = - new ChannelTracer(subchannelLogId, maxTraceEvents, oobChannelCreationTime, - "Subchannel for " + addressGroup); - ChannelLogger subchannelLogger = new ChannelLoggerImpl(subchannelTracer, timeProvider); - final class ManagedOobChannelCallback extends InternalSubchannel.Callback { - @Override - void onTerminated(InternalSubchannel is) { - oobChannels.remove(oobChannel); - channelz.removeSubchannel(is); - oobChannel.handleSubchannelTerminated(); - maybeTerminateChannel(); - } - - @Override - void onStateChange(InternalSubchannel is, ConnectivityStateInfo newState) { - // TODO(chengyuanzhang): change to let LB policies explicitly manage OOB channel's - // state and refresh name resolution if necessary. - handleInternalSubchannelState(newState); - oobChannel.handleSubchannelStateChange(newState); - } - } - - final InternalSubchannel internalSubchannel = new InternalSubchannel( - CreateSubchannelArgs.newBuilder().setAddresses(addressGroup).build(), - authority, userAgent, backoffPolicyProvider, oobTransportFactory, - oobTransportFactory.getScheduledExecutorService(), stopwatchSupplier, syncContext, - // All callback methods are run from syncContext - new ManagedOobChannelCallback(), - channelz, - callTracerFactory.create(), - subchannelTracer, - subchannelLogId, - subchannelLogger, - transportFilters, - target, - lbHelper.getMetricRecorder()); - oobChannelTracer.reportEvent(new ChannelTrace.Event.Builder() - .setDescription("Child Subchannel created") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(oobChannelCreationTime) - .setSubchannelRef(internalSubchannel) - .build()); - channelz.addSubchannel(oobChannel); - channelz.addSubchannel(internalSubchannel); - oobChannel.setSubchannel(internalSubchannel); - final class AddOobChannel implements Runnable { - @Override - public void run() { - if (terminating) { - oobChannel.shutdown(); - } - if (!terminated) { - // If channel has not terminated, it will track the subchannel and block termination - // for it. - oobChannels.add(oobChannel); - } - } - } - - syncContext.execute(new AddOobChannel()); - return oobChannel; + NameResolverRegistry nameResolverRegistry = new NameResolverRegistry(); + OobNameResolverProvider resolverProvider = + new OobNameResolverProvider(authority, addressGroup, syncContext); + nameResolverRegistry.register(resolverProvider); + // We could use a hard-coded target, as the name resolver won't actually use this string. + // However, that would make debugging less clear, as we use the target to identify the + // channel. + String target; + try { + target = new URI("oob", "", "/" + authority, null, null).toString(); + } catch (URISyntaxException ex) { + // Any special characters in the path will be percent encoded. So this should be impossible. + throw new AssertionError(ex); + } + ManagedChannel delegate = createResolvingOobChannelBuilder( + target, new DefaultChannelCreds(), nameResolverRegistry) + // TODO(zdapeng): executors should not outlive the parent channel. + .executor(balancerRpcExecutorHolder.getExecutor()) + .idleTimeout(Integer.MAX_VALUE, TimeUnit.SECONDS) + .disableRetry() + .build(); + return new OobChannel(delegate, resolverProvider); } @Deprecated @@ -1504,11 +1429,17 @@ public ManagedChannelBuilder createResolvingOobChannelBuilder(String target) .overrideAuthority(getAuthority()); } - // TODO(creamsoup) prevent main channel to shutdown if oob channel is not terminated - // TODO(zdapeng) register the channel as a subchannel of the parent channel in channelz. @Override public ManagedChannelBuilder createResolvingOobChannelBuilder( final String target, final ChannelCredentials channelCreds) { + return createResolvingOobChannelBuilder(target, channelCreds, nameResolverRegistry); + } + + // TODO(creamsoup) prevent main channel to shutdown if oob channel is not terminated + // TODO(zdapeng) register the channel as a subchannel of the parent channel in channelz. + private ManagedChannelBuilder createResolvingOobChannelBuilder( + final String target, final ChannelCredentials channelCreds, + NameResolverRegistry nameResolverRegistry) { checkNotNull(channelCreds, "channelCreds"); final class ResolvingOobChannelBuilder @@ -1641,6 +1572,19 @@ public ChannelCredentials withoutBearerTokens() { } } + static final class OobChannel extends ForwardingManagedChannel { + private final OobNameResolverProvider resolverProvider; + + public OobChannel(ManagedChannel delegate, OobNameResolverProvider resolverProvider) { + super(delegate); + this.resolverProvider = checkNotNull(resolverProvider, "resolverProvider"); + } + + public void updateAddresses(List eags) { + resolverProvider.updateAddresses(eags); + } + } + final class NameResolverListener extends NameResolver.Listener2 { final LbHelperImpl helper; final NameResolver resolver; @@ -1786,7 +1730,7 @@ public Status onResult2(final ResolutionResult resolutionResult) { .setAddresses(serversOrError.getValue()) .setAttributes(attributes) .setLoadBalancingPolicyConfig(effectiveServiceConfig.getLoadBalancingConfig()); - Status addressAcceptanceStatus = helper.lb.tryAcceptResolvedAddresses( + Status addressAcceptanceStatus = helper.lb.acceptResolvedAddresses( resolvedAddresses.build()); return addressAcceptanceStatus; } @@ -2056,7 +2000,7 @@ public String toString() { */ private final class DelayedTransportListener implements ManagedClientTransport.Listener { @Override - public void transportShutdown(Status s) { + public void transportShutdown(Status s, DisconnectError e) { checkState(shutdown.get(), "Channel must have been shut down"); } diff --git a/core/src/main/java/io/grpc/internal/ManagedChannelImplBuilder.java b/core/src/main/java/io/grpc/internal/ManagedChannelImplBuilder.java index fc3c7891008..22a933b9158 100644 --- a/core/src/main/java/io/grpc/internal/ManagedChannelImplBuilder.java +++ b/core/src/main/java/io/grpc/internal/ManagedChannelImplBuilder.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import static io.grpc.internal.UriWrapper.wrap; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; @@ -37,6 +38,7 @@ import io.grpc.EquivalentAddressGroup; import io.grpc.InternalChannelz; import io.grpc.InternalConfiguratorRegistry; +import io.grpc.InternalFeatureFlags; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.MethodDescriptor; @@ -46,6 +48,7 @@ import io.grpc.NameResolverRegistry; import io.grpc.ProxyDetector; import io.grpc.StatusOr; +import io.grpc.Uri; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.SocketAddress; @@ -153,6 +156,9 @@ public static ManagedChannelBuilder forTarget(String target) { private final List interceptors = new ArrayList<>(); NameResolverRegistry nameResolverRegistry = NameResolverRegistry.getDefaultRegistry(); + @Nullable + NameResolverProvider nameResolverProvider; + final List transportFilters = new ArrayList<>(); final String target; @@ -288,6 +294,36 @@ public ManagedChannelImplBuilder( String target, @Nullable ChannelCredentials channelCreds, @Nullable CallCredentials callCreds, ClientTransportFactoryBuilder clientTransportFactoryBuilder, @Nullable ChannelBuilderDefaultPortProvider channelBuilderDefaultPortProvider) { + this( + target, + channelCreds, + callCreds, + clientTransportFactoryBuilder, + channelBuilderDefaultPortProvider, + null, + null); + } + + /** + * Creates a new managed channel builder with a target string, which can be + * either a valid {@link io.grpc.NameResolver}-compliant URI, or an authority + * string. Transport + * implementors must provide client transport factory builder, and may set + * custom channel default + * port provider. + * + * @param channelCreds The ChannelCredentials provided by the user. + * These may be used when + * creating derivative channels. + * @param nameResolverRegistry the registry used to look up name resolvers. + * @param nameResolverProvider the provider used to look up name resolvers. + */ + public ManagedChannelImplBuilder( + String target, @Nullable ChannelCredentials channelCreds, @Nullable CallCredentials callCreds, + ClientTransportFactoryBuilder clientTransportFactoryBuilder, + @Nullable ChannelBuilderDefaultPortProvider channelBuilderDefaultPortProvider, + @Nullable NameResolverRegistry nameResolverRegistry, + @Nullable NameResolverProvider nameResolverProvider) { this.target = checkNotNull(target, "target"); this.channelCredentials = channelCreds; this.callCredentials = callCreds; @@ -295,11 +331,16 @@ public ManagedChannelImplBuilder( "clientTransportFactoryBuilder"); this.directServerAddress = null; - if (channelBuilderDefaultPortProvider != null) { - this.channelBuilderDefaultPortProvider = channelBuilderDefaultPortProvider; - } else { - this.channelBuilderDefaultPortProvider = new ManagedChannelDefaultPortProvider(); - } + this.channelBuilderDefaultPortProvider = + channelBuilderDefaultPortProvider != null + ? channelBuilderDefaultPortProvider + : new ManagedChannelDefaultPortProvider(); + this.nameResolverRegistry = + nameResolverRegistry != null + ? nameResolverRegistry + : NameResolverRegistry.getDefaultRegistry(); + this.nameResolverProvider = nameResolverProvider; + // TODO(dnvindhya): Move configurator to all the individual builders InternalConfiguratorRegistry.configureChannelBuilder(this); } @@ -419,6 +460,7 @@ public ManagedChannelImplBuilder nameResolverFactory(NameResolver.Factory resolv Preconditions.checkState(directServerAddress == null, "directServerAddress is set (%s), which forbids the use of NameResolverFactory", directServerAddress); + if (resolverFactory != null) { NameResolverRegistry reg = new NameResolverRegistry(); if (resolverFactory instanceof NameResolverProvider) { @@ -579,8 +621,8 @@ public ManagedChannelImplBuilder defaultServiceConfig(@Nullable Map s parsedMap.put(key, checkListEntryTypes((List) value)); } else if (value instanceof String) { parsedMap.put(key, value); - } else if (value instanceof Double) { - parsedMap.put(key, value); + } else if (value instanceof Number) { + parsedMap.put(key, ((Number) value).doubleValue()); } else if (value instanceof Boolean) { parsedMap.put(key, value); } else { @@ -603,8 +645,8 @@ private static List checkListEntryTypes(List list) { parsedList.add(checkListEntryTypes((List) value)); } else if (value instanceof String) { parsedList.add(value); - } else if (value instanceof Double) { - parsedList.add(value); + } else if (value instanceof Number) { + parsedList.add(((Number) value).doubleValue()); } else if (value instanceof Boolean) { parsedList.add(value); } else { @@ -718,8 +760,11 @@ protected ManagedChannelImplBuilder addMetricSink(MetricSink metricSink) { public ManagedChannel build() { ClientTransportFactory clientTransportFactory = clientTransportFactoryBuilder.buildClientTransportFactory(); - ResolvedNameResolver resolvedResolver = getNameResolverProvider( - target, nameResolverRegistry, clientTransportFactory.getSupportedSocketAddressTypes()); + ResolvedNameResolver resolvedResolver = + InternalFeatureFlags.getRfc3986UrisEnabled() + ? getNameResolverProviderRfc3986(target, nameResolverRegistry, nameResolverProvider) + : getNameResolverProvider(target, nameResolverRegistry, nameResolverProvider); + resolvedResolver.checkAddressTypes(clientTransportFactory.getSupportedSocketAddressTypes()); return new ManagedChannelOrphanWrapper(new ManagedChannelImpl( this, clientTransportFactory, @@ -759,7 +804,7 @@ List getEffectiveInterceptors(String computedTarget) { if (GET_CLIENT_INTERCEPTOR_METHOD != null) { try { statsInterceptor = - (ClientInterceptor) GET_CLIENT_INTERCEPTOR_METHOD + (ClientInterceptor) GET_CLIENT_INTERCEPTOR_METHOD .invoke( null, recordStartedRpcs, @@ -814,19 +859,33 @@ int getDefaultPort() { @VisibleForTesting static class ResolvedNameResolver { - public final URI targetUri; + public final UriWrapper targetUri; public final NameResolverProvider provider; - public ResolvedNameResolver(URI targetUri, NameResolverProvider provider) { + public ResolvedNameResolver(UriWrapper targetUri, NameResolverProvider provider) { this.targetUri = checkNotNull(targetUri, "targetUri"); this.provider = checkNotNull(provider, "provider"); } + + void checkAddressTypes( + Collection> channelTransportSocketAddressTypes) { + if (channelTransportSocketAddressTypes != null) { + Collection> nameResolverSocketAddressTypes = + provider.getProducedSocketAddressTypes(); + if (!channelTransportSocketAddressTypes.containsAll(nameResolverSocketAddressTypes)) { + throw new IllegalArgumentException( + String.format( + "Address types of NameResolver '%s' for '%s' not supported by transport", + provider.getDefaultScheme(), targetUri)); + } + } + } } @VisibleForTesting static ResolvedNameResolver getNameResolverProvider( String target, NameResolverRegistry nameResolverRegistry, - Collection> channelTransportSocketAddressTypes) { + NameResolverProvider nameResolverProvider) { // Finding a NameResolver. Try using the target string as the URI. If that fails, try prepending // "dns:///". NameResolverProvider provider = null; @@ -841,19 +900,33 @@ static ResolvedNameResolver getNameResolverProvider( if (targetUri != null) { // For "localhost:8080" this would likely cause provider to be null, because "localhost" is // parsed as the scheme. Will hit the next case and try "dns:///localhost:8080". - provider = nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); + // Use the explicit provider if its scheme matches the target URI. + if (nameResolverProvider != null + && targetUri.getScheme().equals(nameResolverProvider.getScheme())) { + provider = nameResolverProvider; + } else { + provider = nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); + } } if (provider == null && !URI_PATTERN.matcher(target).matches()) { - // It doesn't look like a URI target. Maybe it's an authority string. Try with the default - // scheme from the registry. + // It doesn't look like a URI target. Maybe it's an authority string. Try with + // the default scheme from the registry (if provider is not specified) or + // the provider's default scheme (if provider is specified). + String scheme = nameResolverProvider != null + ? nameResolverProvider.getScheme() + : nameResolverRegistry.getDefaultScheme(); try { - targetUri = new URI(nameResolverRegistry.getDefaultScheme(), "", "/" + target, null); + targetUri = new URI(scheme, "", "/" + target, null); } catch (URISyntaxException e) { - // Should not be possible. + // Should not be possible throw new IllegalArgumentException(e); } - provider = nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); + if (nameResolverProvider != null) { + provider = nameResolverProvider; + } else { + provider = nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); + } } if (provider == null) { @@ -862,17 +935,60 @@ static ResolvedNameResolver getNameResolverProvider( target, uriSyntaxErrors.length() > 0 ? " (" + uriSyntaxErrors + ")" : "")); } - if (channelTransportSocketAddressTypes != null) { - Collection> nameResolverSocketAddressTypes - = provider.getProducedSocketAddressTypes(); - if (!channelTransportSocketAddressTypes.containsAll(nameResolverSocketAddressTypes)) { - throw new IllegalArgumentException(String.format( - "Address types of NameResolver '%s' for '%s' not supported by transport", - targetUri.getScheme(), target)); + return new ResolvedNameResolver(wrap(targetUri), provider); + } + + @VisibleForTesting + static ResolvedNameResolver getNameResolverProviderRfc3986( + String target, NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { + // Finding a NameResolver. Try using the target string as the URI. If that fails, try prepending + // "dns:///". + NameResolverProvider provider = null; + Uri targetUri = null; + StringBuilder uriSyntaxErrors = new StringBuilder(); + try { + targetUri = Uri.parse(target); + } catch (URISyntaxException e) { + // Can happen with ip addresses like "[::1]:1234" or 127.0.0.1:1234. + uriSyntaxErrors.append(e.getMessage()); + } + if (targetUri != null) { + // For "localhost:8080" this would likely cause provider to be null, because "localhost" is + // parsed as the scheme. Will hit the next case and try "dns:///localhost:8080". + // Use the explicit provider if its scheme matches the target URI. + if (nameResolverProvider != null + && targetUri.getScheme().equals(nameResolverProvider.getScheme())) { + provider = nameResolverProvider; + } else { + provider = nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); } } - return new ResolvedNameResolver(targetUri, provider); + if (provider == null && !URI_PATTERN.matcher(target).matches()) { + // It doesn't look like a URI target. Maybe it's an authority string. Try with + // the default scheme from the registry (if provider is not specified) or + // the provider's default scheme (if provider is specified). + String scheme = nameResolverProvider != null + ? nameResolverProvider.getScheme() + : nameResolverRegistry.getDefaultScheme(); + targetUri = + Uri.newBuilder() + .setScheme(scheme) + .setHost("") + .setPath("/" + target) + .build(); + provider = nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); + } + + if (provider == null) { + throw new IllegalArgumentException( + String.format( + "Could not find a NameResolverProvider for %s%s", + target, uriSyntaxErrors.length() > 0 ? " (" + uriSyntaxErrors + ")" : "")); + } + + return new ResolvedNameResolver(wrap(targetUri), provider); } private static class DirectAddressNameResolverProvider extends NameResolverProvider { diff --git a/core/src/main/java/io/grpc/internal/ManagedChannelOrphanWrapper.java b/core/src/main/java/io/grpc/internal/ManagedChannelOrphanWrapper.java index eac9b64d9db..790d5bd297f 100644 --- a/core/src/main/java/io/grpc/internal/ManagedChannelOrphanWrapper.java +++ b/core/src/main/java/io/grpc/internal/ManagedChannelOrphanWrapper.java @@ -63,12 +63,20 @@ final class ManagedChannelOrphanWrapper extends ForwardingManagedChannel { @Override public ManagedChannel shutdown() { phantom.clearSafely(); + // This dummy check prevents the JIT from collecting 'this' too early + if (this.getClass() == null) { + throw new AssertionError(); + } return super.shutdown(); } @Override public ManagedChannel shutdownNow() { phantom.clearSafely(); + // This dummy check prevents the JIT from collecting 'this' too early + if (this.getClass() == null) { + throw new AssertionError(); + } return super.shutdownNow(); } @@ -151,8 +159,9 @@ static int cleanQueue(ReferenceQueue refqueue) { int orphanedChannels = 0; while ((ref = (ManagedChannelReference) refqueue.poll()) != null) { RuntimeException maybeAllocationSite = ref.allocationSite.get(); + boolean wasShutdown = ref.shutdown.get(); ref.clearInternal(); // technically the reference is gone already. - if (!ref.shutdown.get()) { + if (!wasShutdown) { orphanedChannels++; Level level = Level.SEVERE; if (logger.isLoggable(level)) { diff --git a/core/src/main/java/io/grpc/internal/ManagedClientTransport.java b/core/src/main/java/io/grpc/internal/ManagedClientTransport.java index 184a4d98955..99a3bd1eceb 100644 --- a/core/src/main/java/io/grpc/internal/ManagedClientTransport.java +++ b/core/src/main/java/io/grpc/internal/ManagedClientTransport.java @@ -20,7 +20,6 @@ import io.grpc.Attributes; import io.grpc.Status; import javax.annotation.Nullable; -import javax.annotation.concurrent.ThreadSafe; /** * A {@link ClientTransport} that has life-cycle management. @@ -32,8 +31,9 @@ * implementations may transfer the streams to somewhere else. Either way they must conform to the * contract defined by {@link #shutdown}, {@link Listener#transportShutdown} and * {@link Listener#transportTerminated}. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface ManagedClientTransport extends ClientTransport { /** @@ -77,8 +77,9 @@ interface Listener { *

This is called exactly once, and must be called prior to {@link #transportTerminated}. * * @param s the reason for the shutdown. + * @param e the disconnect error. */ - void transportShutdown(Status s); + void transportShutdown(Status s, DisconnectError e); /** * The transport completed shutting down. All resources have been released. All streams have diff --git a/core/src/main/java/io/grpc/internal/MessageDeframer.java b/core/src/main/java/io/grpc/internal/MessageDeframer.java index 13a01efec0a..f388c006e97 100644 --- a/core/src/main/java/io/grpc/internal/MessageDeframer.java +++ b/core/src/main/java/io/grpc/internal/MessageDeframer.java @@ -314,6 +314,12 @@ private boolean readRequiredBytes() { int totalBytesRead = 0; int deflatedBytesRead = 0; try { + // Avoid allocating nextFrame when idle + if (requiredLength > 0 && fullStreamDecompressor == null + && unprocessed.readableBytes() == 0) { + return false; + } + if (nextFrame == null) { nextFrame = new CompositeReadableBuffer(); } diff --git a/core/src/main/java/io/grpc/internal/MetadataApplierImpl.java b/core/src/main/java/io/grpc/internal/MetadataApplierImpl.java index 09a1018c11c..166f97b78f5 100644 --- a/core/src/main/java/io/grpc/internal/MetadataApplierImpl.java +++ b/core/src/main/java/io/grpc/internal/MetadataApplierImpl.java @@ -120,7 +120,7 @@ ClientStream returnStream() { synchronized (lock) { if (returnedStream == null) { // apply() has not been called, needs to buffer the requests. - delayedStream = new DelayedStream(); + delayedStream = new DelayedStream("call_credentials"); return returnedStream = delayedStream; } else { return returnedStream; diff --git a/core/src/main/java/io/grpc/internal/MetricRecorderImpl.java b/core/src/main/java/io/grpc/internal/MetricRecorderImpl.java index ded9d5ce589..6a12a38d677 100644 --- a/core/src/main/java/io/grpc/internal/MetricRecorderImpl.java +++ b/core/src/main/java/io/grpc/internal/MetricRecorderImpl.java @@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import io.grpc.CallbackMetricInstrument; import io.grpc.DoubleCounterMetricInstrument; import io.grpc.DoubleHistogramMetricInstrument; @@ -49,7 +50,7 @@ final class MetricRecorderImpl implements MetricRecorder { @VisibleForTesting MetricRecorderImpl(List metricSinks, MetricInstrumentRegistry registry) { - this.metricSinks = metricSinks; + this.metricSinks = ImmutableList.copyOf(metricSinks); this.registry = registry; } diff --git a/core/src/main/java/io/grpc/internal/NameResolverFactoryToProviderFacade.java b/core/src/main/java/io/grpc/internal/NameResolverFactoryToProviderFacade.java index 31c20f6e499..e52eb5e38d4 100644 --- a/core/src/main/java/io/grpc/internal/NameResolverFactoryToProviderFacade.java +++ b/core/src/main/java/io/grpc/internal/NameResolverFactoryToProviderFacade.java @@ -19,6 +19,7 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.Args; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import java.net.URI; public class NameResolverFactoryToProviderFacade extends NameResolverProvider { @@ -34,6 +35,11 @@ public NameResolver newNameResolver(URI targetUri, Args args) { return factory.newNameResolver(targetUri, args); } + @Override + public NameResolver newNameResolver(Uri targetUri, Args args) { + return factory.newNameResolver(targetUri, args); + } + @Override public String getDefaultScheme() { return factory.getDefaultScheme(); diff --git a/core/src/main/java/io/grpc/internal/NoopClientStream.java b/core/src/main/java/io/grpc/internal/NoopClientStream.java index d44170f69fa..d77d72a5412 100644 --- a/core/src/main/java/io/grpc/internal/NoopClientStream.java +++ b/core/src/main/java/io/grpc/internal/NoopClientStream.java @@ -45,7 +45,9 @@ public Attributes getAttributes() { public void request(int numMessages) {} @Override - public void writeMessage(InputStream message) {} + public void writeMessage(InputStream message) { + GrpcUtil.closeQuietly(message); + } @Override public void flush() {} diff --git a/core/src/main/java/io/grpc/internal/ObjectPool.java b/core/src/main/java/io/grpc/internal/ObjectPool.java index 13547bc274a..5589cbbdf3c 100644 --- a/core/src/main/java/io/grpc/internal/ObjectPool.java +++ b/core/src/main/java/io/grpc/internal/ObjectPool.java @@ -16,12 +16,11 @@ package io.grpc.internal; -import javax.annotation.concurrent.ThreadSafe; - /** * An object pool. + * + *

This interface is thread-safe. */ -@ThreadSafe public interface ObjectPool { /** * Get an object from the pool. diff --git a/core/src/main/java/io/grpc/internal/OobChannel.java b/core/src/main/java/io/grpc/internal/OobChannel.java deleted file mode 100644 index 30c9f55e796..00000000000 --- a/core/src/main/java/io/grpc/internal/OobChannel.java +++ /dev/null @@ -1,345 +0,0 @@ -/* - * Copyright 2016 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.internal; - -import static com.google.common.base.Preconditions.checkNotNull; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.MoreObjects; -import com.google.common.base.Preconditions; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; -import io.grpc.Attributes; -import io.grpc.CallOptions; -import io.grpc.ClientCall; -import io.grpc.ClientStreamTracer; -import io.grpc.ConnectivityState; -import io.grpc.ConnectivityStateInfo; -import io.grpc.Context; -import io.grpc.EquivalentAddressGroup; -import io.grpc.InternalChannelz; -import io.grpc.InternalChannelz.ChannelStats; -import io.grpc.InternalChannelz.ChannelTrace; -import io.grpc.InternalInstrumented; -import io.grpc.InternalLogId; -import io.grpc.InternalWithLogId; -import io.grpc.LoadBalancer; -import io.grpc.LoadBalancer.PickResult; -import io.grpc.LoadBalancer.PickSubchannelArgs; -import io.grpc.LoadBalancer.Subchannel; -import io.grpc.LoadBalancer.SubchannelPicker; -import io.grpc.ManagedChannel; -import io.grpc.Metadata; -import io.grpc.MethodDescriptor; -import io.grpc.Status; -import io.grpc.SynchronizationContext; -import io.grpc.internal.ClientCallImpl.ClientStreamProvider; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executor; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.annotation.concurrent.ThreadSafe; - -/** - * A ManagedChannel backed by a single {@link InternalSubchannel} and used for {@link LoadBalancer} - * to its own RPC needs. - */ -@ThreadSafe -final class OobChannel extends ManagedChannel implements InternalInstrumented { - private static final Logger log = Logger.getLogger(OobChannel.class.getName()); - - private InternalSubchannel subchannel; - private AbstractSubchannel subchannelImpl; - private SubchannelPicker subchannelPicker; - - private final InternalLogId logId; - private final String authority; - private final DelayedClientTransport delayedTransport; - private final InternalChannelz channelz; - private final ObjectPool executorPool; - private final Executor executor; - private final ScheduledExecutorService deadlineCancellationExecutor; - private final CountDownLatch terminatedLatch = new CountDownLatch(1); - private volatile boolean shutdown; - private final CallTracer channelCallsTracer; - private final ChannelTracer channelTracer; - private final TimeProvider timeProvider; - - private final ClientStreamProvider transportProvider = new ClientStreamProvider() { - @Override - public ClientStream newStream(MethodDescriptor method, - CallOptions callOptions, Metadata headers, Context context) { - ClientStreamTracer[] tracers = GrpcUtil.getClientStreamTracers( - callOptions, headers, 0, /* isTransparentRetry= */ false, - /* isHedging= */ false); - Context origContext = context.attach(); - // delayed transport's newStream() always acquires a lock, but concurrent performance doesn't - // matter here because OOB communication should be sparse, and it's not on application RPC's - // critical path. - try { - return delayedTransport.newStream(method, headers, callOptions, tracers); - } finally { - context.detach(origContext); - } - } - }; - - OobChannel( - String authority, ObjectPool executorPool, - ScheduledExecutorService deadlineCancellationExecutor, SynchronizationContext syncContext, - CallTracer callsTracer, ChannelTracer channelTracer, InternalChannelz channelz, - TimeProvider timeProvider) { - this.authority = checkNotNull(authority, "authority"); - this.logId = InternalLogId.allocate(getClass(), authority); - this.executorPool = checkNotNull(executorPool, "executorPool"); - this.executor = checkNotNull(executorPool.getObject(), "executor"); - this.deadlineCancellationExecutor = checkNotNull( - deadlineCancellationExecutor, "deadlineCancellationExecutor"); - this.delayedTransport = new DelayedClientTransport(executor, syncContext); - this.channelz = Preconditions.checkNotNull(channelz); - this.delayedTransport.start(new ManagedClientTransport.Listener() { - @Override - public void transportShutdown(Status s) { - // Don't care - } - - @Override - public void transportTerminated() { - subchannelImpl.shutdown(); - } - - @Override - public void transportReady() { - // Don't care - } - - @Override - public Attributes filterTransport(Attributes attributes) { - return attributes; - } - - @Override - public void transportInUse(boolean inUse) { - // Don't care - } - }); - this.channelCallsTracer = callsTracer; - this.channelTracer = checkNotNull(channelTracer, "channelTracer"); - this.timeProvider = checkNotNull(timeProvider, "timeProvider"); - } - - // Must be called only once, right after the OobChannel is created. - void setSubchannel(final InternalSubchannel subchannel) { - log.log(Level.FINE, "[{0}] Created with [{1}]", new Object[] {this, subchannel}); - this.subchannel = subchannel; - subchannelImpl = new AbstractSubchannel() { - @Override - public void shutdown() { - subchannel.shutdown(Status.UNAVAILABLE.withDescription("OobChannel is shutdown")); - } - - @Override - InternalInstrumented getInstrumentedInternalSubchannel() { - return subchannel; - } - - @Override - public void requestConnection() { - subchannel.obtainActiveTransport(); - } - - @Override - public List getAllAddresses() { - return subchannel.getAddressGroups(); - } - - @Override - public Attributes getAttributes() { - return Attributes.EMPTY; - } - - @Override - public Object getInternalSubchannel() { - return subchannel; - } - }; - - final class OobSubchannelPicker extends SubchannelPicker { - final PickResult result = PickResult.withSubchannel(subchannelImpl); - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return result; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(OobSubchannelPicker.class) - .add("result", result) - .toString(); - } - } - - subchannelPicker = new OobSubchannelPicker(); - delayedTransport.reprocess(subchannelPicker); - } - - void updateAddresses(List eag) { - subchannel.updateAddresses(eag); - } - - @Override - public ClientCall newCall( - MethodDescriptor methodDescriptor, CallOptions callOptions) { - return new ClientCallImpl<>(methodDescriptor, - callOptions.getExecutor() == null ? executor : callOptions.getExecutor(), - callOptions, transportProvider, deadlineCancellationExecutor, channelCallsTracer, null); - } - - @Override - public String authority() { - return authority; - } - - @Override - public boolean isTerminated() { - return terminatedLatch.getCount() == 0; - } - - @Override - public boolean awaitTermination(long time, TimeUnit unit) throws InterruptedException { - return terminatedLatch.await(time, unit); - } - - @Override - public ConnectivityState getState(boolean requestConnectionIgnored) { - if (subchannel == null) { - return ConnectivityState.IDLE; - } - return subchannel.getState(); - } - - @Override - public ManagedChannel shutdown() { - shutdown = true; - delayedTransport.shutdown(Status.UNAVAILABLE.withDescription("OobChannel.shutdown() called")); - return this; - } - - @Override - public boolean isShutdown() { - return shutdown; - } - - @Override - public ManagedChannel shutdownNow() { - shutdown = true; - delayedTransport.shutdownNow( - Status.UNAVAILABLE.withDescription("OobChannel.shutdownNow() called")); - return this; - } - - void handleSubchannelStateChange(final ConnectivityStateInfo newState) { - channelTracer.reportEvent( - new ChannelTrace.Event.Builder() - .setDescription("Entering " + newState.getState() + " state") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(timeProvider.currentTimeNanos()) - .build()); - switch (newState.getState()) { - case READY: - case IDLE: - delayedTransport.reprocess(subchannelPicker); - break; - case TRANSIENT_FAILURE: - final class OobErrorPicker extends SubchannelPicker { - final PickResult errorResult = PickResult.withError(newState.getStatus()); - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return errorResult; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(OobErrorPicker.class) - .add("errorResult", errorResult) - .toString(); - } - } - - delayedTransport.reprocess(new OobErrorPicker()); - break; - default: - // Do nothing - } - } - - // must be run from channel executor - void handleSubchannelTerminated() { - channelz.removeSubchannel(this); - // When delayedTransport is terminated, it shuts down subchannel. Therefore, at this point - // both delayedTransport and subchannel have terminated. - executorPool.returnObject(executor); - terminatedLatch.countDown(); - } - - @VisibleForTesting - Subchannel getSubchannel() { - return subchannelImpl; - } - - InternalSubchannel getInternalSubchannel() { - return subchannel; - } - - @Override - public ListenableFuture getStats() { - final SettableFuture ret = SettableFuture.create(); - final ChannelStats.Builder builder = new ChannelStats.Builder(); - channelCallsTracer.updateBuilder(builder); - channelTracer.updateBuilder(builder); - builder - .setTarget(authority) - .setState(subchannel.getState()) - .setSubchannels(Collections.singletonList(subchannel)); - ret.set(builder.build()); - return ret; - } - - @Override - public InternalLogId getLogId() { - return logId; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("logId", logId.getId()) - .add("authority", authority) - .toString(); - } - - @Override - public void resetConnectBackoff() { - subchannel.resetConnectBackoff(); - } -} diff --git a/core/src/main/java/io/grpc/internal/OobNameResolverProvider.java b/core/src/main/java/io/grpc/internal/OobNameResolverProvider.java new file mode 100644 index 00000000000..408b92e0c84 --- /dev/null +++ b/core/src/main/java/io/grpc/internal/OobNameResolverProvider.java @@ -0,0 +1,121 @@ +/* + * 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.internal; + +import static java.util.Objects.requireNonNull; + +import io.grpc.EquivalentAddressGroup; +import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; +import io.grpc.StatusOr; +import io.grpc.SynchronizationContext; +import java.net.URI; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +/** + * A provider that is passed addresses and relays those addresses to its created resolvers. + */ +final class OobNameResolverProvider extends NameResolverProvider { + private final String authority; + private final SynchronizationContext parentSyncContext; + // Only accessed from parentSyncContext + @SuppressWarnings("JdkObsolete") // LinkedList uses O(n) memory, including after deletions + private final Collection resolvers = new LinkedList<>(); + // Only accessed from parentSyncContext + private List lastEags; + + public OobNameResolverProvider( + String authority, List eags, SynchronizationContext syncContext) { + this.authority = requireNonNull(authority, "authority"); + this.lastEags = requireNonNull(eags, "eags"); + this.parentSyncContext = requireNonNull(syncContext, "syncContext"); + } + + @Override + public String getDefaultScheme() { + return "oob"; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; // Doesn't matter, as we expect only one provider in the registry + } + + public void updateAddresses(List eags) { + requireNonNull(eags, "eags"); + parentSyncContext.execute(() -> { + this.lastEags = eags; + for (OobNameResolver resolver : resolvers) { + resolver.updateAddresses(eags); + } + }); + } + + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return new OobNameResolver(args.getSynchronizationContext()); + } + + final class OobNameResolver extends NameResolver { + private final SynchronizationContext syncContext; + // Null before started, and after shutdown. Only accessed from syncContext + private Listener2 listener; + + public OobNameResolver(SynchronizationContext syncContext) { + this.syncContext = requireNonNull(syncContext, "syncContext"); + } + + @Override + public String getServiceAuthority() { + return authority; + } + + @Override + public void start(Listener2 listener) { + this.listener = requireNonNull(listener, "listener"); + parentSyncContext.execute(() -> { + resolvers.add(this); + updateAddresses(lastEags); + }); + } + + void updateAddresses(List eags) { + parentSyncContext.throwIfNotInThisSynchronizationContext(); + syncContext.execute(() -> { + if (listener == null) { + return; + } + listener.onResult2(ResolutionResult.newBuilder() + .setAddressesOrError(StatusOr.fromValue(lastEags)) + .build()); + }); + } + + @Override + public void shutdown() { + this.listener = null; + parentSyncContext.execute(() -> resolvers.remove(this)); + } + } +} diff --git a/core/src/main/java/io/grpc/internal/PickFirstLeafLoadBalancer.java b/core/src/main/java/io/grpc/internal/PickFirstLeafLoadBalancer.java index ebe329ca591..ab60a024e1f 100644 --- a/core/src/main/java/io/grpc/internal/PickFirstLeafLoadBalancer.java +++ b/core/src/main/java/io/grpc/internal/PickFirstLeafLoadBalancer.java @@ -24,13 +24,14 @@ import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import com.google.errorprone.annotations.CheckReturnValue; import io.grpc.Attributes; import io.grpc.ConnectivityState; import io.grpc.ConnectivityStateInfo; import io.grpc.EquivalentAddressGroup; +import io.grpc.InternalEquivalentAddressGroup; import io.grpc.LoadBalancer; import io.grpc.Status; import io.grpc.SynchronizationContext.ScheduledHandle; @@ -62,6 +63,8 @@ final class PickFirstLeafLoadBalancer extends LoadBalancer { static final int CONNECTION_DELAY_INTERVAL_MS = 250; private final boolean enableHappyEyeballs = !isSerializingRetries() && PickFirstLoadBalancerProvider.isEnabledHappyEyeballs(); + static boolean weightedShuffling = + GrpcUtil.getFlag("GRPC_EXPERIMENTAL_PF_WEIGHTED_SHUFFLING", true); private final Helper helper; private final Map subchannels = new HashMap<>(); private final Index addressIndex = new Index(ImmutableList.of(), this.enableHappyEyeballs); @@ -129,17 +132,21 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { PickFirstLeafLoadBalancerConfig config = (PickFirstLeafLoadBalancerConfig) resolvedAddresses.getLoadBalancingPolicyConfig(); if (config.shuffleAddressList != null && config.shuffleAddressList) { - Collections.shuffle(cleanServers, - config.randomSeed != null ? new Random(config.randomSeed) : new Random()); + cleanServers = shuffle( + cleanServers, config.randomSeed != null ? new Random(config.randomSeed) : new Random()); } } final ImmutableList newImmutableAddressGroups = - ImmutableList.builder().addAll(cleanServers).build(); + ImmutableList.copyOf(cleanServers); - if (rawConnectivityState == READY || rawConnectivityState == CONNECTING) { + if (rawConnectivityState == READY + || (rawConnectivityState == CONNECTING + && (!enableHappyEyeballs || addressIndex.isValid()))) { // If the previous ready (or connecting) subchannel exists in new address list, - // keep this connection and don't create new subchannels + // keep this connection and don't create new subchannels. Happy Eyeballs is excluded when + // connecting, because it allows multiple attempts simultaneously, thus is fine to start at + // the beginning. SocketAddress previousAddress = addressIndex.getCurrentAddress(); addressIndex.updateGroups(newImmutableAddressGroups); if (addressIndex.seekTo(previousAddress)) { @@ -160,7 +167,7 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { if (noOldAddrs) { // Make tests happy; they don't properly assume starting in CONNECTING rawConnectivityState = CONNECTING; - updateBalancingState(CONNECTING, new Picker(PickResult.withNoResult())); + updateBalancingState(CONNECTING, new FixedResultPicker(PickResult.withNoResult())); } if (rawConnectivityState == READY) { @@ -221,6 +228,46 @@ private static List deDupAddresses(List shuffle(List eags, Random random) { + if (weightedShuffling) { + List weightedEntries = new ArrayList<>(eags.size()); + for (EquivalentAddressGroup eag : eags) { + weightedEntries.add(new WeightEntry(eag, eagToWeight(eag, random))); + } + Collections.sort(weightedEntries, Collections.reverseOrder() /* descending */); + return Lists.transform(weightedEntries, entry -> entry.eag); + } else { + List eagsCopy = new ArrayList<>(eags); + Collections.shuffle(eagsCopy, random); + return eagsCopy; + } + } + + private static double eagToWeight(EquivalentAddressGroup eag, Random random) { + Long weight = eag.getAttributes().get(InternalEquivalentAddressGroup.ATTR_WEIGHT); + if (weight == null) { + weight = 1L; + } + return Math.pow(random.nextDouble(), 1.0 / weight); + } + + private static final class WeightEntry implements Comparable { + final EquivalentAddressGroup eag; + final double weight; + + public WeightEntry(EquivalentAddressGroup eag, double weight) { + this.eag = eag; + this.weight = weight; + } + + @Override + public int compareTo(WeightEntry entry) { + return Double.compare(this.weight, entry.weight); + } + } + @Override public void handleNameResolutionError(Status error) { if (rawConnectivityState == SHUTDOWN) { @@ -233,7 +280,7 @@ public void handleNameResolutionError(Status error) { subchannels.clear(); addressIndex.updateGroups(ImmutableList.of()); rawConnectivityState = TRANSIENT_FAILURE; - updateBalancingState(TRANSIENT_FAILURE, new Picker(PickResult.withError(error))); + updateBalancingState(TRANSIENT_FAILURE, new FixedResultPicker(PickResult.withError(error))); } void processSubchannelState(SubchannelData subchannelData, ConnectivityStateInfo stateInfo) { @@ -286,7 +333,17 @@ void processSubchannelState(SubchannelData subchannelData, ConnectivityStateInfo case CONNECTING: rawConnectivityState = CONNECTING; - updateBalancingState(CONNECTING, new Picker(PickResult.withNoResult())); + // If we get a newly resolved address list via acceptResolvedAddresses, + // as we are in CONNECTING, we will try to .updateAddresses the currently + // connecting subchannel if it exists in the new list. + // As such, We need to make sure that with transitioning to CONNECTING the subchannel for + // the current address of a valid index exists. + if ((!enableHappyEyeballs && !addressIndex.isValid()) + || (addressIndex.isValid() && !subchannels.containsKey( + addressIndex.getCurrentAddress()))) { + addressIndex.seekTo(getAddress(subchannelData.subchannel)); + } + updateBalancingState(CONNECTING, new FixedResultPicker(PickResult.withNoResult())); break; case READY: @@ -318,7 +375,7 @@ void processSubchannelState(SubchannelData subchannelData, ConnectivityStateInfo if (isPassComplete()) { rawConnectivityState = TRANSIENT_FAILURE; updateBalancingState(TRANSIENT_FAILURE, - new Picker(PickResult.withError(stateInfo.getStatus()))); + new FixedResultPicker(PickResult.withError(stateInfo.getStatus()))); // Refresh Name Resolution, but only when all 3 conditions are met // * We are at the end of addressIndex @@ -381,11 +438,11 @@ private void updateHealthCheckedState(SubchannelData subchannelData) { updateBalancingState(READY, new FixedResultPicker(PickResult.withSubchannel(subchannelData.subchannel))); } else if (subchannelData.getHealthState() == TRANSIENT_FAILURE) { - updateBalancingState(TRANSIENT_FAILURE, new Picker(PickResult.withError( + updateBalancingState(TRANSIENT_FAILURE, new FixedResultPicker(PickResult.withError( subchannelData.healthStateInfo.getStatus()))); } else if (concludedState != TRANSIENT_FAILURE) { updateBalancingState(subchannelData.getHealthState(), - new Picker(PickResult.withNoResult())); + new FixedResultPicker(PickResult.withNoResult())); } } @@ -449,7 +506,7 @@ private void shutdownRemaining(SubchannelData activeSubchannelData) { */ @Override public void requestConnection() { - if (!addressIndex.isValid() || rawConnectivityState == SHUTDOWN) { + if (!addressIndex.isValid() || rawConnectivityState == SHUTDOWN || reconnectTask != null) { return; } @@ -589,26 +646,9 @@ ConnectivityState getConcludedConnectivityState() { return this.concludedState; } - /** - * No-op picker which doesn't add any custom picking logic. It just passes already known result - * received in constructor. - */ - private static final class Picker extends SubchannelPicker { - private final PickResult result; - - Picker(PickResult result) { - this.result = checkNotNull(result, "result"); - } - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return result; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(Picker.class).add("result", result).toString(); - } + @VisibleForTesting + ConnectivityState getRawConnectivityState() { + return this.rawConnectivityState; } /** diff --git a/core/src/main/java/io/grpc/internal/PickFirstLoadBalancer.java b/core/src/main/java/io/grpc/internal/PickFirstLoadBalancer.java index a23855e67ec..cf4b4c94e04 100644 --- a/core/src/main/java/io/grpc/internal/PickFirstLoadBalancer.java +++ b/core/src/main/java/io/grpc/internal/PickFirstLoadBalancer.java @@ -22,14 +22,11 @@ import static io.grpc.ConnectivityState.SHUTDOWN; import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; -import com.google.common.base.MoreObjects; import io.grpc.ConnectivityState; import io.grpc.ConnectivityStateInfo; import io.grpc.EquivalentAddressGroup; import io.grpc.LoadBalancer; import io.grpc.Status; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Random; import java.util.concurrent.atomic.AtomicBoolean; @@ -66,9 +63,8 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { PickFirstLoadBalancerConfig config = (PickFirstLoadBalancerConfig) resolvedAddresses.getLoadBalancingPolicyConfig(); if (config.shuffleAddressList != null && config.shuffleAddressList) { - servers = new ArrayList(servers); - Collections.shuffle(servers, - config.randomSeed != null ? new Random(config.randomSeed) : new Random()); + servers = PickFirstLeafLoadBalancer.shuffle( + servers, config.randomSeed != null ? new Random(config.randomSeed) : new Random()); } } @@ -87,7 +83,7 @@ public void onSubchannelState(ConnectivityStateInfo stateInfo) { // The channel state does not get updated when doing name resolving today, so for the moment // let LB report CONNECTION and call subchannel.requestConnection() immediately. - updateBalancingState(CONNECTING, new Picker(PickResult.withSubchannel(subchannel))); + updateBalancingState(CONNECTING, new FixedResultPicker(PickResult.withNoResult())); subchannel.requestConnection(); } else { subchannel.updateAddresses(servers); @@ -105,7 +101,7 @@ public void handleNameResolutionError(Status error) { // NB(lukaszx0) Whether we should propagate the error unconditionally is arguable. It's fine // for time being. - updateBalancingState(TRANSIENT_FAILURE, new Picker(PickResult.withError(error))); + updateBalancingState(TRANSIENT_FAILURE, new FixedResultPicker(PickResult.withError(error))); } private void processSubchannelState(Subchannel subchannel, ConnectivityStateInfo stateInfo) { @@ -139,13 +135,13 @@ private void processSubchannelState(Subchannel subchannel, ConnectivityStateInfo case CONNECTING: // It's safe to use RequestConnectionPicker here, so when coming from IDLE we could leave // the current picker in-place. But ignoring the potential optimization is simpler. - picker = new Picker(PickResult.withNoResult()); + picker = new FixedResultPicker(PickResult.withNoResult()); break; case READY: - picker = new Picker(PickResult.withSubchannel(subchannel)); + picker = new FixedResultPicker(PickResult.withSubchannel(subchannel)); break; case TRANSIENT_FAILURE: - picker = new Picker(PickResult.withError(stateInfo.getStatus())); + picker = new FixedResultPicker(PickResult.withError(stateInfo.getStatus())); break; default: throw new IllegalArgumentException("Unsupported state:" + newState); @@ -173,28 +169,6 @@ public void requestConnection() { } } - /** - * No-op picker which doesn't add any custom picking logic. It just passes already known result - * received in constructor. - */ - private static final class Picker extends SubchannelPicker { - private final PickResult result; - - Picker(PickResult result) { - this.result = checkNotNull(result, "result"); - } - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return result; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(Picker.class).add("result", result).toString(); - } - } - /** Picker that requests connection during the first pick, and returns noResult. */ private final class RequestConnectionPicker extends SubchannelPicker { private final AtomicBoolean connectionRequested = new AtomicBoolean(false); diff --git a/core/src/main/java/io/grpc/internal/ProxyDetectorImpl.java b/core/src/main/java/io/grpc/internal/ProxyDetectorImpl.java index 58c7803346f..2f9903eac03 100644 --- a/core/src/main/java/io/grpc/internal/ProxyDetectorImpl.java +++ b/core/src/main/java/io/grpc/internal/ProxyDetectorImpl.java @@ -207,6 +207,14 @@ private ProxiedSocketAddress detectProxy(InetSocketAddress targetAddr) throws IO } List proxies = proxySelector.select(uri); + // ProxySelector.select(URI) is contractually required to return a non-null, non-empty list. + // Surface the offending implementation's class name so a broken ProxySelector can be fixed. + if (proxies == null || proxies.isEmpty()) { + throw new IOException( + "ProxySelector " + proxySelector.getClass().getName() + + " returned " + (proxies == null ? "null" : "an empty list") + + ", which violates the java.net.ProxySelector#select(URI) contract"); + } if (proxies.size() > 1) { log.warning("More than 1 proxy detected, gRPC will select the first one"); } diff --git a/core/src/main/java/io/grpc/internal/ReadableBuffer.java b/core/src/main/java/io/grpc/internal/ReadableBuffer.java index 6963c78203e..20f64719875 100644 --- a/core/src/main/java/io/grpc/internal/ReadableBuffer.java +++ b/core/src/main/java/io/grpc/internal/ReadableBuffer.java @@ -71,15 +71,6 @@ public interface ReadableBuffer extends Closeable { */ void readBytes(byte[] dest, int destOffset, int length); - /** - * Reads from this buffer until the destination's position reaches its limit, and increases the - * read position by the number of the transferred bytes. - * - * @param dest the destination buffer to receive the bytes. - * @throws IndexOutOfBoundsException if required bytes are not readable - */ - void readBytes(ByteBuffer dest); - /** * Reads {@code length} bytes from this buffer and writes them to the destination stream. * Increments the read position by {@code length}. If the required bytes are not readable, throws diff --git a/core/src/main/java/io/grpc/internal/ReadableBuffers.java b/core/src/main/java/io/grpc/internal/ReadableBuffers.java index e512c810f84..439745e29b2 100644 --- a/core/src/main/java/io/grpc/internal/ReadableBuffers.java +++ b/core/src/main/java/io/grpc/internal/ReadableBuffers.java @@ -171,15 +171,6 @@ public void readBytes(byte[] dest, int destIndex, int length) { offset += length; } - @Override - public void readBytes(ByteBuffer dest) { - Preconditions.checkNotNull(dest, "dest"); - int length = dest.remaining(); - checkReadable(length); - dest.put(bytes, offset, length); - offset += length; - } - @Override public void readBytes(OutputStream dest, int length) throws IOException { checkReadable(length); @@ -262,21 +253,6 @@ public void readBytes(byte[] dest, int destOffset, int length) { bytes.get(dest, destOffset, length); } - @Override - public void readBytes(ByteBuffer dest) { - Preconditions.checkNotNull(dest, "dest"); - int length = dest.remaining(); - checkReadable(length); - - // Change the limit so that only length bytes are available. - int prevLimit = bytes.limit(); - ((Buffer) bytes).limit(bytes.position() + length); - - // Write the bytes and restore the original limit. - dest.put(bytes); - bytes.limit(prevLimit); - } - @Override public void readBytes(OutputStream dest, int length) throws IOException { checkReadable(length); diff --git a/core/src/main/java/io/grpc/internal/RetriableStream.java b/core/src/main/java/io/grpc/internal/RetriableStream.java index 9fe8ab4ffff..0c37a0beaca 100644 --- a/core/src/main/java/io/grpc/internal/RetriableStream.java +++ b/core/src/main/java/io/grpc/internal/RetriableStream.java @@ -166,7 +166,8 @@ private Runnable commit(final Substream winningSubstream) { final boolean wasCancelled = (scheduledRetry != null) ? scheduledRetry.isCancelled() : false; final Future retryFuture; - if (scheduledRetry != null) { + final boolean retryWasScheduled = scheduledRetry != null; + if (retryWasScheduled) { retryFuture = scheduledRetry.markCancelled(); scheduledRetry = null; } else { @@ -190,8 +191,10 @@ public void run() { substream.stream.cancel(CANCELLED_BECAUSE_COMMITTED); } } - if (retryFuture != null) { - retryFuture.cancel(false); + if (retryWasScheduled) { + if (retryFuture != null) { + retryFuture.cancel(false); + } if (!wasCancelled && inFlightSubStreams.decrementAndGet() == Integer.MIN_VALUE) { assert savedCloseMasterListenerReason != null; listenerSerializeExecutor.execute( @@ -853,7 +856,7 @@ public void run() { public static long intervalWithJitter(long intervalNanos) { double inverseJitterFactor = isExperimentalRetryJitterEnabled - ? 0.8 * random.nextDouble() + 0.4 : random.nextDouble(); + ? 0.4 * random.nextDouble() + 0.8 : random.nextDouble(); return (long) (intervalNanos * inverseJitterFactor); } @@ -938,9 +941,8 @@ public void run() { && localOnlyTransparentRetries.incrementAndGet() > 1_000) { commitAndRun(substream); if (state.winningSubstream == substream) { - Status tooManyTransparentRetries = Status.INTERNAL - .withDescription("Too many transparent retries. Might be a bug in gRPC") - .withCause(status.asRuntimeException()); + Status tooManyTransparentRetries = GrpcUtil.statusWithDetails( + Status.Code.INTERNAL, "Too many transparent retries. Might be a bug in gRPC", status); safeCloseMasterListener(tooManyTransparentRetries, rpcProgress, trailers); } return; diff --git a/core/src/main/java/io/grpc/internal/ScParser.java b/core/src/main/java/io/grpc/internal/ScParser.java index f94449f7c7b..71d6d33877f 100644 --- a/core/src/main/java/io/grpc/internal/ScParser.java +++ b/core/src/main/java/io/grpc/internal/ScParser.java @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; +import io.grpc.LoadBalancerProvider; import io.grpc.NameResolver; import io.grpc.NameResolver.ConfigOrError; import io.grpc.Status; @@ -31,18 +32,18 @@ public final class ScParser extends NameResolver.ServiceConfigParser { private final boolean retryEnabled; private final int maxRetryAttemptsLimit; private final int maxHedgedAttemptsLimit; - private final AutoConfiguredLoadBalancerFactory autoLoadBalancerFactory; + private final LoadBalancerProvider parser; /** Creates a parse with global retry settings and an auto configured lb factory. */ public ScParser( boolean retryEnabled, int maxRetryAttemptsLimit, int maxHedgedAttemptsLimit, - AutoConfiguredLoadBalancerFactory autoLoadBalancerFactory) { + LoadBalancerProvider parser) { this.retryEnabled = retryEnabled; this.maxRetryAttemptsLimit = maxRetryAttemptsLimit; this.maxHedgedAttemptsLimit = maxHedgedAttemptsLimit; - this.autoLoadBalancerFactory = checkNotNull(autoLoadBalancerFactory, "autoLoadBalancerFactory"); + this.parser = checkNotNull(parser, "parser"); } @Override @@ -50,7 +51,9 @@ public ConfigOrError parseServiceConfig(Map rawServiceConfig) { try { Object loadBalancingPolicySelection; ConfigOrError choiceFromLoadBalancer = - autoLoadBalancerFactory.parseLoadBalancerPolicy(rawServiceConfig); + parser.parseLoadBalancingPolicyConfig(rawServiceConfig); + // TODO(ejona): The Provider API doesn't allow null, but AutoConfiguredLoadBalancerFactory can + // return null and it will need tweaking to ManagedChannelImpl.defaultServiceConfig to fix. if (choiceFromLoadBalancer == null) { loadBalancingPolicySelection = null; } else if (choiceFromLoadBalancer.getError() != null) { @@ -66,8 +69,19 @@ public ConfigOrError parseServiceConfig(Map rawServiceConfig) { maxHedgedAttemptsLimit, loadBalancingPolicySelection)); } catch (RuntimeException e) { + // TODO(ejona): We really don't want parsers throwing exceptions; they should return an error. + // However, right now ManagedChannelServiceConfig itself uses exceptions like + // ClassCastException. We should handle those with a graceful return within + // ManagedChannelServiceConfig and then get rid of this case. Then all exceptions are + // "unexpected" and the INTERNAL status code makes it clear a bug needs to be fixed. return ConfigOrError.fromError( Status.UNKNOWN.withDescription("failed to parse service config").withCause(e)); + } catch (Throwable t) { + // Even catch Errors, since broken config parsing could trigger AssertionError, + // StackOverflowError, and other errors we can reasonably safely recover. Since the config + // could be untrusted, we want to error on the side of recovering. + return ConfigOrError.fromError( + Status.INTERNAL.withDescription("Unexpected error parsing service config").withCause(t)); } } } diff --git a/core/src/main/java/io/grpc/internal/ServerImpl.java b/core/src/main/java/io/grpc/internal/ServerImpl.java index dc0709e1fb8..d9f64c2d473 100644 --- a/core/src/main/java/io/grpc/internal/ServerImpl.java +++ b/core/src/main/java/io/grpc/internal/ServerImpl.java @@ -99,7 +99,7 @@ public final class ServerImpl extends io.grpc.Server implements InternalInstrume private final ObjectPool executorPool; /** Executor for application processing. Safe to read after {@link #start()}. */ private Executor executor; - private final HandlerRegistry registry; + private final InternalHandlerRegistry registry; private final HandlerRegistry fallbackRegistry; private final List transportFilters; // This is iterated on a per-call basis. Use an array instead of a Collection to avoid iterator @@ -151,7 +151,9 @@ public final class ServerImpl extends io.grpc.Server implements InternalInstrume InternalLogId.allocate("Server", String.valueOf(getListenSocketsIgnoringLifecycle())); // Fork from the passed in context so that it does not propagate cancellation, it only // inherits values. - this.rootContext = Preconditions.checkNotNull(rootContext, "rootContext").fork(); + this.rootContext = Preconditions.checkNotNull(rootContext, "rootContext") + .fork() + .withValue(io.grpc.InternalServer.SERVER_CONTEXT_KEY, ServerImpl.this); this.decompressorRegistry = builder.decompressorRegistry; this.compressorRegistry = builder.compressorRegistry; this.transportFilters = Collections.unmodifiableList( @@ -496,8 +498,12 @@ private void streamCreatedInternal( final StatsTraceContext statsTraceCtx = Preconditions.checkNotNull( stream.statsTraceContext(), "statsTraceCtx not present from stream"); + final ServerMethodDefinition primaryMethod = registry.lookupMethod(methodName, null); final Context.CancellableContext context = createContext(headers, statsTraceCtx); + if (primaryMethod != null) { + statsTraceCtx.serverCallMethodResolved(primaryMethod.getMethodDescriptor()); + } final Link link = PerfMark.linkOut(); @@ -534,7 +540,7 @@ private void runInternal() { ServerMethodDefinition wrapMethod; ServerCallParameters callParams; try { - ServerMethodDefinition method = registry.lookupMethod(methodName); + ServerMethodDefinition method = primaryMethod; if (method == null) { method = fallbackRegistry.lookupMethod(methodName, stream.getAuthority()); } @@ -622,19 +628,7 @@ private void runInternal() { // An extremely short deadline may expire before stream.setListener(jumpListener). // This causes NPE as in issue: https://github.com/grpc/grpc-java/issues/6300 // Delay of setting cancellationListener to context will fix the issue. - final class ServerStreamCancellationListener implements Context.CancellationListener { - @Override - public void cancelled(Context context) { - Status status = statusFromCancelled(context); - if (DEADLINE_EXCEEDED.getCode().equals(status.getCode())) { - // This should rarely get run, since the client will likely cancel the stream - // before the timeout is reached. - stream.cancel(status); - } - } - } - - context.addListener(new ServerStreamCancellationListener(), directExecutor()); + context.addListener(new ServerStreamCancellationListener(stream), directExecutor()); } } @@ -648,8 +642,7 @@ private Context.CancellableContext createContext( Context baseContext = statsTraceCtx - .serverFilterContext(rootContext) - .withValue(io.grpc.InternalServer.SERVER_CONTEXT_KEY, ServerImpl.this); + .serverFilterContext(rootContext); if (timeoutNanos == null) { return baseContext.withCancellation(); @@ -707,6 +700,31 @@ private ServerStreamListener startWrappedCall( } } + /** + * Propagates context cancellation to the ServerStream. + * + *

This is outside of HandleServerCall because that class holds Metadata and other state needed + * only when starting the RPC. The cancellation listener will live for the life of the call, so we + * avoid that useless state being retained. + */ + static final class ServerStreamCancellationListener implements Context.CancellationListener { + private final ServerStream stream; + + ServerStreamCancellationListener(ServerStream stream) { + this.stream = checkNotNull(stream, "stream"); + } + + @Override + public void cancelled(Context context) { + Status status = statusFromCancelled(context); + if (DEADLINE_EXCEEDED.getCode().equals(status.getCode())) { + // This should rarely get run, since the client will likely cancel the stream + // before the timeout is reached. + stream.cancel(status); + } + } + } + @Override public InternalLogId getLogId() { return logId; diff --git a/core/src/main/java/io/grpc/internal/ServerImplBuilder.java b/core/src/main/java/io/grpc/internal/ServerImplBuilder.java index f6566e067db..62a0e66f314 100644 --- a/core/src/main/java/io/grpc/internal/ServerImplBuilder.java +++ b/core/src/main/java/io/grpc/internal/ServerImplBuilder.java @@ -31,6 +31,9 @@ import io.grpc.HandlerRegistry; import io.grpc.InternalChannelz; import io.grpc.InternalConfiguratorRegistry; +import io.grpc.MetricInstrumentRegistry; +import io.grpc.MetricRecorder; +import io.grpc.MetricSink; import io.grpc.Server; import io.grpc.ServerBuilder; import io.grpc.ServerCallExecutorSupplier; @@ -80,6 +83,7 @@ public static ServerBuilder forPort(int port) { final List transportFilters = new ArrayList<>(); final List interceptors = new ArrayList<>(); private final List streamTracerFactories = new ArrayList<>(); + final List metricSinks = new ArrayList<>(); private final ClientTransportServersBuilder clientTransportServersBuilder; HandlerRegistry fallbackRegistry = DEFAULT_FALLBACK_REGISTRY; ObjectPool executorPool = DEFAULT_EXECUTOR_POOL; @@ -104,7 +108,8 @@ public static ServerBuilder forPort(int port) { */ public interface ClientTransportServersBuilder { InternalServer buildClientTransportServers( - List streamTracerFactories); + List streamTracerFactories, + MetricRecorder metricRecorder); } /** @@ -157,6 +162,15 @@ public ServerImplBuilder intercept(ServerInterceptor interceptor) { return this; } + /** + * Adds a MetricSink to the server. + */ + @Override + public ServerImplBuilder addMetricSink(MetricSink metricSink) { + metricSinks.add(checkNotNull(metricSink, "metricSink")); + return this; + } + @Override public ServerImplBuilder addStreamTracerFactory(ServerStreamTracer.Factory factory) { streamTracerFactories.add(checkNotNull(factory, "factory")); @@ -241,8 +255,11 @@ public void setDeadlineTicker(Deadline.Ticker ticker) { @Override public Server build() { + MetricRecorder metricRecorder = new MetricRecorderImpl(metricSinks, + MetricInstrumentRegistry.getDefaultRegistry()); return new ServerImpl(this, - clientTransportServersBuilder.buildClientTransportServers(getTracerFactories()), + clientTransportServersBuilder.buildClientTransportServers( + getTracerFactories(), metricRecorder), Context.ROOT); } diff --git a/core/src/main/java/io/grpc/internal/SharedResourceHolder.java b/core/src/main/java/io/grpc/internal/SharedResourceHolder.java index 67d1a98b545..1dfa1f90718 100644 --- a/core/src/main/java/io/grpc/internal/SharedResourceHolder.java +++ b/core/src/main/java/io/grpc/internal/SharedResourceHolder.java @@ -134,18 +134,16 @@ synchronized T releaseInternal(final Resource resource, final T instance) public void run() { synchronized (SharedResourceHolder.this) { // Refcount may have gone up since the task was scheduled. Re-check it. - if (cached.refcount == 0) { - try { - resource.close(instance); - } finally { - instances.remove(resource); - if (instances.isEmpty()) { - destroyer.shutdown(); - destroyer = null; - } - } + if (cached.refcount != 0) { + return; + } + instances.remove(resource); + if (instances.isEmpty()) { + destroyer.shutdown(); + destroyer = null; } } + resource.close(instance); } }), DESTROY_DELAY_SECONDS, TimeUnit.SECONDS); } diff --git a/core/src/main/java/io/grpc/internal/SimpleDisconnectError.java b/core/src/main/java/io/grpc/internal/SimpleDisconnectError.java new file mode 100644 index 00000000000..addbfbe10a3 --- /dev/null +++ b/core/src/main/java/io/grpc/internal/SimpleDisconnectError.java @@ -0,0 +1,68 @@ +/* + * 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.internal; + +import javax.annotation.concurrent.Immutable; + +/** + * Represents a fixed, static reason for disconnection. + */ +@Immutable +public enum SimpleDisconnectError implements DisconnectError { + /** + * The subchannel was shut down for various reasons like parent channel shutdown, + * idleness, or load balancing policy changes. + */ + SUBCHANNEL_SHUTDOWN("subchannel shutdown"), + + /** + * Connection was reset (e.g., ECONNRESET, WSAECONNERESET). + */ + CONNECTION_RESET("connection reset"), + + /** + * Connection timed out (e.g., ETIMEDOUT, WSAETIMEDOUT), including closures + * from gRPC keepalives. + */ + CONNECTION_TIMED_OUT("connection timed out"), + + /** + * Connection was aborted (e.g., ECONNABORTED, WSAECONNABORTED). + */ + CONNECTION_ABORTED("connection aborted"), + + /** + * Any socket error not covered by other specific disconnect errors. + */ + SOCKET_ERROR("socket error"), + + /** + * A catch-all for any other unclassified reason. + */ + UNKNOWN("unknown"); + + private final String errorTag; + + SimpleDisconnectError(String errorTag) { + this.errorTag = errorTag; + } + + @Override + public String toErrorString() { + return this.errorTag; + } +} diff --git a/core/src/main/java/io/grpc/internal/SpiffeUtil.java b/core/src/main/java/io/grpc/internal/SpiffeUtil.java index 44ef343b6dc..9eafc9950e2 100644 --- a/core/src/main/java/io/grpc/internal/SpiffeUtil.java +++ b/core/src/main/java/io/grpc/internal/SpiffeUtil.java @@ -23,6 +23,7 @@ import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.io.Files; import java.io.ByteArrayInputStream; import java.io.File; @@ -51,7 +52,7 @@ public final class SpiffeUtil { private static final Integer URI_SAN_TYPE = 6; private static final String USE_PARAMETER_VALUE = "x509-svid"; - private static final String KTY_PARAMETER_VALUE = "RSA"; + private static final ImmutableSet KTY_PARAMETER_VALUES = ImmutableSet.of("RSA", "EC"); private static final String CERTIFICATE_PREFIX = "-----BEGIN CERTIFICATE-----\n"; private static final String CERTIFICATE_SUFFIX = "-----END CERTIFICATE-----"; private static final String PREFIX = "spiffe://"; @@ -205,10 +206,12 @@ public static SpiffeBundle loadTrustBundleFromFile(String trustBundleFile) throw private static void checkJwkEntry(Map jwkNode, String trustDomainName) { String kty = JsonUtil.getString(jwkNode, "kty"); - if (kty == null || !kty.equals(KTY_PARAMETER_VALUE)) { - throw new IllegalArgumentException(String.format("'kty' parameter must be '%s' but '%s' " - + "found. Certificate loading for trust domain '%s' failed.", KTY_PARAMETER_VALUE, - kty, trustDomainName)); + if (kty == null || !KTY_PARAMETER_VALUES.contains(kty)) { + throw new IllegalArgumentException( + String.format( + "'kty' parameter must be one of %s but '%s' " + + "found. Certificate loading for trust domain '%s' failed.", + KTY_PARAMETER_VALUES, kty, trustDomainName)); } if (jwkNode.containsKey("kid")) { throw new IllegalArgumentException(String.format("'kid' parameter must not be set. " diff --git a/core/src/main/java/io/grpc/internal/StatsTraceContext.java b/core/src/main/java/io/grpc/internal/StatsTraceContext.java index 650f0b979ae..007aefc0fb8 100644 --- a/core/src/main/java/io/grpc/internal/StatsTraceContext.java +++ b/core/src/main/java/io/grpc/internal/StatsTraceContext.java @@ -23,6 +23,7 @@ import io.grpc.ClientStreamTracer; import io.grpc.Context; import io.grpc.Metadata; +import io.grpc.MethodDescriptor; import io.grpc.ServerStreamTracer; import io.grpc.ServerStreamTracer.ServerCallInfo; import io.grpc.Status; @@ -38,6 +39,14 @@ */ @ThreadSafe public final class StatsTraceContext { + /** + * Internal hook for server tracers that can use the resolved method descriptor before + * {@link ServerStreamTracer#serverCallStarted(ServerCallInfo)} runs. + */ + public interface ServerCallMethodListener { + void serverCallMethodResolved(MethodDescriptor method); + } + public static final StatsTraceContext NOOP = new StatsTraceContext(new StreamTracer[0]); private final StreamTracer[] tracers; @@ -144,6 +153,20 @@ public void serverCallStarted(ServerCallInfo callInfo) { } } + /** + * Notifies server tracers that a primary-registry method descriptor was resolved before + * {@link ServerStreamTracer#serverCallStarted(ServerCallInfo)}. + * + *

Called from {@link io.grpc.internal.ServerImpl}. + */ + public void serverCallMethodResolved(MethodDescriptor method) { + for (StreamTracer tracer : tracers) { + if (tracer instanceof ServerCallMethodListener) { + ((ServerCallMethodListener) tracer).serverCallMethodResolved(method); + } + } + } + /** * See {@link StreamTracer#streamClosed}. This may be called multiple times, and only the first * value will be taken. diff --git a/core/src/main/java/io/grpc/internal/SubchannelMetrics.java b/core/src/main/java/io/grpc/internal/SubchannelMetrics.java index 8921f13ebe6..4bc2cf47046 100644 --- a/core/src/main/java/io/grpc/internal/SubchannelMetrics.java +++ b/core/src/main/java/io/grpc/internal/SubchannelMetrics.java @@ -22,7 +22,6 @@ import io.grpc.LongUpDownCounterMetricInstrument; import io.grpc.MetricInstrumentRegistry; import io.grpc.MetricRecorder; -import javax.annotation.Nullable; final class SubchannelMetrics { @@ -106,84 +105,4 @@ public void recordDisconnection(String target, String backendService, String loc ImmutableList.of(target), ImmutableList.of(securityLevel, backendService, locality)); } - - /** - * Represents the reason for a subchannel failure. - */ - public enum DisconnectError { - - /** - * Represents an HTTP/2 GOAWAY frame. The specific error code - * (e.g., "NO_ERROR", "PROTOCOL_ERROR") should be handled separately - * as it is a dynamic part of the error. - * See RFC 9113 for error codes: https://www.rfc-editor.org/rfc/rfc9113.html#name-error-codes - */ - GOAWAY("goaway"), - - /** - * The subchannel was shut down for various reasons like parent channel shutdown, - * idleness, or load balancing policy changes. - */ - SUBCHANNEL_SHUTDOWN("subchannel shutdown"), - - /** - * Connection was reset (e.g., ECONNRESET, WSAECONNERESET). - */ - CONNECTION_RESET("connection reset"), - - /** - * Connection timed out (e.g., ETIMEDOUT, WSAETIMEDOUT), including closures - * from gRPC keepalives. - */ - CONNECTION_TIMED_OUT("connection timed out"), - - /** - * Connection was aborted (e.g., ECONNABORTED, WSAECONNABORTED). - */ - CONNECTION_ABORTED("connection aborted"), - - /** - * Any socket error not covered by other specific disconnect errors. - */ - SOCKET_ERROR("socket error"), - - /** - * A catch-all for any other unclassified reason. - */ - UNKNOWN("unknown"); - - private final String errorTag; - - /** - * Private constructor to associate a description with each enum constant. - * - * @param errorTag The detailed explanation of the error. - */ - DisconnectError(String errorTag) { - this.errorTag = errorTag; - } - - /** - * Gets the error string suitable for use as a metric tag. - * - *

If the reason is {@code GOAWAY}, this method requires the specific - * HTTP/2 error code to create the complete tag (e.g., "goaway PROTOCOL_ERROR"). - * For all other reasons, the parameter is ignored.

- * - * @param goawayErrorCode The specific HTTP/2 error code. This is only - * used if the reason is GOAWAY and should not be null in that case. - * @return The formatted error string. - */ - public String getErrorString(@Nullable String goawayErrorCode) { - if (this == GOAWAY) { - if (goawayErrorCode == null || goawayErrorCode.isEmpty()) { - // Return the base tag if the code is missing, or consider throwing an exception - // throw new IllegalArgumentException("goawayErrorCode is required for GOAWAY reason."); - return this.errorTag; - } - return this.errorTag + " " + goawayErrorCode; - } - return this.errorTag; - } - } } diff --git a/core/src/main/java/io/grpc/internal/UriWrapper.java b/core/src/main/java/io/grpc/internal/UriWrapper.java new file mode 100644 index 00000000000..ca5835cabd8 --- /dev/null +++ b/core/src/main/java/io/grpc/internal/UriWrapper.java @@ -0,0 +1,139 @@ +/* + * 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import io.grpc.NameResolver; +import io.grpc.Uri; +import java.net.URI; +import javax.annotation.Nullable; + +/** Temporary wrapper for a URI-like object to ease the migration to io.grpc.Uri. */ +interface UriWrapper { + + static UriWrapper wrap(URI uri) { + return new JavaNetUriWrapper(uri); + } + + static UriWrapper wrap(Uri uri) { + return new IoGrpcUriWrapper(uri); + } + + /** Uses the given factory and args to create a {@link NameResolver} for this URI. */ + NameResolver newNameResolver(NameResolver.Factory factory, NameResolver.Args args); + + /** Returns the scheme component of this URI, e.g. "http", "mailto" or "dns". */ + String getScheme(); + + /** + * Returns the authority component of this URI, e.g. "google.com", "127.0.0.1:8080", or null if + * not present. + */ + @Nullable + String getAuthority(); + + /** Wraps an instance of java.net.URI. */ + final class JavaNetUriWrapper implements UriWrapper { + private final URI uri; + + private JavaNetUriWrapper(URI uri) { + this.uri = checkNotNull(uri); + } + + @Override + public NameResolver newNameResolver(NameResolver.Factory factory, NameResolver.Args args) { + return factory.newNameResolver(uri, args); + } + + @Override + public String getScheme() { + return uri.getScheme(); + } + + @Override + public String getAuthority() { + return uri.getAuthority(); + } + + @Override + public String toString() { + return uri.toString(); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (!(other instanceof JavaNetUriWrapper)) { + return false; + } + return uri.equals(((JavaNetUriWrapper) other).uri); + } + + @Override + public int hashCode() { + return uri.hashCode(); + } + } + + /** Wraps an instance of io.grpc.Uri. */ + final class IoGrpcUriWrapper implements UriWrapper { + private final Uri uri; + + private IoGrpcUriWrapper(Uri uri) { + this.uri = checkNotNull(uri); + } + + @Override + public NameResolver newNameResolver(NameResolver.Factory factory, NameResolver.Args args) { + return factory.newNameResolver(uri, args); + } + + @Override + public String getScheme() { + return uri.getScheme(); + } + + @Override + public String getAuthority() { + return uri.getAuthority(); + } + + @Override + public String toString() { + return uri.toString(); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (!(other instanceof IoGrpcUriWrapper)) { + return false; + } + return uri.equals(((IoGrpcUriWrapper) other).uri); + } + + @Override + public int hashCode() { + return uri.hashCode(); + } + } +} diff --git a/core/src/test/java/io/grpc/internal/AutoConfiguredLoadBalancerFactoryTest.java b/core/src/test/java/io/grpc/internal/AutoConfiguredLoadBalancerFactoryTest.java index 8d56968737b..07d19d41a86 100644 --- a/core/src/test/java/io/grpc/internal/AutoConfiguredLoadBalancerFactoryTest.java +++ b/core/src/test/java/io/grpc/internal/AutoConfiguredLoadBalancerFactoryTest.java @@ -193,7 +193,7 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { AutoConfiguredLoadBalancer lb = lbf.newLoadBalancer(helper); LoadBalancer oldDelegate = lb.getDelegate(); - Status addressAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setAttributes(Attributes.EMPTY) @@ -208,7 +208,7 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { public void acceptResolvedAddresses_shutsDownOldBalancer() throws Exception { Map serviceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"round_robin\": { } } ] }"); - ConfigOrError lbConfigs = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError lbConfigs = lbf.parseLoadBalancingPolicyConfig(serviceConfig); final List servers = Collections.singletonList(new EquivalentAddressGroup(new SocketAddress(){})); @@ -235,7 +235,7 @@ public void shutdown() { }; lb.setDelegate(testlb); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -252,7 +252,7 @@ public void shutdown() { public void acceptResolvedAddresses_propagateLbConfigToDelegate() throws Exception { Map rawServiceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb\": { \"setting1\": \"high\" } } ] }"); - ConfigOrError lbConfigs = lbf.parseLoadBalancerPolicy(rawServiceConfig); + ConfigOrError lbConfigs = lbf.parseLoadBalancingPolicyConfig(rawServiceConfig); assertThat(lbConfigs.getConfig()).isNotNull(); final List servers = @@ -260,7 +260,7 @@ public void acceptResolvedAddresses_propagateLbConfigToDelegate() throws Excepti Helper helper = new TestHelper(); AutoConfiguredLoadBalancer lb = lbf.newLoadBalancer(helper); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -280,9 +280,9 @@ public void acceptResolvedAddresses_propagateLbConfigToDelegate() throws Excepti rawServiceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb\": { \"setting1\": \"low\" } } ] }"); - lbConfigs = lbf.parseLoadBalancerPolicy(rawServiceConfig); + lbConfigs = lbf.parseLoadBalancingPolicyConfig(rawServiceConfig); - addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -305,7 +305,7 @@ public void acceptResolvedAddresses_propagateLbConfigToDelegate() throws Excepti public void acceptResolvedAddresses_propagateAddrsToDelegate() throws Exception { Map rawServiceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb\": { \"setting1\": \"high\" } } ] }"); - ConfigOrError lbConfigs = lbf.parseLoadBalancerPolicy(rawServiceConfig); + ConfigOrError lbConfigs = lbf.parseLoadBalancingPolicyConfig(rawServiceConfig); assertThat(lbConfigs.getConfig()).isNotNull(); Helper helper = new TestHelper(); @@ -313,7 +313,7 @@ public void acceptResolvedAddresses_propagateAddrsToDelegate() throws Exception List servers = Collections.singletonList(new EquivalentAddressGroup(new InetSocketAddress(8080){})); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -329,7 +329,7 @@ public void acceptResolvedAddresses_propagateAddrsToDelegate() throws Exception servers = Collections.singletonList(new EquivalentAddressGroup(new InetSocketAddress(9090){})); - addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -353,8 +353,8 @@ public void acceptResolvedAddresses_delegateDoNotAcceptEmptyAddressList_nothing( Map serviceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb\": { \"setting1\": \"high\" } } ] }"); - ConfigOrError lbConfig = lbf.parseLoadBalancerPolicy(serviceConfig); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + ConfigOrError lbConfig = lbf.parseLoadBalancingPolicyConfig(serviceConfig); + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(Collections.emptyList()) .setLoadBalancingPolicyConfig(lbConfig.getConfig()) @@ -373,8 +373,8 @@ public void acceptResolvedAddresses_delegateAcceptsEmptyAddressList() Map rawServiceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb2\": { \"setting1\": \"high\" } } ] }"); ConfigOrError lbConfigs = - lbf.parseLoadBalancerPolicy(rawServiceConfig); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + lbf.parseLoadBalancingPolicyConfig(rawServiceConfig); + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(Collections.emptyList()) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -394,7 +394,7 @@ public void acceptResolvedAddresses_delegateAcceptsEmptyAddressList() public void acceptResolvedAddresses_useSelectedLbPolicy() throws Exception { Map rawServiceConfig = parseConfig("{\"loadBalancingConfig\": [{\"round_robin\": {}}]}"); - ConfigOrError lbConfigs = lbf.parseLoadBalancerPolicy(rawServiceConfig); + ConfigOrError lbConfigs = lbf.parseLoadBalancingPolicyConfig(rawServiceConfig); assertThat(lbConfigs.getConfig()).isNotNull(); assertThat(((PolicySelection) lbConfigs.getConfig()).provider.getClass().getName()) .isEqualTo("io.grpc.util.SecretRoundRobinLoadBalancerProvider$Provider"); @@ -409,7 +409,7 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { } }; AutoConfiguredLoadBalancer lb = lbf.newLoadBalancer(helper); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -431,7 +431,7 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { } }; AutoConfiguredLoadBalancer lb = lbf.newLoadBalancer(helper); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(null) @@ -446,7 +446,7 @@ public void acceptResolvedAddresses_noLbPolicySelected_defaultToCustomDefault() .newLoadBalancer(new TestHelper()); List servers = Collections.singletonList(new EquivalentAddressGroup(new SocketAddress(){})); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(null) @@ -468,7 +468,7 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { AutoConfiguredLoadBalancer lb = new AutoConfiguredLoadBalancerFactory(GrpcUtil.DEFAULT_LB_POLICY).newLoadBalancer(helper); - Status addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + Status addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setAttributes(Attributes.EMPTY) @@ -481,8 +481,8 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { nextParsedConfigOrError.set(testLbParsedConfig); Map serviceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb\": { } } ] }"); - ConfigOrError lbConfigs = lbf.parseLoadBalancerPolicy(serviceConfig); - addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + ConfigOrError lbConfigs = lbf.parseLoadBalancingPolicyConfig(serviceConfig); + addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -504,8 +504,8 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { testLbParsedConfig = ConfigOrError.fromConfig("bar"); nextParsedConfigOrError.set(testLbParsedConfig); serviceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"test_lb\": { } } ] }"); - lbConfigs = lbf.parseLoadBalancerPolicy(serviceConfig); - addressesAcceptanceStatus = lb.tryAcceptResolvedAddresses( + lbConfigs = lbf.parseLoadBalancingPolicyConfig(serviceConfig); + addressesAcceptanceStatus = lb.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(servers) .setLoadBalancingPolicyConfig(lbConfigs.getConfig()) @@ -519,33 +519,33 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { } @Test - public void parseLoadBalancerConfig_failedOnUnknown() throws Exception { + public void parseLoadBalancingConfig_failedOnUnknown() throws Exception { Map serviceConfig = parseConfig("{\"loadBalancingConfig\": [ {\"magic_balancer\": {} } ] }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed.getError()).isNotNull(); assertThat(parsed.getError().getDescription()) .isEqualTo("None of [magic_balancer] specified by Service Config are available."); } @Test - public void parseLoadBalancerPolicy_failedOnUnknown() throws Exception { + public void parseLoadBalancingPolicy_failedOnUnknown() throws Exception { Map serviceConfig = parseConfig("{\"loadBalancingPolicy\": \"magic_balancer\"}"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed.getError()).isNotNull(); assertThat(parsed.getError().getDescription()) .isEqualTo("None of [magic_balancer] specified by Service Config are available."); } @Test - public void parseLoadBalancerConfig_multipleValidPolicies() throws Exception { + public void parseLoadBalancingConfig_multipleValidPolicies() throws Exception { Map serviceConfig = parseConfig( "{\"loadBalancingConfig\": [" + "{\"round_robin\": {}}," + "{\"test_lb\": {} } ] }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed).isNotNull(); assertThat(parsed.getError()).isNull(); assertThat(parsed.getConfig()).isInstanceOf(PolicySelection.class); @@ -554,12 +554,12 @@ public void parseLoadBalancerConfig_multipleValidPolicies() throws Exception { } @Test - public void parseLoadBalancerConfig_policyShouldBeIgnoredIfConfigExists() throws Exception { + public void parseLoadBalancingConfig_policyShouldBeIgnoredIfConfigExists() throws Exception { Map serviceConfig = parseConfig( "{\"loadBalancingConfig\": [{\"round_robin\": {} } ]," + "\"loadBalancingPolicy\": \"pick_first\" }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed).isNotNull(); assertThat(parsed.getError()).isNull(); assertThat(parsed.getConfig()).isInstanceOf(PolicySelection.class); @@ -568,13 +568,13 @@ public void parseLoadBalancerConfig_policyShouldBeIgnoredIfConfigExists() throws } @Test - public void parseLoadBalancerConfig_policyShouldBeIgnoredEvenIfUnknownPolicyExists() + public void parseLoadBalancingConfig_policyShouldBeIgnoredEvenIfUnknownPolicyExists() throws Exception { Map serviceConfig = parseConfig( "{\"loadBalancingConfig\": [{\"magic_balancer\": {} } ]," + "\"loadBalancingPolicy\": \"round_robin\" }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed.getError()).isNotNull(); assertThat(parsed.getError().getDescription()) .isEqualTo("None of [magic_balancer] specified by Service Config are available."); @@ -582,7 +582,7 @@ public void parseLoadBalancerConfig_policyShouldBeIgnoredEvenIfUnknownPolicyExis @Test @SuppressWarnings("unchecked") - public void parseLoadBalancerConfig_firstInvalidPolicy() throws Exception { + public void parseLoadBalancingConfig_firstInvalidPolicy() throws Exception { when(testLbBalancerProvider.parseLoadBalancingPolicyConfig(any(Map.class))) .thenReturn(ConfigOrError.fromError(Status.UNKNOWN)); Map serviceConfig = @@ -590,7 +590,7 @@ public void parseLoadBalancerConfig_firstInvalidPolicy() throws Exception { "{\"loadBalancingConfig\": [" + "{\"test_lb\": {}}," + "{\"round_robin\": {} } ] }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed).isNotNull(); assertThat(parsed.getConfig()).isNull(); assertThat(parsed.getError()).isEqualTo(Status.UNKNOWN); @@ -598,7 +598,7 @@ public void parseLoadBalancerConfig_firstInvalidPolicy() throws Exception { @Test @SuppressWarnings("unchecked") - public void parseLoadBalancerConfig_firstValidSecondInvalidPolicy() throws Exception { + public void parseLoadBalancingConfig_firstValidSecondInvalidPolicy() throws Exception { when(testLbBalancerProvider.parseLoadBalancingPolicyConfig(any(Map.class))) .thenReturn(ConfigOrError.fromError(Status.UNKNOWN)); Map serviceConfig = @@ -606,32 +606,32 @@ public void parseLoadBalancerConfig_firstValidSecondInvalidPolicy() throws Excep "{\"loadBalancingConfig\": [" + "{\"round_robin\": {}}," + "{\"test_lb\": {} } ] }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed).isNotNull(); assertThat(parsed.getConfig()).isNotNull(); assertThat(((PolicySelection) parsed.getConfig()).config).isNotNull(); } @Test - public void parseLoadBalancerConfig_someProvidesAreNotAvailable() throws Exception { + public void parseLoadBalancingConfig_someProvidesAreNotAvailable() throws Exception { Map serviceConfig = parseConfig("{\"loadBalancingConfig\": [ " + "{\"magic_balancer\": {} }," + "{\"round_robin\": {}} ] }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(serviceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(serviceConfig); assertThat(parsed).isNotNull(); assertThat(parsed.getConfig()).isNotNull(); assertThat(((PolicySelection) parsed.getConfig()).config).isNotNull(); } @Test - public void parseLoadBalancerConfig_lbConfigPropagated() throws Exception { + public void parseLoadBalancingConfig_lbConfigPropagated() throws Exception { Map rawServiceConfig = parseConfig( "{\"loadBalancingConfig\": [" + "{\"pick_first\": {\"shuffleAddressList\": true } }" + "] }"); - ConfigOrError parsed = lbf.parseLoadBalancerPolicy(rawServiceConfig); + ConfigOrError parsed = lbf.parseLoadBalancingPolicyConfig(rawServiceConfig); assertThat(parsed).isNotNull(); assertThat(parsed.getConfig()).isNotNull(); PolicySelection policySelection = (PolicySelection) parsed.getConfig(); diff --git a/core/src/test/java/io/grpc/internal/CompositeReadableBufferTest.java b/core/src/test/java/io/grpc/internal/CompositeReadableBufferTest.java index 8d9248a8910..749b71d681e 100644 --- a/core/src/test/java/io/grpc/internal/CompositeReadableBufferTest.java +++ b/core/src/test/java/io/grpc/internal/CompositeReadableBufferTest.java @@ -28,8 +28,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.nio.Buffer; -import java.nio.ByteBuffer; import java.nio.InvalidMarkException; import org.junit.After; import org.junit.Before; @@ -121,27 +119,6 @@ public void readByteArrayShouldSucceed() { assertEquals(EXPECTED_VALUE, new String(bytes, UTF_8)); } - @Test - public void readByteBufferShouldSucceed() { - ByteBuffer byteBuffer = ByteBuffer.allocate(EXPECTED_VALUE.length()); - int remaining = EXPECTED_VALUE.length(); - - ((Buffer) byteBuffer).limit(1); - composite.readBytes(byteBuffer); - remaining--; - assertEquals(remaining, composite.readableBytes()); - - ((Buffer) byteBuffer).limit(byteBuffer.limit() + 5); - composite.readBytes(byteBuffer); - remaining -= 5; - assertEquals(remaining, composite.readableBytes()); - - ((Buffer) byteBuffer).limit(byteBuffer.limit() + remaining); - composite.readBytes(byteBuffer); - assertEquals(0, composite.readableBytes()); - assertEquals(EXPECTED_VALUE, new String(byteBuffer.array(), UTF_8)); - } - @Test public void readStreamShouldSucceed() throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); @@ -216,18 +193,6 @@ public void markAndResetWithReadByteArrayShouldSucceed() { assertArrayEquals(first, second); } - @Test - public void markAndResetWithReadByteBufferShouldSucceed() { - byte[] first = new byte[EXPECTED_VALUE.length()]; - composite.mark(); - composite.readBytes(ByteBuffer.wrap(first)); - composite.reset(); - byte[] second = new byte[EXPECTED_VALUE.length()]; - assertEquals(EXPECTED_VALUE.length(), composite.readableBytes()); - composite.readBytes(ByteBuffer.wrap(second)); - assertArrayEquals(first, second); - } - @Test public void markAndResetWithReadStreamShouldSucceed() throws IOException { ByteArrayOutputStream first = new ByteArrayOutputStream(); diff --git a/core/src/test/java/io/grpc/internal/DelayedClientCallTest.java b/core/src/test/java/io/grpc/internal/DelayedClientCallTest.java index 45682b3a385..cd7d71daab4 100644 --- a/core/src/test/java/io/grpc/internal/DelayedClientCallTest.java +++ b/core/src/test/java/io/grpc/internal/DelayedClientCallTest.java @@ -66,8 +66,8 @@ public class DelayedClientCallTest { @Test public void allMethodsForwarded() throws Exception { - DelayedClientCall delayedClientCall = - new DelayedClientCall<>(callExecutor, fakeClock.getScheduledExecutorService(), null); + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); callMeMaybe(delayedClientCall.setCall(mockRealCall)); ForwardingTestUtil.testMethodsForwarded( ClientCall.class, @@ -94,18 +94,22 @@ public Object get(Method method, int argPos, Class clazz) { @Test public void deadlineExceededWhileCallIsStartedButStillPending() { DelayedClientCall delayedClientCall = new DelayedClientCall<>( - callExecutor, fakeClock.getScheduledExecutorService(), Deadline.after(10, SECONDS)); + "tESt", callExecutor, fakeClock.getScheduledExecutorService(), + Deadline.after(10, SECONDS, fakeClock.getDeadlineTicker())); delayedClientCall.start(listener, new Metadata()); fakeClock.forwardTime(10, SECONDS); verify(listener).onClose(statusCaptor.capture(), any(Metadata.class)); assertThat(statusCaptor.getValue().getCode()).isEqualTo(Status.Code.DEADLINE_EXCEEDED); + assertThat(statusCaptor.getValue().getDescription()) + .isEqualTo("Deadline CallOptions was exceeded after 10.000000000s waiting for tESt"); } @Test public void listenerEventsPropagated() { DelayedClientCall delayedClientCall = new DelayedClientCall<>( - callExecutor, fakeClock.getScheduledExecutorService(), Deadline.after(10, SECONDS)); + "test", callExecutor, fakeClock.getScheduledExecutorService(), + Deadline.after(10, SECONDS, fakeClock.getDeadlineTicker())); delayedClientCall.start(listener, new Metadata()); callMeMaybe(delayedClientCall.setCall(mockRealCall)); @SuppressWarnings("unchecked") @@ -130,7 +134,7 @@ public void listenerEventsPropagated() { @Test public void setCallThenStart() { DelayedClientCall delayedClientCall = new DelayedClientCall<>( - callExecutor, fakeClock.getScheduledExecutorService(), null); + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); callMeMaybe(delayedClientCall.setCall(mockRealCall)); delayedClientCall.start(listener, new Metadata()); delayedClientCall.request(1); @@ -146,15 +150,17 @@ public void setCallThenStart() { @Test public void startThenSetCall() { DelayedClientCall delayedClientCall = new DelayedClientCall<>( - callExecutor, fakeClock.getScheduledExecutorService(), null); + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); delayedClientCall.start(listener, new Metadata()); delayedClientCall.request(1); Runnable r = delayedClientCall.setCall(mockRealCall); assertThat(r).isNotNull(); - r.run(); @SuppressWarnings("unchecked") ArgumentCaptor> listenerCaptor = ArgumentCaptor.forClass(Listener.class); + // start() must be called before setCall() returns (not in runnable), to ensure the in-use + // counts keeping the channel alive after shutdown() don't momentarily decrease to zero. verify(mockRealCall).start(listenerCaptor.capture(), any(Metadata.class)); + r.run(); Listener realCallListener = listenerCaptor.getValue(); verify(mockRealCall).request(1); realCallListener.onMessage(1); @@ -165,7 +171,7 @@ public void startThenSetCall() { @SuppressWarnings("unchecked") public void cancelThenSetCall() { DelayedClientCall delayedClientCall = new DelayedClientCall<>( - callExecutor, fakeClock.getScheduledExecutorService(), null); + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); delayedClientCall.start(listener, new Metadata()); delayedClientCall.request(1); delayedClientCall.cancel("cancel", new StatusException(Status.CANCELLED)); @@ -181,7 +187,7 @@ public void cancelThenSetCall() { @SuppressWarnings("unchecked") public void setCallThenCancel() { DelayedClientCall delayedClientCall = new DelayedClientCall<>( - callExecutor, fakeClock.getScheduledExecutorService(), null); + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); delayedClientCall.start(listener, new Metadata()); delayedClientCall.request(1); Runnable r = delayedClientCall.setCall(mockRealCall); @@ -204,7 +210,8 @@ public void delayedCallsRunUnderContext() throws Exception { Object goldenValue = new Object(); DelayedClientCall delayedClientCall = Context.current().withValue(contextKey, goldenValue).call(() -> - new DelayedClientCall<>(callExecutor, fakeClock.getScheduledExecutorService(), null)); + new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null)); AtomicReference readyContext = new AtomicReference<>(); delayedClientCall.start(new ClientCall.Listener() { @Override public void onReady() { @@ -227,6 +234,232 @@ public void delayedCallsRunUnderContext() throws Exception { assertThat(contextKey.get(readyContext.get())).isEqualTo(goldenValue); } + @Test + public void listenerThrowsInPendingCallback_cancelsRealCall() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onMessage(Integer msg) { + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + // Deliver onMessage while the wrapping DelayedListener is still buffering, by firing + // it from within realCall.start() — drainPendingCalls has not yet flipped the listener + // to pass-through. The queued onMessage is then drained and throws; the fix must catch + // the throwable and cancel the real call rather than let it escape. + Runnable r = delayedClientCall.setCall(new SimpleForwardingClientCall( + mockRealCall) { + @Override + public void start(Listener listener, Metadata metadata) { + super.start(listener, metadata); + listener.onMessage(42); + } + }); + assertThat(r).isNotNull(); + r.run(); // Must not propagate `boom`. + verify(mockRealCall).cancel(eq("Failed to read message."), eq(boom)); + } + + @Test + public void listenerThrowsInPendingOnHeaders_cancelsRealCall() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(new SimpleForwardingClientCall( + mockRealCall) { + @Override + public void start(Listener listener, Metadata metadata) { + super.start(listener, metadata); + listener.onHeaders(new Metadata()); + } + }); + assertThat(r).isNotNull(); + r.run(); + verify(mockRealCall).cancel(eq("Failed to read headers"), eq(boom)); + } + + @Test + public void listenerThrowsInPendingOnReady_cancelsRealCall() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onReady() { + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(new SimpleForwardingClientCall( + mockRealCall) { + @Override + public void start(Listener listener, Metadata metadata) { + super.start(listener, metadata); + listener.onReady(); + } + }); + assertThat(r).isNotNull(); + r.run(); + verify(mockRealCall).cancel(eq("Failed to call onReady."), eq(boom)); + } + + @Test + public void onCloseExceptionCaughtAndLogged() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + final AtomicReference observed = new AtomicReference<>(); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + observed.set(status); + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(new SimpleForwardingClientCall( + mockRealCall) { + @Override + public void start(Listener listener, Metadata metadata) { + super.start(listener, metadata); + listener.onClose(Status.DATA_LOSS, new Metadata()); + } + }); + assertThat(r).isNotNull(); + r.run(); // Must not propagate `boom`. + assertThat(observed.get().getCode()).isEqualTo(Status.Code.DATA_LOSS); + verify(mockRealCall, never()).cancel(any(), any()); + } + + @Test + public void listenerThrowsInPassThroughOnMessage_cancelsRealCall() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onMessage(Integer msg) { + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(mockRealCall); + assertThat(r).isNotNull(); + r.run(); // drain completes, listener transitions to passThrough + @SuppressWarnings("unchecked") + ArgumentCaptor> listenerCaptor = ArgumentCaptor.forClass(Listener.class); + verify(mockRealCall).start(listenerCaptor.capture(), any(Metadata.class)); + Listener realCallListener = listenerCaptor.getValue(); + realCallListener.onMessage(42); // dispatched on passThrough fast path + verify(mockRealCall).cancel(eq("Failed to read message."), eq(boom)); + } + + @Test + public void listenerThrowsInPassThroughOnHeaders_cancelsRealCall() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(mockRealCall); + assertThat(r).isNotNull(); + r.run(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listenerCaptor = ArgumentCaptor.forClass(Listener.class); + verify(mockRealCall).start(listenerCaptor.capture(), any(Metadata.class)); + Listener realCallListener = listenerCaptor.getValue(); + realCallListener.onHeaders(new Metadata()); + verify(mockRealCall).cancel(eq("Failed to read headers"), eq(boom)); + } + + @Test + public void listenerThrowsInPassThroughOnReady_cancelsRealCall() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onReady() { + throw boom; + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(mockRealCall); + assertThat(r).isNotNull(); + r.run(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listenerCaptor = ArgumentCaptor.forClass(Listener.class); + verify(mockRealCall).start(listenerCaptor.capture(), any(Metadata.class)); + Listener realCallListener = listenerCaptor.getValue(); + realCallListener.onReady(); + verify(mockRealCall).cancel(eq("Failed to call onReady."), eq(boom)); + } + + @Test + public void listenerThrowsInPassThrough_subsequentCallbacksSwallowedAndOnCloseOverridden() { + DelayedClientCall delayedClientCall = new DelayedClientCall<>( + "test", callExecutor, fakeClock.getScheduledExecutorService(), null); + final RuntimeException boom = new RuntimeException("boom"); + final AtomicReference lastMessage = new AtomicReference<>(); + final AtomicReference closeStatus = new AtomicReference<>(); + final AtomicReference closeTrailers = new AtomicReference<>(); + ClientCall.Listener throwingListener = new ClientCall.Listener() { + @Override + public void onMessage(Integer msg) { + lastMessage.set(msg); + if (msg == 1) { + throw boom; + } + } + + @Override + public void onClose(Status status, Metadata trailers) { + closeStatus.set(status); + closeTrailers.set(trailers); + } + }; + delayedClientCall.start(throwingListener, new Metadata()); + Runnable r = delayedClientCall.setCall(mockRealCall); + assertThat(r).isNotNull(); + r.run(); + @SuppressWarnings("unchecked") + ArgumentCaptor> listenerCaptor = ArgumentCaptor.forClass(Listener.class); + verify(mockRealCall).start(listenerCaptor.capture(), any(Metadata.class)); + Listener realCallListener = listenerCaptor.getValue(); + + realCallListener.onMessage(1); // throws -> exceptionStatus captured + assertThat(lastMessage.get()).isEqualTo(1); + verify(mockRealCall).cancel(eq("Failed to read message."), eq(boom)); + + // Later callbacks are swallowed — the listener must not see message 2. + realCallListener.onMessage(2); + assertThat(lastMessage.get()).isEqualTo(1); + + // Transport onClose with OK must be overridden by the captured CANCELLED status. + Metadata serverTrailers = new Metadata(); + serverTrailers.put(Metadata.Key.of("k", Metadata.ASCII_STRING_MARSHALLER), "v"); + realCallListener.onClose(Status.OK, serverTrailers); + assertThat(closeStatus.get().getCode()).isEqualTo(Status.Code.CANCELLED); + assertThat(closeStatus.get().getCause()).isEqualTo(boom); + // Trailers replaced to avoid mixing sources. + assertThat(closeTrailers.get()).isNotSameInstanceAs(serverTrailers); + } + private void callMeMaybe(Runnable r) { if (r != null) { r.run(); diff --git a/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java b/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java index 902c2835a92..d7e1d4ca4f6 100644 --- a/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java +++ b/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java @@ -175,7 +175,8 @@ public void uncaughtException(Thread t, Throwable e) { delayedTransport.reprocess(mockPicker); assertEquals(0, delayedTransport.getPendingStreamsCount()); delayedTransport.shutdown(SHUTDOWN_STATUS); - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); assertEquals(0, fakeExecutor.runDueTasks()); verify(mockRealTransport).newStream( @@ -187,7 +188,8 @@ public void uncaughtException(Thread t, Throwable e) { @Test public void transportTerminatedThenAssignTransport() { delayedTransport.shutdown(SHUTDOWN_STATUS); - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); delayedTransport.reprocess(mockPicker); verifyNoMoreInteractions(transportListener); @@ -196,7 +198,8 @@ public void uncaughtException(Thread t, Throwable e) { @Test public void assignTransportThenShutdownThenNewStream() { delayedTransport.reprocess(mockPicker); delayedTransport.shutdown(SHUTDOWN_STATUS); - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); ClientStream stream = delayedTransport.newStream( method, headers, callOptions, tracers); @@ -210,7 +213,8 @@ public void uncaughtException(Thread t, Throwable e) { @Test public void assignTransportThenShutdownNowThenNewStream() { delayedTransport.reprocess(mockPicker); delayedTransport.shutdownNow(Status.UNAVAILABLE); - verify(transportListener).transportShutdown(any(Status.class)); + verify(transportListener).transportShutdown(any(Status.class), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); ClientStream stream = delayedTransport.newStream( method, headers, callOptions, tracers); @@ -241,7 +245,8 @@ public void uncaughtException(Thread t, Throwable e) { delayedTransport.shutdown(SHUTDOWN_STATUS); // Stream is still buffered - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener, times(0)).transportTerminated(); assertEquals(1, delayedTransport.getPendingStreamsCount()); @@ -275,7 +280,8 @@ public void uncaughtException(Thread t, Throwable e) { ClientStream stream = delayedTransport.newStream( method, new Metadata(), CallOptions.DEFAULT, tracers); delayedTransport.shutdown(SHUTDOWN_STATUS); - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener, times(0)).transportTerminated(); assertEquals(1, delayedTransport.getPendingStreamsCount()); stream.start(streamListener); @@ -288,7 +294,8 @@ public void uncaughtException(Thread t, Throwable e) { @Test public void shutdownThenNewStream() { delayedTransport.shutdown(SHUTDOWN_STATUS); - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); ClientStream stream = delayedTransport.newStream( method, new Metadata(), CallOptions.DEFAULT, tracers); @@ -303,7 +310,8 @@ public void uncaughtException(Thread t, Throwable e) { method, new Metadata(), CallOptions.DEFAULT, tracers); stream.start(streamListener); delayedTransport.shutdownNow(Status.UNAVAILABLE); - verify(transportListener).transportShutdown(any(Status.class)); + verify(transportListener).transportShutdown(any(Status.class), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); verify(streamListener) .closed(statusCaptor.capture(), eq(RpcProgress.REFUSED), any(Metadata.class)); @@ -312,7 +320,8 @@ public void uncaughtException(Thread t, Throwable e) { @Test public void shutdownNowThenNewStream() { delayedTransport.shutdownNow(Status.UNAVAILABLE); - verify(transportListener).transportShutdown(any(Status.class)); + verify(transportListener).transportShutdown(any(Status.class), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener).transportTerminated(); ClientStream stream = delayedTransport.newStream( method, new Metadata(), CallOptions.DEFAULT, tracers); @@ -487,7 +496,8 @@ public void uncaughtException(Thread t, Throwable e) { // wfr5 will stop delayed transport from terminating delayedTransport.shutdown(SHUTDOWN_STATUS); - verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS)); + verify(transportListener).transportShutdown(same(SHUTDOWN_STATUS), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); verify(transportListener, never()).transportTerminated(); // ... until it's gone picker = mock(SubchannelPicker.class); @@ -742,7 +752,7 @@ public void pendingStream_appendTimeoutInsight_waitForReady() { InsightBuilder insight = new InsightBuilder(); stream.appendTimeoutInsight(insight); assertThat(insight.toString()) - .matches("\\[wait_for_ready, buffered_nanos=[0-9]+\\, waiting_for_connection]"); + .matches("\\[wait_for_ready, connecting_and_lb_delay=[0-9]+ns\\, was_still_waiting]"); } @Test @@ -759,7 +769,7 @@ public void pendingStream_appendTimeoutInsight_waitForReady_withLastPickFailure( assertThat(insight.toString()) .matches("\\[wait_for_ready, " + "Last Pick Failure=Status\\{code=PERMISSION_DENIED, description=null, cause=null\\}," - + " buffered_nanos=[0-9]+, waiting_for_connection]"); + + " connecting_and_lb_delay=[0-9]+ns, was_still_waiting]"); } private static TransportProvider newTransportProvider(final ClientTransport transport) { diff --git a/core/src/test/java/io/grpc/internal/DelayedStreamTest.java b/core/src/test/java/io/grpc/internal/DelayedStreamTest.java index a47bea9f4ab..12c32fcf126 100644 --- a/core/src/test/java/io/grpc/internal/DelayedStreamTest.java +++ b/core/src/test/java/io/grpc/internal/DelayedStreamTest.java @@ -71,7 +71,7 @@ public class DelayedStreamTest { @Mock private ClientStreamListener listener; @Mock private ClientStream realStream; @Captor private ArgumentCaptor listenerCaptor; - private DelayedStream stream = new DelayedStream(); + private DelayedStream stream = new DelayedStream("test_op"); @Test public void setStream_setAuthority() { @@ -450,7 +450,7 @@ public void appendTimeoutInsight_realStreamNotSet() { InsightBuilder insight = new InsightBuilder(); stream.start(listener); stream.appendTimeoutInsight(insight); - assertThat(insight.toString()).matches("\\[buffered_nanos=[0-9]+\\, waiting_for_connection]"); + assertThat(insight.toString()).matches("\\[test_op_delay=[0-9]+ns\\, was_still_waiting]"); } @Test @@ -469,7 +469,7 @@ public Void answer(InvocationOnMock in) { InsightBuilder insight = new InsightBuilder(); stream.appendTimeoutInsight(insight); assertThat(insight.toString()) - .matches("\\[buffered_nanos=[0-9]+, remote_addr=127\\.0\\.0\\.1:443\\]"); + .matches("\\[test_op_delay=[0-9]+ns, remote_addr=127\\.0\\.0\\.1:443\\]"); } private void callMeMaybe(Runnable r) { diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java index aff10ce9337..75b82df544f 100644 --- a/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java +++ b/core/src/test/java/io/grpc/internal/DnsNameResolverProviderTest.java @@ -16,8 +16,9 @@ package io.grpc.internal; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.TruthJUnit.assume; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -25,16 +26,27 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import java.net.URI; +import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; /** Unit tests for {@link DnsNameResolverProvider}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class DnsNameResolverProviderTest { private final FakeClock fakeClock = new FakeClock(); + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + private final SynchronizationContext syncContext = new SynchronizationContext( new Thread.UncaughtExceptionHandler() { @Override @@ -59,10 +71,75 @@ public void isAvailable() { } @Test - public void newNameResolver() { - assertSame(DnsNameResolver.class, - provider.newNameResolver(URI.create("dns:///localhost:443"), args).getClass()); - assertNull( - provider.newNameResolver(URI.create("notdns:///localhost:443"), args)); + public void newNameResolver_acceptsHostAndPort() { + NameResolver nameResolver = newNameResolver("dns:///localhost:443", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443"); + assertThat(((DnsNameResolver) nameResolver).getPort()).isEqualTo(443); + } + + @Test + public void newNameResolver_acceptsRootless() { + assume().that(enableRfc3986UrisParam).isTrue(); + NameResolver nameResolver = newNameResolver("dns:localhost:443", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("localhost:443"); + } + + @Test + public void newNameResolver_rejectsNonDnsScheme() { + NameResolver nameResolver = newNameResolver("notdns:///localhost:443", args); + assertThat(nameResolver).isNull(); + } + + @Test + public void newNameResolver_validDnsNameWithoutPort_usesDefaultPort() { + DnsNameResolver nameResolver = + (DnsNameResolver) newNameResolver("dns:/foo.googleapis.com", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); + assertThat(nameResolver.getPort()).isEqualTo(args.getDefaultPort()); + } + + // TODO(jdcormie): Trailing path segments *should* be forbidden. This test just demonstrates that + // both newNameResolver() overloads behave the same with respect to this bug. + @Test + public void newNameResolver_toleratesTrailingPathSegments() { + NameResolver nameResolver = newNameResolver("dns:///foo.googleapis.com/ig/nor/ed", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); + } + + @Test + public void newNameResolver_toleratesAuthority() { + NameResolver nameResolver = newNameResolver("dns://8.8.8.8/foo.googleapis.com", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); + } + + @Test + public void newNameResolver_validIpv6Host() { + NameResolver nameResolver = newNameResolver("dns:/%5B::1%5D", args); + assertThat(nameResolver).isNotNull(); + assertThat(nameResolver.getClass()).isSameInstanceAs(DnsNameResolver.class); + assertThat(nameResolver.getServiceAuthority()).isEqualTo("[::1]"); + } + + @Test + public void newNameResolver_invalidIpv6Host_throws() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> newNameResolver("dns:/%5Binvalid%5D", args)); + assertThat(e).hasMessageThat().contains("invalid"); + } + + private NameResolver newNameResolver(String uriString, NameResolver.Args args) { + return enableRfc3986UrisParam + ? provider.newNameResolver(Uri.create(uriString), args) + : provider.newNameResolver(URI.create(uriString), args); } } diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java index f5be078f83a..c53863dcf5d 100644 --- a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java +++ b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java @@ -17,6 +17,7 @@ package io.grpc.internal; import static com.google.common.truth.Truth.assertThat; +import static io.grpc.internal.DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -43,6 +44,7 @@ import com.google.common.testing.FakeTicker; import io.grpc.ChannelLogger; import io.grpc.EquivalentAddressGroup; +import io.grpc.FlagResetRule; import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.NameResolver; import io.grpc.NameResolver.ConfigOrError; @@ -63,7 +65,6 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketAddress; -import java.net.URI; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; @@ -78,8 +79,6 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; -import javax.annotation.Nullable; -import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -100,6 +99,7 @@ public class DnsNameResolverTest { @Rule public final TestRule globalTimeout = new DisableOnDebug(Timeout.seconds(10)); @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + @Rule public final FlagResetRule flagResetRule = new FlagResetRule(); private final Map serviceConfig = new LinkedHashMap<>(); @@ -112,7 +112,6 @@ public void uncaughtException(Thread t, Throwable e) { } }); - private final DnsNameResolverProvider provider = new DnsNameResolverProvider(); private final FakeClock fakeClock = new FakeClock(); private final FakeClock fakeExecutor = new FakeClock(); private static final FakeClock.TaskFilter NAME_RESOLVER_REFRESH_TASK_FILTER = @@ -139,21 +138,10 @@ public Executor create() { public void close(Executor instance) {} } - private final NameResolver.Args args = NameResolver.Args.newBuilder() - .setDefaultPort(DEFAULT_PORT) - .setProxyDetector(GrpcUtil.DEFAULT_PROXY_DETECTOR) - .setSynchronizationContext(syncContext) - .setServiceConfigParser(mock(ServiceConfigParser.class)) - .setChannelLogger(mock(ChannelLogger.class)) - .setScheduledExecutorService(fakeExecutor.getScheduledExecutorService()) - .build(); - @Mock private NameResolver.Listener2 mockListener; @Captor private ArgumentCaptor resultCaptor; - @Nullable - private String networkaddressCacheTtlPropertyValue; @Mock private RecordFetcher recordFetcher; @Mock private ProxyDetector mockProxyDetector; @@ -163,12 +151,6 @@ private RetryingNameResolver newResolver(String name, int defaultPort) { name, defaultPort, GrpcUtil.NOOP_PROXY_DETECTOR, Stopwatch.createUnstarted()); } - private RetryingNameResolver newResolver(String name, int defaultPort, boolean isAndroid) { - return newResolver( - name, defaultPort, GrpcUtil.NOOP_PROXY_DETECTOR, Stopwatch.createUnstarted(), - isAndroid); - } - private RetryingNameResolver newResolver( String name, int defaultPort, @@ -213,46 +195,11 @@ private RetryingNameResolver newResolver( @Before public void setUp() { DnsNameResolver.enableJndi = true; - networkaddressCacheTtlPropertyValue = - System.getProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY); // By default the mock listener processes the result successfully. when(mockListener.onResult2(isA(ResolutionResult.class))).thenReturn(Status.OK); } - @After - public void restoreSystemProperty() { - if (networkaddressCacheTtlPropertyValue == null) { - System.clearProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY); - } else { - System.setProperty( - DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, - networkaddressCacheTtlPropertyValue); - } - } - - @Test - public void invalidDnsName() throws Exception { - testInvalidUri(new URI("dns", null, "/[invalid]", null)); - } - - @Test - public void validIpv6() throws Exception { - testValidUri(new URI("dns", null, "/[::1]", null), "[::1]", DEFAULT_PORT); - } - - @Test - public void validDnsNameWithoutPort() throws Exception { - testValidUri(new URI("dns", null, "/foo.googleapis.com", null), - "foo.googleapis.com", DEFAULT_PORT); - } - - @Test - public void validDnsNameWithPort() throws Exception { - testValidUri(new URI("dns", null, "/foo.googleapis.com:456", null), - "foo.googleapis.com:456", 456); - } - @Test public void nullDnsName() { try { @@ -273,30 +220,14 @@ public void invalidDnsName_containsUnderscore() { } } - @Test - public void resolve_androidIgnoresPropertyValue() throws Exception { - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, Long.toString(2)); - resolveNeverCache(true); - } - - @Test - public void resolve_androidIgnoresPropertyValueCacheForever() throws Exception { - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, Long.toString(-1)); - resolveNeverCache(true); - } - @Test public void resolve_neverCache() throws Exception { - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, "0"); - resolveNeverCache(false); - } - - private void resolveNeverCache(boolean isAndroid) throws Exception { + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, "0"); final List answer1 = createAddressList(2); final List answer2 = createAddressList(1); String name = "foo.googleapis.com"; - RetryingNameResolver resolver = newResolver(name, 81, isAndroid); + RetryingNameResolver resolver = newResolver(name, 81); DnsNameResolver dnsResolver = (DnsNameResolver) resolver.getRetriedNameResolver(); AddressResolver mockResolver = mock(AddressResolver.class); when(mockResolver.resolveAddress(anyString())).thenReturn(answer1).thenReturn(answer2); @@ -387,7 +318,7 @@ public void execute(Runnable command) { @Test public void resolve_cacheForever() throws Exception { - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, "-1"); + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, "-1"); final List answer1 = createAddressList(2); String name = "foo.googleapis.com"; FakeTicker fakeTicker = new FakeTicker(); @@ -421,7 +352,7 @@ public void resolve_cacheForever() throws Exception { @Test public void resolve_usingCache() throws Exception { long ttl = 60; - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, Long.toString(ttl)); + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, Long.toString(ttl)); final List answer = createAddressList(2); String name = "foo.googleapis.com"; FakeTicker fakeTicker = new FakeTicker(); @@ -456,7 +387,7 @@ public void resolve_usingCache() throws Exception { @Test public void resolve_cacheExpired() throws Exception { long ttl = 60; - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, Long.toString(ttl)); + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, Long.toString(ttl)); final List answer1 = createAddressList(2); final List answer2 = createAddressList(1); String name = "foo.googleapis.com"; @@ -489,26 +420,38 @@ public void resolve_cacheExpired() throws Exception { verify(mockResolver, times(2)).resolveAddress(anyString()); } + @Test + public void resolve_androidIgnoresPropertyValue() throws Exception { + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, "2"); + resolveDefaultValue(true); + } + + @Test + public void resolve_androidIgnoresPropertyValueCacheForever() throws Exception { + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, "-1"); + resolveDefaultValue(true); + } + @Test public void resolve_invalidTtlPropertyValue() throws Exception { - System.setProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY, "not_a_number"); - resolveDefaultValue(); + flagResetRule.setSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY, "not_a_number"); + resolveDefaultValue(false); } @Test public void resolve_noPropertyValue() throws Exception { - System.clearProperty(DnsNameResolver.NETWORKADDRESS_CACHE_TTL_PROPERTY); - resolveDefaultValue(); + flagResetRule.clearSystemPropertyForTest(NETWORKADDRESS_CACHE_TTL_PROPERTY); + resolveDefaultValue(false); } - private void resolveDefaultValue() throws Exception { + private void resolveDefaultValue(boolean isAndroid) throws Exception { final List answer1 = createAddressList(2); final List answer2 = createAddressList(1); String name = "foo.googleapis.com"; FakeTicker fakeTicker = new FakeTicker(); RetryingNameResolver resolver = newResolver( - name, 81, GrpcUtil.NOOP_PROXY_DETECTOR, Stopwatch.createUnstarted(fakeTicker)); + name, 81, GrpcUtil.NOOP_PROXY_DETECTOR, Stopwatch.createUnstarted(fakeTicker), isAndroid); DnsNameResolver dnsResolver = (DnsNameResolver) resolver.getRetriedNameResolver(); AddressResolver mockResolver = mock(AddressResolver.class); when(mockResolver.resolveAddress(anyString())).thenReturn(answer1).thenReturn(answer2); @@ -741,7 +684,7 @@ public ConfigOrError parseServiceConfig(Map rawServiceConfig) { } @Test - public void resolve_addressFailure_neverLookUpServiceConfig() throws Exception { + public void resolve_addressFailure_stillLookUpServiceConfig() throws Exception { DnsNameResolver.enableTxt = true; AddressResolver mockAddressResolver = mock(AddressResolver.class); when(mockAddressResolver.resolveAddress(anyString())) @@ -760,7 +703,7 @@ public void resolve_addressFailure_neverLookUpServiceConfig() throws Exception { Status errorStatus = resultCaptor.getValue().getAddressesOrError().getStatus(); assertThat(errorStatus.getCode()).isEqualTo(Code.UNAVAILABLE); assertThat(errorStatus.getCause()).hasMessageThat().contains("no addr"); - verify(mockResourceResolver, never()).resolveTxt(anyString()); + verify(mockResourceResolver).resolveTxt("_grpc_config." + name); assertEquals(0, fakeClock.numPendingTasks()); // A retry should be scheduled @@ -1299,22 +1242,6 @@ public void parseServiceConfig_matches() { assertThat(result.getConfig()).isEqualTo(ImmutableMap.of()); } - private void testInvalidUri(URI uri) { - try { - provider.newNameResolver(uri, args); - fail("Should have failed"); - } catch (IllegalArgumentException e) { - // expected - } - } - - private void testValidUri(URI uri, String exportedAuthority, int expectedPort) { - DnsNameResolver resolver = (DnsNameResolver) provider.newNameResolver(uri, args); - assertNotNull(resolver); - assertEquals(expectedPort, resolver.getPort()); - assertEquals(exportedAuthority, resolver.getServiceAuthority()); - } - private byte lastByte = 0; private List createAddressList(int n) throws UnknownHostException { diff --git a/core/src/test/java/io/grpc/internal/ForwardingReadableBufferTest.java b/core/src/test/java/io/grpc/internal/ForwardingReadableBufferTest.java index 8ce45bc77cf..696fb35e379 100644 --- a/core/src/test/java/io/grpc/internal/ForwardingReadableBufferTest.java +++ b/core/src/test/java/io/grpc/internal/ForwardingReadableBufferTest.java @@ -25,7 +25,6 @@ import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Method; -import java.nio.ByteBuffer; import java.util.Collections; import org.junit.Before; import org.junit.Rule; @@ -91,14 +90,6 @@ public void readBytes() { verify(delegate).readBytes(dest, 1, 2); } - @Test - public void readBytes_overload1() { - ByteBuffer dest = ByteBuffer.allocate(0); - buffer.readBytes(dest); - - verify(delegate).readBytes(dest); - } - @Test public void readBytes_overload2() throws IOException { OutputStream dest = mock(OutputStream.class); diff --git a/core/src/test/java/io/grpc/internal/Http2ClientStreamTransportStateTest.java b/core/src/test/java/io/grpc/internal/Http2ClientStreamTransportStateTest.java index 9d32bf1af7d..66df062a3e0 100644 --- a/core/src/test/java/io/grpc/internal/Http2ClientStreamTransportStateTest.java +++ b/core/src/test/java/io/grpc/internal/Http2ClientStreamTransportStateTest.java @@ -301,6 +301,24 @@ public void transportTrailersReceived_missingStatusUsesHttpStatus() { assertTrue(statusCaptor.getValue().getDescription().contains("401")); } + @Test + public void transportTrailersReceived_missingContentTypeUsesHttpStatus() { + BaseTransportState state = new BaseTransportState(transportTracer); + state.setListener(mockListener); + Metadata trailers = new Metadata(); + trailers.put(testStatusMashaller, "431"); + + state.transportTrailersReceived(trailers); + + verify(mockListener, never()).headersRead(any(Metadata.class)); + verify(mockListener).closed(statusCaptor.capture(), same(PROCESSED), same(trailers)); + assertEquals(Code.INTERNAL, statusCaptor.getValue().getCode()); + assertTrue(statusCaptor.getValue().getDescription().contains("HTTP status code 431")); + assertTrue( + statusCaptor.getValue().getDescription().contains( + "missing content-type in response headers")); + } + @Test public void transportTrailersReceived_missingHttpStatus() { BaseTransportState state = new BaseTransportState(transportTracer); diff --git a/core/src/test/java/io/grpc/internal/InternalSubchannelTest.java b/core/src/test/java/io/grpc/internal/InternalSubchannelTest.java index 4ac5fbac362..4236c091d9c 100644 --- a/core/src/test/java/io/grpc/internal/InternalSubchannelTest.java +++ b/core/src/test/java/io/grpc/internal/InternalSubchannelTest.java @@ -48,6 +48,7 @@ import io.grpc.ConnectivityStateInfo; import io.grpc.EquivalentAddressGroup; import io.grpc.InternalChannelz; +import io.grpc.InternalEquivalentAddressGroup; import io.grpc.InternalLogId; import io.grpc.InternalWithLogId; import io.grpc.LoadBalancer; @@ -233,7 +234,8 @@ public void constructor_eagListWithNull_throws() { // Fail this one. Because there is only one address to try, enter TRANSIENT_FAILURE. assertNoCallbackInvoke(); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(TRANSIENT_FAILURE, internalSubchannel.getState()); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); // Backoff reset and using first back-off value interval @@ -264,7 +266,8 @@ public void constructor_eagListWithNull_throws() { assertNoCallbackInvoke(); // Here we use a different status from the first failure, and verify that it's passed to // the callback. - transports.poll().listener.transportShutdown(Status.RESOURCE_EXHAUSTED); + transports.poll().listener.transportShutdown(Status.RESOURCE_EXHAUSTED, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(TRANSIENT_FAILURE, internalSubchannel.getState()); assertExactCallbackInvokes("onStateChange:" + RESOURCE_EXHAUSTED_STATE); // Second back-off interval @@ -302,7 +305,8 @@ public void constructor_eagListWithNull_throws() { // Close the READY transport, will enter IDLE state. assertNoCallbackInvoke(); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(IDLE, internalSubchannel.getState()); assertExactCallbackInvokes("onStateChange:IDLE"); @@ -334,7 +338,8 @@ public void constructor_eagListWithNull_throws() { assertEquals(CONNECTING, internalSubchannel.getState()); verify(mockTransportFactory).newClientTransport(eq(addr1), any(), any()); // Let this one fail without success - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // Still in CONNECTING assertNull(internalSubchannel.obtainActiveTransport()); assertNoCallbackInvoke(); @@ -350,7 +355,8 @@ public void constructor_eagListWithNull_throws() { assertNull(internalSubchannel.obtainActiveTransport()); // Fail this one too assertNoCallbackInvoke(); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // All addresses have failed, but we aren't controlling retries. assertEquals(IDLE, internalSubchannel.getState()); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); @@ -394,7 +400,8 @@ public void constructor_eagListWithNull_throws() { isA(TransportLogger.class)); // Let this one fail without success - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // Still in CONNECTING assertNull(internalSubchannel.obtainActiveTransport()); assertNoCallbackInvoke(); @@ -410,7 +417,8 @@ public void constructor_eagListWithNull_throws() { assertNull(internalSubchannel.obtainActiveTransport()); // Fail this one too assertNoCallbackInvoke(); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // All addresses have failed. Delayed transport will be in back-off interval. assertEquals(TRANSIENT_FAILURE, internalSubchannel.getState()); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); @@ -441,7 +449,8 @@ public void constructor_eagListWithNull_throws() { eq(createClientTransportOptions()), isA(TransportLogger.class)); // Fail this one too - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(CONNECTING, internalSubchannel.getState()); // Forth attempt will start immediately. Keep back-off policy. @@ -455,7 +464,8 @@ public void constructor_eagListWithNull_throws() { isA(TransportLogger.class)); // Fail this one too assertNoCallbackInvoke(); - transports.poll().listener.transportShutdown(Status.RESOURCE_EXHAUSTED); + transports.poll().listener.transportShutdown(Status.RESOURCE_EXHAUSTED, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // All addresses have failed again. Delayed transport will be in back-off interval. assertExactCallbackInvokes("onStateChange:" + RESOURCE_EXHAUSTED_STATE); assertEquals(TRANSIENT_FAILURE, internalSubchannel.getState()); @@ -492,7 +502,8 @@ public void constructor_eagListWithNull_throws() { ((CallTracingTransport) internalSubchannel.obtainActiveTransport()).delegate()); // Then close it. assertNoCallbackInvoke(); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); assertEquals(IDLE, internalSubchannel.getState()); @@ -508,7 +519,8 @@ public void constructor_eagListWithNull_throws() { eq(createClientTransportOptions()), isA(TransportLogger.class)); // Fail the transport - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(CONNECTING, internalSubchannel.getState()); // Second attempt will start immediately. Still no new back-off policy. @@ -520,7 +532,8 @@ public void constructor_eagListWithNull_throws() { isA(TransportLogger.class)); // Fail this one too assertEquals(CONNECTING, internalSubchannel.getState()); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // All addresses have failed. Enter TRANSIENT_FAILURE. Back-off in effect. assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); assertEquals(TRANSIENT_FAILURE, internalSubchannel.getState()); @@ -584,7 +597,8 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr1), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(CONNECTING, internalSubchannel.getState()); // Second address connects @@ -606,7 +620,8 @@ public void updateAddresses_eagListWithNull_throws() { verify(transports.peek().transport, never()).shutdownNow(any(Status.class)); // And new addresses chosen when re-connecting - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); assertNull(internalSubchannel.obtainActiveTransport()); @@ -616,13 +631,15 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr2), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verify(mockTransportFactory) .newClientTransport( eq(addr3), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verifyNoMoreInteractions(mockTransportFactory); fakeClock.forwardNanos(10); // Drain retry, but don't care about result @@ -643,7 +660,8 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr1), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(CONNECTING, internalSubchannel.getState()); // Second address connecting @@ -666,7 +684,8 @@ public void updateAddresses_eagListWithNull_throws() { // And new addresses chosen when re-connecting transports.peek().listener.transportReady(); assertExactCallbackInvokes("onStateChange:READY"); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); assertNull(internalSubchannel.obtainActiveTransport()); @@ -676,13 +695,15 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr2), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verify(mockTransportFactory) .newClientTransport( eq(addr3), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verifyNoMoreInteractions(mockTransportFactory); fakeClock.forwardNanos(10); // Drain retry, but don't care about result @@ -721,7 +742,8 @@ public void updateAddresses_eagListWithNull_throws() { // And no other addresses attempted assertEquals(0, fakeClock.numPendingTasks()); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); assertEquals(TRANSIENT_FAILURE, internalSubchannel.getState()); verifyNoMoreInteractions(mockTransportFactory); @@ -745,7 +767,8 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr1), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(CONNECTING, internalSubchannel.getState()); // Second address connects @@ -769,7 +792,8 @@ public void updateAddresses_eagListWithNull_throws() { verify(transports.peek().transport).shutdown(any(Status.class)); // And new addresses chosen when re-connecting - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertNoCallbackInvoke(); assertEquals(IDLE, internalSubchannel.getState()); @@ -780,13 +804,15 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr3), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verify(mockTransportFactory) .newClientTransport( eq(addr4), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verifyNoMoreInteractions(mockTransportFactory); fakeClock.forwardNanos(10); // Drain retry, but don't care about result @@ -808,7 +834,8 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr1), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(CONNECTING, internalSubchannel.getState()); // Second address connecting @@ -838,13 +865,15 @@ public void updateAddresses_eagListWithNull_throws() { eq(addr3), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verify(mockTransportFactory) .newClientTransport( eq(addr4), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); verifyNoMoreInteractions(mockTransportFactory); fakeClock.forwardNanos(10); // Drain retry, but don't care about result @@ -928,7 +957,8 @@ public void connectIsLazy() { isA(TransportLogger.class)); // Fail this one - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); // Will always reconnect after back-off @@ -944,7 +974,8 @@ public void connectIsLazy() { transports.peek().listener.transportReady(); assertExactCallbackInvokes("onStateChange:READY"); // Then go-away - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); // No scheduled tasks that would ever try to reconnect ... @@ -974,7 +1005,8 @@ public void shutdownWhenReady() throws Exception { internalSubchannel.shutdown(SHUTDOWN_REASON); verify(transportInfo.transport).shutdown(same(SHUTDOWN_REASON)); assertExactCallbackInvokes("onStateChange:SHUTDOWN"); - transportInfo.listener.transportShutdown(SHUTDOWN_REASON); + transportInfo.listener.transportShutdown(SHUTDOWN_REASON, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo.listener.transportTerminated(); assertExactCallbackInvokes("onTerminated"); @@ -997,7 +1029,8 @@ public void shutdownBeforeTransportCreated() throws Exception { // Fail this one MockClientTransportInfo transportInfo = transports.poll(); - transportInfo.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo.listener.transportTerminated(); // Entering TRANSIENT_FAILURE, waiting for back-off @@ -1053,7 +1086,8 @@ public void shutdownBeforeTransportReady() throws Exception { // The transport should've been shut down even though it's not the active transport yet. verify(transportInfo.transport).shutdown(same(SHUTDOWN_REASON)); - transportInfo.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertNoCallbackInvoke(); transportInfo.listener.transportTerminated(); assertExactCallbackInvokes("onTerminated"); @@ -1069,7 +1103,7 @@ public void shutdownNow() throws Exception { MockClientTransportInfo t1 = transports.poll(); t1.listener.transportReady(); assertExactCallbackInvokes("onStateChange:CONNECTING", "onStateChange:READY"); - t1.listener.transportShutdown(Status.UNAVAILABLE); + t1.listener.transportShutdown(Status.UNAVAILABLE, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); internalSubchannel.obtainActiveTransport(); @@ -1126,7 +1160,7 @@ public void inUseState() { t0.listener.transportInUse(true); assertExactCallbackInvokes("onInUse"); - t0.listener.transportShutdown(Status.UNAVAILABLE); + t0.listener.transportShutdown(Status.UNAVAILABLE, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); assertNull(internalSubchannel.obtainActiveTransport()); @@ -1159,7 +1193,7 @@ public void transportTerminateWithoutExitingInUse() { t0.listener.transportInUse(true); assertExactCallbackInvokes("onInUse"); - t0.listener.transportShutdown(Status.UNAVAILABLE); + t0.listener.transportShutdown(Status.UNAVAILABLE, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:IDLE"); t0.listener.transportTerminated(); assertExactCallbackInvokes("onNotInUse"); @@ -1186,12 +1220,12 @@ public void run() { assertEquals(1, runnableInvokes.get()); MockClientTransportInfo t0 = transports.poll(); - t0.listener.transportShutdown(Status.UNAVAILABLE); + t0.listener.transportShutdown(Status.UNAVAILABLE, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertEquals(2, runnableInvokes.get()); // 2nd address: reconnect immediatly MockClientTransportInfo t1 = transports.poll(); - t1.listener.transportShutdown(Status.UNAVAILABLE); + t1.listener.transportShutdown(Status.UNAVAILABLE, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // Addresses exhausted, waiting for back-off. assertEquals(2, runnableInvokes.get()); @@ -1218,7 +1252,8 @@ public void resetConnectBackoff() throws Exception { eq(addr), eq(createClientTransportOptions()), isA(TransportLogger.class)); - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); // Save the reconnectTask @@ -1254,7 +1289,8 @@ public void resetConnectBackoff() throws Exception { // Fail the reconnect attempt to verify that a fresh reconnect policy is generated after // invoking resetConnectBackoff() - transports.poll().listener.transportShutdown(Status.UNAVAILABLE); + transports.poll().listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertExactCallbackInvokes("onStateChange:" + UNAVAILABLE_STATE); verify(mockBackoffPolicyProvider, times(2)).get(); fakeClock.forwardNanos(10); @@ -1282,7 +1318,8 @@ public void channelzMembership() throws Exception { MockClientTransportInfo t0 = transports.poll(); t0.listener.transportReady(); assertTrue(channelz.containsClientSocket(t0.transport.getLogId())); - t0.listener.transportShutdown(Status.RESOURCE_EXHAUSTED); + t0.listener.transportShutdown(Status.RESOURCE_EXHAUSTED, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); t0.listener.transportTerminated(); assertFalse(channelz.containsClientSocket(t0.transport.getLogId())); } @@ -1474,7 +1511,7 @@ public void subchannelStateChanges_triggersAttemptFailedMetric() { when(mockBackoffPolicyProvider.get()).thenReturn(mockBackoffPolicy); SocketAddress addr = mock(SocketAddress.class); Attributes eagAttributes = Attributes.newBuilder() - .set(NameResolver.ATTR_BACKEND_SERVICE, BACKEND_SERVICE) + .set(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE, BACKEND_SERVICE) .set(EquivalentAddressGroup.ATTR_LOCALITY_NAME, LOCALITY) .set(GrpcAttributes.ATTR_SECURITY_LEVEL, SECURITY_LEVEL) .build(); @@ -1502,7 +1539,8 @@ public void subchannelStateChanges_triggersAttemptFailedMetric() { // b. Fail the transport before it can signal `transportReady()`. transportInfo.listener.transportShutdown( - Status.INTERNAL.withDescription("Simulated connect failure")); + Status.INTERNAL.withDescription("Simulated connect failure"), + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); fakeClock.runDueTasks(); // Process the failure event // --- Verification --- @@ -1527,7 +1565,7 @@ public void subchannelStateChanges_triggersSuccessAndDisconnectMetrics() { // 2. Setup Subchannel with attributes SocketAddress addr = mock(SocketAddress.class); Attributes eagAttributes = Attributes.newBuilder() - .set(NameResolver.ATTR_BACKEND_SERVICE, BACKEND_SERVICE) + .set(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE, BACKEND_SERVICE) .set(EquivalentAddressGroup.ATTR_LOCALITY_NAME, LOCALITY) .set(GrpcAttributes.ATTR_SECURITY_LEVEL, SECURITY_LEVEL) .build(); @@ -1556,7 +1594,8 @@ public void subchannelStateChanges_triggersSuccessAndDisconnectMetrics() { fakeClock.runDueTasks(); // Process the successful connection // --- Action: Transport is shut down --- - transportInfo.listener.transportShutdown(Status.UNAVAILABLE.withDescription("unknown")); + transportInfo.listener.transportShutdown(Status.UNAVAILABLE.withDescription("unknown"), + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); fakeClock.runDueTasks(); // Process the shutdown // --- Verification --- @@ -1581,7 +1620,7 @@ public void subchannelStateChanges_triggersSuccessAndDisconnectMetrics() { eqMetricInstrumentName("grpc.subchannel.disconnections"), eq(1L), eq(Arrays.asList(AUTHORITY)), - eq(Arrays.asList(BACKEND_SERVICE, LOCALITY, "unknown")) + eq(Arrays.asList(BACKEND_SERVICE, LOCALITY, "subchannel shutdown")) ); inOrder.verify(mockMetricRecorder).addLongUpDownCounter( eqMetricInstrumentName("grpc.subchannel.open_connections"), @@ -1593,6 +1632,45 @@ public void subchannelStateChanges_triggersSuccessAndDisconnectMetrics() { inOrder.verifyNoMoreInteractions(); } + @Test + public void subchannelStateChanges_backendServiceFallsBackToResolutionResultAttr() { + when(mockBackoffPolicyProvider.get()).thenReturn(mockBackoffPolicy); + SocketAddress addr = mock(SocketAddress.class); + Attributes eagAttributes = Attributes.newBuilder() + .set(NameResolver.ATTR_BACKEND_SERVICE, BACKEND_SERVICE) + .set(EquivalentAddressGroup.ATTR_LOCALITY_NAME, LOCALITY) + .set(GrpcAttributes.ATTR_SECURITY_LEVEL, SECURITY_LEVEL) + .build(); + List addressGroups = + Arrays.asList(new EquivalentAddressGroup(Arrays.asList(addr), eagAttributes)); + InternalLogId logId = InternalLogId.allocate("Subchannel", /*details=*/ AUTHORITY); + ChannelTracer subchannelTracer = new ChannelTracer(logId, 10, + fakeClock.getTimeProvider().currentTimeNanos(), "Subchannel"); + LoadBalancer.CreateSubchannelArgs createSubchannelArgs = + LoadBalancer.CreateSubchannelArgs.newBuilder().setAddresses(addressGroups).build(); + internalSubchannel = new InternalSubchannel( + createSubchannelArgs, AUTHORITY, USER_AGENT, mockBackoffPolicyProvider, + mockTransportFactory, fakeClock.getScheduledExecutorService(), + fakeClock.getStopwatchSupplier(), syncContext, mockInternalSubchannelCallback, channelz, + CallTracer.getDefaultFactory().create(), subchannelTracer, logId, + new ChannelLoggerImpl(subchannelTracer, fakeClock.getTimeProvider()), + Collections.emptyList(), AUTHORITY, mockMetricRecorder + ); + + internalSubchannel.obtainActiveTransport(); + MockClientTransportInfo transportInfo = transports.poll(); + assertNotNull(transportInfo); + transportInfo.listener.transportReady(); + fakeClock.runDueTasks(); + + verify(mockMetricRecorder).addLongCounter( + eqMetricInstrumentName("grpc.subchannel.connection_attempts_succeeded"), + eq(1L), + eq(Arrays.asList(AUTHORITY)), + eq(Arrays.asList(BACKEND_SERVICE, LOCALITY)) + ); + } + private void assertNoCallbackInvoke() { while (fakeExecutor.runDueTasks() > 0) {} assertEquals(0, callbackInvokes.size()); diff --git a/core/src/test/java/io/grpc/internal/JsonUtilTest.java b/core/src/test/java/io/grpc/internal/JsonUtilTest.java index 058171814ea..f4aa2578f0b 100644 --- a/core/src/test/java/io/grpc/internal/JsonUtilTest.java +++ b/core/src/test/java/io/grpc/internal/JsonUtilTest.java @@ -50,7 +50,6 @@ public void getNumber() { assertThat(JsonUtil.getNumberAsLong(map, "key_number_1")).isEqualTo(1L); assertThat(JsonUtil.getNumberAsDouble(map, "key_string_2.0")).isEqualTo(2D); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_2.0")).isEqualTo(2F); try { JsonUtil.getNumberAsInteger(map, "key_string_2.0"); fail("expecting to throw but did not"); @@ -69,10 +68,8 @@ public void getNumber() { assertThat(JsonUtil.getNumberAsDouble(map, "key_string_3")).isEqualTo(3D); assertThat(JsonUtil.getNumberAsInteger(map, "key_string_3")).isEqualTo(3); assertThat(JsonUtil.getNumberAsLong(map, "key_string_3")).isEqualTo(3L); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_3")).isEqualTo(3F); assertThat(JsonUtil.getNumberAsDouble(map, "key_string_nan")).isNaN(); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_nan")).isNaN(); try { JsonUtil.getNumberAsInteger(map, "key_string_nan"); fail("expecting to throw but did not"); @@ -123,41 +120,18 @@ public void getNumber() { assertThat(e).hasMessageThat().isEqualTo( "value 'six' for key 'key_string_six' is not a long integer"); } - try { - JsonUtil.getNumberAsFloat(map, "key_string_six"); - fail("expecting to throw but did not"); - } catch (RuntimeException e) { - assertThat(e).hasMessageThat().isEqualTo( - "string value 'six' for key 'key_string_six' cannot be parsed as a float"); - } - - assertThat(JsonUtil.getNumberAsFloat(map, "key_number_7")).isEqualTo(7F); assertThat(JsonUtil.getNumberAsDouble(map, "key_string_infinity")).isPositiveInfinity(); assertThat(JsonUtil.getNumberAsDouble(map, "key_string_minus_infinity")).isNegativeInfinity(); assertThat(JsonUtil.getNumberAsDouble(map, "key_string_exponent")).isEqualTo(2.998e8D); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_infinity")).isPositiveInfinity(); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_minus_infinity")).isNegativeInfinity(); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_exponent")).isEqualTo(2.998e8F); - assertThat(JsonUtil.getNumberAsDouble(map, "key_string_minus_zero")).isZero(); assertThat(JsonUtil.getNumberAsInteger(map, "key_string_minus_zero")).isEqualTo(0); assertThat(JsonUtil.getNumberAsLong(map, "key_string_minus_zero")).isEqualTo(0L); - assertThat(JsonUtil.getNumberAsFloat(map, "key_string_minus_zero")).isZero(); assertThat(JsonUtil.getNumberAsDouble(map, "key_nonexistent")).isNull(); assertThat(JsonUtil.getNumberAsInteger(map, "key_nonexistent")).isNull(); assertThat(JsonUtil.getNumberAsLong(map, "key_nonexistent")).isNull(); - assertThat(JsonUtil.getNumberAsFloat(map, "key_nonexistent")).isNull(); - - try { - JsonUtil.getNumberAsFloat(map, "key_string_boolean"); - fail("expecting to throw but did not"); - } catch (RuntimeException e) { - assertThat(e).hasMessageThat().isEqualTo( - "value true for key 'key_string_boolean' is not a float"); - } } @Test diff --git a/core/src/test/java/io/grpc/internal/KeepAliveManagerTest.java b/core/src/test/java/io/grpc/internal/KeepAliveManagerTest.java index 3cf7bfcedfe..81e3d1b2638 100644 --- a/core/src/test/java/io/grpc/internal/KeepAliveManagerTest.java +++ b/core/src/test/java/io/grpc/internal/KeepAliveManagerTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -104,13 +105,15 @@ public void keepAlivePingDelayedByIncomingData() { @Test public void clientKeepAlivePinger_pingTimeout() { - ConnectionClientTransport transport = mock(ConnectionClientTransport.class); + ClientKeepAlivePinger.TransportWithDisconnectReason transport = + mock(ClientKeepAlivePinger.TransportWithDisconnectReason.class); ClientKeepAlivePinger pinger = new ClientKeepAlivePinger(transport); pinger.onPingTimeout(); ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); - verify(transport).shutdownNow(statusCaptor.capture()); + verify(transport).shutdownNow(statusCaptor.capture(), + eq(SimpleDisconnectError.CONNECTION_TIMED_OUT)); Status status = statusCaptor.getValue(); assertThat(status.getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(status.getDescription()).isEqualTo( @@ -119,7 +122,8 @@ public void clientKeepAlivePinger_pingTimeout() { @Test public void clientKeepAlivePinger_pingFailure() { - ConnectionClientTransport transport = mock(ConnectionClientTransport.class); + ClientKeepAlivePinger.TransportWithDisconnectReason transport = + mock(ClientKeepAlivePinger.TransportWithDisconnectReason.class); ClientKeepAlivePinger pinger = new ClientKeepAlivePinger(transport); pinger.ping(); ArgumentCaptor pingCallbackCaptor = @@ -130,7 +134,8 @@ public void clientKeepAlivePinger_pingFailure() { pingCallback.onFailure(Status.UNAVAILABLE.withDescription("I must write descriptions")); ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); - verify(transport).shutdownNow(statusCaptor.capture()); + verify(transport).shutdownNow(statusCaptor.capture(), + eq(SimpleDisconnectError.CONNECTION_TIMED_OUT)); Status status = statusCaptor.getValue(); assertThat(status.getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(status.getDescription()).isEqualTo( diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelImplBuilderTest.java b/core/src/test/java/io/grpc/internal/ManagedChannelImplBuilderTest.java index a054e65a6e8..e058a1dca1f 100644 --- a/core/src/test/java/io/grpc/internal/ManagedChannelImplBuilderTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedChannelImplBuilderTest.java @@ -38,19 +38,23 @@ import io.grpc.ClientInterceptor; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; +import io.grpc.FlagResetRule; import io.grpc.InternalConfigurator; import io.grpc.InternalConfiguratorRegistry; +import io.grpc.InternalFeatureFlags; import io.grpc.InternalManagedChannelBuilder.InternalInterceptorFactory; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.MethodDescriptor; import io.grpc.MetricSink; import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; import io.grpc.NameResolverRegistry; import io.grpc.StaticTestingClassLoader; import io.grpc.internal.ManagedChannelImplBuilder.ChannelBuilderDefaultPortProvider; import io.grpc.internal.ManagedChannelImplBuilder.ClientTransportFactoryBuilder; import io.grpc.internal.ManagedChannelImplBuilder.FixedPortProvider; +import io.grpc.internal.ManagedChannelImplBuilder.ResolvedNameResolver; import io.grpc.internal.ManagedChannelImplBuilder.UnsupportedClientTransportFactoryBuilder; import io.grpc.testing.GrpcCleanupRule; import java.net.InetSocketAddress; @@ -69,13 +73,15 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; /** Unit tests for {@link ManagedChannelImplBuilder}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class ManagedChannelImplBuilderTest { private static final int DUMMY_PORT = 42; private static final String DUMMY_TARGET = "fake-target"; @@ -98,8 +104,16 @@ public ClientCall interceptCall( } }; + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); @Rule public final GrpcCleanupRule grpcCleanupRule = new GrpcCleanupRule(); + @Rule public final FlagResetRule flagResetRule = new FlagResetRule(); @Mock private ClientTransportFactory mockClientTransportFactory; @Mock private ClientTransportFactoryBuilder mockClientTransportFactoryBuilder; @@ -117,6 +131,9 @@ public ClientCall interceptCall( @Before public void setUp() throws Exception { + flagResetRule.setFlagForTest( + InternalFeatureFlags::setRfc3986UrisEnabled, enableRfc3986UrisParam); + builder = new ManagedChannelImplBuilder( DUMMY_TARGET, new UnsupportedClientTransportFactoryBuilder(), @@ -370,11 +387,14 @@ public void transportDoesNotSupportAddressTypes() { builder = new ManagedChannelImplBuilder(DUMMY_AUTHORITY_VALID, mockClientTransportFactoryBuilder, new FixedPortProvider(DUMMY_PORT)); try { - ManagedChannel unused = grpcCleanupRule.register(builder.build()); + grpcCleanupRule.register(builder.build()); fail("Should fail"); } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().isEqualTo( - "Address types of NameResolver 'dns' for 'valid:1234' not supported by transport"); + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Address types of NameResolver 'dns' for 'dns:///valid:1234' not supported by" + + " transport"); } } @@ -390,7 +410,7 @@ public void transportAddressTypeCompatibilityCheckSkipped() { builder = new ManagedChannelImplBuilder(DUMMY_AUTHORITY_VALID, mockClientTransportFactoryBuilder, new FixedPortProvider(DUMMY_PORT)); // should not fail - ManagedChannel unused = grpcCleanupRule.register(builder.build()); + grpcCleanupRule.register(builder.build()); } @Test @@ -717,9 +737,31 @@ public void defaultServiceConfig_intKey() { @Test public void defaultServiceConfig_intValue() { Map config = new HashMap<>(); + config.put("key", 3); - assertThrows(IllegalArgumentException.class, () -> builder.defaultServiceConfig(config)); + builder.defaultServiceConfig(config); + + assertThat(builder.defaultServiceConfig).containsEntry("key", 3D); + } + + @Test + public void defaultServiceConfig_nestedIntValue() { + Map config = new HashMap<>(); + List list = new ArrayList<>(); + Map nested = new HashMap<>(); + + list.add(123); + nested.put("key", 456); + list.add(nested); + config.put("list", list); + + builder.defaultServiceConfig(config); + + assertThat(builder.defaultServiceConfig) + .containsEntry( + "list", + Arrays.asList(123D, Collections.singletonMap("key", 456D))); } @Test @@ -782,4 +824,150 @@ public void uriPattern() { } private static class CustomSocketAddress extends SocketAddress {} + + @Test + public void getNameResolverProvider_explicitProviderWithIpTarget() { + String target = "127.0.0.1:8080"; + NameResolverRegistry registry = new NameResolverRegistry(); + NameResolverProvider explicitProvider = mock(NameResolverProvider.class); + when(explicitProvider.getScheme()).thenReturn("dns"); + when(explicitProvider.getDefaultScheme()).thenReturn("dns"); + + ManagedChannelImplBuilder.ResolvedNameResolver resolved; + resolved = ManagedChannelImplBuilder + .getNameResolverProvider(target, registry, explicitProvider); + + assertThat(resolved.provider).isSameInstanceAs(explicitProvider); + assertThat(resolved.targetUri.toString()).isEqualTo("dns:///127.0.0.1:8080"); + } + + @Test + public void getNameResolverProvider_explicitProviderWithInvalidUri() { + String target = "::1"; + NameResolverRegistry registry = new NameResolverRegistry(); + NameResolverProvider explicitProvider = mock(NameResolverProvider.class); + when(explicitProvider.getScheme()).thenReturn("dns"); + when(explicitProvider.getDefaultScheme()).thenReturn("dns"); + + ManagedChannelImplBuilder.ResolvedNameResolver resolved; + resolved = ManagedChannelImplBuilder + .getNameResolverProvider(target, registry, explicitProvider); + + assertThat(resolved.provider).isSameInstanceAs(explicitProvider); + assertThat(resolved.targetUri.toString()).isEqualTo("dns:///::1"); + } + + @Test + public void getNameResolverProvider_explicitProviderWithValidUri() { + String target = "dns:///localhost"; + NameResolverRegistry registry = new NameResolverRegistry(); + NameResolverProvider explicitProvider = new NameResolverProvider() { + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return null; + } + + @Override + public String getDefaultScheme() { + return "dns"; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + }; + + ResolvedNameResolver resolved = ManagedChannelImplBuilder.getNameResolverProvider( + target, registry, explicitProvider); + + // Should prefer explicit provider if scheme matches? + // Current logic: provider passed to getNameResolverProvider is prioritized for + // SCHEME determination for fallback + // BUT for valid URI, it logic matches URI scheme. + // If explicit provider is passed, it is used if target not valid URI. + // If target IS valid URI, it checks if provider != null. + // Wait, the code: + // if (provider == null) { ... } + // If explicit 'provider' arg is NOT null, logic uses it? + // Let's re-read ManagedChannelImplBuilder.getNameResolverProvider + assertThat(resolved.provider).isSameInstanceAs(explicitProvider); + } + + @Test + public void getNameResolverProvider_registryFallback() { + String target = "dns:///localhost"; + final NameResolverProvider registryProvider = new NameResolverProvider() { + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return null; + } + + @Override + public String getDefaultScheme() { + return "dns"; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + }; + NameResolverRegistry registry = new NameResolverRegistry(); + registry.register(registryProvider); + + ResolvedNameResolver resolved = ManagedChannelImplBuilder.getNameResolverProvider( + target, registry, null); + + assertThat(resolved.provider).isSameInstanceAs(registryProvider); + } + + @Test + public void getNameResolverProviderRfc3986_explicitProviderWithAuthorityTarget() { + String target = "authority-target"; + NameResolverRegistry registry = new NameResolverRegistry(); + NameResolverProvider explicitProvider = new NameResolverProvider() { + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return null; + } + + @Override + public String getScheme() { + return "customscheme"; + } + + @Override + public String getDefaultScheme() { + return "customscheme"; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + }; + registry.register(explicitProvider); + + ManagedChannelImplBuilder.ResolvedNameResolver resolved = ManagedChannelImplBuilder + .getNameResolverProviderRfc3986(target, registry, explicitProvider); + + assertThat(resolved.provider).isSameInstanceAs(explicitProvider); + assertThat(resolved.targetUri.toString()).isEqualTo("customscheme:///authority-target"); + } } diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverRfc3986Test.java b/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverRfc3986Test.java new file mode 100644 index 00000000000..5fb90fa8998 --- /dev/null +++ b/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverRfc3986Test.java @@ -0,0 +1,245 @@ +/* + * Copyright 2015 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.internal; + +import static com.google.common.truth.Truth.assertThat; +import static io.grpc.internal.UriWrapper.wrap; +import static org.junit.Assert.fail; + +import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; +import io.grpc.Uri; +import java.net.SocketAddress; +import java.net.URI; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for ManagedChannelImplBuilder#getNameResolverProviderNew(). */ +@RunWith(JUnit4.class) +public class ManagedChannelImplGetNameResolverRfc3986Test { + @Test + public void invalidUriTarget() { + testInvalidTarget("defaultscheme:///[invalid]"); + } + + @Test + public void invalidUnescapedSquareBracketsInRfc3986UriFragment() { + testInvalidTarget("defaultscheme://8.8.8.8/host#section[1]"); + } + + @Test + public void invalidUnescapedSquareBracketsInRfc3986UriQuery() { + testInvalidTarget("dns://8.8.8.8/path?section=[1]"); + } + + @Test + public void validTargetWithInvalidDnsName() throws Exception { + testValidTarget( + "[valid]", + "defaultscheme:///%5Bvalid%5D", + Uri.newBuilder().setScheme("defaultscheme").setHost("").setPath("/[valid]").build()); + } + + @Test + public void validAuthorityTarget() throws Exception { + testValidTarget( + "foo.googleapis.com:8080", + "defaultscheme:///foo.googleapis.com:8080", + Uri.newBuilder() + .setScheme("defaultscheme") + .setHost("") + .setPath("/foo.googleapis.com:8080") + .build()); + } + + @Test + public void validUriTarget() throws Exception { + testValidTarget( + "scheme:///foo.googleapis.com:8080", + "scheme:///foo.googleapis.com:8080", + Uri.newBuilder() + .setScheme("scheme") + .setHost("") + .setPath("/foo.googleapis.com:8080") + .build()); + } + + @Test + public void validIpv4AuthorityTarget() throws Exception { + testValidTarget( + "127.0.0.1:1234", + "defaultscheme:///127.0.0.1:1234", + Uri.newBuilder().setScheme("defaultscheme").setHost("").setPath("/127.0.0.1:1234").build()); + } + + @Test + public void validIpv4UriTarget() throws Exception { + testValidTarget( + "dns:///127.0.0.1:1234", + "dns:///127.0.0.1:1234", + Uri.newBuilder().setScheme("dns").setHost("").setPath("/127.0.0.1:1234").build()); + } + + @Test + public void validIpv6AuthorityTarget() throws Exception { + testValidTarget( + "[::1]:1234", + "defaultscheme:///%5B::1%5D:1234", + Uri.newBuilder().setScheme("defaultscheme").setHost("").setPath("/[::1]:1234").build()); + } + + @Test + public void invalidIpv6UriTarget() throws Exception { + testInvalidTarget("dns:///[::1]:1234"); + } + + @Test + public void invalidIpv6UriWithUnescapedScope() { + testInvalidTarget("dns://[::1%eth0]:53/host"); + } + + @Test + public void validIpv6UriTarget() throws Exception { + testValidTarget( + "dns:///%5B::1%5D:1234", + "dns:///%5B::1%5D:1234", + Uri.newBuilder().setScheme("dns").setHost("").setPath("/[::1]:1234").build()); + } + + @Test + public void validTargetStartingWithSlash() throws Exception { + testValidTarget( + "/target", + "defaultscheme:////target", + Uri.newBuilder().setScheme("defaultscheme").setHost("").setPath("//target").build()); + } + + @Test + public void validTargetNoProvider() { + NameResolverRegistry nameResolverRegistry = new NameResolverRegistry(); + try { + ManagedChannelImplBuilder.getNameResolverProviderRfc3986( + "foo.googleapis.com:8080", nameResolverRegistry, null); + fail("Should fail"); + } catch (IllegalArgumentException e) { + // expected + } + } + + @Test + public void validTargetProviderAddrTypesNotSupported() { + NameResolverRegistry nameResolverRegistry = getTestRegistry("testscheme"); + try { + ManagedChannelImplBuilder.getNameResolverProviderRfc3986( + "testscheme:///foo.googleapis.com:8080", nameResolverRegistry, null) + .checkAddressTypes(Collections.singleton(CustomSocketAddress.class)); + fail("Should fail"); + } catch (IllegalArgumentException e) { + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Address types of NameResolver 'testscheme' for " + + "'testscheme:///foo.googleapis.com:8080' not supported by transport"); + } + } + + private void testValidTarget(String target, String expectedUriString, Uri expectedUri) { + NameResolverRegistry nameResolverRegistry = getTestRegistry(expectedUri.getScheme()); + ManagedChannelImplBuilder.ResolvedNameResolver resolved = + ManagedChannelImplBuilder.getNameResolverProviderRfc3986(target, nameResolverRegistry, + null); + assertThat(resolved.provider).isInstanceOf(FakeNameResolverProvider.class); + assertThat(resolved.targetUri).isEqualTo(wrap(expectedUri)); + assertThat(resolved.targetUri.toString()).isEqualTo(expectedUriString); + } + + private void testInvalidTarget(String target) { + NameResolverRegistry nameResolverRegistry = getTestRegistry("dns"); + + try { + ManagedChannelImplBuilder.ResolvedNameResolver resolved = + ManagedChannelImplBuilder.getNameResolverProviderRfc3986(target, nameResolverRegistry, + null); + FakeNameResolverProvider nameResolverProvider = (FakeNameResolverProvider) resolved.provider; + fail("Should have failed, but got resolver provider " + nameResolverProvider); + } catch (IllegalArgumentException e) { + // expected + } + } + + private static NameResolverRegistry getTestRegistry(String expectedScheme) { + NameResolverRegistry nameResolverRegistry = new NameResolverRegistry(); + FakeNameResolverProvider nameResolverProvider = new FakeNameResolverProvider(expectedScheme); + nameResolverRegistry.register(nameResolverProvider); + return nameResolverRegistry; + } + + private static class FakeNameResolverProvider extends NameResolverProvider { + final String expectedScheme; + + FakeNameResolverProvider(String expectedScheme) { + this.expectedScheme = expectedScheme; + } + + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + if (expectedScheme.equals(targetUri.getScheme())) { + return new FakeNameResolver(targetUri); + } + return null; + } + + @Override + public String getDefaultScheme() { + return expectedScheme; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + } + + private static class FakeNameResolver extends NameResolver { + final URI uri; + + FakeNameResolver(URI uri) { + this.uri = uri; + } + + @Override + public String getServiceAuthority() { + return uri.getAuthority(); + } + + @Override + public void start(final Listener2 listener) {} + + @Override + public void shutdown() {} + } + + private static class CustomSocketAddress extends SocketAddress {} +} diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverTest.java b/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverTest.java index a0bd388b1b6..4bfc66beb12 100644 --- a/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedChannelImplGetNameResolverTest.java @@ -17,12 +17,12 @@ package io.grpc.internal; import static com.google.common.truth.Truth.assertThat; +import static io.grpc.internal.UriWrapper.wrap; import static org.junit.Assert.fail; import io.grpc.NameResolver; import io.grpc.NameResolverProvider; import io.grpc.NameResolverRegistry; -import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; import java.util.Collections; @@ -38,6 +38,21 @@ public void invalidUriTarget() { testInvalidTarget("defaultscheme:///[invalid]"); } + @Test + public void validSquareBracketsInRfc2396UriFragment() throws Exception { + testValidTarget("dns://8.8.8.8/host#section[1]", + "dns://8.8.8.8/host#section[1]", + new URI("dns", "8.8.8.8", "/host", null, "section[1]")); + } + + + @Test + public void validSquareBracketsInRfc2396UriQuery() throws Exception { + testValidTarget("dns://8.8.8.8/host?section=[1]", + "dns://8.8.8.8/host?section=[1]", + new URI("dns", "8.8.8.8", "/host", "section=[1]", null)); + } + @Test public void validTargetWithInvalidDnsName() throws Exception { testValidTarget("[valid]", "defaultscheme:///%5Bvalid%5D", @@ -74,6 +89,13 @@ public void validIpv6AuthorityTarget() throws Exception { new URI("defaultscheme", "", "/[::1]:1234", null)); } + @Test + public void validIpv6UriWithJavaNetUriScopeName() throws Exception { + testValidTarget("dns://[::1%eth0]:53/host", + "dns://[::1%eth0]:53/host", + new URI("dns", "[::1%eth0]:53", "/host", null, null)); + } + @Test public void invalidIpv6UriTarget() throws Exception { testInvalidTarget("dns:///[::1]:1234"); @@ -97,7 +119,7 @@ public void validTargetNoProvider() { try { ManagedChannelImplBuilder.getNameResolverProvider( "foo.googleapis.com:8080", nameResolverRegistry, - Collections.singleton(InetSocketAddress.class)); + null); fail("Should fail"); } catch (IllegalArgumentException e) { // expected @@ -109,8 +131,8 @@ public void validTargetProviderAddrTypesNotSupported() { NameResolverRegistry nameResolverRegistry = getTestRegistry("testscheme"); try { ManagedChannelImplBuilder.getNameResolverProvider( - "testscheme:///foo.googleapis.com:8080", nameResolverRegistry, - Collections.singleton(CustomSocketAddress.class)); + "testscheme:///foo.googleapis.com:8080", nameResolverRegistry, null) + .checkAddressTypes(Collections.singleton(CustomSocketAddress.class)); fail("Should fail"); } catch (IllegalArgumentException e) { assertThat(e).hasMessageThat().isEqualTo( @@ -122,10 +144,9 @@ public void validTargetProviderAddrTypesNotSupported() { private void testValidTarget(String target, String expectedUriString, URI expectedUri) { NameResolverRegistry nameResolverRegistry = getTestRegistry(expectedUri.getScheme()); ManagedChannelImplBuilder.ResolvedNameResolver resolved = - ManagedChannelImplBuilder.getNameResolverProvider( - target, nameResolverRegistry, Collections.singleton(InetSocketAddress.class)); + ManagedChannelImplBuilder.getNameResolverProvider(target, nameResolverRegistry, null); assertThat(resolved.provider).isInstanceOf(FakeNameResolverProvider.class); - assertThat(resolved.targetUri).isEqualTo(expectedUri); + assertThat(resolved.targetUri).isEqualTo(wrap(expectedUri)); assertThat(resolved.targetUri.toString()).isEqualTo(expectedUriString); } @@ -134,8 +155,7 @@ private void testInvalidTarget(String target) { try { ManagedChannelImplBuilder.ResolvedNameResolver resolved = - ManagedChannelImplBuilder.getNameResolverProvider( - target, nameResolverRegistry, Collections.singleton(InetSocketAddress.class)); + ManagedChannelImplBuilder.getNameResolverProvider(target, nameResolverRegistry, null); FakeNameResolverProvider nameResolverProvider = (FakeNameResolverProvider) resolved.provider; fail("Should have failed, but got resolver provider " + nameResolverProvider); } catch (IllegalArgumentException e) { diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelImplIdlenessTest.java b/core/src/test/java/io/grpc/internal/ManagedChannelImplIdlenessTest.java index 293d0e70961..97e92be7fdd 100644 --- a/core/src/test/java/io/grpc/internal/ManagedChannelImplIdlenessTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedChannelImplIdlenessTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import static io.grpc.ConnectivityState.READY; import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; +import static io.grpc.internal.UriWrapper.wrap; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -185,7 +186,7 @@ public void setUp() { NameResolverProvider nameResolverProvider = builder.nameResolverRegistry.getProviderForScheme(targetUri.getScheme()); channel = new ManagedChannelImpl( - builder, mockTransportFactory, targetUri, nameResolverProvider, + builder, mockTransportFactory, wrap(targetUri), nameResolverProvider, new FakeBackoffPolicyProvider(), oobExecutorPool, timer.getStopwatchSupplier(), Collections.emptyList(), diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java b/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java index efc582703ba..ae224af27e1 100644 --- a/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java @@ -28,6 +28,7 @@ import static io.grpc.EquivalentAddressGroup.ATTR_AUTHORITY_OVERRIDE; import static io.grpc.PickSubchannelArgsMatcher.eqPickSubchannelArgs; import static io.grpc.internal.ClientStreamListener.RpcProgress.PROCESSED; +import static io.grpc.internal.UriWrapper.wrap; import static junit.framework.TestCase.assertNotSame; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -285,10 +286,6 @@ public String getPolicyName() { @Mock private ClientCall.Listener mockCallListener3; @Mock - private ClientCall.Listener mockCallListener4; - @Mock - private ClientCall.Listener mockCallListener5; - @Mock private ObjectPool executorPool; @Mock private ObjectPool balancerRpcExecutorPool; @@ -319,7 +316,7 @@ private void createChannel(boolean nameResolutionExpectedToFail, NameResolverProvider nameResolverProvider = channelBuilder.nameResolverRegistry.getProviderForScheme(expectedUri.getScheme()); channel = new ManagedChannelImpl( - channelBuilder, mockTransportFactory, expectedUri, nameResolverProvider, + channelBuilder, mockTransportFactory, wrap(expectedUri), nameResolverProvider, new FakeBackoffPolicyProvider(), balancerRpcExecutorPool, timer.getStopwatchSupplier(), Arrays.asList(interceptors), timer.getTimeProvider()); @@ -508,7 +505,7 @@ public void startCallBeforeNameResolution() throws Exception { when(mockTransportFactory.getSupportedSocketAddressTypes()).thenReturn(Collections.singleton( InetSocketAddress.class)); channel = new ManagedChannelImpl( - channelBuilder, mockTransportFactory, expectedUri, nameResolverFactory, + channelBuilder, mockTransportFactory, wrap(expectedUri), nameResolverFactory, new FakeBackoffPolicyProvider(), balancerRpcExecutorPool, timer.getStopwatchSupplier(), Collections.emptyList(), timer.getTimeProvider()); @@ -573,7 +570,7 @@ public void newCallWithConfigSelector() { when(mockTransportFactory.getSupportedSocketAddressTypes()).thenReturn(Collections.singleton( InetSocketAddress.class)); channel = new ManagedChannelImpl( - channelBuilder, mockTransportFactory, expectedUri, nameResolverFactory, + channelBuilder, mockTransportFactory, wrap(expectedUri), nameResolverFactory, new FakeBackoffPolicyProvider(), balancerRpcExecutorPool, timer.getStopwatchSupplier(), Collections.emptyList(), timer.getTimeProvider()); @@ -798,7 +795,8 @@ public void channelzMembership_subchannel() throws Exception { transportInfo.listener.transportReady(); // terminate transport - transportInfo.listener.transportShutdown(Status.CANCELLED); + transportInfo.listener.transportShutdown(Status.CANCELLED, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo.listener.transportTerminated(); assertFalse(channelz.containsClientSocket(transportInfo.transport.getLogId())); @@ -816,46 +814,6 @@ public void channelzMembership_subchannel() throws Exception { assertNotNull(channelz.getRootChannel(channel.getLogId().getId())); } - @Test - public void channelzMembership_oob() throws Exception { - createChannel(); - OobChannel oob = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), AUTHORITY); - // oob channels are not root channels - assertNull(channelz.getRootChannel(oob.getLogId().getId())); - assertTrue(channelz.containsSubchannel(oob.getLogId())); - assertThat(getStats(channel).subchannels).containsExactly(oob); - assertTrue(channelz.containsSubchannel(oob.getLogId())); - - AbstractSubchannel subchannel = (AbstractSubchannel) oob.getSubchannel(); - assertTrue( - channelz.containsSubchannel(subchannel.getInstrumentedInternalSubchannel().getLogId())); - assertThat(getStats(oob).subchannels) - .containsExactly(subchannel.getInstrumentedInternalSubchannel()); - assertTrue( - channelz.containsSubchannel(subchannel.getInstrumentedInternalSubchannel().getLogId())); - - oob.getSubchannel().requestConnection(); - MockClientTransportInfo transportInfo = transports.poll(); - assertNotNull(transportInfo); - assertTrue(channelz.containsClientSocket(transportInfo.transport.getLogId())); - - // terminate transport - transportInfo.listener.transportShutdown(Status.INTERNAL); - transportInfo.listener.transportTerminated(); - assertFalse(channelz.containsClientSocket(transportInfo.transport.getLogId())); - - // terminate oobchannel - oob.shutdown(); - assertFalse(channelz.containsSubchannel(oob.getLogId())); - assertThat(getStats(channel).subchannels).isEmpty(); - assertFalse( - channelz.containsSubchannel(subchannel.getInstrumentedInternalSubchannel().getLogId())); - - // channel still appears - assertNotNull(channelz.getRootChannel(channel.getLogId().getId())); - } - @Test public void callsAndShutdown() { subtestCallsAndShutdown(false, false); @@ -1002,7 +960,8 @@ private void subtestCallsAndShutdown(boolean shutdownNow, boolean shutdownNowAft } // Killing the remaining real transport will terminate the channel - transportListener.transportShutdown(Status.UNAVAILABLE); + transportListener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); assertFalse(channel.isTerminated()); verify(executorPool, never()).returnObject(any()); transportListener.transportTerminated(); @@ -1072,7 +1031,8 @@ public void noMoreCallbackAfterLoadBalancerShutdown() { // Since subchannels are shutdown, SubchannelStateListeners will only get SHUTDOWN regardless of // the transport states. - transportInfo1.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo1.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo2.listener.transportReady(); verify(stateListener1).onSubchannelState(ConnectivityStateInfo.forNonError(SHUTDOWN)); verify(stateListener2).onSubchannelState(ConnectivityStateInfo.forNonError(SHUTDOWN)); @@ -1141,7 +1101,8 @@ public void noMoreCallbackAfterLoadBalancerShutdown_configError() throws Interru // Since subchannels are shutdown, SubchannelStateListeners will only get SHUTDOWN regardless of // the transport states. - transportInfo1.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo1.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo2.listener.transportReady(); verify(stateListener1).onSubchannelState(ConnectivityStateInfo.forNonError(SHUTDOWN)); verify(stateListener2).onSubchannelState(ConnectivityStateInfo.forNonError(SHUTDOWN)); @@ -1277,7 +1238,8 @@ public void callOptionsExecutor() { verify(mockCallListener).onClose(same(Status.CANCELLED), same(trailers)); - transportListener.transportShutdown(Status.UNAVAILABLE); + transportListener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportListener.transportTerminated(); // Clean up as much as possible to allow the channel to terminate. @@ -1343,7 +1305,7 @@ public ClientStreamTracer newClientStreamTracer(StreamInfo info, Metadata header PickResult.withSubchannel(subchannel)); updateBalancingStateSafely(helper, READY, mockPicker); - assertEquals(2, executor.runDueTasks()); + assertEquals(3, executor.runDueTasks()); verify(mockPicker).pickSubchannel(any(PickSubchannelArgs.class)); verify(mockTransport).newStream( @@ -1429,7 +1391,8 @@ public void firstResolvedServerFailedToConnect() throws Exception { MockClientTransportInfo badTransportInfo = transports.poll(); // Which failed to connect - badTransportInfo.listener.transportShutdown(Status.UNAVAILABLE); + badTransportInfo.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); inOrder.verifyNoMoreInteractions(); // The channel then try the second address (goodAddress) @@ -1579,7 +1542,8 @@ public void allServersFailedToConnect() throws Exception { .newClientTransport( same(addr2), any(ClientTransportOptions.class), any(ChannelLogger.class)); MockClientTransportInfo transportInfo1 = transports.poll(); - transportInfo1.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo1.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // Connecting to server2, which will fail too verify(mockTransportFactory) @@ -1587,7 +1551,8 @@ public void allServersFailedToConnect() throws Exception { same(addr2), any(ClientTransportOptions.class), any(ChannelLogger.class)); MockClientTransportInfo transportInfo2 = transports.poll(); Status server2Error = Status.UNAVAILABLE.withDescription("Server2 failed to connect"); - transportInfo2.listener.transportShutdown(server2Error); + transportInfo2.listener.transportShutdown(server2Error, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // ... which makes the subchannel enter TRANSIENT_FAILURE. The last error Status is propagated // to LoadBalancer. @@ -1697,9 +1662,11 @@ public void run() { verify(transportInfo2.transport).shutdown(same(ManagedChannelImpl.SHUTDOWN_STATUS)); // Cleanup - transportInfo1.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo1.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo1.listener.transportTerminated(); - transportInfo2.listener.transportShutdown(Status.UNAVAILABLE); + transportInfo2.listener.transportShutdown(Status.UNAVAILABLE, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo2.listener.transportTerminated(); timer.forwardTime(ManagedChannelImpl.SUBCHANNEL_SHUTDOWN_DELAY_SECONDS, TimeUnit.SECONDS); } @@ -1739,8 +1706,10 @@ public void subchannelsWhenChannelShutdownNow() { verify(ti1.transport).shutdownNow(any(Status.class)); verify(ti2.transport).shutdownNow(any(Status.class)); - ti1.listener.transportShutdown(Status.UNAVAILABLE.withDescription("shutdown now")); - ti2.listener.transportShutdown(Status.UNAVAILABLE.withDescription("shutdown now")); + ti1.listener.transportShutdown(Status.UNAVAILABLE.withDescription("shutdown now"), + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); + ti2.listener.transportShutdown(Status.UNAVAILABLE.withDescription("shutdown now"), + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); ti1.listener.transportTerminated(); assertFalse(channel.isTerminated()); @@ -1788,7 +1757,7 @@ public void subchannelsNoConnectionShutdownNow() { channel.shutdownNow(); verify(mockLoadBalancer).shutdown(); - // Channel's shutdownNow() will call shutdownNow() on all subchannels and oobchannels. + // Channel's shutdownNow() will call shutdownNow() on all subchannels. // Therefore, channel is terminated without relying on LoadBalancer to shutdown subchannels. assertTrue(channel.isTerminated()); verify(mockTransportFactory, never()) @@ -1796,112 +1765,6 @@ public void subchannelsNoConnectionShutdownNow() { any(SocketAddress.class), any(ClientTransportOptions.class), any(ChannelLogger.class)); } - @Test - public void oobchannels() { - createChannel(); - - ManagedChannel oob1 = helper.createOobChannel( - Collections.singletonList(addressGroup), "oob1authority"); - ManagedChannel oob2 = helper.createOobChannel( - Collections.singletonList(addressGroup), "oob2authority"); - verify(balancerRpcExecutorPool, times(2)).getObject(); - - assertEquals("oob1authority", oob1.authority()); - assertEquals("oob2authority", oob2.authority()); - - // OOB channels create connections lazily. A new call will initiate the connection. - Metadata headers = new Metadata(); - ClientCall call = oob1.newCall(method, CallOptions.DEFAULT); - call.start(mockCallListener, headers); - verify(mockTransportFactory) - .newClientTransport( - eq(socketAddress), - eq(new ClientTransportOptions().setAuthority("oob1authority").setUserAgent(USER_AGENT)), - isA(ChannelLogger.class)); - MockClientTransportInfo transportInfo = transports.poll(); - assertNotNull(transportInfo); - - assertEquals(0, balancerRpcExecutor.numPendingTasks()); - transportInfo.listener.transportReady(); - assertEquals(1, balancerRpcExecutor.runDueTasks()); - verify(transportInfo.transport).newStream( - same(method), same(headers), same(CallOptions.DEFAULT), - ArgumentMatchers.any()); - - // The transport goes away - transportInfo.listener.transportShutdown(Status.UNAVAILABLE); - transportInfo.listener.transportTerminated(); - - // A new call will trigger a new transport - ClientCall call2 = oob1.newCall(method, CallOptions.DEFAULT); - call2.start(mockCallListener2, headers); - ClientCall call3 = - oob1.newCall(method, CallOptions.DEFAULT.withWaitForReady()); - call3.start(mockCallListener3, headers); - verify(mockTransportFactory, times(2)).newClientTransport( - eq(socketAddress), - eq(new ClientTransportOptions().setAuthority("oob1authority").setUserAgent(USER_AGENT)), - isA(ChannelLogger.class)); - transportInfo = transports.poll(); - assertNotNull(transportInfo); - - // This transport fails - Status transportError = Status.UNAVAILABLE.withDescription("Connection refused"); - assertEquals(0, balancerRpcExecutor.numPendingTasks()); - transportInfo.listener.transportShutdown(transportError); - assertTrue(balancerRpcExecutor.runDueTasks() > 0); - - // Fail-fast RPC will fail, while wait-for-ready RPC will still be pending - verify(mockCallListener2).onClose(same(transportError), any(Metadata.class)); - verify(mockCallListener3, never()).onClose(any(Status.class), any(Metadata.class)); - - // Shutdown - assertFalse(oob1.isShutdown()); - assertFalse(oob2.isShutdown()); - oob1.shutdown(); - oob2.shutdownNow(); - assertTrue(oob1.isShutdown()); - assertTrue(oob2.isShutdown()); - assertTrue(oob2.isTerminated()); - verify(balancerRpcExecutorPool).returnObject(balancerRpcExecutor.getScheduledExecutorService()); - - // New RPCs will be rejected. - assertEquals(0, balancerRpcExecutor.numPendingTasks()); - ClientCall call4 = oob1.newCall(method, CallOptions.DEFAULT); - ClientCall call5 = oob2.newCall(method, CallOptions.DEFAULT); - call4.start(mockCallListener4, headers); - call5.start(mockCallListener5, headers); - assertTrue(balancerRpcExecutor.runDueTasks() > 0); - verify(mockCallListener4).onClose(statusCaptor.capture(), any(Metadata.class)); - Status status4 = statusCaptor.getValue(); - assertEquals(Status.Code.UNAVAILABLE, status4.getCode()); - verify(mockCallListener5).onClose(statusCaptor.capture(), any(Metadata.class)); - Status status5 = statusCaptor.getValue(); - assertEquals(Status.Code.UNAVAILABLE, status5.getCode()); - - // The pending RPC will still be pending - verify(mockCallListener3, never()).onClose(any(Status.class), any(Metadata.class)); - - // This will shutdownNow() the delayed transport, terminating the pending RPC - assertEquals(0, balancerRpcExecutor.numPendingTasks()); - oob1.shutdownNow(); - assertTrue(balancerRpcExecutor.runDueTasks() > 0); - verify(mockCallListener3).onClose(any(Status.class), any(Metadata.class)); - - // Shut down the channel, and it will not terminated because OOB channel has not. - channel.shutdown(); - assertFalse(channel.isTerminated()); - // Delayed transport has already terminated. Terminating the transport terminates the - // subchannel, which in turn terimates the OOB channel, which terminates the channel. - assertFalse(oob1.isTerminated()); - verify(balancerRpcExecutorPool).returnObject(balancerRpcExecutor.getScheduledExecutorService()); - transportInfo.listener.transportTerminated(); - assertTrue(oob1.isTerminated()); - assertTrue(channel.isTerminated()); - verify(balancerRpcExecutorPool, times(2)) - .returnObject(balancerRpcExecutor.getScheduledExecutorService()); - } - @Test public void oobChannelHasNoChannelCallCredentials() { Metadata.Key metadataKey = @@ -1953,7 +1816,7 @@ public void oobChannelHasNoChannelCallCredentials() { balancerRpcExecutor.runDueTasks(); verify(transportInfo.transport).newStream( - same(method), same(headers), same(callOptions), + same(method), same(headers), ArgumentMatchers.any(), ArgumentMatchers.any()); assertThat(headers.getAll(metadataKey)).containsExactly(callCredValue); oob.shutdownNow(); @@ -2080,74 +1943,6 @@ public SwapChannelCredentialsResult answer(InvocationOnMock invocation) { oob.shutdownNow(); } - @Test - public void oobChannelsWhenChannelShutdownNow() { - createChannel(); - ManagedChannel oob1 = helper.createOobChannel( - Collections.singletonList(addressGroup), "oob1Authority"); - ManagedChannel oob2 = helper.createOobChannel( - Collections.singletonList(addressGroup), "oob2Authority"); - - oob1.newCall(method, CallOptions.DEFAULT).start(mockCallListener, new Metadata()); - oob2.newCall(method, CallOptions.DEFAULT).start(mockCallListener2, new Metadata()); - - assertThat(transports).hasSize(2); - MockClientTransportInfo ti1 = transports.poll(); - MockClientTransportInfo ti2 = transports.poll(); - - ti1.listener.transportReady(); - ti2.listener.transportReady(); - - channel.shutdownNow(); - verify(ti1.transport).shutdownNow(any(Status.class)); - verify(ti2.transport).shutdownNow(any(Status.class)); - - ti1.listener.transportShutdown(Status.UNAVAILABLE.withDescription("shutdown now")); - ti2.listener.transportShutdown(Status.UNAVAILABLE.withDescription("shutdown now")); - ti1.listener.transportTerminated(); - - assertFalse(channel.isTerminated()); - ti2.listener.transportTerminated(); - assertTrue(channel.isTerminated()); - } - - @Test - public void oobChannelsNoConnectionShutdown() { - createChannel(); - ManagedChannel oob1 = helper.createOobChannel( - Collections.singletonList(addressGroup), "oob1Authority"); - ManagedChannel oob2 = helper.createOobChannel( - Collections.singletonList(addressGroup), "oob2Authority"); - channel.shutdown(); - - verify(mockLoadBalancer).shutdown(); - oob1.shutdown(); - assertTrue(oob1.isTerminated()); - assertFalse(channel.isTerminated()); - oob2.shutdown(); - assertTrue(oob2.isTerminated()); - assertTrue(channel.isTerminated()); - verify(mockTransportFactory, never()) - .newClientTransport( - any(SocketAddress.class), any(ClientTransportOptions.class), any(ChannelLogger.class)); - } - - @Test - public void oobChannelsNoConnectionShutdownNow() { - createChannel(); - helper.createOobChannel(Collections.singletonList(addressGroup), "oob1Authority"); - helper.createOobChannel(Collections.singletonList(addressGroup), "oob2Authority"); - channel.shutdownNow(); - - verify(mockLoadBalancer).shutdown(); - assertTrue(channel.isTerminated()); - // Channel's shutdownNow() will call shutdownNow() on all subchannels and oobchannels. - // Therefore, channel is terminated without relying on LoadBalancer to shutdown oobchannels. - verify(mockTransportFactory, never()) - .newClientTransport( - any(SocketAddress.class), any(ClientTransportOptions.class), any(ChannelLogger.class)); - } - @Test public void subchannelChannel_normalUsage() { createChannel(); @@ -2293,67 +2088,6 @@ public void lbHelper_getNonDefaultNameResolverRegistry() { .isNotSameInstanceAs(NameResolverRegistry.getDefaultRegistry()); } - @Test - public void refreshNameResolution_whenOobChannelConnectionFailed_notIdle() { - subtestNameResolutionRefreshWhenConnectionFailed(false); - } - - @Test - public void notRefreshNameResolution_whenOobChannelConnectionFailed_idle() { - subtestNameResolutionRefreshWhenConnectionFailed(true); - } - - private void subtestNameResolutionRefreshWhenConnectionFailed(boolean isIdle) { - FakeNameResolverFactory nameResolverFactory = - new FakeNameResolverFactory.Builder(expectedUri) - .setServers(Collections.singletonList(new EquivalentAddressGroup(socketAddress))) - .build(); - channelBuilder.nameResolverFactory(nameResolverFactory); - createChannel(); - OobChannel oobChannel = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), "oobAuthority"); - oobChannel.getSubchannel().requestConnection(); - - MockClientTransportInfo transportInfo = transports.poll(); - assertNotNull(transportInfo); - - FakeNameResolverFactory.FakeNameResolver resolver = nameResolverFactory.resolvers.remove(0); - - if (isIdle) { - channel.enterIdle(); - // Entering idle mode will result in a new resolver - resolver = nameResolverFactory.resolvers.remove(0); - } - - assertEquals(0, nameResolverFactory.resolvers.size()); - - int expectedRefreshCount = 0; - - // Transport closed when connecting - assertEquals(expectedRefreshCount, resolver.refreshCalled); - transportInfo.listener.transportShutdown(Status.UNAVAILABLE); - // When channel enters idle, new resolver is created but not started. - if (!isIdle) { - expectedRefreshCount++; - } - assertEquals(expectedRefreshCount, resolver.refreshCalled); - - timer.forwardNanos(RECONNECT_BACKOFF_INTERVAL_NANOS); - transportInfo = transports.poll(); - assertNotNull(transportInfo); - - transportInfo.listener.transportReady(); - - // Transport closed when ready - assertEquals(expectedRefreshCount, resolver.refreshCalled); - transportInfo.listener.transportShutdown(Status.UNAVAILABLE); - // When channel enters idle, new resolver is created but not started. - if (!isIdle) { - expectedRefreshCount++; - } - assertEquals(expectedRefreshCount, resolver.refreshCalled); - } - /** * Test that information such as the Call's context, MethodDescriptor, authority, executor are * propagated to newStream() and applyRequestMetadata(). @@ -3506,48 +3240,6 @@ public void channelTracing_subchannelStateChangeEvent() throws Exception { .build()); } - @Test - public void channelTracing_oobChannelStateChangeEvent() throws Exception { - channelBuilder.maxTraceEvents(10); - createChannel(); - OobChannel oobChannel = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), "authority"); - timer.forwardNanos(1234); - oobChannel.handleSubchannelStateChange( - ConnectivityStateInfo.forNonError(ConnectivityState.CONNECTING)); - assertThat(getStats(oobChannel).channelTrace.events).contains(new ChannelTrace.Event.Builder() - .setDescription("Entering CONNECTING state") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(timer.getTicker().read()) - .build()); - } - - @Test - public void channelTracing_oobChannelCreationEvents() throws Exception { - channelBuilder.maxTraceEvents(10); - createChannel(); - timer.forwardNanos(1234); - OobChannel oobChannel = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), "authority"); - assertThat(getStats(channel).channelTrace.events).contains(new ChannelTrace.Event.Builder() - .setDescription("Child OobChannel created") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(timer.getTicker().read()) - .setChannelRef(oobChannel) - .build()); - assertThat(getStats(oobChannel).channelTrace.events).contains(new ChannelTrace.Event.Builder() - .setDescription("OobChannel for [[[test-addr]/{}]] created") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(timer.getTicker().read()) - .build()); - assertThat(getStats(oobChannel.getInternalSubchannel()).channelTrace.events).contains( - new ChannelTrace.Event.Builder() - .setDescription("Subchannel for [[[test-addr]/{}]] created") - .setSeverity(ChannelTrace.Event.Severity.CT_INFO) - .setTimestampNanos(timer.getTicker().read()) - .build()); - } - @Test public void channelsAndSubchannels_instrumented_state() throws Exception { createChannel(); @@ -3663,115 +3355,6 @@ private void channelsAndSubchannels_instrumented0(boolean success) throws Except } } - @Test - public void channelsAndSubchannels_oob_instrumented_success() throws Exception { - channelsAndSubchannels_oob_instrumented0(true); - } - - @Test - public void channelsAndSubchannels_oob_instrumented_fail() throws Exception { - channelsAndSubchannels_oob_instrumented0(false); - } - - private void channelsAndSubchannels_oob_instrumented0(boolean success) throws Exception { - // set up - ClientStream mockStream = mock(ClientStream.class); - createChannel(); - - OobChannel oobChannel = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), "oobauthority"); - AbstractSubchannel oobSubchannel = (AbstractSubchannel) oobChannel.getSubchannel(); - FakeClock callExecutor = new FakeClock(); - CallOptions options = - CallOptions.DEFAULT.withExecutor(callExecutor.getScheduledExecutorService()); - ClientCall call = oobChannel.newCall(method, options); - Metadata headers = new Metadata(); - - // Channel stat bumped when ClientCall.start() called - assertEquals(0, getStats(oobChannel).callsStarted); - call.start(mockCallListener, headers); - assertEquals(1, getStats(oobChannel).callsStarted); - - MockClientTransportInfo transportInfo = transports.poll(); - ConnectionClientTransport mockTransport = transportInfo.transport; - ManagedClientTransport.Listener transportListener = transportInfo.listener; - when(mockTransport.newStream( - same(method), same(headers), any(CallOptions.class), - ArgumentMatchers.any())) - .thenReturn(mockStream); - - // subchannel stat bumped when call gets assigned to it - assertEquals(0, getStats(oobSubchannel).callsStarted); - transportListener.transportReady(); - callExecutor.runDueTasks(); - verify(mockStream).start(streamListenerCaptor.capture()); - assertEquals(1, getStats(oobSubchannel).callsStarted); - - ClientStreamListener streamListener = streamListenerCaptor.getValue(); - call.halfClose(); - - // closing stream listener affects subchannel stats immediately - assertEquals(0, getStats(oobSubchannel).callsSucceeded); - assertEquals(0, getStats(oobSubchannel).callsFailed); - streamListener.closed(success ? Status.OK : Status.UNKNOWN, PROCESSED, new Metadata()); - if (success) { - assertEquals(1, getStats(oobSubchannel).callsSucceeded); - assertEquals(0, getStats(oobSubchannel).callsFailed); - } else { - assertEquals(0, getStats(oobSubchannel).callsSucceeded); - assertEquals(1, getStats(oobSubchannel).callsFailed); - } - - // channel stats bumped when the ClientCall.Listener is notified - assertEquals(0, getStats(oobChannel).callsSucceeded); - assertEquals(0, getStats(oobChannel).callsFailed); - callExecutor.runDueTasks(); - if (success) { - assertEquals(1, getStats(oobChannel).callsSucceeded); - assertEquals(0, getStats(oobChannel).callsFailed); - } else { - assertEquals(0, getStats(oobChannel).callsSucceeded); - assertEquals(1, getStats(oobChannel).callsFailed); - } - // oob channel is separate from the original channel - assertEquals(0, getStats(channel).callsSucceeded); - assertEquals(0, getStats(channel).callsFailed); - } - - @Test - public void channelsAndSubchannels_oob_instrumented_name() throws Exception { - createChannel(); - - String authority = "oobauthority"; - OobChannel oobChannel = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), authority); - assertEquals(authority, getStats(oobChannel).target); - } - - @Test - public void channelsAndSubchannels_oob_instrumented_state() throws Exception { - createChannel(); - - OobChannel oobChannel = (OobChannel) helper.createOobChannel( - Collections.singletonList(addressGroup), "oobauthority"); - assertEquals(IDLE, getStats(oobChannel).state); - - oobChannel.getSubchannel().requestConnection(); - assertEquals(CONNECTING, getStats(oobChannel).state); - - MockClientTransportInfo transportInfo = transports.poll(); - ManagedClientTransport.Listener transportListener = transportInfo.listener; - - transportListener.transportReady(); - assertEquals(READY, getStats(oobChannel).state); - - // oobchannel state is separate from the ManagedChannel - assertEquals(CONNECTING, getStats(channel).state); - channel.shutdownNow(); - assertEquals(SHUTDOWN, getStats(channel).state); - assertEquals(SHUTDOWN, getStats(oobChannel).state); - } - @Test public void binaryLogInstalled() throws Exception { final SettableFuture intercepted = SettableFuture.create(); @@ -3917,7 +3500,8 @@ public double nextDouble() { verify(mockLoadBalancer).shutdown(); // simulating the shutdown of load balancer triggers the shutdown of subchannel shutdownSafely(helper, subchannel); - transportInfo.listener.transportShutdown(Status.INTERNAL); + transportInfo.listener.transportShutdown(Status.INTERNAL, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo.listener.transportTerminated(); // simulating transport terminated assertTrue( "channel.isTerminated() is expected to be true but was false", @@ -4022,7 +3606,8 @@ public void hedgingScheduledThenChannelShutdown_hedgeShouldStillHappen_newCallSh // simulating the shutdown of load balancer triggers the shutdown of subchannel shutdownSafely(helper, subchannel); // simulating transport shutdown & terminated - transportInfo.listener.transportShutdown(Status.INTERNAL); + transportInfo.listener.transportShutdown(Status.INTERNAL, + SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportInfo.listener.transportTerminated(); assertTrue( "channel.isTerminated() is expected to be true but was false", @@ -4357,13 +3942,37 @@ public void nameResolverHelper_badConfigFails() { assertThat(coe.getError().getCause()).isInstanceOf(ClassCastException.class); } + @Test + public void nameResolverHelper_badParser_failsGracefully() { + boolean retryEnabled = false; + int maxRetryAttemptsLimit = 2; + int maxHedgedAttemptsLimit = 3; + + Throwable t = new Error("really poor config parser"); + when(mockLoadBalancerProvider.parseLoadBalancingPolicyConfig(any())).thenThrow(t); + ScParser parser = new ScParser( + retryEnabled, + maxRetryAttemptsLimit, + maxHedgedAttemptsLimit, + mockLoadBalancerProvider); + + ConfigOrError coe = parser.parseServiceConfig(ImmutableMap.of()); + + assertThat(coe.getError()).isNotNull(); + assertThat(coe.getError().getCode()).isEqualTo(Code.INTERNAL); + assertThat(coe.getError().getDescription()).contains("Unexpected error parsing service config"); + assertThat(coe.getError().getCause()).isSameInstanceAs(t); + } + @Test public void nameResolverHelper_noConfigChosen() { boolean retryEnabled = false; int maxRetryAttemptsLimit = 2; int maxHedgedAttemptsLimit = 3; + LoadBalancerRegistry registry = new LoadBalancerRegistry(); + registry.register(mockLoadBalancerProvider); AutoConfiguredLoadBalancerFactory autoConfiguredLoadBalancerFactory = - new AutoConfiguredLoadBalancerFactory("pick_first"); + new AutoConfiguredLoadBalancerFactory(registry, MOCK_POLICY_NAME); ScParser parser = new ScParser( retryEnabled, @@ -4792,7 +4401,7 @@ public void transportTerminated(Attributes transportAttrs) { assertEquals(1, readyCallbackCalled.get()); assertEquals(0, terminationCallbackCalled.get()); - transportListener.transportShutdown(Status.OK); + transportListener.transportShutdown(Status.OK, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); transportListener.transportTerminated(); assertEquals(1, terminationCallbackCalled.get()); @@ -4830,11 +4439,11 @@ public void validAuthorityTarget_overrideAuthority() throws Exception { URI targetUri = new URI("defaultscheme", "", "/foo.googleapis.com:8080", null); NameResolver nameResolver = ManagedChannelImpl.getNameResolver( - targetUri, null, nameResolverProvider, NAMERESOLVER_ARGS); + wrap(targetUri), null, nameResolverProvider, NAMERESOLVER_ARGS); assertThat(nameResolver.getServiceAuthority()).isEqualTo(serviceAuthority); nameResolver = ManagedChannelImpl.getNameResolver( - targetUri, overrideAuthority, nameResolverProvider, NAMERESOLVER_ARGS); + wrap(targetUri), overrideAuthority, nameResolverProvider, NAMERESOLVER_ARGS); assertThat(nameResolver.getServiceAuthority()).isEqualTo(overrideAuthority); } @@ -4863,7 +4472,7 @@ public String getDefaultScheme() { }; try { ManagedChannelImpl.getNameResolver( - URI.create("defaultscheme:///foo.gogoleapis.com:8080"), + wrap(URI.create("defaultscheme:///foo.gogoleapis.com:8080")), null, nameResolverProvider, NAMERESOLVER_ARGS); fail("Should fail"); } catch (IllegalArgumentException e) { diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelOrphanWrapperTest.java b/core/src/test/java/io/grpc/internal/ManagedChannelOrphanWrapperTest.java index 5ae97c69211..45fb3881722 100644 --- a/core/src/test/java/io/grpc/internal/ManagedChannelOrphanWrapperTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedChannelOrphanWrapperTest.java @@ -101,6 +101,45 @@ public boolean isDone() { } } + @Test + public void shutdown_withDelegateStillReferenced_doesNotLogWarning() { + ManagedChannel mc = new TestManagedChannel(); + final ReferenceQueue refqueue = new ReferenceQueue<>(); + ConcurrentMap refs = + new ConcurrentHashMap<>(); + + ManagedChannelOrphanWrapper wrapper = new ManagedChannelOrphanWrapper(mc, refqueue, refs); + WeakReference wrapperWeakRef = new WeakReference<>(wrapper); + + final List records = new ArrayList<>(); + Logger orphanLogger = Logger.getLogger(ManagedChannelOrphanWrapper.class.getName()); + Filter oldFilter = orphanLogger.getFilter(); + orphanLogger.setFilter(new Filter() { + @Override + public boolean isLoggable(LogRecord record) { + synchronized (records) { + records.add(record); + } + return false; + } + }); + + try { + wrapper.shutdown(); + wrapper = null; + + // Wait for the WRAPPER itself to be garbage collected + GcFinalization.awaitClear(wrapperWeakRef); + ManagedChannelReference.cleanQueue(refqueue); + + synchronized (records) { + assertEquals("Warning was logged even though shutdownNow() was called!", 0, records.size()); + } + } finally { + orphanLogger.setFilter(oldFilter); + } + } + @Test public void refCycleIsGCed() { ReferenceQueue refqueue = diff --git a/core/src/test/java/io/grpc/internal/ManagedClientTransportTest.java b/core/src/test/java/io/grpc/internal/ManagedClientTransportTest.java index 0af88a62728..5ddea08131b 100644 --- a/core/src/test/java/io/grpc/internal/ManagedClientTransportTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedClientTransportTest.java @@ -32,7 +32,7 @@ public class ManagedClientTransportTest { public void testListener() { ManagedClientTransport.Listener listener = new ManagedClientTransport.Listener() { @Override - public void transportShutdown(Status s) {} + public void transportShutdown(Status s, DisconnectError e) {} @Override public void transportTerminated() {} @@ -45,7 +45,7 @@ public void transportInUse(boolean inUse) {} }; // Test that the listener methods do not throw. - listener.transportShutdown(Status.OK); + listener.transportShutdown(Status.OK, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); listener.transportTerminated(); listener.transportReady(); listener.transportInUse(true); diff --git a/core/src/test/java/io/grpc/internal/NoopClientStreamTest.java b/core/src/test/java/io/grpc/internal/NoopClientStreamTest.java new file mode 100644 index 00000000000..d68642dad85 --- /dev/null +++ b/core/src/test/java/io/grpc/internal/NoopClientStreamTest.java @@ -0,0 +1,44 @@ +/* + * 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.internal; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.InputStream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link NoopClientStream}. + */ +@RunWith(JUnit4.class) +public class NoopClientStreamTest { + + @Test + public void writeMessageShouldCloseInputStream() throws Exception { + // NoopClientStream.writeMessage() is called when a stream is cancelled or failed + // before the real transport stream is established (e.g. via DelayedStream draining + // buffered messages to NoopClientStream on cancellation, or FailingClientStream + // which extends NoopClientStream). The InputStream must be closed to avoid leaking + // resources such as ref-counted ByteBufs. + InputStream message = mock(InputStream.class); + NoopClientStream.INSTANCE.writeMessage(message); + verify(message).close(); + } +} diff --git a/core/src/test/java/io/grpc/internal/PickFirstLeafLoadBalancerTest.java b/core/src/test/java/io/grpc/internal/PickFirstLeafLoadBalancerTest.java index 0e902bfdd56..0467e57223d 100644 --- a/core/src/test/java/io/grpc/internal/PickFirstLeafLoadBalancerTest.java +++ b/core/src/test/java/io/grpc/internal/PickFirstLeafLoadBalancerTest.java @@ -23,6 +23,7 @@ import static io.grpc.ConnectivityState.READY; import static io.grpc.ConnectivityState.SHUTDOWN; import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; +import static io.grpc.InternalEquivalentAddressGroup.ATTR_WEIGHT; import static io.grpc.LoadBalancer.HAS_HEALTH_PRODUCER_LISTENER_KEY; import static io.grpc.LoadBalancer.HEALTH_CONSUMER_LISTENER_ARG_KEY; import static io.grpc.LoadBalancer.IS_PETIOLE_POLICY; @@ -70,10 +71,13 @@ import io.grpc.internal.PickFirstLeafLoadBalancer.PickFirstLeafLoadBalancerConfig; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Queue; +import java.util.Random; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.junit.After; @@ -149,6 +153,7 @@ public void uncaughtException(Thread t, Throwable e) { private String originalHappyEyeballsEnabledValue; private String originalSerializeRetriesValue; + private boolean originalWeightedShuffling; private long backoffMillis; @@ -165,6 +170,8 @@ public void setUp() { System.setProperty(PickFirstLoadBalancerProvider.GRPC_PF_USE_HAPPY_EYEBALLS, Boolean.toString(enableHappyEyeballs)); + originalWeightedShuffling = PickFirstLeafLoadBalancer.weightedShuffling; + for (int i = 1; i <= 5; i++) { SocketAddress addr = new FakeSocketAddress("server" + i); servers.add(new EquivalentAddressGroup(addr)); @@ -207,6 +214,7 @@ public void tearDown() { System.setProperty(PickFirstLoadBalancerProvider.GRPC_PF_USE_HAPPY_EYEBALLS, originalHappyEyeballsEnabledValue); } + PickFirstLeafLoadBalancer.weightedShuffling = originalWeightedShuffling; loadBalancer.shutdown(); verifyNoMoreInteractions(mockArgs); @@ -242,6 +250,12 @@ public void pickAfterResolved() { verifyNoMoreInteractions(mockHelper); } + @Test + public void pickAfterResolved_shuffle_oppositeWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = !PickFirstLeafLoadBalancer.weightedShuffling; + pickAfterResolved_shuffle(); + } + @Test public void pickAfterResolved_shuffle() { servers.remove(4); @@ -305,6 +319,103 @@ public void pickAfterResolved_noShuffle() { assertNotNull(pickerCaptor.getValue().pickSubchannel(mockArgs)); } + @Test + public void pickAfterResolved_shuffleImplicitUniform_oppositeWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = !PickFirstLeafLoadBalancer.weightedShuffling; + pickAfterResolved_shuffleImplicitUniform(); + } + + @Test + public void pickAfterResolved_shuffleImplicitUniform() { + EquivalentAddressGroup eag1 = new EquivalentAddressGroup(new FakeSocketAddress("server1")); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup(new FakeSocketAddress("server2")); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup(new FakeSocketAddress("server3")); + + int[] counts = countAddressSelections(99, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(33); + assertThat(counts[1]).isWithin(7).of(33); + assertThat(counts[2]).isWithin(7).of(33); + } + + @Test + public void pickAfterResolved_shuffleExplicitUniform_oppositeWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = !PickFirstLeafLoadBalancer.weightedShuffling; + pickAfterResolved_shuffleExplicitUniform(); + } + + @Test + public void pickAfterResolved_shuffleExplicitUniform() { + EquivalentAddressGroup eag1 = new EquivalentAddressGroup( + new FakeSocketAddress("server1"), Attributes.newBuilder().set(ATTR_WEIGHT, 111L).build()); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup( + new FakeSocketAddress("server2"), Attributes.newBuilder().set(ATTR_WEIGHT, 111L).build()); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup( + new FakeSocketAddress("server3"), Attributes.newBuilder().set(ATTR_WEIGHT, 111L).build()); + + int[] counts = countAddressSelections(99, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(33); + assertThat(counts[1]).isWithin(7).of(33); + assertThat(counts[2]).isWithin(7).of(33); + } + + @Test + public void pickAfterResolved_shuffleWeighted_noWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = false; + EquivalentAddressGroup eag1 = new EquivalentAddressGroup( + new FakeSocketAddress("server1"), Attributes.newBuilder().set(ATTR_WEIGHT, 12L).build()); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup( + new FakeSocketAddress("server2"), Attributes.newBuilder().set(ATTR_WEIGHT, 3L).build()); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup( + new FakeSocketAddress("server3"), Attributes.newBuilder().set(ATTR_WEIGHT, 1L).build()); + + int[] counts = countAddressSelections(100, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(33); + assertThat(counts[1]).isWithin(7).of(33); + assertThat(counts[2]).isWithin(7).of(33); + } + + @Test + public void pickAfterResolved_shuffleWeighted_weightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = true; + EquivalentAddressGroup eag1 = new EquivalentAddressGroup( + new FakeSocketAddress("server1"), Attributes.newBuilder().set(ATTR_WEIGHT, 12L).build()); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup( + new FakeSocketAddress("server2"), Attributes.newBuilder().set(ATTR_WEIGHT, 3L).build()); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup( + new FakeSocketAddress("server3"), Attributes.newBuilder().set(ATTR_WEIGHT, 1L).build()); + + int[] counts = countAddressSelections(100, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(75); // 100*12/16 + assertThat(counts[1]).isWithin(7).of(19); // 100*3/16 + assertThat(counts[2]).isWithin(7).of(6); // 100*1/16 + } + + /** Returns int[index_of_eag] array with number of times each eag was selected. */ + private int[] countAddressSelections(int trials, List eags) { + int[] counts = new int[eags.size()]; + Random random = new Random(1); + for (int i = 0; i < trials; i++) { + RecordingHelper helper = new RecordingHelper(); + LoadBalancer lb = new PickFirstLeafLoadBalancer(helper); + assertThat(lb.acceptResolvedAddresses(ResolvedAddresses.newBuilder() + .setAddresses(eags) + .setAttributes(affinity) + .setLoadBalancingPolicyConfig( + new PickFirstLeafLoadBalancerConfig(true, random.nextLong())) + .build())) + .isSameInstanceAs(Status.OK); + helper.subchannels.remove().listener.onSubchannelState( + ConnectivityStateInfo.forNonError(READY)); + + assertThat(helper.state).isEqualTo(READY); + Subchannel subchannel = helper.picker.pickSubchannel(mockArgs).getSubchannel(); + counts[eags.indexOf(subchannel.getAddresses())]++; + + lb.shutdown(); + } + return counts; + } + @Test public void requestConnectionPicker() { // Set up @@ -531,6 +642,139 @@ public void healthCheckFlow() { verifyNoMoreInteractions(mockHelper); } + // reproduces #12796 + @Test + public void healthCheckWithTF_AllowsStateInconsistency() { + assumeTrue(!serializeRetries); + + when(mockSubchannel1.getAttributes()).thenReturn( + Attributes.newBuilder().set(HAS_HEALTH_PRODUCER_LISTENER_KEY, true).build()); + + Attributes petioleAttributes = + Attributes.newBuilder().set(IS_PETIOLE_POLICY, true).build(); + + loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses( + Lists.newArrayList( + /* server 1 */servers.get(0), + /* server 3 */servers.get(2) + )) + .setAttributes(petioleAttributes) + .build()); + + // Get the state and health listener for subchannel 1 + verify(mockHelper).createSubchannel(createArgsCaptor.capture()); + SubchannelStateListener healthListener1 = + createArgsCaptor.getValue().getOption(HEALTH_CONSUMER_LISTENER_ARG_KEY); + verify(mockSubchannel1).start(stateListenerCaptor.capture()); + SubchannelStateListener stateListener1 = stateListenerCaptor.getValue(); + + // As start() was called, we transition subchannel 1 to CONNECTING... + stateListener1.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); + healthListener1.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); + + // ...which eventually ends up READY. + stateListener1.onSubchannelState(ConnectivityStateInfo.forNonError(READY)); + healthListener1.onSubchannelState(ConnectivityStateInfo.forNonError(READY)); + + // Let the fun begin: subchannel 1's health turns into TRANSIENT_FAILURE + healthListener1.onSubchannelState( + ConnectivityStateInfo.forTransientFailure( + Status.UNAVAILABLE.withDescription("health failure"))); + // HealthListener.onSubchannelState gets called. It updates the LBs balancing + // state/concludedState. + assertEquals(TRANSIENT_FAILURE, loadBalancer.getConcludedConnectivityState()); + assertEquals(READY, loadBalancer.getRawConnectivityState()); + + // Subchannel 1's transport goes idle + stateListener1.onSubchannelState(ConnectivityStateInfo.forNonError(IDLE)); + + // LB's raw connectivity stays ready as the TRANSIENT_FAILURE health state + assertEquals(TRANSIENT_FAILURE, loadBalancer.getConcludedConnectivityState()); + assertEquals(READY, loadBalancer.getRawConnectivityState()); + assertEquals(0, loadBalancer.getIndexLocation()); + + // LB tries to reconnect subchannel 1. + verify(mockSubchannel1, times(2)).requestConnection(); + + stateListener1.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); + + // LB is waiting for subchannel 1 to report status. + assertEquals(TRANSIENT_FAILURE, loadBalancer.getConcludedConnectivityState()); + assertEquals(READY, loadBalancer.getRawConnectivityState()); + assertEquals(0, loadBalancer.getIndexLocation()); + + // Subchannel 1's new connection attempt fails and reports TRANSIENT_FAILURE. + stateListener1.onSubchannelState(ConnectivityStateInfo.forTransientFailure(CONNECTION_ERROR)); + + // LB increments the index and tries to connect to server 3. + assertEquals(TRANSIENT_FAILURE, loadBalancer.getConcludedConnectivityState()); + assertEquals(READY, loadBalancer.getRawConnectivityState()); + assertEquals(1, loadBalancer.getIndexLocation()); + verify(mockSubchannel3).start(stateListenerCaptor.capture()); + SubchannelStateListener stateListener3 = stateListenerCaptor.getValue(); + verify(mockSubchannel3).requestConnection(); + stateListener3.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); + + // Subchannel 3 connection did not change the state as we are + // still in TRANSIENT_FAILURE health state. + assertEquals(TRANSIENT_FAILURE, loadBalancer.getConcludedConnectivityState()); + assertEquals(READY, loadBalancer.getRawConnectivityState()); + assertEquals(1, loadBalancer.getIndexLocation()); + + List newServers = + Lists.newArrayList( + /* server 2 */ + servers.get(1), + /* server 1 */ + servers.get(0) + ); + + // The resolver update removes the (current) subchannel 3, keeps server 1, and + // resets addressIndex to server2, which has no subchannel. + loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(newServers) + .setAttributes(petioleAttributes) + .build()); + + verify(mockSubchannel3, times(1)).shutdown(); + + // LB thinks that there are no subchannels that are trying to connect. + assertEquals(IDLE, loadBalancer.getRawConnectivityState()); + assertEquals(IDLE, loadBalancer.getConcludedConnectivityState()); + // As mentioned, the LB resets the index to 0 by calling addressIndex.updateGroups. + // Given the new list, it is now pointing to server 2 which does not have a subchannel. + assertEquals(0, loadBalancer.getIndexLocation()); + + // Subchannel 1 is still in TRANSIENT_FAILURE state. Is backoff expires, + // and now it is retrying to connect. This state listener transitions the LB to CONNECTING. + stateListener1.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); + + // As our health state is IDLE now the LB handles the CONNECTING subchannel state change + // by transitioning into CONNECTING itself. + assertEquals(CONNECTING, loadBalancer.getRawConnectivityState()); + assertEquals(CONNECTING, loadBalancer.getConcludedConnectivityState()); + + // Before the fix: + // The index is now pointing to server 2 for which the LB did not create a subchannel yet. + // assertEquals(0, loadBalancer.getIndexLocation()); + + // The index is now pointing to server 1 + assertEquals(1, loadBalancer.getIndexLocation()); + + // The resolver refreshes and provides the same addresses. + // As the LB is in CONNECTING, acceptResolvedAddresses tries + // to get the subchannel represented from the current index (server 2) and + // update its addresses. As the subchannel still does not exist an NPE is thrown. + assertEquals(Status.OK, loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(newServers) + .setAttributes(petioleAttributes) + .build())); + } + @Test public void pickAfterStateChangeAfterResolution() { InOrder inOrder = @@ -1335,6 +1579,11 @@ public void updateAddresses_disjoint_transient_failure() { loadBalancer.acceptResolvedAddresses( ResolvedAddresses.newBuilder().setAddresses(newServers).setAttributes(affinity).build()); + if (serializeRetries) { + inOrder.verify(mockSubchannel3, never()).start(stateListenerCaptor.capture()); + forwardTimeByBackoffDelay(); + } + // subchannel 3 still attempts a connection even though we stay in transient failure assertEquals(TRANSIENT_FAILURE, loadBalancer.getConcludedConnectivityState()); inOrder.verify(mockSubchannel3).start(stateListenerCaptor.capture()); @@ -1872,6 +2121,45 @@ public void updateAddresses_identical_transient_failure() { assertEquals(PickResult.withSubchannel(mockSubchannel1), picker.pickSubchannel(mockArgs)); } + @Test + public void updateAddresses_identicalSingleAddress_connecting() { + // Creating first set of endpoints/addresses + List oldServers = Lists.newArrayList(servers.get(0)); + + // Accept Addresses and verify proper connection flow + assertEquals(IDLE, loadBalancer.getConcludedConnectivityState()); + loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder().setAddresses(oldServers).setAttributes(affinity).build()); + verify(mockSubchannel1).start(stateListenerCaptor.capture()); + SubchannelStateListener stateListener = stateListenerCaptor.getValue(); + assertEquals(CONNECTING, loadBalancer.getConcludedConnectivityState()); + + // First connection attempt is successful + stateListener.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); + assertEquals(CONNECTING, loadBalancer.getConcludedConnectivityState()); + fakeClock.forwardTime(CONNECTION_DELAY_INTERVAL_MS, TimeUnit.MILLISECONDS); + + // verify that picker returns no subchannel + verify(mockHelper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); + SubchannelPicker picker = pickerCaptor.getValue(); + assertEquals(PickResult.withNoResult(), picker.pickSubchannel(mockArgs)); + + // Accept same resolved addresses to update + reset(mockHelper); + loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder().setAddresses(oldServers).setAttributes(affinity).build()); + fakeClock.forwardTime(CONNECTION_DELAY_INTERVAL_MS, TimeUnit.MILLISECONDS); + + // Verify that no new subchannels were created or started + verify(mockSubchannel2, never()).start(any()); + assertEquals(CONNECTING, loadBalancer.getConcludedConnectivityState()); + + // verify that picker hasn't changed via checking mock helper's interactions + verify(mockHelper, atLeast(0)).getSynchronizationContext(); // Don't care + verify(mockHelper, atLeast(0)).getScheduledExecutorService(); + verifyNoMoreInteractions(mockHelper); + } + @Test public void twoAddressesSeriallyConnect() { // Starting first connection attempt @@ -2906,13 +3194,7 @@ public String toString() { } } - private class MockHelperImpl extends LoadBalancer.Helper { - private final List subchannels; - - public MockHelperImpl(List subchannels) { - this.subchannels = new ArrayList(subchannels); - } - + private class BaseHelper extends LoadBalancer.Helper { @Override public ManagedChannel createOobChannel(EquivalentAddressGroup eag, String authority) { return null; @@ -2942,6 +3224,14 @@ public ScheduledExecutorService getScheduledExecutorService() { public void refreshNameResolution() { // noop } + } + + private class MockHelperImpl extends BaseHelper { + private final List subchannels; + + public MockHelperImpl(List subchannels) { + this.subchannels = new ArrayList(subchannels); + } @Override public Subchannel createSubchannel(CreateSubchannelArgs args) { @@ -2958,4 +3248,23 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { throw new IllegalArgumentException("Unexpected addresses: " + args.getAddresses()); } } + + class RecordingHelper extends BaseHelper { + ConnectivityState state; + SubchannelPicker picker; + final Queue subchannels = new ArrayDeque<>(); + + @Override + public void updateBalancingState(ConnectivityState newState, SubchannelPicker newPicker) { + this.state = newState; + this.picker = newPicker; + } + + @Override + public Subchannel createSubchannel(CreateSubchannelArgs args) { + FakeSubchannel subchannel = new FakeSubchannel(args.getAddresses(), args.getAttributes()); + subchannels.add(subchannel); + return subchannel; + } + } } diff --git a/core/src/test/java/io/grpc/internal/PickFirstLoadBalancerTest.java b/core/src/test/java/io/grpc/internal/PickFirstLoadBalancerTest.java index 3e0258f2e40..1e130423a45 100644 --- a/core/src/test/java/io/grpc/internal/PickFirstLoadBalancerTest.java +++ b/core/src/test/java/io/grpc/internal/PickFirstLoadBalancerTest.java @@ -21,6 +21,7 @@ import static io.grpc.ConnectivityState.IDLE; import static io.grpc.ConnectivityState.READY; import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; +import static io.grpc.InternalEquivalentAddressGroup.ATTR_WEIGHT; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -49,12 +50,18 @@ import io.grpc.LoadBalancer.Subchannel; import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.LoadBalancer.SubchannelStateListener; +import io.grpc.ManagedChannel; import io.grpc.Status; import io.grpc.Status.Code; import io.grpc.SynchronizationContext; import io.grpc.internal.PickFirstLoadBalancer.PickFirstLoadBalancerConfig; import java.net.SocketAddress; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Queue; +import java.util.Random; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -103,8 +110,12 @@ public void uncaughtException(Thread t, Throwable e) { @Mock // This LoadBalancer doesn't use any of the arg fields, as verified in tearDown(). private PickSubchannelArgs mockArgs; + private boolean originalWeightedShuffling; + @Before public void setUp() { + originalWeightedShuffling = PickFirstLeafLoadBalancer.weightedShuffling; + for (int i = 0; i < 3; i++) { SocketAddress addr = new FakeSocketAddress("server" + i); servers.add(new EquivalentAddressGroup(addr)); @@ -120,6 +131,7 @@ public void setUp() { @After public void tearDown() throws Exception { + PickFirstLeafLoadBalancer.weightedShuffling = originalWeightedShuffling; verifyNoMoreInteractions(mockArgs); } @@ -141,6 +153,12 @@ public void pickAfterResolved() throws Exception { verifyNoMoreInteractions(mockHelper); } + @Test + public void pickAfterResolved_shuffle_oppositeWeightedShuffling() throws Exception { + PickFirstLeafLoadBalancer.weightedShuffling = !PickFirstLeafLoadBalancer.weightedShuffling; + pickAfterResolved_shuffle(); + } + @Test public void pickAfterResolved_shuffle() throws Exception { loadBalancer.acceptResolvedAddresses( @@ -184,6 +202,103 @@ public void pickAfterResolved_noShuffle() throws Exception { verifyNoMoreInteractions(mockHelper); } + @Test + public void pickAfterResolved_shuffleImplicitUniform_oppositeWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = !PickFirstLeafLoadBalancer.weightedShuffling; + pickAfterResolved_shuffleImplicitUniform(); + } + + @Test + public void pickAfterResolved_shuffleImplicitUniform() { + EquivalentAddressGroup eag1 = new EquivalentAddressGroup(new FakeSocketAddress("server1")); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup(new FakeSocketAddress("server2")); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup(new FakeSocketAddress("server3")); + + int[] counts = countAddressSelections(99, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(33); + assertThat(counts[1]).isWithin(7).of(33); + assertThat(counts[2]).isWithin(7).of(33); + } + + @Test + public void pickAfterResolved_shuffleExplicitUniform_oppositeWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = !PickFirstLeafLoadBalancer.weightedShuffling; + pickAfterResolved_shuffleExplicitUniform(); + } + + @Test + public void pickAfterResolved_shuffleExplicitUniform() { + EquivalentAddressGroup eag1 = new EquivalentAddressGroup( + new FakeSocketAddress("server1"), Attributes.newBuilder().set(ATTR_WEIGHT, 111L).build()); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup( + new FakeSocketAddress("server2"), Attributes.newBuilder().set(ATTR_WEIGHT, 111L).build()); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup( + new FakeSocketAddress("server3"), Attributes.newBuilder().set(ATTR_WEIGHT, 111L).build()); + + int[] counts = countAddressSelections(99, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(33); + assertThat(counts[1]).isWithin(7).of(33); + assertThat(counts[2]).isWithin(7).of(33); + } + + @Test + public void pickAfterResolved_shuffleWeighted_noWeightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = false; + EquivalentAddressGroup eag1 = new EquivalentAddressGroup( + new FakeSocketAddress("server1"), Attributes.newBuilder().set(ATTR_WEIGHT, 12L).build()); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup( + new FakeSocketAddress("server2"), Attributes.newBuilder().set(ATTR_WEIGHT, 3L).build()); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup( + new FakeSocketAddress("server3"), Attributes.newBuilder().set(ATTR_WEIGHT, 1L).build()); + + int[] counts = countAddressSelections(100, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(33); + assertThat(counts[1]).isWithin(7).of(33); + assertThat(counts[2]).isWithin(7).of(33); + } + + @Test + public void pickAfterResolved_shuffleWeighted_weightedShuffling() { + PickFirstLeafLoadBalancer.weightedShuffling = true; + EquivalentAddressGroup eag1 = new EquivalentAddressGroup( + new FakeSocketAddress("server1"), Attributes.newBuilder().set(ATTR_WEIGHT, 12L).build()); + EquivalentAddressGroup eag2 = new EquivalentAddressGroup( + new FakeSocketAddress("server2"), Attributes.newBuilder().set(ATTR_WEIGHT, 3L).build()); + EquivalentAddressGroup eag3 = new EquivalentAddressGroup( + new FakeSocketAddress("server3"), Attributes.newBuilder().set(ATTR_WEIGHT, 1L).build()); + + int[] counts = countAddressSelections(100, Arrays.asList(eag1, eag2, eag3)); + assertThat(counts[0]).isWithin(7).of(75); // 100*12/16 + assertThat(counts[1]).isWithin(7).of(19); // 100*3/16 + assertThat(counts[2]).isWithin(7).of(6); // 100*1/16 + } + + /** Returns int[index_of_eag] array with number of times each eag was selected. */ + private int[] countAddressSelections(int trials, List eags) { + int[] counts = new int[eags.size()]; + Random random = new Random(1); + for (int i = 0; i < trials; i++) { + RecordingHelper helper = new RecordingHelper(); + PickFirstLoadBalancer lb = new PickFirstLoadBalancer(helper); + assertThat(lb.acceptResolvedAddresses(ResolvedAddresses.newBuilder() + .setAddresses(eags) + .setAttributes(affinity) + .setLoadBalancingPolicyConfig( + new PickFirstLoadBalancerConfig(true, random.nextLong())) + .build())) + .isSameInstanceAs(Status.OK); + helper.subchannels.remove().listener.onSubchannelState( + ConnectivityStateInfo.forNonError(READY)); + + assertThat(helper.state).isEqualTo(READY); + Subchannel subchannel = helper.picker.pickSubchannel(mockArgs).getSubchannel(); + counts[eags.indexOf(subchannel.getAllAddresses().get(0))]++; + + lb.shutdown(); + } + return counts; + } + @Test public void requestConnectionPicker() throws Exception { loadBalancer.acceptResolvedAddresses( @@ -219,7 +334,7 @@ public void refreshNameResolutionAfterSubchannelConnectionBroken() { inOrder.verify(mockSubchannel).start(stateListenerCaptor.capture()); SubchannelStateListener stateListener = stateListenerCaptor.getValue(); inOrder.verify(mockHelper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - assertSame(mockSubchannel, pickerCaptor.getValue().pickSubchannel(mockArgs).getSubchannel()); + assertThat(pickerCaptor.getValue().pickSubchannel(mockArgs).hasResult()).isFalse(); inOrder.verify(mockSubchannel).requestConnection(); stateListener.onSubchannelState(ConnectivityStateInfo.forNonError(CONNECTING)); @@ -278,7 +393,7 @@ public void pickAfterResolvedAndChanged() throws Exception { assertThat(args.getAddresses()).isEqualTo(servers); inOrder.verify(mockHelper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); verify(mockSubchannel).requestConnection(); - assertEquals(mockSubchannel, pickerCaptor.getValue().pickSubchannel(mockArgs).getSubchannel()); + assertThat(pickerCaptor.getValue().pickSubchannel(mockArgs).hasResult()).isFalse(); loadBalancer.acceptResolvedAddresses( ResolvedAddresses.newBuilder().setAddresses(newServers).setAttributes(affinity).build()); @@ -300,7 +415,7 @@ public void pickAfterStateChangeAfterResolution() throws Exception { verify(mockSubchannel).start(stateListenerCaptor.capture()); SubchannelStateListener stateListener = stateListenerCaptor.getValue(); verify(mockHelper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - Subchannel subchannel = pickerCaptor.getValue().pickSubchannel(mockArgs).getSubchannel(); + assertThat(pickerCaptor.getValue().pickSubchannel(mockArgs).hasResult()).isFalse(); reset(mockHelper); when(mockHelper.getSynchronizationContext()).thenReturn(syncContext); @@ -317,7 +432,7 @@ public void pickAfterStateChangeAfterResolution() throws Exception { stateListener.onSubchannelState(ConnectivityStateInfo.forNonError(READY)); inOrder.verify(mockHelper).updateBalancingState(eq(READY), pickerCaptor.capture()); - assertEquals(subchannel, pickerCaptor.getValue().pickSubchannel(mockArgs).getSubchannel()); + assertEquals(mockSubchannel, pickerCaptor.getValue().pickSubchannel(mockArgs).getSubchannel()); verify(mockHelper, atLeast(0)).getSynchronizationContext(); // Don't care verifyNoMoreInteractions(mockHelper); @@ -405,8 +520,7 @@ public void nameResolutionSuccessAfterError() throws Exception { inOrder.verify(mockHelper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); verify(mockSubchannel).requestConnection(); - assertEquals(mockSubchannel, pickerCaptor.getValue().pickSubchannel(mockArgs) - .getSubchannel()); + assertThat(pickerCaptor.getValue().pickSubchannel(mockArgs).hasResult()).isFalse(); assertEquals(pickerCaptor.getValue().pickSubchannel(mockArgs), pickerCaptor.getValue().pickSubchannel(mockArgs)); @@ -487,4 +601,96 @@ public String toString() { return "FakeSocketAddress-" + name; } } + + private static class FakeSubchannel extends Subchannel { + private final Attributes attributes; + private List eags; + private SubchannelStateListener listener; + + public FakeSubchannel(List eags, Attributes attributes) { + this.eags = Collections.unmodifiableList(eags); + this.attributes = attributes; + } + + @Override + public List getAllAddresses() { + return eags; + } + + @Override + public Attributes getAttributes() { + return attributes; + } + + @Override + public void start(SubchannelStateListener listener) { + this.listener = listener; + } + + @Override + public void updateAddresses(List addrs) { + this.eags = Collections.unmodifiableList(addrs); + } + + @Override + public void shutdown() { + listener.onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.SHUTDOWN)); + } + + @Override + public void requestConnection() { + } + + @Override + public String toString() { + return "FakeSubchannel@" + hashCode() + "(" + eags + ")"; + } + } + + private class BaseHelper extends Helper { + @Override + public ManagedChannel createOobChannel(EquivalentAddressGroup eag, String authority) { + return null; + } + + @Override + public String getAuthority() { + return null; + } + + @Override + public void updateBalancingState(ConnectivityState newState, SubchannelPicker newPicker) { + // ignore + } + + @Override + public SynchronizationContext getSynchronizationContext() { + return syncContext; + } + + @Override + public void refreshNameResolution() { + // noop + } + } + + class RecordingHelper extends BaseHelper { + ConnectivityState state; + SubchannelPicker picker; + final Queue subchannels = new ArrayDeque<>(); + + @Override + public void updateBalancingState(ConnectivityState newState, SubchannelPicker newPicker) { + this.state = newState; + this.picker = newPicker; + } + + @Override + public Subchannel createSubchannel(CreateSubchannelArgs args) { + FakeSubchannel subchannel = new FakeSubchannel(args.getAddresses(), args.getAttributes()); + subchannels.add(subchannel); + return subchannel; + } + } + } diff --git a/core/src/test/java/io/grpc/internal/ProxyDetectorImplTest.java b/core/src/test/java/io/grpc/internal/ProxyDetectorImplTest.java index 771050f119d..af0ed1f35d3 100644 --- a/core/src/test/java/io/grpc/internal/ProxyDetectorImplTest.java +++ b/core/src/test/java/io/grpc/internal/ProxyDetectorImplTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -33,6 +34,7 @@ import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.ProxiedSocketAddress; import io.grpc.ProxyDetector; +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.PasswordAuthentication; @@ -40,6 +42,7 @@ import java.net.ProxySelector; import java.net.SocketAddress; import java.net.URI; +import java.util.Collections; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -191,4 +194,24 @@ public ProxySelector get() { authenticator); assertNull(proxyDetector.proxyFor(destination)); } + + @Test + public void throwsWhenProxySelectorReturnsEmptyList() throws Exception { + when(proxySelector.select(any(URI.class))).thenReturn(Collections.emptyList()); + + IOException e = + assertThrows(IOException.class, () -> proxyDetector.proxyFor(destination)); + assertTrue(e.getMessage(), e.getMessage().contains("empty list")); + assertTrue(e.getMessage(), e.getMessage().contains(proxySelector.getClass().getName())); + } + + @Test + public void throwsWhenProxySelectorReturnsNullList() throws Exception { + when(proxySelector.select(any(URI.class))).thenReturn(null); + + IOException e = + assertThrows(IOException.class, () -> proxyDetector.proxyFor(destination)); + assertTrue(e.getMessage(), e.getMessage().contains("null")); + assertTrue(e.getMessage(), e.getMessage().contains(proxySelector.getClass().getName())); + } } diff --git a/core/src/test/java/io/grpc/internal/ServerImplBuilderTest.java b/core/src/test/java/io/grpc/internal/ServerImplBuilderTest.java index 7ad7f15f358..c2cb281a19e 100644 --- a/core/src/test/java/io/grpc/internal/ServerImplBuilderTest.java +++ b/core/src/test/java/io/grpc/internal/ServerImplBuilderTest.java @@ -22,6 +22,9 @@ import io.grpc.InternalConfigurator; import io.grpc.InternalConfiguratorRegistry; import io.grpc.Metadata; +import io.grpc.MetricRecorder; +import io.grpc.MetricSink; +import io.grpc.NoopMetricSink; import io.grpc.ServerBuilder; import io.grpc.ServerCall; import io.grpc.ServerCallHandler; @@ -73,7 +76,8 @@ public void setUp() throws Exception { new ClientTransportServersBuilder() { @Override public InternalServer buildClientTransportServers( - List streamTracerFactories) { + List streamTracerFactories, + MetricRecorder metricRecorder) { throw new UnsupportedOperationException(); } }); @@ -128,6 +132,13 @@ public void getTracerFactories_disableBoth() { assertThat(factories).containsExactly(DUMMY_USER_TRACER); } + @Test + public void addMetricSink_addsToSinks() { + MetricSink noopMetricSink = new NoopMetricSink(); + builder.addMetricSink(noopMetricSink); + assertThat(builder.metricSinks).containsExactly(noopMetricSink); + } + @Test public void getTracerFactories_callsGet() throws Exception { Class runnable = classLoader.loadClass(StaticTestingClassLoaderCallsGet.class.getName()); @@ -139,7 +150,7 @@ public static final class StaticTestingClassLoaderCallsGet implements Runnable { public void run() { ServerImplBuilder builder = new ServerImplBuilder( - streamTracerFactories -> { + (streamTracerFactories, metricRecorder) -> { throw new UnsupportedOperationException(); }); assertThat(builder.getTracerFactories()).hasSize(2); @@ -169,7 +180,7 @@ public void configureServerBuilder(ServerBuilder builder) { })); ServerImplBuilder builder = new ServerImplBuilder( - streamTracerFactories -> { + (streamTracerFactories, metricRecorder) -> { throw new UnsupportedOperationException(); }); assertThat(builder.getTracerFactories()).containsExactly(DUMMY_USER_TRACER); @@ -192,7 +203,7 @@ public void run() { InternalConfiguratorRegistry.setConfigurators(Collections.emptyList()); ServerImplBuilder builder = new ServerImplBuilder( - streamTracerFactories -> { + (streamTracerFactories, metricRecorder) -> { throw new UnsupportedOperationException(); }); assertThat(builder.getTracerFactories()).isEmpty(); diff --git a/core/src/test/java/io/grpc/internal/ServerImplTest.java b/core/src/test/java/io/grpc/internal/ServerImplTest.java index 0f18efe078c..91969dd6910 100644 --- a/core/src/test/java/io/grpc/internal/ServerImplTest.java +++ b/core/src/test/java/io/grpc/internal/ServerImplTest.java @@ -65,6 +65,7 @@ import io.grpc.InternalServerInterceptors; import io.grpc.Metadata; import io.grpc.MethodDescriptor; +import io.grpc.MetricRecorder; import io.grpc.ServerCall; import io.grpc.ServerCall.Listener; import io.grpc.ServerCallExecutorSupplier; @@ -128,6 +129,10 @@ public class ServerImplTest { .setRequestMarshaller(STRING_MARSHALLER) .setResponseMarshaller(INTEGER_MARSHALLER) .build(); + private static final MethodDescriptor GENERATED_METHOD = + METHOD.toBuilder() + .setSampledToLocalTracing(true) + .build(); private static final Context.Key SERVER_ONLY = Context.key("serverOnly"); private static final Context.Key SERVER_TRACER_ADDED_KEY = Context.key("tracer-added"); private static final Context.CancellableContext SERVER_CONTEXT = @@ -141,6 +146,60 @@ public boolean shouldAccept(Runnable runnable) { }; private static final String AUTHORITY = "some_authority"; + private static final class MethodNameCapturingTracer extends ServerStreamTracer + implements StatsTraceContext.ServerCallMethodListener { + @Nullable private ServerCallInfo serverCallInfo; + @Nullable private String recordedMethodName; + @Nullable private String resolvedMethodName; + private boolean streamClosed; + + @Override + public synchronized void serverCallMethodResolved(MethodDescriptor method) { + resolvedMethodName = + recordMethodName(method.isSampledToLocalTracing(), method.getFullMethodName()); + } + + @Override + public synchronized void streamClosed(Status status) { + streamClosed = true; + if (serverCallInfo != null) { + recordedMethodName = + recordMethodName( + serverCallInfo.getMethodDescriptor().isSampledToLocalTracing(), + serverCallInfo.getMethodDescriptor().getFullMethodName()); + } else if (resolvedMethodName != null) { + recordedMethodName = resolvedMethodName; + } else { + recordedMethodName = "other"; + } + } + + @Override + public synchronized void serverCallStarted(ServerCallInfo callInfo) { + serverCallInfo = callInfo; + if (streamClosed) { + recordedMethodName = + recordMethodName( + callInfo.getMethodDescriptor().isSampledToLocalTracing(), + callInfo.getMethodDescriptor().getFullMethodName()); + } + } + + @Nullable + synchronized ServerCallInfo getServerCallInfo() { + return serverCallInfo; + } + + @Nullable + synchronized String getRecordedMethodName() { + return recordedMethodName; + } + + private static String recordMethodName(boolean generatedMethod, String fullMethodName) { + return generatedMethod ? fullMethodName : "other"; + } + } + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); @BeforeClass @@ -206,7 +265,8 @@ public void startUp() throws IOException { new ClientTransportServersBuilder() { @Override public InternalServer buildClientTransportServers( - List streamTracerFactories) { + List streamTracerFactories, + MetricRecorder metricRecorder) { throw new UnsupportedOperationException(); } }); @@ -460,6 +520,172 @@ public void methodNotFound() throws Exception { assertEquals(Status.Code.UNIMPLEMENTED, statusCaptor.getValue().getCode()); } + @Test + public void primaryRegistryGeneratedMethod_streamClosedBeforeStart_preservesMethodName() + throws Exception { + MethodNameCapturingTracer methodNameTracer = new MethodNameCapturingTracer(); + streamTracerFactories = + Collections.singletonList( + new ServerStreamTracer.Factory() { + @Override + public ServerStreamTracer newServerStreamTracer( + String fullMethodName, Metadata headers) { + return methodNameTracer; + } + }); + builder.addService( + ServerServiceDefinition.builder(new ServiceDescriptor("Waiter", GENERATED_METHOD)) + .addMethod( + GENERATED_METHOD, + new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + return callListener; + } + }) + .build()); + + createAndStartServer(); + ServerTransportListener transportListener + = transportServer.registerNewServerTransport(new SimpleServerTransport()); + transportListener.transportReady(Attributes.EMPTY); + Metadata requestHeaders = new Metadata(); + StatsTraceContext statsTraceCtx = + StatsTraceContext.newServerContext( + streamTracerFactories, GENERATED_METHOD.getFullMethodName(), requestHeaders); + when(stream.getAttributes()).thenReturn(Attributes.EMPTY); + when(stream.statsTraceContext()).thenReturn(statsTraceCtx); + + transportListener.streamCreated(stream, GENERATED_METHOD.getFullMethodName(), requestHeaders); + verify(stream).setListener(isA(ServerStreamListener.class)); + verify(stream, atLeast(1)).statsTraceContext(); + + statsTraceCtx.streamClosed(Status.CANCELLED); + assertNull(methodNameTracer.getServerCallInfo()); + assertEquals( + GENERATED_METHOD.getFullMethodName(), + methodNameTracer.getRecordedMethodName()); + + assertEquals(1, executor.runDueTasks()); + + assertNotNull(methodNameTracer.getServerCallInfo()); + assertSame(GENERATED_METHOD, methodNameTracer.getServerCallInfo().getMethodDescriptor()); + assertEquals( + GENERATED_METHOD.getFullMethodName(), + methodNameTracer.getRecordedMethodName()); + verify(fallbackRegistry, never()).lookupMethod(anyString(), any()); + } + + @Test + public void primaryRegistryNonGeneratedMethod_streamClosedBeforeStart_recordsOther() + throws Exception { + MethodNameCapturingTracer methodNameTracer = new MethodNameCapturingTracer(); + streamTracerFactories = + Collections.singletonList( + new ServerStreamTracer.Factory() { + @Override + public ServerStreamTracer newServerStreamTracer( + String fullMethodName, Metadata headers) { + return methodNameTracer; + } + }); + builder.addService( + ServerServiceDefinition.builder(new ServiceDescriptor("Waiter", METHOD)) + .addMethod( + METHOD, + new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + return callListener; + } + }) + .build()); + + createAndStartServer(); + ServerTransportListener transportListener + = transportServer.registerNewServerTransport(new SimpleServerTransport()); + transportListener.transportReady(Attributes.EMPTY); + Metadata requestHeaders = new Metadata(); + StatsTraceContext statsTraceCtx = + StatsTraceContext.newServerContext( + streamTracerFactories, METHOD.getFullMethodName(), requestHeaders); + when(stream.getAttributes()).thenReturn(Attributes.EMPTY); + when(stream.statsTraceContext()).thenReturn(statsTraceCtx); + + transportListener.streamCreated(stream, METHOD.getFullMethodName(), requestHeaders); + verify(stream).setListener(isA(ServerStreamListener.class)); + verify(stream, atLeast(1)).statsTraceContext(); + + statsTraceCtx.streamClosed(Status.CANCELLED); + assertNull(methodNameTracer.getServerCallInfo()); + assertEquals("other", methodNameTracer.getRecordedMethodName()); + + assertEquals(1, executor.runDueTasks()); + + assertNotNull(methodNameTracer.getServerCallInfo()); + assertSame(METHOD, methodNameTracer.getServerCallInfo().getMethodDescriptor()); + assertEquals("other", methodNameTracer.getRecordedMethodName()); + verify(fallbackRegistry, never()).lookupMethod(anyString(), any()); + } + + @Test + public void fallbackRegistryGeneratedMethod_streamClosedBeforeStart_resolvesOnAsyncLookup() + throws Exception { + MethodNameCapturingTracer methodNameTracer = new MethodNameCapturingTracer(); + streamTracerFactories = + Collections.singletonList( + new ServerStreamTracer.Factory() { + @Override + public ServerStreamTracer newServerStreamTracer( + String fullMethodName, Metadata headers) { + return methodNameTracer; + } + }); + mutableFallbackRegistry.addService( + ServerServiceDefinition.builder(new ServiceDescriptor("Waiter", GENERATED_METHOD)) + .addMethod( + GENERATED_METHOD, + new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + return callListener; + } + }) + .build()); + + createAndStartServer(); + ServerTransportListener transportListener + = transportServer.registerNewServerTransport(new SimpleServerTransport()); + transportListener.transportReady(Attributes.EMPTY); + Metadata requestHeaders = new Metadata(); + StatsTraceContext statsTraceCtx = + StatsTraceContext.newServerContext( + streamTracerFactories, GENERATED_METHOD.getFullMethodName(), requestHeaders); + when(stream.getAttributes()).thenReturn(Attributes.EMPTY); + when(stream.statsTraceContext()).thenReturn(statsTraceCtx); + + transportListener.streamCreated(stream, GENERATED_METHOD.getFullMethodName(), requestHeaders); + verify(stream).setListener(isA(ServerStreamListener.class)); + verify(stream, atLeast(1)).statsTraceContext(); + + statsTraceCtx.streamClosed(Status.CANCELLED); + assertNull(methodNameTracer.getServerCallInfo()); + assertEquals("other", methodNameTracer.getRecordedMethodName()); + verify(fallbackRegistry, never()).lookupMethod(anyString(), any()); + + assertEquals(1, executor.runDueTasks()); + + assertNotNull(methodNameTracer.getServerCallInfo()); + assertSame(GENERATED_METHOD, methodNameTracer.getServerCallInfo().getMethodDescriptor()); + assertEquals( + GENERATED_METHOD.getFullMethodName(), + methodNameTracer.getRecordedMethodName()); + verify(fallbackRegistry).lookupMethod(GENERATED_METHOD.getFullMethodName(), AUTHORITY); + } + @Test public void executorSupplierSameExecutorBasic() throws Exception { diff --git a/core/src/test/java/io/grpc/internal/ServiceConfigErrorHandlingTest.java b/core/src/test/java/io/grpc/internal/ServiceConfigErrorHandlingTest.java index 6f255763d30..0daee676b82 100644 --- a/core/src/test/java/io/grpc/internal/ServiceConfigErrorHandlingTest.java +++ b/core/src/test/java/io/grpc/internal/ServiceConfigErrorHandlingTest.java @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkState; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static io.grpc.internal.UriWrapper.wrap; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.AdditionalAnswers.delegatesTo; @@ -168,7 +169,7 @@ private void createChannel(ClientInterceptor... interceptors) { new ManagedChannelImpl( channelBuilder, mockTransportFactory, - expectedUri, + wrap(expectedUri), nameResolverProvider, new FakeBackoffPolicyProvider(), balancerRpcExecutorPool, diff --git a/core/src/test/java/io/grpc/internal/SharedResourceHolderTest.java b/core/src/test/java/io/grpc/internal/SharedResourceHolderTest.java index d27195e2490..692b22a0a68 100644 --- a/core/src/test/java/io/grpc/internal/SharedResourceHolderTest.java +++ b/core/src/test/java/io/grpc/internal/SharedResourceHolderTest.java @@ -30,7 +30,9 @@ import io.grpc.internal.SharedResourceHolder.Resource; import java.util.LinkedList; +import java.util.concurrent.CyclicBarrier; import java.util.concurrent.Delayed; +import java.util.concurrent.FutureTask; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -201,6 +203,46 @@ public void close(ResourceInstance instance) { assertNotSame(instance, holder.getInternal(resource)); } + @Test(timeout = 5000) + public void closeRunsConcurrently() throws Exception { + CyclicBarrier barrier = new CyclicBarrier(2); + class SlowResource implements Resource { + @Override + public ResourceInstance create() { + return new ResourceInstance(); + } + + @Override + public void close(ResourceInstance instance) { + instance.closed = true; + try { + barrier.await(); + barrier.await(); + } catch (Exception ex) { + throw new AssertionError(ex); + } + } + } + + Resource resource = new SlowResource(); + ResourceInstance instance = holder.getInternal(resource); + holder.releaseInternal(resource, instance); + MockScheduledFuture scheduledDestroyTask = scheduledDestroyTasks.poll(); + FutureTask runTask = new FutureTask<>(scheduledDestroyTask::runTask, null); + Thread t = new Thread(runTask); + t.start(); + + barrier.await(); // Ensure the other thread has blocked + assertTrue(instance.closed); + instance = holder.getInternal(resource); + assertFalse(instance.closed); + holder.releaseInternal(resource, instance); + + barrier.await(); // Resume the other thread + t.join(); + runTask.get(); // Check for exception + } + private class MockExecutorFactory implements SharedResourceHolder.ScheduledExecutorFactory { @Override diff --git a/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java b/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java index d5155728936..57824cf207f 100644 --- a/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java +++ b/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java @@ -218,6 +218,7 @@ public static class CertificateApiTest { private static final String SERVER_0_PEM_FILE = "server0.pem"; private static final String TEST_DIRECTORY_PREFIX = "io/grpc/internal/"; private static final String SPIFFE_TRUST_BUNDLE = "spiffebundle.json"; + private static final String SPIFFE_TRUST_BUNDLE_WITH_EC_KTY = "spiffebundle_ec.json"; private static final String SPIFFE_TRUST_BUNDLE_MALFORMED = "spiffebundle_malformed.json"; private static final String SPIFFE_TRUST_BUNDLE_CORRUPTED_CERT = "spiffebundle_corrupted_cert.json"; @@ -311,6 +312,21 @@ public void loadTrustBundleFromFileSuccessTest() throws Exception { .toArray(new X509Certificate[0])); assertTrue(spiffeId.isPresent()); assertEquals("foo.bar.com", spiffeId.get().getTrustDomain()); + + SpiffeBundle tb_ec = SpiffeUtil.loadTrustBundleFromFile( + copyFileToTmp(SPIFFE_TRUST_BUNDLE_WITH_EC_KTY)); + assertEquals(2, tb_ec.getSequenceNumbers().size()); + assertEquals(12035488L, (long) tb_ec.getSequenceNumbers().get("example.com")); + assertEquals(-1L, (long) tb_ec.getSequenceNumbers().get("test.example.com")); + assertEquals(3, tb_ec.getBundleMap().size()); + assertEquals(0, tb_ec.getBundleMap().get("test.google.com.au").size()); + assertEquals(1, tb_ec.getBundleMap().get("example.com").size()); + assertEquals(2, tb_ec.getBundleMap().get("test.example.com").size()); + Optional spiffeId_ec = + SpiffeUtil.extractSpiffeId(tb_ec.getBundleMap().get("example.com") + .toArray(new X509Certificate[0])); + assertTrue(spiffeId_ec.isPresent()); + assertEquals("foo.bar.com", spiffeId_ec.get().getTrustDomain()); } @Test @@ -338,7 +354,8 @@ public void loadTrustBundleFromFileFailureTest() { // Check the exception if 'kty' value differs from 'RSA' iae = assertThrows(IllegalArgumentException.class, () -> SpiffeUtil .loadTrustBundleFromFile(copyFileToTmp(SPIFFE_TRUST_BUNDLE_WRONG_KTY))); - assertEquals("'kty' parameter must be 'RSA' but 'null' found." + DOMAIN_ERROR_MESSAGE, + assertEquals( + "'kty' parameter must be one of [RSA, EC] but 'null' found." + DOMAIN_ERROR_MESSAGE, iae.getMessage()); // Check the exception if 'kid' has a value iae = assertThrows(IllegalArgumentException.class, () -> SpiffeUtil diff --git a/core/src/test/resources/io/grpc/internal/spiffebundle_ec.json b/core/src/test/resources/io/grpc/internal/spiffebundle_ec.json new file mode 100644 index 00000000000..1732310f8cf --- /dev/null +++ b/core/src/test/resources/io/grpc/internal/spiffebundle_ec.json @@ -0,0 +1,116 @@ +{ + "trust_domains": { + "test.google.com.au": {}, + "example.com": { + "spiffe_sequence": 12035488, + "keys": [ + { + + "kty": "EC", + "use": "x509-svid", + "x5c": ["MIIFsjCCA5qgAwIBAgIURygVMMzdr+Q7rsUaz189JozyHMwwDQYJKoZIhvcNAQEL + BQAwTjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQwwCgYDVQQHDANTVkwxDTAL + BgNVBAoMBGdSUEMxFTATBgNVBAMMDHRlc3QtY2xpZW50MTAeFw0yMTEyMjMxODQy + NTJaFw0zMTEyMjExODQyNTJaME4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEM + MAoGA1UEBwwDU1ZMMQ0wCwYDVQQKDARnUlBDMRUwEwYDVQQDDAx0ZXN0LWNsaWVu + dDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ4AqpGetyVSqGUuBJ + LVFla+7bEfca7UYzfVSSZLZ/X+JDmWIVN8UIPuFib5jhMEc3XaUnFXUmM7zEtz/Z + G5hapwLwOb2C3ZxOP6PQjYCJxbkLie+b43UQrFu1xxd3vMhVJgcj/AIxEpmszuqO + a6kUrkYifjJADQ+64kZgl66bsTdXMCzpxyFl9xUfff59L8OX+HUfAcoZz3emjg3Z + JPYURQEmjdZTOau1EjFilwHgd989Jt7NKgx30NXoHmw7nusVBIY94fL2VKN3f1XV + m0dHu5NI279Q6zr0ZBU7k5T3IeHnzsUesQS4NGlklDWoVTKk73Uv9Pna8yQsSW75 + 7PEbHOGp9Knu4bnoGPOlsG81yIPipO6hTgGFK24pF97M9kpGbWqYX4+2vLlrCAfc + msHqaUPmQlYeRVTT6vw7ctYo2kyUYGtnODXk76LqewRBVvkzx75QUhfjAyb740Yc + DmIenc56Tq6gebJHjhEmVSehR6xIpXP7SVeurTyhPsEQnpJHtgs4dcwWOZp7BvPN + zHXmJqfr7vsshie3vS5kQ0u1e1yqAqXgyDjqKXOkx+dpgUTehSJHhPNHvTc5LXRs + vvXKYz6FrwR/DZ8t7BNEvPeLjFgxpH7QVJFLCvCbXs5K6yYbsnLfxFIBPRnrbJkI + sK+sQwnRdnsiUdPsTkG5B2lQfQIDAQABo4GHMIGEMB0GA1UdDgQWBBQ2lBp0PiRH + HvQ5IRURm8aHsj4RETAfBgNVHSMEGDAWgBQ2lBp0PiRHHvQ5IRURm8aHsj4RETAP + BgNVHRMBAf8EBTADAQH/MDEGA1UdEQQqMCiGJnNwaWZmZTovL2Zvby5iYXIuY29t + L2NsaWVudC93b3JrbG9hZC8xMA0GCSqGSIb3DQEBCwUAA4ICAQA1mSkgRclAl+E/ + aS9zJ7t8+Y4n3T24nOKKveSIjxXm/zjhWqVsLYBI6kglWtih2+PELvU8JdPqNZK3 + 4Kl0Q6FWpVSGDdWN1i6NyORt2ocggL3ke3iXxRk3UpUKJmqwz81VhA2KUHnMlyE0 + IufFfZNwNWWHBv13uJfRbjeQpKPhU+yf4DeXrsWcvrZlGvAET+mcplafUzCp7Iv+ + PcISJtUerbxbVtuHVeZCLlgDXWkLAWJN8rf0dIG4x060LJ+j6j9uRVhb9sZn1HJV + +j4XdIYm1VKilluhOtNwP2d3Ox/JuTBxf7hFHXZPfMagQE5k5PzmxRaCAEMJ1l2D + vUbZw+shJfSNoWcBo2qadnUaWT3BmmJRBDh7ZReib/RQ1Rd4ygOyzP3E0vkV4/gq + yjLdApXh5PZP8KLQZ+1JN/sdWt7VfIt9wYOpkIqujdll51ESHzwQeAK9WVCB4UvV + z6zdhItB9CRbXPreWC+wCB1xDovIzFKOVsLs5+Gqs1m7VinG2LxbDqaKyo/FB0Hx + x0acBNzezLWoDwXYQrN0T0S4pnqhKD1CYPpdArBkNezUYAjS725FkApuK+mnBX3U + 0msBffEaUEOkcyar1EW2m/33vpetD/k3eQQkmvQf4Hbiu9AF+9cNDm/hMuXEw5EX + GA91fn0891b5eEW8BJHXX0jri0aN8g=="], + "n": "", + "e": "AQAB" + } + ] + }, + "test.example.com": { + "keys": [ + { + "kty": "RSA", + "use": "x509-svid", + "x5c": ["MIIFsjCCA5qgAwIBAgIURygVMMzdr+Q7rsUaz189JozyHMwwDQYJKoZIhvcNAQEL + BQAwTjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQwwCgYDVQQHDANTVkwxDTAL + BgNVBAoMBGdSUEMxFTATBgNVBAMMDHRlc3QtY2xpZW50MTAeFw0yMTEyMjMxODQy + NTJaFw0zMTEyMjExODQyNTJaME4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEM + MAoGA1UEBwwDU1ZMMQ0wCwYDVQQKDARnUlBDMRUwEwYDVQQDDAx0ZXN0LWNsaWVu + dDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ4AqpGetyVSqGUuBJ + LVFla+7bEfca7UYzfVSSZLZ/X+JDmWIVN8UIPuFib5jhMEc3XaUnFXUmM7zEtz/Z + G5hapwLwOb2C3ZxOP6PQjYCJxbkLie+b43UQrFu1xxd3vMhVJgcj/AIxEpmszuqO + a6kUrkYifjJADQ+64kZgl66bsTdXMCzpxyFl9xUfff59L8OX+HUfAcoZz3emjg3Z + JPYURQEmjdZTOau1EjFilwHgd989Jt7NKgx30NXoHmw7nusVBIY94fL2VKN3f1XV + m0dHu5NI279Q6zr0ZBU7k5T3IeHnzsUesQS4NGlklDWoVTKk73Uv9Pna8yQsSW75 + 7PEbHOGp9Knu4bnoGPOlsG81yIPipO6hTgGFK24pF97M9kpGbWqYX4+2vLlrCAfc + msHqaUPmQlYeRVTT6vw7ctYo2kyUYGtnODXk76LqewRBVvkzx75QUhfjAyb740Yc + DmIenc56Tq6gebJHjhEmVSehR6xIpXP7SVeurTyhPsEQnpJHtgs4dcwWOZp7BvPN + zHXmJqfr7vsshie3vS5kQ0u1e1yqAqXgyDjqKXOkx+dpgUTehSJHhPNHvTc5LXRs + vvXKYz6FrwR/DZ8t7BNEvPeLjFgxpH7QVJFLCvCbXs5K6yYbsnLfxFIBPRnrbJkI + sK+sQwnRdnsiUdPsTkG5B2lQfQIDAQABo4GHMIGEMB0GA1UdDgQWBBQ2lBp0PiRH + HvQ5IRURm8aHsj4RETAfBgNVHSMEGDAWgBQ2lBp0PiRHHvQ5IRURm8aHsj4RETAP + BgNVHRMBAf8EBTADAQH/MDEGA1UdEQQqMCiGJnNwaWZmZTovL2Zvby5iYXIuY29t + L2NsaWVudC93b3JrbG9hZC8xMA0GCSqGSIb3DQEBCwUAA4ICAQA1mSkgRclAl+E/ + aS9zJ7t8+Y4n3T24nOKKveSIjxXm/zjhWqVsLYBI6kglWtih2+PELvU8JdPqNZK3 + 4Kl0Q6FWpVSGDdWN1i6NyORt2ocggL3ke3iXxRk3UpUKJmqwz81VhA2KUHnMlyE0 + IufFfZNwNWWHBv13uJfRbjeQpKPhU+yf4DeXrsWcvrZlGvAET+mcplafUzCp7Iv+ + PcISJtUerbxbVtuHVeZCLlgDXWkLAWJN8rf0dIG4x060LJ+j6j9uRVhb9sZn1HJV + +j4XdIYm1VKilluhOtNwP2d3Ox/JuTBxf7hFHXZPfMagQE5k5PzmxRaCAEMJ1l2D + vUbZw+shJfSNoWcBo2qadnUaWT3BmmJRBDh7ZReib/RQ1Rd4ygOyzP3E0vkV4/gq + yjLdApXh5PZP8KLQZ+1JN/sdWt7VfIt9wYOpkIqujdll51ESHzwQeAK9WVCB4UvV + z6zdhItB9CRbXPreWC+wCB1xDovIzFKOVsLs5+Gqs1m7VinG2LxbDqaKyo/FB0Hx + x0acBNzezLWoDwXYQrN0T0S4pnqhKD1CYPpdArBkNezUYAjS725FkApuK+mnBX3U + 0msBffEaUEOkcyar1EW2m/33vpetD/k3eQQkmvQf4Hbiu9AF+9cNDm/hMuXEw5EX + GA91fn0891b5eEW8BJHXX0jri0aN8g=="], + "n": "", + "e": "AQAB" + }, + { + "kty": "RSA", + "use": "x509-svid", + "x5c": ["MIIELTCCAxWgAwIBAgIUVXGlXjNENtOZbI12epjgIhMaShEwDQYJKoZIhvcNAQEL + BQAwVjELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM + GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEPMA0GA1UEAwwGdGVzdGNhMB4XDTI0 + MDkxNzE2MTk0NFoXDTM0MDkxNTE2MTk0NFowTjELMAkGA1UEBhMCVVMxCzAJBgNV + BAgMAkNBMQwwCgYDVQQHDANTVkwxDTALBgNVBAoMBGdSUEMxFTATBgNVBAMMDHRl + c3QtY2xpZW50MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOcTjjcS + SfG/EGrr6G+f+3T2GXyHHfroQFi9mZUz80L7uKBdECOImID+YhoK8vcxLQjPmEEv + FIYgJT5amugDcYIgUhMjBx/8RPJaP/nGmBngAqsuuNCaZfyaHBRqN8XdS/AwmsI5 + Wo+nru0+0/7aQFdqqtd2+e9dHjUWwgHxXvMgC4hkHpsdCGIZWVzWyBliwTYQYb1Y + yYe1LzqqQA5OMbZfKOY9MYDCEYOliRiunOn30iIOHj9V5qLzWGfSyxCRuvLRdEP8 + iDeNweHbdaKuI80nQmxuBdRIspE9k5sD1WA4vLZpeg3zggxp4rfLL5zBJgb/33D3 + d9Rkm14xfDPihhkCAwEAAaOB+jCB9zBZBgNVHREEUjBQhiZzcGlmZmU6Ly9mb28u + YmFyLmNvbS9jbGllbnQvd29ya2xvYWQvMYYmc3BpZmZlOi8vZm9vLmJhci5jb20v + Y2xpZW50L3dvcmtsb2FkLzIwHQYDVR0OBBYEFG9GkBgdBg/p0U9/lXv8zIJ+2c2N + MHsGA1UdIwR0MHKhWqRYMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0 + YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMM + BnRlc3RjYYIUWrP0VvHcy+LP6UuYNtiL9gBhD5owDQYJKoZIhvcNAQELBQADggEB + AJ4Cbxv+02SpUgkEu4hP/1+8DtSBXUxNxI0VG4e3Ap2+Rhjm3YiFeS/UeaZhNrrw + UEjkSTPFODyXR7wI7UO9OO1StyD6CMkp3SEvevU5JsZtGL6mTiTLTi3Qkywa91Bt + GlyZdVMghA1bBJLBMwiD5VT5noqoJBD7hDy6v9yNmt1Sw2iYBJPqI3Gnf5bMjR3s + UICaxmFyqaMCZsPkfJh0DmZpInGJys3m4QqGz6ZE2DWgcSr1r/ML7/5bSPjjr8j4 + WFFSqFR3dMu8CbGnfZTCTXa4GTX/rARXbAO67Z/oJbJBK7VKayskL+PzKuohb9ox + jGL772hQMbwtFCOFXu5VP0s="] + } + ] + } + } +} \ No newline at end of file diff --git a/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java b/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java index c60174b2faf..5d07de32df9 100644 --- a/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java +++ b/core/src/testFixtures/java/io/grpc/internal/AbstractTransportTest.java @@ -185,7 +185,7 @@ public void log(ChannelLogLevel level, String messageFormat, Object... args) {} protected final ClientStreamTracer[] tracers = new ClientStreamTracer[] { clientStreamTracer1, clientStreamTracer2 }; - private final ClientStreamTracer[] noopTracers = new ClientStreamTracer[] { + protected final ClientStreamTracer[] noopTracers = new ClientStreamTracer[] { new ClientStreamTracer() {} }; @@ -326,7 +326,8 @@ public void serverNotListening() throws Exception { runIfNotNull(client.start(mockClientTransportListener)); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportTerminated(); ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); - inOrder.verify(mockClientTransportListener).transportShutdown(statusCaptor.capture()); + inOrder.verify(mockClientTransportListener).transportShutdown(statusCaptor.capture(), + any(DisconnectError.class)); assertCodeEquals(Status.UNAVAILABLE, statusCaptor.getValue()); inOrder.verify(mockClientTransportListener).transportTerminated(); verify(mockClientTransportListener, never()).transportReady(); @@ -342,7 +343,8 @@ public void clientStartStop() throws Exception { Status shutdownReason = Status.UNAVAILABLE.withDescription("shutdown called"); client.shutdown(shutdownReason); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportTerminated(); - inOrder.verify(mockClientTransportListener).transportShutdown(same(shutdownReason)); + inOrder.verify(mockClientTransportListener).transportShutdown(same(shutdownReason), + any(DisconnectError.class)); inOrder.verify(mockClientTransportListener).transportTerminated(); verify(mockClientTransportListener, never()).transportInUse(anyBoolean()); } @@ -358,7 +360,8 @@ public void clientStartAndStopOnceConnected() throws Exception { = serverListener.takeListenerOrFail(TIMEOUT_MS, TimeUnit.MILLISECONDS); client.shutdown(Status.UNAVAILABLE); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportTerminated(); - inOrder.verify(mockClientTransportListener).transportShutdown(any(Status.class)); + inOrder.verify(mockClientTransportListener).transportShutdown(any(Status.class), + any(DisconnectError.class)); inOrder.verify(mockClientTransportListener).transportTerminated(); assertTrue(serverTransportListener.waitForTermination(TIMEOUT_MS, TimeUnit.MILLISECONDS)); server.shutdown(); @@ -454,7 +457,8 @@ public void openStreamPreventsTermination() throws Exception { serverTransport.shutdown(); serverTransport = null; - verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class), + any(DisconnectError.class)); assertTrue(serverListener.waitForShutdown(TIMEOUT_MS, TimeUnit.MILLISECONDS)); // A new server should be able to start listening, since the current server has given up @@ -504,7 +508,8 @@ public void shutdownNowKillsClientStream() throws Exception { client.shutdownNow(status); client = null; - verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class), + any(DisconnectError.class)); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportTerminated(); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportInUse(false); assertTrue(serverTransportListener.waitForTermination(TIMEOUT_MS, TimeUnit.MILLISECONDS)); @@ -543,7 +548,8 @@ public void shutdownNowKillsServerStream() throws Exception { serverTransport.shutdownNow(shutdownStatus); serverTransport = null; - verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class), + any(DisconnectError.class)); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportTerminated(); verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportInUse(false); assertTrue(serverTransportListener.waitForTermination(TIMEOUT_MS, TimeUnit.MILLISECONDS)); @@ -591,7 +597,8 @@ public void ping_duringShutdown() throws Exception { ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase(); stream.start(clientStreamListener); client.shutdown(Status.UNAVAILABLE); - verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class), + any(DisconnectError.class)); ClientTransport.PingCallback mockPingCallback = mock(ClientTransport.PingCallback.class); try { client.ping(mockPingCallback, MoreExecutors.directExecutor()); @@ -635,7 +642,8 @@ public void newStream_duringShutdown() throws Exception { ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase(); stream.start(clientStreamListener); client.shutdown(Status.UNAVAILABLE); - verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class)); + verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportShutdown(any(Status.class), + any(DisconnectError.class)); ClientStream stream2 = client.newStream( methodDescriptor, new Metadata(), callOptions, tracers); diff --git a/core/src/testFixtures/java/io/grpc/internal/ReadableBufferTestBase.java b/core/src/testFixtures/java/io/grpc/internal/ReadableBufferTestBase.java index 202fb7ee8a4..2262f0466f7 100644 --- a/core/src/testFixtures/java/io/grpc/internal/ReadableBufferTestBase.java +++ b/core/src/testFixtures/java/io/grpc/internal/ReadableBufferTestBase.java @@ -21,7 +21,6 @@ import static org.junit.Assert.assertEquals; import java.io.ByteArrayOutputStream; -import java.nio.Buffer; import java.nio.ByteBuffer; import java.util.Arrays; import org.junit.Assume; @@ -83,30 +82,6 @@ public void partialReadToStreamShouldSucceed() throws Exception { assertEquals(msg.length() - 2, buffer.readableBytes()); } - @Test - public void readToByteBufferShouldSucceed() { - ReadableBuffer buffer = buffer(); - ByteBuffer byteBuffer = ByteBuffer.allocate(msg.length()); - buffer.readBytes(byteBuffer); - ((Buffer) byteBuffer).flip(); - byte[] array = new byte[msg.length()]; - byteBuffer.get(array); - assertArrayEquals(msg.getBytes(UTF_8), array); - assertEquals(0, buffer.readableBytes()); - } - - @Test - public void partialReadToByteBufferShouldSucceed() { - ReadableBuffer buffer = buffer(); - ByteBuffer byteBuffer = ByteBuffer.allocate(2); - buffer.readBytes(byteBuffer); - ((Buffer) byteBuffer).flip(); - byte[] array = new byte[2]; - byteBuffer.get(array); - assertArrayEquals(new byte[]{'h', 'e'}, array); - assertEquals(msg.length() - 2, buffer.readableBytes()); - } - @Test public void partialReadToReadableBufferShouldSucceed() { ReadableBuffer buffer = buffer(); diff --git a/cronet/build.gradle b/cronet/build.gradle index d6d773a97e4..e096761ddd2 100644 --- a/cronet/build.gradle +++ b/cronet/build.gradle @@ -14,7 +14,7 @@ android { namespace = 'io.grpc.cronet' compileSdkVersion 33 defaultConfig { - minSdkVersion 22 + minSdkVersion 23 targetSdkVersion 33 versionCode 1 versionName "1.0" diff --git a/cronet/src/main/java/io/grpc/cronet/CronetChannelBuilder.java b/cronet/src/main/java/io/grpc/cronet/CronetChannelBuilder.java index f42dabdd55a..7ea1bc891c2 100644 --- a/cronet/src/main/java/io/grpc/cronet/CronetChannelBuilder.java +++ b/cronet/src/main/java/io/grpc/cronet/CronetChannelBuilder.java @@ -21,7 +21,6 @@ import static io.grpc.internal.GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; import android.net.Network; -import android.os.Build; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.util.concurrent.MoreExecutors; @@ -340,9 +339,7 @@ public BidirectionalStream.Builder newBidirectionalStreamBuilder( builder.setTrafficStatsUid(trafficStatsUid); } if (network != null) { - if (Build.VERSION.SDK_INT >= 23) { - builder.bindToNetwork(network.getNetworkHandle()); - } + builder.bindToNetwork(network.getNetworkHandle()); } return builder; } diff --git a/cronet/src/main/java/io/grpc/cronet/CronetClientStream.java b/cronet/src/main/java/io/grpc/cronet/CronetClientStream.java index fcba49a7ae1..07bbb953489 100644 --- a/cronet/src/main/java/io/grpc/cronet/CronetClientStream.java +++ b/cronet/src/main/java/io/grpc/cronet/CronetClientStream.java @@ -59,7 +59,6 @@ * Client stream for the cronet transport. */ class CronetClientStream extends AbstractClientStream { - private static final int READ_BUFFER_CAPACITY = 4 * 1024; private static final ByteBuffer EMPTY_BUFFER = ByteBuffer.allocateDirect(0); private static final String LOG_TAG = "grpc-java-cronet"; @@ -69,6 +68,12 @@ class CronetClientStream extends AbstractClientStream { static final CallOptions.Key> CRONET_ANNOTATIONS_KEY = CallOptions.Key.create("cronet-annotations"); + /** + * Sets the read buffer size which the GRPC layer will use to read data from Cronet. Higher buffer + * size leads to less overhead but more memory consumption. The current default value is 4KB. + */ + static final CallOptions.Key CRONET_READ_BUFFER_SIZE_KEY = + CallOptions.Key.createWithDefault("cronet-read-buffer-size", 4 * 1024); private final String url; private final String userAgent; @@ -85,6 +90,8 @@ class CronetClientStream extends AbstractClientStream { private final Collection annotations; private final TransportState state; private final Sink sink = new Sink(); + @VisibleForTesting + final int readBufferSize; private StreamBuilderFactory streamFactory; CronetClientStream( @@ -120,6 +127,7 @@ class CronetClientStream extends AbstractClientStream { this.annotations = callOptions.getOption(CRONET_ANNOTATIONS_KEY); this.state = new TransportState(maxMessageSize, statsTraceCtx, lock, transportTracer, callOptions); + this.readBufferSize = callOptions.getOption(CRONET_READ_BUFFER_SIZE_KEY); // Tests expect the "plain" deframer behavior, not MigratingDeframer // https://github.com/grpc/grpc-java/issues/7140 @@ -309,7 +317,7 @@ public void bytesRead(int processedBytes) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "BidirectionalStream.read"); } - stream.read(ByteBuffer.allocateDirect(READ_BUFFER_CAPACITY)); + stream.read(ByteBuffer.allocateDirect(readBufferSize)); } } @@ -429,7 +437,7 @@ public void onResponseHeadersReceived(BidirectionalStream stream, UrlResponseInf Log.v(LOG_TAG, "BidirectionalStream.read"); } reportHeaders(info.getAllHeadersAsList(), false); - stream.read(ByteBuffer.allocateDirect(READ_BUFFER_CAPACITY)); + stream.read(ByteBuffer.allocateDirect(readBufferSize)); } @Override diff --git a/cronet/src/main/java/io/grpc/cronet/CronetClientTransport.java b/cronet/src/main/java/io/grpc/cronet/CronetClientTransport.java index 465df8b2cc9..99eb88737aa 100644 --- a/cronet/src/main/java/io/grpc/cronet/CronetClientTransport.java +++ b/cronet/src/main/java/io/grpc/cronet/CronetClientTransport.java @@ -34,6 +34,7 @@ import io.grpc.internal.ConnectionClientTransport; import io.grpc.internal.GrpcAttributes; import io.grpc.internal.GrpcUtil; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.StatsTraceContext; import io.grpc.internal.TransportTracer; import java.net.InetSocketAddress; @@ -229,7 +230,7 @@ private void startGoAway(Status status) { startedGoAway = true; } - listener.transportShutdown(status); + listener.transportShutdown(status, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); synchronized (lock) { goAway = true; diff --git a/cronet/src/main/java/io/grpc/cronet/InternalCronetCallOptions.java b/cronet/src/main/java/io/grpc/cronet/InternalCronetCallOptions.java index e7c4144e63a..9261a0a8f4b 100644 --- a/cronet/src/main/java/io/grpc/cronet/InternalCronetCallOptions.java +++ b/cronet/src/main/java/io/grpc/cronet/InternalCronetCallOptions.java @@ -36,6 +36,18 @@ public static CallOptions withAnnotation(CallOptions callOptions, Object annotat return CronetClientStream.withAnnotation(callOptions, annotation); } + public static CallOptions withReadBufferSize(CallOptions callOptions, int size) { + return callOptions.withOption(CronetClientStream.CRONET_READ_BUFFER_SIZE_KEY, size); + } + + /** + * Returns Cronet read buffer size for gRPC included in the given {@code callOptions}. Read + * buffer can be customized via {@link #withReadBufferSize(CallOptions, int)}. + */ + public static int getReadBufferSize(CallOptions callOptions) { + return callOptions.getOption(CronetClientStream.CRONET_READ_BUFFER_SIZE_KEY); + } + /** * Returns Cronet annotations for gRPC included in the given {@code callOptions}. Annotations * are attached via {@link #withAnnotation(CallOptions, Object)}. diff --git a/cronet/src/test/java/io/grpc/cronet/CronetChannelBuilderTest.java b/cronet/src/test/java/io/grpc/cronet/CronetChannelBuilderTest.java index 41f48bc03bb..be437b3c80b 100644 --- a/cronet/src/test/java/io/grpc/cronet/CronetChannelBuilderTest.java +++ b/cronet/src/test/java/io/grpc/cronet/CronetChannelBuilderTest.java @@ -16,7 +16,9 @@ package io.grpc.cronet; +import static io.grpc.cronet.CronetClientStream.CRONET_READ_BUFFER_SIZE_KEY; import static io.grpc.internal.GrpcUtil.TIMER_SERVICE; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -92,6 +94,41 @@ public void alwaysUsePut_defaultsToFalse() throws Exception { assertFalse(stream.idempotent); } + @Test + public void channelBuilderReadBufferSize_defaultsTo4Kb() throws Exception { + CronetChannelBuilder builder = CronetChannelBuilder.forAddress("address", 1234, mockEngine); + CronetTransportFactory transportFactory = + (CronetTransportFactory) builder.buildTransportFactory(); + CronetClientTransport transport = + (CronetClientTransport) + transportFactory.newClientTransport( + new InetSocketAddress("localhost", 443), + new ClientTransportOptions(), + channelLogger); + CronetClientStream stream = transport.newStream( + method, new Metadata(), CallOptions.DEFAULT, tracers); + + assertEquals(4 * 1024, stream.readBufferSize); + } + + @Test + public void channelBuilderReadBufferSize_changeReflected() throws Exception { + CronetChannelBuilder builder = CronetChannelBuilder.forAddress("address", 1234, mockEngine); + CronetTransportFactory transportFactory = + (CronetTransportFactory) builder.buildTransportFactory(); + CronetClientTransport transport = + (CronetClientTransport) + transportFactory.newClientTransport( + new InetSocketAddress("localhost", 443), + new ClientTransportOptions(), + channelLogger); + CronetClientStream stream = transport.newStream( + method, new Metadata(), + CallOptions.DEFAULT.withOption(CRONET_READ_BUFFER_SIZE_KEY, 32 * 1024), tracers); + + assertEquals(32 * 1024, stream.readBufferSize); + } + @Test public void scheduledExecutorService_default() { CronetChannelBuilder builder = CronetChannelBuilder.forAddress("address", 1234, mockEngine); diff --git a/cronet/src/test/java/io/grpc/cronet/CronetClientTransportTest.java b/cronet/src/test/java/io/grpc/cronet/CronetClientTransportTest.java index 03c31f93329..3a79cc0b6a8 100644 --- a/cronet/src/test/java/io/grpc/cronet/CronetClientTransportTest.java +++ b/cronet/src/test/java/io/grpc/cronet/CronetClientTransportTest.java @@ -34,6 +34,7 @@ import io.grpc.Status; import io.grpc.cronet.CronetChannelBuilder.StreamBuilderFactory; import io.grpc.internal.ClientStreamListener; +import io.grpc.internal.DisconnectError; import io.grpc.internal.GrpcAttributes; import io.grpc.internal.ManagedClientTransport; import io.grpc.internal.TransportTracer; @@ -128,7 +129,8 @@ public void shutdownTransport() throws Exception { BidirectionalStream.Callback callback2 = callbackCaptor.getValue(); // Shut down the transport. transportShutdown should be called immediately. transport.shutdown(); - verify(clientTransportListener).transportShutdown(any(Status.class)); + verify(clientTransportListener).transportShutdown(any(Status.class), + any(DisconnectError.class)); // Have two live streams. Transport has not been terminated. verify(clientTransportListener, times(0)).transportTerminated(); diff --git a/examples/.bazelrc b/examples/.bazelrc index 554440cfe3d..53485cb9743 100644 --- a/examples/.bazelrc +++ b/examples/.bazelrc @@ -1 +1 @@ -build --cxxopt=-std=c++14 --host_cxxopt=-std=c++14 +build --cxxopt=-std=c++17 --host_cxxopt=-std=c++17 diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index 3a0936780a0..e3ef8c5ac5d 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -1,5 +1,8 @@ -load("@rules_proto//proto:defs.bzl", "proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("@io_grpc_grpc_java//:java_grpc_library.bzl", "java_grpc_library") +load("@rules_java//java:java_binary.bzl", "java_binary") +load("@rules_java//java:java_library.bzl", "java_library") proto_library( name = "helloworld_proto", diff --git a/examples/MODULE.bazel b/examples/MODULE.bazel index 7f639476b84..87da1b2426b 100644 --- a/examples/MODULE.bazel +++ b/examples/MODULE.bazel @@ -1,8 +1,8 @@ -bazel_dep(name = "grpc-java", repo_name = "io_grpc_grpc_java", version = "1.77.0-SNAPSHOT") # CURRENT_GRPC_VERSION -bazel_dep(name = "grpc-proto", repo_name = "io_grpc_grpc_proto", version = "0.0.0-20240627-ec30f58") -bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "23.1") +bazel_dep(name = "grpc-java", version = "1.83.0-SNAPSHOT", repo_name = "io_grpc_grpc_java") # CURRENT_GRPC_VERSION +bazel_dep(name = "rules_java", version = "9.3.0") +bazel_dep(name = "grpc-proto", version = "0.0.0-20240627-ec30f58", repo_name = "io_grpc_grpc_proto") +bazel_dep(name = "protobuf", version = "33.1", repo_name = "com_google_protobuf") bazel_dep(name = "rules_jvm_external", version = "6.0") -bazel_dep(name = "rules_proto", version = "5.3.0-21.7") # Do not use this override in your own MODULE.bazel. It is unnecessary when # using a version from BCR. Be aware the gRPC Java team does not update the @@ -20,7 +20,6 @@ local_path_override( ) maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") - use_repo(maven, "maven") maven.install( diff --git a/examples/WORKSPACE b/examples/WORKSPACE index 66a713a1a01..1387cc4cf12 100644 --- a/examples/WORKSPACE +++ b/examples/WORKSPACE @@ -14,30 +14,41 @@ local_repository( path = "..", ) +load("@io_grpc_grpc_java//:repositories.bzl", "IO_GRPC_GRPC_JAVA_ARTIFACTS", "IO_GRPC_GRPC_JAVA_OVERRIDE_TARGETS", "grpc_java_repositories") + +grpc_java_repositories() + http_archive( - name = "rules_jvm_external", - sha256 = "d31e369b854322ca5098ea12c69d7175ded971435e55c18dd9dd5f29cc5249ac", - strip_prefix = "rules_jvm_external-5.3", - url = "https://github.com/bazelbuild/rules_jvm_external/releases/download/5.3/rules_jvm_external-5.3.tar.gz", + name = "rules_java", + sha256 = "47632cc506c858011853073449801d648e10483d4b50e080ec2549a4b2398960", + urls = [ + "https://github.com/bazelbuild/rules_java/releases/download/8.15.2/rules_java-8.15.2.tar.gz", + ], ) -load("@rules_jvm_external//:defs.bzl", "maven_install") -load("@io_grpc_grpc_java//:repositories.bzl", "IO_GRPC_GRPC_JAVA_ARTIFACTS") -load("@io_grpc_grpc_java//:repositories.bzl", "IO_GRPC_GRPC_JAVA_OVERRIDE_TARGETS") -load("@io_grpc_grpc_java//:repositories.bzl", "grpc_java_repositories") +# Protobuf now requires C++14 or higher, which requires Bazel configuration +# outside the WORKSPACE. See .bazelrc in this directory. +load("@com_google_protobuf//:protobuf_deps.bzl", "PROTOBUF_MAVEN_ARTIFACTS", "protobuf_deps") -grpc_java_repositories() +protobuf_deps() + +load("@rules_java//java:rules_java_deps.bzl", "compatibility_proxy_repo", "rules_java_dependencies") + +rules_java_dependencies() + +load("@bazel_features//:deps.bzl", "bazel_features_deps") + +bazel_features_deps() + +compatibility_proxy_repo() load("@bazel_jar_jar//:jar_jar.bzl", "jar_jar_repositories") jar_jar_repositories() -# Protobuf now requires C++14 or higher, which requires Bazel configuration -# outside the WORKSPACE. See .bazelrc in this directory. -load("@com_google_protobuf//:protobuf_deps.bzl", "PROTOBUF_MAVEN_ARTIFACTS") -load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps") +load("@rules_python//python:repositories.bzl", "py_repositories") -protobuf_deps() +py_repositories() load("@com_google_googleapis//:repository_rules.bzl", "switched_rules_by_language") @@ -45,6 +56,15 @@ switched_rules_by_language( name = "com_google_googleapis_imports", ) +http_archive( + name = "rules_jvm_external", + sha256 = "d31e369b854322ca5098ea12c69d7175ded971435e55c18dd9dd5f29cc5249ac", + strip_prefix = "rules_jvm_external-5.3", + url = "https://github.com/bazelbuild/rules_jvm_external/releases/download/5.3/rules_jvm_external-5.3.tar.gz", +) + +load("@rules_jvm_external//:defs.bzl", "maven_install") + maven_install( artifacts = [ "com.google.api.grpc:grpc-google-cloud-pubsub-v1:0.1.24", diff --git a/examples/android/clientcache/app/build.gradle b/examples/android/clientcache/app/build.gradle index d9b3f50a941..6105d57c076 100644 --- a/examples/android/clientcache/app/build.gradle +++ b/examples/android/clientcache/app/build.gradle @@ -10,9 +10,8 @@ android { defaultConfig { applicationId "io.grpc.clientcacheexample" - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 33 - multiDexEnabled true versionCode 1 versionName "1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" @@ -34,7 +33,7 @@ android { protobuf { protoc { artifact = 'com.google.protobuf:protoc:3.25.1' } plugins { - grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } } generateProtoTasks { @@ -54,11 +53,11 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.0.0' // You need to build grpc-java to obtain these libraries below. - implementation 'io.grpc:grpc-okhttp:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-protobuf-lite:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-stub:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-okhttp:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-protobuf-lite:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-stub:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION testImplementation 'junit:junit:4.13.2' - testImplementation 'com.google.truth:truth:1.1.5' - testImplementation 'io.grpc:grpc-testing:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + testImplementation 'com.google.truth:truth:1.4.5' + testImplementation 'io.grpc:grpc-testing:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } diff --git a/examples/android/helloworld/app/build.gradle b/examples/android/helloworld/app/build.gradle index 81ea9235155..693ecff0c0a 100644 --- a/examples/android/helloworld/app/build.gradle +++ b/examples/android/helloworld/app/build.gradle @@ -10,7 +10,7 @@ android { defaultConfig { applicationId "io.grpc.helloworldexample" - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 33 versionCode 1 versionName "1.0" @@ -32,7 +32,7 @@ android { protobuf { protoc { artifact = 'com.google.protobuf:protoc:3.25.1' } plugins { - grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } } generateProtoTasks { @@ -52,7 +52,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.0.0' // You need to build grpc-java to obtain these libraries below. - implementation 'io.grpc:grpc-okhttp:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-protobuf-lite:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-stub:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-okhttp:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-protobuf-lite:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-stub:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } diff --git a/examples/android/routeguide/app/build.gradle b/examples/android/routeguide/app/build.gradle index a0aef26cb61..06e55de9c94 100644 --- a/examples/android/routeguide/app/build.gradle +++ b/examples/android/routeguide/app/build.gradle @@ -10,7 +10,7 @@ android { defaultConfig { applicationId "io.grpc.routeguideexample" - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 33 versionCode 1 versionName "1.0" @@ -32,7 +32,7 @@ android { protobuf { protoc { artifact = 'com.google.protobuf:protoc:3.25.1' } plugins { - grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } } generateProtoTasks { @@ -52,7 +52,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.0.0' // You need to build grpc-java to obtain these libraries below. - implementation 'io.grpc:grpc-okhttp:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-protobuf-lite:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-stub:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-okhttp:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-protobuf-lite:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-stub:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } diff --git a/examples/android/strictmode/app/build.gradle b/examples/android/strictmode/app/build.gradle index e1e6b8badc4..81d14c6205e 100644 --- a/examples/android/strictmode/app/build.gradle +++ b/examples/android/strictmode/app/build.gradle @@ -33,7 +33,7 @@ android { protobuf { protoc { artifact = 'com.google.protobuf:protoc:3.25.1' } plugins { - grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } } generateProtoTasks { @@ -53,7 +53,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.0.0' // You need to build grpc-java to obtain these libraries below. - implementation 'io.grpc:grpc-okhttp:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-protobuf-lite:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION - implementation 'io.grpc:grpc-stub:1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-okhttp:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-protobuf-lite:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION + implementation 'io.grpc:grpc-stub:1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION } diff --git a/examples/build.gradle b/examples/build.gradle index f0965338b8f..89b21ccc017 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -21,12 +21,14 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION -def protobufVersion = '3.25.8' +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def protobufVersion = '3.25.9' def protocVersion = protobufVersion dependencies { implementation "io.grpc:grpc-protobuf:${grpcVersion}" + // Even though client-side won't call grpc-services directly, it needs the + // dependency to enable the health-aware round_robin implementation implementation "io.grpc:grpc-services:${grpcVersion}" implementation "io.grpc:grpc-stub:${grpcVersion}" @@ -51,6 +53,15 @@ protobuf { } } +// gRPC uses java.util.ServiceLoader, which reads class names from +// META-INF/services in jars. If you package your application as a "fat" jar +// that includes dependencies, you need to make sure the packaging tool +// concatenates duplicate files in META-INF/services. +// +// For the Shadow Gradle Plugin, use call mergeServiceFiles() within the +// shadowJar task. +// https://gradleup.com/shadow/configuration/merging/#merging-service-descriptor-files + startScripts.enabled = false // Creates start scripts for a class name and adds it to the distribution. The diff --git a/examples/example-alts/BUILD.bazel b/examples/example-alts/BUILD.bazel index 0404dcccf81..4d66accfc19 100644 --- a/examples/example-alts/BUILD.bazel +++ b/examples/example-alts/BUILD.bazel @@ -1,5 +1,8 @@ -load("@rules_proto//proto:defs.bzl", "proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("@io_grpc_grpc_java//:java_grpc_library.bzl", "java_grpc_library") +load("@rules_java//java:java_binary.bzl", "java_binary") +load("@rules_java//java:java_library.bzl", "java_library") proto_library( name = "helloworld_proto", diff --git a/examples/example-alts/build.gradle b/examples/example-alts/build.gradle index 970e287f609..fb6cc62d886 100644 --- a/examples/example-alts/build.gradle +++ b/examples/example-alts/build.gradle @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/example-debug/build.gradle b/examples/example-debug/build.gradle index b9baf5f3817..061f4ba9030 100644 --- a/examples/example-debug/build.gradle +++ b/examples/example-debug/build.gradle @@ -23,7 +23,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protobufVersion = '3.25.8' dependencies { diff --git a/examples/example-debug/pom.xml b/examples/example-debug/pom.xml index 4bb8b33f9ab..57cb177ba0c 100644 --- a/examples/example-debug/pom.xml +++ b/examples/example-debug/pom.xml @@ -6,13 +6,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-debug https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 1.8 diff --git a/examples/example-dualstack/build.gradle b/examples/example-dualstack/build.gradle index 8c32559a6dc..55b80a3219b 100644 --- a/examples/example-dualstack/build.gradle +++ b/examples/example-dualstack/build.gradle @@ -23,7 +23,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protobufVersion = '3.25.8' dependencies { diff --git a/examples/example-dualstack/pom.xml b/examples/example-dualstack/pom.xml index 3561254ed0c..1f94d70f513 100644 --- a/examples/example-dualstack/pom.xml +++ b/examples/example-dualstack/pom.xml @@ -6,13 +6,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-dualstack https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 1.8 diff --git a/examples/example-gauth/BUILD.bazel b/examples/example-gauth/BUILD.bazel index edc4a291e27..033c51f8856 100644 --- a/examples/example-gauth/BUILD.bazel +++ b/examples/example-gauth/BUILD.bazel @@ -1,4 +1,5 @@ -load("@io_grpc_grpc_java//:java_grpc_library.bzl", "java_grpc_library") +load("@rules_java//java:java_binary.bzl", "java_binary") +load("@rules_java//java:java_library.bzl", "java_library") java_library( name = "example-gauth", diff --git a/examples/example-gauth/build.gradle b/examples/example-gauth/build.gradle index 3c3978603c7..dec3b3e5d40 100644 --- a/examples/example-gauth/build.gradle +++ b/examples/example-gauth/build.gradle @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protobufVersion = '3.25.8' def protocVersion = protobufVersion @@ -30,7 +30,7 @@ dependencies { implementation "io.grpc:grpc-protobuf:${grpcVersion}" implementation "io.grpc:grpc-stub:${grpcVersion}" implementation "io.grpc:grpc-auth:${grpcVersion}" - implementation "com.google.auth:google-auth-library-oauth2-http:1.23.0" + implementation "com.google.auth:google-auth-library-oauth2-http:1.42.1" implementation "com.google.api.grpc:grpc-google-cloud-pubsub-v1:0.1.24" runtimeOnly "io.grpc:grpc-netty-shaded:${grpcVersion}" } diff --git a/examples/example-gauth/pom.xml b/examples/example-gauth/pom.xml index 70d7ea56a5a..28b6f3bb35a 100644 --- a/examples/example-gauth/pom.xml +++ b/examples/example-gauth/pom.xml @@ -6,13 +6,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-gauth https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 1.8 @@ -28,6 +28,11 @@ pom import + + com.google.code.gson + gson + 2.14.0 + @@ -57,7 +62,7 @@ com.google.auth google-auth-library-oauth2-http - 1.23.0 + 1.40.0 com.google.api.grpc diff --git a/examples/example-gcp-csm-observability/build.gradle b/examples/example-gcp-csm-observability/build.gradle index a1e767d02df..a1f08b00162 100644 --- a/examples/example-gcp-csm-observability/build.gradle +++ b/examples/example-gcp-csm-observability/build.gradle @@ -22,10 +22,10 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' -def openTelemetryVersion = '1.52.0' -def openTelemetryPrometheusVersion = '1.52.0-alpha' +def openTelemetryVersion = '1.63.0' +def openTelemetryPrometheusVersion = '1.63.0-alpha' dependencies { implementation "io.grpc:grpc-protobuf:${grpcVersion}" diff --git a/examples/example-gcp-observability/build.gradle b/examples/example-gcp-observability/build.gradle index 605c93ea75f..6298a4ed8f6 100644 --- a/examples/example-gcp-observability/build.gradle +++ b/examples/example-gcp-observability/build.gradle @@ -22,7 +22,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/example-hostname/BUILD.bazel b/examples/example-hostname/BUILD.bazel index 8b76f790983..d5bd3aba94c 100644 --- a/examples/example-hostname/BUILD.bazel +++ b/examples/example-hostname/BUILD.bazel @@ -1,5 +1,8 @@ -load("@rules_proto//proto:defs.bzl", "proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("@io_grpc_grpc_java//:java_grpc_library.bzl", "java_grpc_library") +load("@rules_java//java:java_binary.bzl", "java_binary") +load("@rules_java//java:java_library.bzl", "java_library") proto_library( name = "helloworld_proto", diff --git a/examples/example-hostname/build.gradle b/examples/example-hostname/build.gradle index 19fe0816580..136cbcc2f9c 100644 --- a/examples/example-hostname/build.gradle +++ b/examples/example-hostname/build.gradle @@ -3,7 +3,7 @@ plugins { id 'java' id "com.google.protobuf" version "0.9.5" - id 'com.google.cloud.tools.jib' version '3.4.4' // For releasing to Docker Hub + id 'com.google.cloud.tools.jib' version '3.5.3' // For releasing to Docker Hub } repositories { @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protobufVersion = '3.25.8' dependencies { diff --git a/examples/example-hostname/pom.xml b/examples/example-hostname/pom.xml index 3d9225d72da..d7a38fb2277 100644 --- a/examples/example-hostname/pom.xml +++ b/examples/example-hostname/pom.xml @@ -6,13 +6,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-hostname https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 1.8 diff --git a/examples/example-jwt-auth/build.gradle b/examples/example-jwt-auth/build.gradle index e1509f08b79..7ce0e9e538a 100644 --- a/examples/example-jwt-auth/build.gradle +++ b/examples/example-jwt-auth/build.gradle @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protobufVersion = '3.25.8' def protocVersion = protobufVersion diff --git a/examples/example-jwt-auth/pom.xml b/examples/example-jwt-auth/pom.xml index 2a097386637..be25bf6259b 100644 --- a/examples/example-jwt-auth/pom.xml +++ b/examples/example-jwt-auth/pom.xml @@ -7,13 +7,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-jwt-auth https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 3.25.8 diff --git a/examples/example-oauth/build.gradle b/examples/example-oauth/build.gradle index 38f105ec37f..24b604fa8c3 100644 --- a/examples/example-oauth/build.gradle +++ b/examples/example-oauth/build.gradle @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protobufVersion = '3.25.8' def protocVersion = protobufVersion @@ -29,7 +29,7 @@ dependencies { implementation "io.grpc:grpc-protobuf:${grpcVersion}" implementation "io.grpc:grpc-stub:${grpcVersion}" implementation "io.grpc:grpc-auth:${grpcVersion}" - implementation "com.google.auth:google-auth-library-oauth2-http:1.23.0" + implementation "com.google.auth:google-auth-library-oauth2-http:1.42.1" runtimeOnly "io.grpc:grpc-netty-shaded:${grpcVersion}" diff --git a/examples/example-oauth/pom.xml b/examples/example-oauth/pom.xml index 30c0608c24a..1168103d18e 100644 --- a/examples/example-oauth/pom.xml +++ b/examples/example-oauth/pom.xml @@ -7,13 +7,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-oauth https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 3.25.8 @@ -30,6 +30,11 @@ pom import + + com.google.code.gson + gson + 2.14.0 + @@ -50,17 +55,11 @@ io.grpc grpc-auth - - - com.google.auth - google-auth-library-credentials - - com.google.auth google-auth-library-oauth2-http - 1.23.0 + 1.40.0 io.grpc diff --git a/examples/example-opentelemetry/build.gradle b/examples/example-opentelemetry/build.gradle index 46591b42c80..9bcf65d209b 100644 --- a/examples/example-opentelemetry/build.gradle +++ b/examples/example-opentelemetry/build.gradle @@ -21,10 +21,10 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' -def openTelemetryVersion = '1.52.0' -def openTelemetryPrometheusVersion = '1.52.0-alpha' +def openTelemetryVersion = '1.63.0' +def openTelemetryPrometheusVersion = '1.63.0-alpha' dependencies { implementation "io.grpc:grpc-protobuf:${grpcVersion}" diff --git a/examples/example-orca/build.gradle b/examples/example-orca/build.gradle index 3e3d141b5a1..58cd6824cb3 100644 --- a/examples/example-orca/build.gradle +++ b/examples/example-orca/build.gradle @@ -16,7 +16,7 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/example-reflection/build.gradle b/examples/example-reflection/build.gradle index 9c49388a946..c46fdcac4d8 100644 --- a/examples/example-reflection/build.gradle +++ b/examples/example-reflection/build.gradle @@ -16,7 +16,7 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/example-servlet/build.gradle b/examples/example-servlet/build.gradle index ee891209000..7970cf2939d 100644 --- a/examples/example-servlet/build.gradle +++ b/examples/example-servlet/build.gradle @@ -15,7 +15,7 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/example-tls/BUILD.bazel b/examples/example-tls/BUILD.bazel index 81913836766..cb46ef5bb30 100644 --- a/examples/example-tls/BUILD.bazel +++ b/examples/example-tls/BUILD.bazel @@ -1,5 +1,8 @@ -load("@rules_proto//proto:defs.bzl", "proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("@io_grpc_grpc_java//:java_grpc_library.bzl", "java_grpc_library") +load("@rules_java//java:java_binary.bzl", "java_binary") +load("@rules_java//java:java_library.bzl", "java_library") proto_library( name = "helloworld_proto", diff --git a/examples/example-tls/build.gradle b/examples/example-tls/build.gradle index e55a7ce7f33..f9cb4511b9a 100644 --- a/examples/example-tls/build.gradle +++ b/examples/example-tls/build.gradle @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/example-tls/pom.xml b/examples/example-tls/pom.xml index 202ad17d896..7437ae0e5b0 100644 --- a/examples/example-tls/pom.xml +++ b/examples/example-tls/pom.xml @@ -6,13 +6,13 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT example-tls https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT 3.25.8 1.8 diff --git a/examples/example-xds/build.gradle b/examples/example-xds/build.gradle index bd040511b3e..ae6d55d95af 100644 --- a/examples/example-xds/build.gradle +++ b/examples/example-xds/build.gradle @@ -21,7 +21,7 @@ java { // Feel free to delete the comment at the next line. It is just for safely // updating the version in our release process. -def grpcVersion = '1.77.0-SNAPSHOT' // CURRENT_GRPC_VERSION +def grpcVersion = '1.83.0-SNAPSHOT' // CURRENT_GRPC_VERSION def protocVersion = '3.25.8' dependencies { diff --git a/examples/maven-assembly-jar-with-dependencies.xml b/examples/maven-assembly-jar-with-dependencies.xml new file mode 100644 index 00000000000..6c8abbfe7e8 --- /dev/null +++ b/examples/maven-assembly-jar-with-dependencies.xml @@ -0,0 +1,27 @@ + + + jar-with-dependencies + + jar + + false + + + / + true + true + runtime + + + + + metaInf-services + + + diff --git a/examples/pom.xml b/examples/pom.xml index 2a6817a56a0..bf4e34068a1 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -6,15 +6,15 @@ jar - 1.77.0-SNAPSHOT + 1.83.0-SNAPSHOT examples https://github.com/grpc/grpc-java UTF-8 - 1.77.0-SNAPSHOT - 3.25.8 - 3.25.8 + 1.83.0-SNAPSHOT + 3.25.9 + 3.25.9 1.8 1.8 @@ -58,7 +58,7 @@ com.google.j2objc j2objc-annotations - 3.0.0 + 3.1 io.grpc @@ -124,6 +124,35 @@ + + + + + + + + maven-assembly-plugin + 3.7.1 + + ${project.basedir}/maven-assembly-jar-with-dependencies.xml + + + + make-assembly + package + + single + + + + diff --git a/examples/src/main/java/io/grpc/examples/customloadbalance/ShufflingPickFirstLoadBalancer.java b/examples/src/main/java/io/grpc/examples/customloadbalance/ShufflingPickFirstLoadBalancer.java index b49c856c4d0..4715b551524 100644 --- a/examples/src/main/java/io/grpc/examples/customloadbalance/ShufflingPickFirstLoadBalancer.java +++ b/examples/src/main/java/io/grpc/examples/customloadbalance/ShufflingPickFirstLoadBalancer.java @@ -92,7 +92,7 @@ public void onSubchannelState(ConnectivityStateInfo stateInfo) { }); this.subchannel = subchannel; - helper.updateBalancingState(CONNECTING, new Picker(PickResult.withNoResult())); + helper.updateBalancingState(CONNECTING, new FixedResultPicker(PickResult.withNoResult())); subchannel.requestConnection(); } else { subchannel.updateAddresses(servers); @@ -107,7 +107,8 @@ public void handleNameResolutionError(Status error) { subchannel.shutdown(); subchannel = null; } - helper.updateBalancingState(TRANSIENT_FAILURE, new Picker(PickResult.withError(error))); + helper.updateBalancingState( + TRANSIENT_FAILURE, new FixedResultPicker(PickResult.withError(error))); } private void processSubchannelState(Subchannel subchannel, ConnectivityStateInfo stateInfo) { @@ -125,13 +126,13 @@ private void processSubchannelState(Subchannel subchannel, ConnectivityStateInfo picker = new RequestConnectionPicker(); break; case CONNECTING: - picker = new Picker(PickResult.withNoResult()); + picker = new FixedResultPicker(PickResult.withNoResult()); break; case READY: - picker = new Picker(PickResult.withSubchannel(subchannel)); + picker = new FixedResultPicker(PickResult.withSubchannel(subchannel)); break; case TRANSIENT_FAILURE: - picker = new Picker(PickResult.withError(stateInfo.getStatus())); + picker = new FixedResultPicker(PickResult.withError(stateInfo.getStatus())); break; default: throw new IllegalArgumentException("Unsupported state:" + currentState); @@ -154,29 +155,6 @@ public void requestConnection() { } } - /** - * No-op picker which doesn't add any custom picking logic. It just passes already known result - * received in constructor. - */ - private static final class Picker extends SubchannelPicker { - - private final PickResult result; - - Picker(PickResult result) { - this.result = checkNotNull(result, "result"); - } - - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return result; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(Picker.class).add("result", result).toString(); - } - } - /** * Picker that requests connection during the first pick, and returns noResult. */ diff --git a/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceClient.java b/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceClient.java index 471084feab6..a7963630965 100644 --- a/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceClient.java +++ b/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceClient.java @@ -32,6 +32,7 @@ import io.grpc.health.v1.HealthCheckResponse.ServingStatus; import io.grpc.health.v1.HealthGrpc; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -45,25 +46,17 @@ public class HealthServiceClient { private static final Logger logger = Logger.getLogger(HealthServiceClient.class.getName()); private final GreeterGrpc.GreeterBlockingStub greeterBlockingStub; - private final HealthGrpc.HealthStub healthStub; private final HealthGrpc.HealthBlockingStub healthBlockingStub; - private final HealthCheckRequest healthRequest; - /** Construct client for accessing HelloWorld server using the existing channel. */ public HealthServiceClient(Channel channel) { greeterBlockingStub = GreeterGrpc.newBlockingStub(channel); - healthStub = HealthGrpc.newStub(channel); healthBlockingStub = HealthGrpc.newBlockingStub(channel); - healthRequest = HealthCheckRequest.getDefaultInstance(); - LoadBalancerProvider roundRobin = LoadBalancerRegistry.getDefaultRegistry() - .getProvider("round_robin"); - } private ServingStatus checkHealth(String prefix) { HealthCheckResponse response = - healthBlockingStub.check(healthRequest); + healthBlockingStub.check(HealthCheckRequest.getDefaultInstance()); logger.info(prefix + ", current health is: " + response.getStatus()); return response.getStatus(); } @@ -86,34 +79,35 @@ public void greet(String name) { } - private static void runTest(String target, String[] users, boolean useRoundRobin) + private static void runTest(String target, String[] users, boolean enableHealthChecking) throws InterruptedException { - ManagedChannelBuilder builder = - Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()); - - // Round Robin, when a healthCheckConfig is present in the default service configuration, runs - // a watch on the health service and when picking an endpoint will - // consider a transport to a server whose service is not in SERVING state to be unavailable. - // Since we only have a single server we are connecting to, then the load balancer will - // return an error without sending the RPC. - if (useRoundRobin) { - builder = builder - .defaultLoadBalancingPolicy("round_robin") - .defaultServiceConfig(generateHealthConfig("")); + String healthServiceName; + if (enableHealthChecking) { + healthServiceName = ""; // requests the backend's "overall health status" + } else { + healthServiceName = null; // disables health checking in generateServiceConfig() } + ManagedChannel channel = + Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()) + // Enable the round_robin load balancer, with or without health checking + .defaultServiceConfig(generateServiceConfig(healthServiceName)) + .build(); - ManagedChannel channel = builder.build(); + // Round Robin, when a healthCheckConfig is present in the service configuration, runs a watch + // on the health service and when picking an endpoint will consider a transport to a server + // whose service is not in SERVING state to be unavailable. Since we only have a single server + // we are connecting to, then the load balancer will return an error without sending the RPC. - System.out.println("\nDoing test with" + (useRoundRobin ? "" : "out") - + " the Round Robin load balancer\n"); + System.out.println("\nDoing test with" + (enableHealthChecking ? "" : "out") + + " health checking\n"); try { HealthServiceClient client = new HealthServiceClient(channel); - if (!useRoundRobin) { + if (!enableHealthChecking) { client.checkHealth("Before call"); } client.greet(users[0]); - if (!useRoundRobin) { + if (!enableHealthChecking) { client.checkHealth("After user " + users[0]); } @@ -122,7 +116,7 @@ private static void runTest(String target, String[] users, boolean useRoundRobin Thread.sleep(100); // Since the health update is asynchronous give it time to propagate } - if (!useRoundRobin) { + if (!enableHealthChecking) { client.checkHealth("After all users"); Thread.sleep(10000); client.checkHealth("After 10 second wait"); @@ -137,12 +131,17 @@ private static void runTest(String target, String[] users, boolean useRoundRobin channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); } } - private static Map generateHealthConfig(String serviceName) { + private static Map generateServiceConfig(String healthServiceName) { Map config = new HashMap<>(); - Map serviceMap = new HashMap<>(); - - config.put("healthCheckConfig", serviceMap); - serviceMap.put("serviceName", serviceName); + if (healthServiceName != null) { + config.put("healthCheckConfig", Collections.singletonMap("serviceName", healthServiceName)); + } + // There is more than one round_robin implementation. If the client doesn't depend on + // io.grpc:grpc-services, then the round_robin implementation does not support health watching + // (to avoid a Protobuf dependency). When the client depends on grpc-services the + // health-supporting round_robin implementation is used instead. + config.put("loadBalancingConfig", Arrays.asList( + Collections.singletonMap("round_robin", Collections.emptyMap()))); return config; } diff --git a/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceServer.java b/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceServer.java index f6547c11103..2170c9d3e08 100644 --- a/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceServer.java +++ b/examples/src/main/java/io/grpc/examples/healthservice/HealthServiceServer.java @@ -94,7 +94,7 @@ public static void main(String[] args) throws IOException, InterruptedException } private class GreeterImpl extends GreeterGrpc.GreeterImplBase { - boolean isServing = true; + private volatile boolean isServing = true; @Override public void sayHello(HelloRequest req, StreamObserver responseObserver) { @@ -134,7 +134,7 @@ public void run() { } private boolean isNameLongEnough(HelloRequest req) { - return isServing && req.getName().length() >= 5; + return req.getName().length() >= 5; } } } diff --git a/examples/src/main/java/io/grpc/examples/healthservice/README.md b/examples/src/main/java/io/grpc/examples/healthservice/README.md index 181bd70977f..9b17f96a624 100644 --- a/examples/src/main/java/io/grpc/examples/healthservice/README.md +++ b/examples/src/main/java/io/grpc/examples/healthservice/README.md @@ -1,10 +1,13 @@ gRPC Health Service Example ===================== -The Health Service example provides a HelloWorld gRPC server that doesn't like short names along with a -health service. It also provides a client application which makes HelloWorld -calls and checks the health status. +The Health Service example provides a HelloWorld gRPC server that doesn't like +short names along with a health service. It also provides a client application +which makes HelloWorld calls and checks the health status. The client application also shows how the round robin load balancer can utilize the health status to avoid making calls to a service that is not actively serving. + +Note that clients must depend on `io.grpc:grpc-services` for the health-aware +round_robin implementation to be used. diff --git a/examples/src/main/java/io/grpc/examples/manualflowcontrol/README.md b/examples/src/main/java/io/grpc/examples/manualflowcontrol/README.md index a30688cea15..f700d428aca 100644 --- a/examples/src/main/java/io/grpc/examples/manualflowcontrol/README.md +++ b/examples/src/main/java/io/grpc/examples/manualflowcontrol/README.md @@ -1,5 +1,5 @@ -gRPC Manual Flow Control Example -===================== +# gRPC Manual Flow Control Example + Flow control is relevant for streaming RPC calls. By default, gRPC will handle dealing with flow control. However, for specific @@ -25,14 +25,13 @@ value. ### Outgoing Flow Control -The underlying layer (such as Netty) will make the write wait when there is no -space to write the next message. This causes the request stream to go into -a not ready state and the outgoing onNext method invocation waits. You can -explicitly check that the stream is ready for writing before calling onNext to -avoid blocking. This is done with `CallStreamObserver.isReady()`. You can -utilize this to start doing reads, which may allow -the other side of the channel to complete a write and then to do its own reads, -thereby avoiding deadlock. +The underlying layer (such as Netty) manages a buffer for outgoing messages. If +you write messages faster than they can be sent over the network, this buffer +will grow, which can eventually lead to an OutOfMemoryError. The outgoing onNext +method invocation does not block when this happens. Therefore, you should +explicitly check that the stream is ready for writing via +`CallStreamObserver.isReady()` before generating messages to avoid buffering +excessive amounts of data in memory. ### Incoming Manual Flow Control @@ -71,6 +70,7 @@ When you are ready to begin processing the next value from the stream call `serverCallStreamObserver.request(1)` ### Related documents + Also see [gRPC Flow Control Users Guide][user guide] - [user guide]: https://grpc.io/docs/guides/flow-control \ No newline at end of file +[user guide]: https://grpc.io/docs/guides/flow-control diff --git a/gae-interop-testing/gae-jdk8/build.gradle b/gae-interop-testing/gae-jdk8/build.gradle index 14abbc05a9b..07033f403de 100644 --- a/gae-interop-testing/gae-jdk8/build.gradle +++ b/gae-interop-testing/gae-jdk8/build.gradle @@ -58,6 +58,7 @@ def createDefaultVersion() { return new java.text.SimpleDateFormat("yyyyMMdd't'HHmmss").format(new Date()) } +def nonShadowedProject = project // [START model] appengine { // App Engine tasks configuration @@ -67,13 +68,13 @@ appengine { deploy { // deploy configuration - projectId = 'GCLOUD_CONFIG' + projectId = nonShadowedProject.findProperty('gaeProjectId') ?: 'GCLOUD_CONFIG' // default - stop the current version - stopPreviousVersion = System.getProperty('gaeStopPreviousVersion') ?: true + stopPreviousVersion = nonShadowedProject.findProperty('gaeStopPreviousVersion') ?: true // default - do not make this the promoted version - promote = System.getProperty('gaePromote') ?: false - // Use -DgaeDeployVersion if set, otherwise the version is null and the plugin will generate it - version = System.getProperty('gaeDeployVersion', createDefaultVersion()) + promote = nonShadowedProject.findProperty('gaePromote') ?: false + // Use -PgaeDeployVersion if set, otherwise the version is null and the plugin will generate it + version = nonShadowedProject.findProperty('gaeDeployVersion') ?: createDefaultVersion() } } // [END model] @@ -83,6 +84,10 @@ version = '1.0-SNAPSHOT' // Version in generated output /** Returns the service name. */ String getGaeProject() { + def configuredProjectId = appengine.deploy.projectId + if (!"GCLOUD_CONFIG".equals(configuredProjectId)) { + return configuredProjectId + } def stream = new ByteArrayOutputStream() exec { executable 'gcloud' @@ -110,11 +115,8 @@ String getAppUrl(String project, String service, String version) { } tasks.register("runInteropTestRemote") { - dependsOn appengineDeploy + mustRunAfter appengineDeploy doLast { - // give remote app some time to settle down - sleep(20000) - def appUrl = getAppUrl( getGaeProject(), getService(project.getProjectDir().toPath()), diff --git a/gcp-observability/build.gradle b/gcp-observability/build.gradle index c6d6fa28ddc..1d8c7a9f961 100644 --- a/gcp-observability/build.gradle +++ b/gcp-observability/build.gradle @@ -59,7 +59,6 @@ dependencies { project(path: ':grpc-alts', configuration: 'shadow'), project(':grpc-auth'), // Align grpc versions project(':grpc-core'), // Align grpc versions - project(':grpc-grpclb'), // Align grpc versions project(':grpc-services'), // Align grpc versions libraries.animalsniffer.annotations, // Use our newer version libraries.auto.value.annotations, // Use our newer version diff --git a/googleapis/BUILD.bazel b/googleapis/BUILD.bazel index 42632e0f99d..5b62b21cb3a 100644 --- a/googleapis/BUILD.bazel +++ b/googleapis/BUILD.bazel @@ -13,5 +13,6 @@ java_library( "//core:internal", "//xds", artifact("com.google.guava:guava"), + artifact("com.google.errorprone:error_prone_annotations"), ], ) diff --git a/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdExperimentalNameResolverProvider.java b/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdExperimentalNameResolverProvider.java index 349e1c94380..db674aeb2ee 100644 --- a/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdExperimentalNameResolverProvider.java +++ b/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdExperimentalNameResolverProvider.java @@ -20,6 +20,7 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.Args; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import java.net.URI; /** @@ -35,6 +36,11 @@ public NameResolver newNameResolver(URI targetUri, Args args) { return delegate.newNameResolver(targetUri, args); } + @Override + public NameResolver newNameResolver(Uri targetUri, Args args) { + return delegate.newNameResolver(targetUri, args); + } + @Override public String getDefaultScheme() { return delegate.getDefaultScheme(); diff --git a/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolver.java b/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolver.java index ebc7dd05ea4..488eaa8c75f 100644 --- a/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolver.java +++ b/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolver.java @@ -20,28 +20,37 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; -import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.io.CharStreams; +import com.google.errorprone.annotations.concurrent.GuardedBy; +import io.grpc.MetricRecorder; import io.grpc.NameResolver; import io.grpc.NameResolverRegistry; +import io.grpc.QueryParams; import io.grpc.Status; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.alts.InternalCheckGcpEnvironment; import io.grpc.internal.GrpcUtil; import io.grpc.internal.SharedResourceHolder; import io.grpc.internal.SharedResourceHolder.Resource; +import io.grpc.xds.InternalGrpcBootstrapperImpl; +import io.grpc.xds.InternalSharedXdsClientPoolProvider; +import io.grpc.xds.InternalSharedXdsClientPoolProvider.XdsClientResult; +import io.grpc.xds.XdsNameResolverProvider; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.XdsClient; +import io.grpc.xds.client.XdsInitializationException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.net.HttpURLConnection; import java.net.URI; -import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.Map; +import java.util.List; import java.util.Random; import java.util.concurrent.Executor; import java.util.logging.Level; @@ -63,53 +72,70 @@ final class GoogleCloudToProdNameResolver extends NameResolver { static final String C2P_AUTHORITY = "traffic-director-c2p.xds.googleapis.com"; @VisibleForTesting static boolean isOnGcp = InternalCheckGcpEnvironment.isOnGcp(); - @VisibleForTesting - static boolean xdsBootstrapProvided = - System.getenv("GRPC_XDS_BOOTSTRAP") != null - || System.getProperty("io.grpc.xds.bootstrap") != null - || System.getenv("GRPC_XDS_BOOTSTRAP_CONFIG") != null - || System.getProperty("io.grpc.xds.bootstrapConfig") != null; - @VisibleForTesting - static boolean enableFederation = - Strings.isNullOrEmpty(System.getenv("GRPC_EXPERIMENTAL_XDS_FEDERATION")) - || Boolean.parseBoolean(System.getenv("GRPC_EXPERIMENTAL_XDS_FEDERATION")); private static final String serverUriOverride = System.getenv("GRPC_TEST_ONLY_GOOGLE_C2P_RESOLVER_TRAFFIC_DIRECTOR_URI"); - private HttpConnectionProvider httpConnectionProvider = HttpConnectionFactory.INSTANCE; + @GuardedBy("GoogleCloudToProdNameResolver.class") + private static BootstrapInfo bootstrapInfo; + private static HttpConnectionProvider httpConnectionProvider = HttpConnectionFactory.INSTANCE; + private static int c2pId = new Random().nextInt(); + + private static synchronized BootstrapInfo getBootstrapInfo(boolean isForcedXds) + throws XdsInitializationException, IOException { + if (bootstrapInfo != null) { + return bootstrapInfo; + } + BootstrapInfo newInfo; + if (isForcedXds) { + newInfo = InternalGrpcBootstrapperImpl.parseBootstrap( + generateBootstrap("", true)); + } else { + newInfo = InternalGrpcBootstrapperImpl.parseBootstrap( + generateBootstrap( + queryZoneMetadata(METADATA_URL_ZONE), + queryIpv6SupportMetadata(METADATA_URL_SUPPORT_IPV6))); + } + // Avoid setting global when testing + if (httpConnectionProvider == HttpConnectionFactory.INSTANCE) { + bootstrapInfo = newInfo; + } + return newInfo; + } + private final String authority; private final SynchronizationContext syncContext; private final Resource executorResource; - private final BootstrapSetter bootstrapSetter; + private final String target; + private final MetricRecorder metricRecorder; private final NameResolver delegate; - private final Random rand; private final boolean usingExecutorResource; - // It's not possible to use both PSM and DirectPath C2P in the same application. - // Delegate to DNS if user-provided bootstrap is found. - private final String schemeOverride = - !isOnGcp - || (xdsBootstrapProvided && !enableFederation) - ? "dns" : "xds"; + private final boolean forceXds; + private final String schemeOverride; + private XdsClientResult xdsClientPool; + private XdsClient xdsClient; private Executor executor; private Listener2 listener; private boolean succeeded; private boolean resolving; private boolean shutdown; - GoogleCloudToProdNameResolver(URI targetUri, Args args, Resource executorResource, - BootstrapSetter bootstrapSetter) { - this(targetUri, args, executorResource, new Random(), bootstrapSetter, + GoogleCloudToProdNameResolver(URI targetUri, Args args, Resource executorResource) { + this(targetUri, args, executorResource, NameResolverRegistry.getDefaultRegistry().asFactory()); } + // TODO(jdcormie): Remove after io.grpc.Uri migration. @VisibleForTesting GoogleCloudToProdNameResolver(URI targetUri, Args args, Resource executorResource, - Random rand, BootstrapSetter bootstrapSetter, NameResolver.Factory nameResolverFactory) { + NameResolver.Factory nameResolverFactory) { this.executorResource = checkNotNull(executorResource, "executorResource"); - this.bootstrapSetter = checkNotNull(bootstrapSetter, "bootstrapSetter"); - this.rand = checkNotNull(rand, "rand"); String targetPath = checkNotNull(checkNotNull(targetUri, "targetUri").getPath(), "targetPath"); + Uri grpcUri = Uri.create(targetUri.toString()); + QueryParams queryParams = QueryParams.fromRawQuery(grpcUri.getRawQuery()); + this.forceXds = checkForceXds(queryParams); + this.schemeOverride = (forceXds || isOnGcp) ? "xds" : "dns"; + Preconditions.checkArgument( targetPath.startsWith("/"), "the path component (%s) of the target (%s) must start with '/'", @@ -117,16 +143,72 @@ final class GoogleCloudToProdNameResolver extends NameResolver { targetUri); authority = GrpcUtil.checkAuthority(targetPath.substring(1)); syncContext = checkNotNull(args, "args").getSynchronizationContext(); - targetUri = overrideUriScheme(targetUri, schemeOverride); - if (schemeOverride.equals("xds") && enableFederation) { - targetUri = overrideUriAuthority(targetUri, C2P_AUTHORITY); + + Uri.Builder modifiedTargetBuilder = grpcUri.toBuilder().setScheme(schemeOverride); + modifiedTargetBuilder.setRawQuery(null); + if (schemeOverride.equals("xds")) { + modifiedTargetBuilder.setRawAuthority(C2P_AUTHORITY); } + targetUri = URI.create(modifiedTargetBuilder.build().toString()); + + if (schemeOverride.equals("xds")) { + args = args.toBuilder() + .setArg(XdsNameResolverProvider.XDS_CLIENT_SUPPLIER, () -> xdsClient) + .build(); + } + target = targetUri.toString(); + metricRecorder = args.getMetricRecorder(); delegate = checkNotNull(nameResolverFactory, "nameResolverFactory").newNameResolver( targetUri, args); executor = args.getOffloadExecutor(); usingExecutorResource = executor == null; } + GoogleCloudToProdNameResolver(Uri targetUri, Args args, Resource executorResource) { + this(targetUri, args, executorResource, NameResolverRegistry.getDefaultRegistry().asFactory()); + } + + @VisibleForTesting + GoogleCloudToProdNameResolver( + Uri targetUri, + Args args, + Resource executorResource, + NameResolver.Factory nameResolverFactory) { + this.executorResource = checkNotNull(executorResource, "executorResource"); + QueryParams queryParams = QueryParams.fromRawQuery(targetUri.getRawQuery()); + this.forceXds = checkForceXds(queryParams); + this.schemeOverride = (forceXds || isOnGcp) ? "xds" : "dns"; + + Preconditions.checkArgument( + targetUri.isPathAbsolute(), + "the path component of the target (%s) must start with '/'", + targetUri); + List pathSegments = targetUri.getPathSegments(); + Preconditions.checkArgument( + pathSegments.size() == 1, + "the path component of the target (%s) must have exactly one segment", + targetUri); + authority = GrpcUtil.checkAuthority(pathSegments.get(0)); + syncContext = checkNotNull(args, "args").getSynchronizationContext(); + Uri.Builder modifiedTargetBuilder = targetUri.toBuilder().setScheme(schemeOverride); + modifiedTargetBuilder.setRawQuery(null); + + if (schemeOverride.equals("xds")) { + modifiedTargetBuilder.setRawAuthority(C2P_AUTHORITY); + args = + args.toBuilder() + .setArg(XdsNameResolverProvider.XDS_CLIENT_SUPPLIER, () -> xdsClient) + .build(); + } + targetUri = modifiedTargetBuilder.build(); + target = targetUri.toString(); + metricRecorder = args.getMetricRecorder(); + delegate = + checkNotNull(nameResolverFactory, "nameResolverFactory").newNameResolver(targetUri, args); + executor = args.getOffloadExecutor(); + usingExecutorResource = executor == null; + } + @Override public String getServiceAuthority() { return authority; @@ -150,7 +232,7 @@ private void resolve() { resolving = true; if (logger.isLoggable(Level.FINE)) { - logger.fine("resolve with schemaOverride = " + schemeOverride); + logger.log(Level.FINE, "start with schemaOverride = {0}", schemeOverride); } if (schemeOverride.equals("dns")) { @@ -168,28 +250,28 @@ private void resolve() { class Resolve implements Runnable { @Override public void run() { - ImmutableMap rawBootstrap = null; + BootstrapInfo bootstrapInfo = null; try { - // User provided bootstrap configs are only supported with federation. If federation is - // not enabled or there is no user provided config, we set a custom bootstrap override. - // Otherwise, we don't set the override, which will allow a user provided bootstrap config - // to take effect. - if (!enableFederation || !xdsBootstrapProvided) { - rawBootstrap = generateBootstrap(queryZoneMetadata(METADATA_URL_ZONE), - queryIpv6SupportMetadata(METADATA_URL_SUPPORT_IPV6)); - } + bootstrapInfo = getBootstrapInfo(forceXds); } catch (IOException e) { listener.onError( Status.INTERNAL.withDescription("Unable to get metadata").withCause(e)); + } catch (XdsInitializationException e) { + listener.onError( + Status.INTERNAL.withDescription("Unable to create c2p bootstrap").withCause(e)); + } catch (Throwable t) { + listener.onError( + Status.INTERNAL.withDescription("Unexpected error creating c2p bootstrap") + .withCause(t)); } finally { - final ImmutableMap finalRawBootstrap = rawBootstrap; + final BootstrapInfo finalBootstrapInfo = bootstrapInfo; syncContext.execute(new Runnable() { @Override public void run() { - if (!shutdown) { - if (finalRawBootstrap != null) { - bootstrapSetter.setBootstrap(finalRawBootstrap); - } + if (!shutdown && finalBootstrapInfo != null) { + xdsClientPool = InternalSharedXdsClientPoolProvider.getOrCreate( + target, finalBootstrapInfo, metricRecorder, null); + xdsClient = xdsClientPool.getObject(); delegate.start(listener); succeeded = true; } @@ -203,9 +285,11 @@ public void run() { executor.execute(new Resolve()); } - private ImmutableMap generateBootstrap(String zone, boolean supportIpv6) { + private static ImmutableMap generateBootstrap( + String zone, boolean supportIpv6) { ImmutableMap.Builder nodeBuilder = ImmutableMap.builder(); - nodeBuilder.put("id", "C2P-" + (rand.nextInt() & Integer.MAX_VALUE)); + String nodeIdPrefix = isOnGcp ? "C2P-" : "C2P-non-gcp-"; + nodeBuilder.put("id", nodeIdPrefix + (c2pId & Integer.MAX_VALUE)); if (!zone.isEmpty()) { nodeBuilder.put("locality", ImmutableMap.of("zone", zone)); } @@ -250,12 +334,15 @@ public void shutdown() { if (delegate != null) { delegate.shutdown(); } + if (xdsClient != null) { + xdsClient = xdsClientPool.returnObject(xdsClient); + } if (executor != null && usingExecutorResource) { executor = SharedResourceHolder.release(executorResource, executor); } } - private String queryZoneMetadata(String url) throws IOException { + private static String queryZoneMetadata(String url) throws IOException { HttpURLConnection con = null; String respBody; try { @@ -275,7 +362,7 @@ private String queryZoneMetadata(String url) throws IOException { return index == -1 ? "" : respBody.substring(index + 1); } - private boolean queryIpv6SupportMetadata(String url) throws IOException { + private static boolean queryIpv6SupportMetadata(String url) throws IOException { HttpURLConnection con = null; try { con = httpConnectionProvider.createConnection(url); @@ -294,30 +381,30 @@ private boolean queryIpv6SupportMetadata(String url) throws IOException { } @VisibleForTesting - void setHttpConnectionProvider(HttpConnectionProvider httpConnectionProvider) { - this.httpConnectionProvider = httpConnectionProvider; + static void setHttpConnectionProvider(HttpConnectionProvider httpConnectionProvider) { + if (httpConnectionProvider == null) { + GoogleCloudToProdNameResolver.httpConnectionProvider = HttpConnectionFactory.INSTANCE; + } else { + GoogleCloudToProdNameResolver.httpConnectionProvider = httpConnectionProvider; + } } - private static URI overrideUriScheme(URI uri, String scheme) { - URI res; - try { - res = new URI(scheme, uri.getAuthority(), uri.getPath(), uri.getQuery(), uri.getFragment()); - } catch (URISyntaxException ex) { - throw new IllegalArgumentException("Invalid scheme: " + scheme, ex); - } - return res; + @VisibleForTesting + static void setC2pId(int c2pId) { + GoogleCloudToProdNameResolver.c2pId = c2pId; } - private static URI overrideUriAuthority(URI uri, String authority) { - URI res; - try { - res = new URI(uri.getScheme(), authority, uri.getPath(), uri.getQuery(), uri.getFragment()); - } catch (URISyntaxException ex) { - throw new IllegalArgumentException("Invalid authority: " + authority, ex); + private static boolean checkForceXds(QueryParams params) { + for (QueryParams.Entry entry : params.asList()) { + if ("force-xds".equals(entry.getKey())) { + return true; + } } - return res; + return false; } + + private enum HttpConnectionFactory implements HttpConnectionProvider { INSTANCE; @@ -335,8 +422,4 @@ public HttpURLConnection createConnection(String url) throws IOException { interface HttpConnectionProvider { HttpURLConnection createConnection(String url) throws IOException; } - - public interface BootstrapSetter { - void setBootstrap(Map bootstrap); - } } diff --git a/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProvider.java b/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProvider.java index 8ad292a3d98..f936de086e9 100644 --- a/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProvider.java +++ b/googleapis/src/main/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProvider.java @@ -21,14 +21,13 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.Args; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import io.grpc.internal.GrpcUtil; -import io.grpc.xds.InternalSharedXdsClientPoolProvider; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; import java.util.Collection; import java.util.Collections; -import java.util.Map; /** * A provider for {@link GoogleCloudToProdNameResolver}. @@ -48,12 +47,21 @@ public GoogleCloudToProdNameResolverProvider() { this.scheme = Preconditions.checkNotNull(scheme, "scheme"); } + // TODO(jdcormie): Remove after io.grpc.Uri migration is complete. @Override public NameResolver newNameResolver(URI targetUri, Args args) { if (scheme.equals(targetUri.getScheme())) { return new GoogleCloudToProdNameResolver( - targetUri, args, GrpcUtil.SHARED_CHANNEL_EXECUTOR, - new SharedXdsClientPoolProviderBootstrapSetter()); + targetUri, args, GrpcUtil.SHARED_CHANNEL_EXECUTOR); + } + return null; + } + + @Override + public NameResolver newNameResolver(Uri targetUri, Args args) { + if (scheme.equals(targetUri.getScheme())) { + return new GoogleCloudToProdNameResolver( + targetUri, args, GrpcUtil.SHARED_CHANNEL_EXECUTOR); } return null; } @@ -77,12 +85,4 @@ protected int priority() { public Collection> getProducedSocketAddressTypes() { return Collections.singleton(InetSocketAddress.class); } - - private static final class SharedXdsClientPoolProviderBootstrapSetter - implements GoogleCloudToProdNameResolver.BootstrapSetter { - @Override - public void setBootstrap(Map bootstrap) { - InternalSharedXdsClientPoolProvider.setDefaultProviderBootstrapOverride(bootstrap); - } - } } diff --git a/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProviderTest.java b/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProviderTest.java index 447b102c8c7..39468472985 100644 --- a/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProviderTest.java +++ b/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverProviderTest.java @@ -23,20 +23,23 @@ import io.grpc.ChannelLogger; import io.grpc.InternalServiceProviders; import io.grpc.NameResolver; +import io.grpc.NameResolver.Args; import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.NameResolverProvider; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; import java.net.URI; +import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; -/** - * Unit tests for {@link GoogleCloudToProdNameResolverProvider}. - */ -@RunWith(JUnit4.class) +/** Unit tests for {@link GoogleCloudToProdNameResolverProvider}. */ +@RunWith(Parameterized.class) public class GoogleCloudToProdNameResolverProviderTest { private final SynchronizationContext syncContext = new SynchronizationContext( new Thread.UncaughtExceptionHandler() { @@ -59,6 +62,13 @@ public void uncaughtException(Thread t, Throwable e) { private GoogleCloudToProdNameResolverProvider provider = new GoogleCloudToProdNameResolverProvider(); + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + @Test public void provided() { for (NameResolverProvider current @@ -84,16 +94,24 @@ NameResolverProvider.class, getClass().getClassLoader())) { } @Test - public void newNameResolver() { - assertThat(provider - .newNameResolver(URI.create("google-c2p:///foo.googleapis.com"), args)) + public void shouldProvideNameResolverOfExpectedType() { + assertThat(newNameResolver(provider, "google-c2p:///foo.googleapis.com", args)) .isInstanceOf(GoogleCloudToProdNameResolver.class); } @Test - public void experimentalNewNameResolver() { - assertThat(new GoogleCloudToProdExperimentalNameResolverProvider() - .newNameResolver(URI.create("google-c2p-experimental:///foo.googleapis.com"), args)) + public void shouldProvideExperimentalNameResolverOfExpectedType() { + assertThat( + newNameResolver( + new GoogleCloudToProdExperimentalNameResolverProvider(), + "google-c2p-experimental:///foo.googleapis.com", + args)) .isInstanceOf(GoogleCloudToProdNameResolver.class); } + + private NameResolver newNameResolver(NameResolverProvider provider, String uri, Args args) { + return enableRfc3986UrisParam + ? provider.newNameResolver(Uri.create(uri), args) + : provider.newNameResolver(URI.create(uri), args); + } } diff --git a/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverTest.java b/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverTest.java index edb3126d1e3..0251b3477b5 100644 --- a/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverTest.java +++ b/googleapis/src/test/java/io/grpc/googleapis/GoogleCloudToProdNameResolverTest.java @@ -21,10 +21,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import io.grpc.ChannelLogger; +import io.grpc.MetricRecorder; import io.grpc.NameResolver; import io.grpc.NameResolver.Args; import io.grpc.NameResolver.ServiceConfigParser; @@ -33,6 +32,7 @@ import io.grpc.Status; import io.grpc.Status.Code; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.googleapis.GoogleCloudToProdNameResolver.HttpConnectionProvider; import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; @@ -42,31 +42,32 @@ import java.net.HttpURLConnection; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Random; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicReference; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class GoogleCloudToProdNameResolverTest { @Rule public final MockitoRule mocks = MockitoJUnit.rule(); - private static final URI TARGET_URI = URI.create("google-c2p:///googleapis.com"); + private static final String TARGET_URI = "google-c2p:///googleapis.com"; private static final String ZONE = "us-central1-a"; private static final int DEFAULT_PORT = 887; @@ -77,15 +78,16 @@ public void uncaughtException(Thread t, Throwable e) { throw new AssertionError(e); } }); + private final FakeClock fakeExecutor = new FakeClock(); private final NameResolver.Args args = NameResolver.Args.newBuilder() .setDefaultPort(DEFAULT_PORT) .setProxyDetector(GrpcUtil.DEFAULT_PROXY_DETECTOR) .setSynchronizationContext(syncContext) + .setScheduledExecutorService(fakeExecutor.getScheduledExecutorService()) .setServiceConfigParser(mock(ServiceConfigParser.class)) .setChannelLogger(mock(ChannelLogger.class)) + .setMetricRecorder(new MetricRecorder() {}) .build(); - private final FakeClock fakeExecutor = new FakeClock(); - private final FakeBootstrapSetter fakeBootstrapSetter = new FakeBootstrapSetter(); private final Resource fakeExecutorResource = new Resource() { @Override public Executor create() { @@ -98,37 +100,30 @@ public void close(Executor instance) {} private final NameResolverRegistry nsRegistry = new NameResolverRegistry(); private final Map delegatedResolver = new HashMap<>(); + private final Map delegatedUri = new HashMap<>(); + private final Map delegatedRfcUri = new HashMap<>(); @Mock private NameResolver.Listener2 mockListener; - private Random random = new Random(1); @Captor private ArgumentCaptor errorCaptor; private boolean originalIsOnGcp; - private boolean originalXdsBootstrapProvided; private GoogleCloudToProdNameResolver resolver; + private String responseToIpV6 = "1:1:1"; + + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; @Before public void setUp() { nsRegistry.register(new FakeNsProvider("dns")); nsRegistry.register(new FakeNsProvider("xds")); originalIsOnGcp = GoogleCloudToProdNameResolver.isOnGcp; - originalXdsBootstrapProvided = GoogleCloudToProdNameResolver.xdsBootstrapProvided; - } - - @After - public void tearDown() { - GoogleCloudToProdNameResolver.isOnGcp = originalIsOnGcp; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = originalXdsBootstrapProvided; - resolver.shutdown(); - verify(Iterables.getOnlyElement(delegatedResolver.values())).shutdown(); - } - private void createResolver() { - createResolver("1:1:1"); - } - - private void createResolver(String responseToIpV6) { HttpConnectionProvider httpConnections = new HttpConnectionProvider() { @Override public HttpURLConnection createConnection(String url) throws IOException { @@ -148,10 +143,28 @@ public HttpURLConnection createConnection(String url) throws IOException { throw new AssertionError("Unknown http query"); } }; - resolver = new GoogleCloudToProdNameResolver( - TARGET_URI, args, fakeExecutorResource, random, fakeBootstrapSetter, - nsRegistry.asFactory()); - resolver.setHttpConnectionProvider(httpConnections); + GoogleCloudToProdNameResolver.setHttpConnectionProvider(httpConnections); + + GoogleCloudToProdNameResolver.setC2pId(new Random(1).nextInt()); + } + + @After + public void tearDown() { + GoogleCloudToProdNameResolver.isOnGcp = originalIsOnGcp; + GoogleCloudToProdNameResolver.setHttpConnectionProvider(null); + if (resolver != null) { + resolver.shutdown(); + verify(Iterables.getOnlyElement(delegatedResolver.values())).shutdown(); + } + } + + private void createResolver() { + resolver = + enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(TARGET_URI), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(TARGET_URI), args, fakeExecutorResource, nsRegistry.asFactory()); } @Test @@ -164,137 +177,186 @@ public void notOnGcp_DelegateToDns() { } @Test - public void hasProvidedBootstrap_DelegateToDns() { + public void onGcpAndNoProvidedBootstrap_DelegateToXds() { GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = true; - GoogleCloudToProdNameResolver.enableFederation = false; createResolver(); resolver.start(mockListener); - assertThat(delegatedResolver.keySet()).containsExactly("dns"); + fakeExecutor.runDueTasks(); + assertThat(delegatedResolver.keySet()).containsExactly("xds"); verify(Iterables.getOnlyElement(delegatedResolver.values())).start(mockListener); } - @SuppressWarnings("unchecked") @Test - public void onGcpAndNoProvidedBootstrap_DelegateToXds() { - GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = false; - createResolver(); + public void notOnGcpButForceXds_DelegateToXds() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?force-xds"; + resolver = + enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); resolver.start(mockListener); fakeExecutor.runDueTasks(); assertThat(delegatedResolver.keySet()).containsExactly("xds"); - verify(Iterables.getOnlyElement(delegatedResolver.values())).start(mockListener); - Map bootstrap = fakeBootstrapSetter.bootstrapRef.get(); - Map node = (Map) bootstrap.get("node"); - assertThat(node).containsExactly( - "id", "C2P-991614323", - "locality", ImmutableMap.of("zone", ZONE), - "metadata", ImmutableMap.of("TRAFFICDIRECTOR_DIRECTPATH_C2P_IPV6_CAPABLE", true)); - Map server = Iterables.getOnlyElement( - (List>) bootstrap.get("xds_servers")); - assertThat(server).containsExactly( - "server_uri", "directpath-pa.googleapis.com", - "channel_creds", ImmutableList.of(ImmutableMap.of("type", "google_default")), - "server_features", ImmutableList.of("xds_v3", "ignore_resource_deletion")); - Map authorities = (Map) bootstrap.get("authorities"); - assertThat(authorities).containsExactly( - "traffic-director-c2p.xds.googleapis.com", - ImmutableMap.of("xds_servers", ImmutableList.of(server))); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("xds"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("xds"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getQuery()).isNull(); + } } - @SuppressWarnings("unchecked") @Test - public void onGcpAndNoProvidedBootstrap_DelegateToXds_noIpV6() { - GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = false; - createResolver(null); + public void notOnGcpButForceXds_WithValue_DelegateToXds() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?force-xds=foo"; + resolver = enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); resolver.start(mockListener); fakeExecutor.runDueTasks(); assertThat(delegatedResolver.keySet()).containsExactly("xds"); - verify(Iterables.getOnlyElement(delegatedResolver.values())).start(mockListener); - Map bootstrap = fakeBootstrapSetter.bootstrapRef.get(); - Map node = (Map) bootstrap.get("node"); - assertThat(node).containsExactly( - "id", "C2P-991614323", - "locality", ImmutableMap.of("zone", ZONE)); - Map server = Iterables.getOnlyElement( - (List>) bootstrap.get("xds_servers")); - assertThat(server).containsExactly( - "server_uri", "directpath-pa.googleapis.com", - "channel_creds", ImmutableList.of(ImmutableMap.of("type", "google_default")), - "server_features", ImmutableList.of("xds_v3", "ignore_resource_deletion")); - Map authorities = (Map) bootstrap.get("authorities"); - assertThat(authorities).containsExactly( - "traffic-director-c2p.xds.googleapis.com", - ImmutableMap.of("xds_servers", ImmutableList.of(server))); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("xds"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("xds"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getQuery()).isNull(); + } } - @SuppressWarnings("unchecked") @Test - public void emptyResolverMeetadataValue() { - GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = false; - createResolver(""); + public void notOnGcpButForceXds_PercentEncoded_DelegateToXds() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?force%2Dxds"; + resolver = enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); resolver.start(mockListener); fakeExecutor.runDueTasks(); assertThat(delegatedResolver.keySet()).containsExactly("xds"); - verify(Iterables.getOnlyElement(delegatedResolver.values())).start(mockListener); - Map bootstrap = fakeBootstrapSetter.bootstrapRef.get(); - Map node = (Map) bootstrap.get("node"); - assertThat(node).containsExactly( - "id", "C2P-991614323", - "locality", ImmutableMap.of("zone", ZONE)); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("xds"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("xds"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getQuery()).isNull(); + } } - @SuppressWarnings("unchecked") @Test - public void onGcpAndNoProvidedBootstrapAndFederationEnabled_DelegateToXds() { - GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = false; - GoogleCloudToProdNameResolver.enableFederation = true; - createResolver(); + public void notOnGcpButForceXds_DuplicateKeys_DelegateToXds() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?force-xds=&force-xds=true"; + resolver = enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); resolver.start(mockListener); fakeExecutor.runDueTasks(); assertThat(delegatedResolver.keySet()).containsExactly("xds"); - verify(Iterables.getOnlyElement(delegatedResolver.values())).start(mockListener); - // check bootstrap - Map bootstrap = fakeBootstrapSetter.bootstrapRef.get(); - Map node = (Map) bootstrap.get("node"); - assertThat(node).containsExactly( - "id", "C2P-991614323", - "locality", ImmutableMap.of("zone", ZONE), - "metadata", ImmutableMap.of("TRAFFICDIRECTOR_DIRECTPATH_C2P_IPV6_CAPABLE", true)); - Map server = Iterables.getOnlyElement( - (List>) bootstrap.get("xds_servers")); - assertThat(server).containsExactly( - "server_uri", "directpath-pa.googleapis.com", - "channel_creds", ImmutableList.of(ImmutableMap.of("type", "google_default")), - "server_features", ImmutableList.of("xds_v3", "ignore_resource_deletion")); - Map authorities = (Map) bootstrap.get("authorities"); - assertThat(authorities).containsExactly( - "traffic-director-c2p.xds.googleapis.com", - ImmutableMap.of("xds_servers", ImmutableList.of(server))); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("xds"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("xds"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getQuery()).isNull(); + } } - @SuppressWarnings("unchecked") @Test - public void onGcpAndProvidedBootstrapAndFederationEnabled_DontDelegateToXds() { - GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = true; - GoogleCloudToProdNameResolver.enableFederation = true; - createResolver(); + public void notOnGcpButForceXds_WithMultipleParams_DelegateToXds() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?foo=bar&force-xds&baz=qux"; + resolver = enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); resolver.start(mockListener); fakeExecutor.runDueTasks(); assertThat(delegatedResolver.keySet()).containsExactly("xds"); - verify(Iterables.getOnlyElement(delegatedResolver.values())).start(mockListener); - // Bootstrapper should not have been set, since there was no user provided config. - assertThat(fakeBootstrapSetter.bootstrapRef.get()).isNull(); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("xds"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("xds"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getQuery()).isNull(); + } + } + + @Test + public void notOnGcpButForceXds_WithEncodedAmpersand_DelegateToXds() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?force-xds&foo=bar%26baz"; + resolver = enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); + resolver.start(mockListener); + fakeExecutor.runDueTasks(); + assertThat(delegatedResolver.keySet()).containsExactly("xds"); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("xds"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("xds"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getRawQuery()).isNull(); + } + } + + @Test + public void notOnGcpButForceXds_CaseSensitive_DelegateToDns() { + GoogleCloudToProdNameResolver.isOnGcp = false; + String target = TARGET_URI + "?FORCE-XDS"; + resolver = enableRfc3986UrisParam + ? new GoogleCloudToProdNameResolver( + Uri.create(target), args, fakeExecutorResource, nsRegistry.asFactory()) + : new GoogleCloudToProdNameResolver( + URI.create(target), args, fakeExecutorResource, nsRegistry.asFactory()); + resolver.start(mockListener); + assertThat(delegatedResolver.keySet()).containsExactly("dns"); + + if (enableRfc3986UrisParam) { + Uri delegatedRfcUriValue = delegatedRfcUri.get("dns"); + assertThat(delegatedRfcUriValue).isNotNull(); + assertThat(delegatedRfcUriValue.getRawQuery()).isNull(); + } else { + URI delegatedUriValue = delegatedUri.get("dns"); + assertThat(delegatedUriValue).isNotNull(); + assertThat(delegatedUriValue.getQuery()).isNull(); + } } @Test public void failToQueryMetadata() { GoogleCloudToProdNameResolver.isOnGcp = true; - GoogleCloudToProdNameResolver.xdsBootstrapProvided = false; createResolver(); HttpConnectionProvider httpConnections = new HttpConnectionProvider() { @Override @@ -304,7 +366,7 @@ public HttpURLConnection createConnection(String url) throws IOException { return con; } }; - resolver.setHttpConnectionProvider(httpConnections); + GoogleCloudToProdNameResolver.setHttpConnectionProvider(httpConnections); resolver.start(mockListener); fakeExecutor.runDueTasks(); verify(mockListener).onError(errorCaptor.capture()); @@ -322,6 +384,18 @@ private FakeNsProvider(String scheme) { @Override public NameResolver newNameResolver(URI targetUri, Args args) { if (scheme.equals(targetUri.getScheme())) { + delegatedUri.put(scheme, targetUri); + NameResolver resolver = mock(NameResolver.class); + delegatedResolver.put(scheme, resolver); + return resolver; + } + return null; + } + + @Override + public NameResolver newNameResolver(Uri targetUri, Args args) { + if (scheme.equals(targetUri.getScheme())) { + delegatedRfcUri.put(scheme, targetUri); NameResolver resolver = mock(NameResolver.class); delegatedResolver.put(scheme, resolver); return resolver; @@ -344,14 +418,4 @@ public String getDefaultScheme() { return scheme; } } - - private static final class FakeBootstrapSetter - implements GoogleCloudToProdNameResolver.BootstrapSetter { - private final AtomicReference> bootstrapRef = new AtomicReference<>(); - - @Override - public void setBootstrap(Map bootstrap) { - bootstrapRef.set(bootstrap); - } - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb5dce02843..9bd4fe51358 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,114 +1,152 @@ [versions] -netty = '4.1.124.Final' -# Keep the following references of tcnative version in sync whenever it's updated: -# SECURITY.md -nettytcnative = '2.0.72.Final' opencensus = "0.31.1" -# Not upgrading to 4.x as it is not yet ABI compatible. -# https://github.com/protocolbuffers/protobuf/issues/17247 -protobuf = "3.25.8" [libraries] android-annotations = "com.google.android:annotations:4.1.1.4" -# androidx-annotation 1.9.1+ uses Kotlin and requires Android Gradle Plugin 9+ +# 1.9.1+ uses Kotlin and requires Android Gradle Plugin 9+ +# checkForUpdates: androidx-annotation:1.9.0 androidx-annotation = "androidx.annotation:annotation:1.9.0" -# 1.15.0 requires libraries and applications that depend on it to compile against -# version 35 or later of the Android APIs. +# 1.14.x doesn't exist. +# 1.15.0+ requires compileSdkVersion 35 which officially requires AGP 8.6.0+. +# It might work before then, but AGP 7.4.1 fails with: +# RES_TABLE_TYPE_TYPE entry offsets overlap actual entry data. +# 1.16.0+ requires AGP 8.6.0+ +# checkForUpdates: androidx-core:1.13.+ androidx-core = "androidx.core:core:1.13.1" -# androidx-lifecycle 2.9+ requires Java 17 +# 2.9+ requires AGP 8.1.1+ +# checkForUpdates: androidx-lifecycle-common:2.8.+ androidx-lifecycle-common = "androidx.lifecycle:lifecycle-common:2.8.7" +# checkForUpdates: androidx-lifecycle-service:2.8.+ androidx-lifecycle-service = "androidx.lifecycle:lifecycle-service:2.8.7" -androidx-test-core = "androidx.test:core:1.6.1" -androidx-test-ext-junit = "androidx.test.ext:junit:1.2.1" -androidx-test-rules = "androidx.test:rules:1.6.1" -animalsniffer = "org.codehaus.mojo:animal-sniffer:1.24" -animalsniffer-annotations = "org.codehaus.mojo:animal-sniffer-annotations:1.24" -assertj-core = "org.assertj:assertj-core:3.27.3" +androidx-test-core = "androidx.test:core:1.7.0" +androidx-test-ext-junit = "androidx.test.ext:junit:1.3.0" +androidx-test-rules = "androidx.test:rules:1.7.0" +androidx-test-runner = "androidx.test:runner:1.7.0" +animalsniffer = "org.codehaus.mojo:animal-sniffer:1.27" +animalsniffer-annotations = "org.codehaus.mojo:animal-sniffer-annotations:1.27" +assertj-core = "org.assertj:assertj-core:3.27.7" +# 1.11.1 started converting jsr305 @Nullable to jspecify +# checkForUpdates: auto-value:1.11.0 auto-value = "com.google.auto.value:auto-value:1.11.0" +# checkForUpdates: auto-value-annotations:1.11.0 auto-value-annotations = "com.google.auto.value:auto-value-annotations:1.11.0" -checkstyle = "com.puppycrawl.tools:checkstyle:10.21.2" +# 11.0+ requires Java 17+ +# https://checkstyle.sourceforge.io/releasenotes.html +# checkForUpdates: checkstyle:10.+ +checkstyle = "com.puppycrawl.tools:checkstyle:10.26.1" +# checkstyle 10.0+ requires Java 11+ +# See https://checkstyle.sourceforge.io/releasenotes_old_8-35_10-26.html#Release_10.0 +# checkForUpdates: checkstylejava8:9.+ +cel-runtime = "dev.cel:runtime:0.13.0" +cel-protobuf = "dev.cel:protobuf:0.13.0" +cel-compiler = "dev.cel:compiler:0.13.0" +checkstylejava8 = "com.puppycrawl.tools:checkstyle:9.3" commons-math3 = "org.apache.commons:commons-math3:3.6.1" conscrypt = "org.conscrypt:conscrypt-openjdk-uber:2.5.2" +# 141.7340.3+ requires Java 17+ +# checkForUpdates: cronet-api:119.6045.31 cronet-api = "org.chromium.net:cronet-api:119.6045.31" +# checkForUpdates: cronet-embedded:119.6045.31 cronet-embedded = "org.chromium.net:cronet-embedded:119.6045.31" -errorprone-annotations = "com.google.errorprone:error_prone_annotations:2.36.0" -# error-prone 2.32.0+ require Java 17+ +errorprone-annotations = "com.google.errorprone:error_prone_annotations:2.50.0" +# 2.32.0+ requires Java 17+ +# checkForUpdates: errorprone-core:2.31.+ errorprone-core = "com.google.errorprone:error_prone_core:2.31.0" -google-api-protos = "com.google.api.grpc:proto-google-common-protos:2.59.2" -# google-auth-library 1.25.0+ requires error_prone_annotations 2.31.0+, which -# breaks the Android build -google-auth-credentials = "com.google.auth:google-auth-library-credentials:1.24.1" -google-auth-oauth2Http = "com.google.auth:google-auth-library-oauth2-http:1.24.1" +# 2.11.0+ requires JDK 11+ (See https://github.com/google/error-prone/releases/tag/v2.11.0) +# checkForUpdates: errorprone-corejava8:2.10.+ +errorprone-corejava8 = "com.google.errorprone:error_prone_core:2.10.0" +# 2.65.0+ requires protobuf 4.x +# checkForUpdates: google-api-protos:2.64.+ +google-api-protos = "com.google.api.grpc:proto-google-common-protos:2.64.1" +# 1.43.0+ versions of google-auth-library requires protobuf 4.x +# checkForUpdates: google-auth-credentials:1.42.+ +google-auth-credentials = "com.google.auth:google-auth-library-credentials:1.42.1" +# checkForUpdates: google-auth-oauth2Http:1.42.+ +google-auth-oauth2Http = "com.google.auth:google-auth-library-oauth2-http:1.42.1" # Release notes: https://cloud.google.com/logging/docs/release-notes -google-cloud-logging = "com.google.cloud:google-cloud-logging:3.23.1" -# 2.12.1 requires error_prone_annotations:2.36.0 but we are stuck with 2.30.0 -gson = "com.google.code.gson:gson:2.11.0" -# 33.4.8 requires com.google.errorprone:error_prone_annotations:2.36.0 -guava = "com.google.guava:guava:33.4.8-android" +# 3.23.11+ require protobuf 4.x +# checkForUpdates: google-cloud-logging:3.23.10 +google-cloud-logging = "com.google.cloud:google-cloud-logging:3.23.10" +gson = "com.google.code.gson:gson:2.14.0" +guava = "com.google.guava:guava:33.6.0-android" guava-betaChecker = "com.google.guava:guava-beta-checker:1.0" -guava-testlib = "com.google.guava:guava-testlib:33.4.8-android" +guava-testlib = "com.google.guava:guava-testlib:33.6.0-android" # JRE version is needed for projects where its a transitive dependency, f.e. gcp-observability. # May be different from the -android version. -guava-jre = "com.google.guava:guava:33.4.8-jre" +guava-jre = "com.google.guava:guava:33.6.0-jre" hdrhistogram = "org.hdrhistogram:HdrHistogram:2.2.2" # 6.0.0+ use java.lang.Deprecated forRemoval and since from Java 9 +# checkForUpdates: jakarta-servlet-api:5.+ jakarta-servlet-api = "jakarta.servlet:jakarta.servlet-api:5.0.0" javax-servlet-api = "javax.servlet:javax.servlet-api:4.0.1" # 12.0.0+ require Java 17+ -jetty-client = "org.eclipse.jetty:jetty-client:11.0.24" -jetty-http2-server = "org.eclipse.jetty.http2:jetty-http2-server:12.0.23" -jetty-http2-server10 = "org.eclipse.jetty.http2:http2-server:10.0.20" -jetty-servlet = "org.eclipse.jetty.ee10:jetty-ee10-servlet:12.0.16" -jetty-servlet10 = "org.eclipse.jetty:jetty-servlet:10.0.20" +# checkForUpdates: jetty-client:11.+ +jetty-client = "org.eclipse.jetty:jetty-client:11.0.26" +jetty-http2-server = "org.eclipse.jetty.http2:jetty-http2-server:12.1.10" +# 10.0.25+ uses uses @Deprecated(since=/forRemoval=) from Java 9 +# checkForUpdates: jetty-http2-server10:10.0.24 +jetty-http2-server10 = "org.eclipse.jetty.http2:http2-server:10.0.24" +jetty-servlet = "org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.10" +# checkForUpdates: jetty-servlet10:10.0.24 +jetty-servlet10 = "org.eclipse.jetty:jetty-servlet:10.0.24" jsr305 = "com.google.code.findbugs:jsr305:3.0.2" junit = "junit:junit:4.13.2" -lincheck = "org.jetbrains.lincheck:lincheck:3.2" +lincheck = "org.jetbrains.lincheck:lincheck:3.6" # Update notes / 2023-07-19 sergiitk: # Couldn't update to 5.4.0, updated to the last in 4.x line. Version 5.x breaks some tests. # Error log: https://github.com/grpc/grpc-java/pull/10359#issuecomment-1632834435 # Update notes / 2023-10-09 temawi: -# 4.11.0 Has been breaking the android integration tests as mockito now uses streams +# 4.5.0 Has been breaking the android integration tests as mockito now uses streams # (not available in API levels < 24). https://github.com/grpc/grpc-java/issues/10457 +# checkForUpdates: mockito-android:4.4.+ mockito-android = "org.mockito:mockito-android:4.4.0" +# checkForUpdates: mockito-core:4.4.+ mockito-core = "org.mockito:mockito-core:4.4.0" -netty-codec-http2 = { module = "io.netty:netty-codec-http2", version.ref = "netty" } -netty-handler-proxy = { module = "io.netty:netty-handler-proxy", version.ref = "netty" } -netty-tcnative = { module = "io.netty:netty-tcnative-boringssl-static", version.ref = "nettytcnative" } -netty-tcnative-classes = { module = "io.netty:netty-tcnative-classes", version.ref = "nettytcnative" } -netty-transport-epoll = { module = "io.netty:netty-transport-native-epoll", version.ref = "netty" } -netty-unix-common = { module = "io.netty:netty-transport-native-unix-common", version.ref = "netty" } +netty-codec-http2 = "io.netty:netty-codec-http2:4.2.15.Final" +netty-handler-proxy = "io.netty:netty-handler-proxy:4.2.15.Final" +# Keep the following references of tcnative version in sync whenever it's updated: +# SECURITY.md +netty-tcnative = "io.netty:netty-tcnative-boringssl-static:2.0.75.Final" +netty-tcnative-classes = "io.netty:netty-tcnative-classes:2.0.75.Final" +netty-transport-epoll = "io.netty:netty-transport-native-epoll:4.2.15.Final" +netty-unix-common = "io.netty:netty-transport-native-unix-common:4.2.15.Final" okhttp = "com.squareup.okhttp:okhttp:2.7.5" # okio 3.5+ uses Kotlin 1.9+ which requires Android Gradle Plugin 9+ +# checkForUpdates: okio:3.4.+ okio = "com.squareup.okio:okio:3.4.0" opencensus-api = { module = "io.opencensus:opencensus-api", version.ref = "opencensus" } opencensus-contrib-grpc-metrics = { module = "io.opencensus:opencensus-contrib-grpc-metrics", version.ref = "opencensus" } opencensus-exporter-stats-stackdriver = { module = "io.opencensus:opencensus-exporter-stats-stackdriver", version.ref = "opencensus" } opencensus-exporter-trace-stackdriver = { module = "io.opencensus:opencensus-exporter-trace-stackdriver", version.ref = "opencensus" } opencensus-impl = { module = "io.opencensus:opencensus-impl", version.ref = "opencensus" } -opentelemetry-api = "io.opentelemetry:opentelemetry-api:1.52.0" -opentelemetry-exporter-prometheus = "io.opentelemetry:opentelemetry-exporter-prometheus:1.52.0-alpha" -opentelemetry-gcp-resources = "io.opentelemetry.contrib:opentelemetry-gcp-resources:1.48.0-alpha" -opentelemetry-sdk-extension-autoconfigure = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure:1.52.0" -opentelemetry-sdk-testing = "io.opentelemetry:opentelemetry-sdk-testing:1.52.0" +opentelemetry-api = "io.opentelemetry:opentelemetry-api:1.63.0" +opentelemetry-exporter-prometheus = "io.opentelemetry:opentelemetry-exporter-prometheus:1.63.0-alpha" +opentelemetry-gcp-resources = "io.opentelemetry.contrib:opentelemetry-gcp-resources:1.57.0-alpha" +opentelemetry-sdk-extension-autoconfigure = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure:1.63.0" +opentelemetry-sdk-testing = "io.opentelemetry:opentelemetry-sdk-testing:1.63.0" perfmark-api = "io.perfmark:perfmark-api:0.27.0" -protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } -protobuf-java-util = { module = "com.google.protobuf:protobuf-java-util", version.ref = "protobuf" } -protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf" } -protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } +# Not upgrading to 4.x as it is not yet ABI compatible. +# https://github.com/protocolbuffers/protobuf/issues/17247 +# checkForUpdates: protobuf-java:3.+ +protobuf-java = "com.google.protobuf:protobuf-java:3.25.9" +# checkForUpdates: protobuf-java-util:3.+ +protobuf-java-util = "com.google.protobuf:protobuf-java-util:3.25.9" +# checkForUpdates: protobuf-javalite:3.+ +protobuf-javalite = "com.google.protobuf:protobuf-javalite:3.25.9" +# checkForUpdates: protobuf-protoc:3.+ +protobuf-protoc = "com.google.protobuf:protoc:3.25.9" re2j = "com.google.re2j:re2j:1.8" -robolectric = "org.robolectric:robolectric:4.15.1" -s2a-proto = "com.google.s2a.proto.v2:s2a-proto:0.1.2" +robolectric = "org.robolectric:robolectric:4.16.1" +s2a-proto = "com.google.s2a.proto.v2:s2a-proto:0.1.3" signature-android = "net.sf.androidscents.signature:android-api-level-21:5.0.1_r2" signature-java = "org.codehaus.mojo.signature:java18:1.0" # 11.0.0+ require Java 17+ -tomcat-embed-core = "org.apache.tomcat.embed:tomcat-embed-core:10.1.31" -tomcat-embed-core9 = "org.apache.tomcat.embed:tomcat-embed-core:9.0.89" -truth = "com.google.truth:truth:1.4.4" -undertow-servlet22 = "io.undertow:undertow-servlet:2.2.37.Final" -undertow-servlet = "io.undertow:undertow-servlet:2.3.18.Final" - -# Do not update: Pinned to the last version supporting Java 8. -# See https://checkstyle.sourceforge.io/releasenotes.html#Release_10.1 -checkstylejava8 = "com.puppycrawl.tools:checkstyle:9.3" -# 2.11.0+ requires JDK 11+ (See https://github.com/google/error-prone/releases/tag/v2.11.0) -errorprone-corejava8 = "com.google.errorprone:error_prone_core:2.10.0" +# checkForUpdates: tomcat-embed-core:10.+ +tomcat-embed-core = "org.apache.tomcat.embed:tomcat-embed-core:10.1.55" +# checkForUpdates: tomcat-embed-core9:9.+ +tomcat-embed-core9 = "org.apache.tomcat.embed:tomcat-embed-core:9.0.118" +truth = "com.google.truth:truth:1.4.5" +# checkForUpdates: undertow-servlet22:2.2.+ +undertow-servlet22 = "io.undertow:undertow-servlet:2.2.39.Final" +# checkForUpdates: undertow-servlet:2.3.+ +undertow-servlet = "io.undertow:undertow-servlet:2.3.21.Final" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da476b..4f5eb9dcc0e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/grpclb/BUILD.bazel b/grpclb/BUILD.bazel index 4612968ebcd..ca9975b7ce6 100644 --- a/grpclb/BUILD.bazel +++ b/grpclb/BUILD.bazel @@ -17,6 +17,7 @@ java_library( "//context", "//core:internal", "//stub", + "//util", "@com_google_protobuf//:protobuf_java_util", "@io_grpc_grpc_proto//:grpclb_load_balancer_java_proto", artifact("com.google.code.findbugs:jsr305"), diff --git a/grpclb/build.gradle b/grpclb/build.gradle index f543e0d71fc..e8896604f03 100644 --- a/grpclb/build.gradle +++ b/grpclb/build.gradle @@ -19,6 +19,7 @@ dependencies { implementation project(':grpc-core'), project(':grpc-protobuf'), project(':grpc-stub'), + project(':grpc-util'), libraries.guava, libraries.protobuf.java, libraries.protobuf.java.util diff --git a/grpclb/src/main/java/io/grpc/grpclb/GrpclbLoadBalancer.java b/grpclb/src/main/java/io/grpc/grpclb/GrpclbLoadBalancer.java index 872937b03c1..bf9eea69af0 100644 --- a/grpclb/src/main/java/io/grpc/grpclb/GrpclbLoadBalancer.java +++ b/grpclb/src/main/java/io/grpc/grpclb/GrpclbLoadBalancer.java @@ -154,6 +154,8 @@ public void handleNameResolutionError(Status error) { } @Override + @Deprecated + @SuppressWarnings("InlineMeSuggester") public boolean canHandleEmptyAddressListFromNameResolution() { return true; } diff --git a/grpclb/src/main/java/io/grpc/grpclb/GrpclbNameResolver.java b/grpclb/src/main/java/io/grpc/grpclb/GrpclbNameResolver.java index d17587fb14d..60d02220e64 100644 --- a/grpclb/src/main/java/io/grpc/grpclb/GrpclbNameResolver.java +++ b/grpclb/src/main/java/io/grpc/grpclb/GrpclbNameResolver.java @@ -21,6 +21,7 @@ import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.NameResolver; +import io.grpc.StatusOr; import io.grpc.internal.DnsNameResolver; import io.grpc.internal.SharedResourceHolder.Resource; import java.net.InetAddress; @@ -58,14 +59,22 @@ final class GrpclbNameResolver extends DnsNameResolver { } @Override - protected InternalResolutionResult doResolve(boolean forceTxt) { + protected ResolutionResult doResolve() { + ResolutionResult result = super.doResolve(); List balancerAddrs = resolveBalancerAddresses(); - InternalResolutionResult result = super.doResolve(!balancerAddrs.isEmpty()); if (!balancerAddrs.isEmpty()) { - result.attributes = - Attributes.newBuilder() + ResolutionResult.Builder resultBuilder = result.toBuilder() + .setAttributes(result.getAttributes().toBuilder() .set(GrpclbConstants.ATTR_LB_ADDRS, balancerAddrs) - .build(); + .build()); + if (!result.getAddressesOrError().hasValue()) { + // While ResolutionResult is powerful enough to communicate attributes simultaneously with + // an address resolution failure, LoadBalancer.ResolvedAddresses isn't yet and so the + // attributes are lost if addresses fail. GrpclbLB will be able to handle the lack of + // addresses since there are LB addresses, so discard the failure for now. + resultBuilder.setAddressesOrError(StatusOr.fromValue(Collections.emptyList())); + } + result = resultBuilder.build(); } return result; } diff --git a/grpclb/src/main/java/io/grpc/grpclb/GrpclbState.java b/grpclb/src/main/java/io/grpc/grpclb/GrpclbState.java index 49b74645ec8..5ed84ade2f8 100644 --- a/grpclb/src/main/java/io/grpc/grpclb/GrpclbState.java +++ b/grpclb/src/main/java/io/grpc/grpclb/GrpclbState.java @@ -37,13 +37,16 @@ import io.grpc.ConnectivityStateInfo; import io.grpc.Context; import io.grpc.EquivalentAddressGroup; -import io.grpc.LoadBalancer.CreateSubchannelArgs; +import io.grpc.LoadBalancer; +import io.grpc.LoadBalancer.FixedResultPicker; import io.grpc.LoadBalancer.Helper; import io.grpc.LoadBalancer.PickResult; import io.grpc.LoadBalancer.PickSubchannelArgs; +import io.grpc.LoadBalancer.ResolvedAddresses; import io.grpc.LoadBalancer.Subchannel; import io.grpc.LoadBalancer.SubchannelPicker; -import io.grpc.LoadBalancer.SubchannelStateListener; +import io.grpc.LoadBalancerProvider; +import io.grpc.LoadBalancerRegistry; import io.grpc.ManagedChannel; import io.grpc.Metadata; import io.grpc.Status; @@ -62,6 +65,7 @@ import io.grpc.lb.v1.Server; import io.grpc.lb.v1.ServerList; import io.grpc.stub.StreamObserver; +import io.grpc.util.ForwardingLoadBalancerHelper; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; @@ -119,7 +123,7 @@ final class GrpclbState { @VisibleForTesting static final RoundRobinEntry BUFFER_ENTRY = new RoundRobinEntry() { @Override - public PickResult picked(Metadata headers) { + public PickResult picked(PickSubchannelArgs args) { return PickResult.withNoResult(); } @@ -183,10 +187,20 @@ enum Mode { private List dropList = Collections.emptyList(); // Contains only non-drop, i.e., backends from the round-robin list from the balancer. private List backendList = Collections.emptyList(); + private ConnectivityState currentState = ConnectivityState.CONNECTING; private RoundRobinPicker currentPicker = new RoundRobinPicker(Collections.emptyList(), Arrays.asList(BUFFER_ENTRY)); private boolean requestConnectionPending; + // Child LoadBalancer and state for PICK_FIRST mode delegation. + private final LoadBalancerProvider pickFirstLbProvider; + @Nullable + private LoadBalancer pickFirstLb; + private ConnectivityState pickFirstLbState = CONNECTING; + private SubchannelPicker pickFirstLbPicker = new FixedResultPicker(PickResult.withNoResult()); + @Nullable + private GrpclbClientLoadRecorder currentPickFirstLoadRecorder; + GrpclbState( GrpclbConfig config, Helper helper, @@ -212,6 +226,9 @@ public void onSubchannelState( } else { this.subchannelPool = null; } + this.pickFirstLbProvider = checkNotNull( + LoadBalancerRegistry.getDefaultRegistry().getProvider("pick_first"), + "pick_first balancer not available"); this.time = checkNotNull(time, "time provider"); this.stopwatch = checkNotNull(stopwatch, "stopwatch"); this.timerService = checkNotNull(helper.getScheduledExecutorService(), "timerService"); @@ -309,6 +326,12 @@ void handleAddresses( void requestConnection() { requestConnectionPending = true; + // For PICK_FIRST mode with delegation, forward to the child LB. + if (config.getMode() == Mode.PICK_FIRST && pickFirstLb != null) { + pickFirstLb.requestConnection(); + requestConnectionPending = false; + return; + } for (RoundRobinEntry entry : currentPicker.pickList) { if (entry instanceof IdleSubchannelEntry) { ((IdleSubchannelEntry) entry).subchannel.requestConnection(); @@ -323,15 +346,23 @@ private void maybeUseFallbackBackends() { } // Balancer RPC should have either been broken or timed out. checkState(fallbackReason != null, "no reason to fallback"); - for (Subchannel subchannel : subchannels.values()) { - ConnectivityStateInfo stateInfo = subchannel.getAttributes().get(STATE_INFO).get(); - if (stateInfo.getState() == READY) { + // For PICK_FIRST mode with delegation, check the child LB's state. + if (config.getMode() == Mode.PICK_FIRST) { + if (pickFirstLb != null && pickFirstLbState == READY) { return; } - // If we do have balancer-provided backends, use one of its error in the error message if - // fail to fallback. - if (stateInfo.getState() == TRANSIENT_FAILURE) { - fallbackReason = stateInfo.getStatus(); + // For PICK_FIRST, we don't have individual subchannel states to use as fallback reason. + } else { + for (Subchannel subchannel : subchannels.values()) { + ConnectivityStateInfo stateInfo = subchannel.getAttributes().get(STATE_INFO).get(); + if (stateInfo.getState() == READY) { + return; + } + // If we do have balancer-provided backends, use one of its error in the error message if + // fail to fallback. + if (stateInfo.getState() == TRANSIENT_FAILURE) { + fallbackReason = stateInfo.getStatus(); + } } } // Fallback conditions met @@ -355,11 +386,12 @@ private void useFallbackBackends() { } private void shutdownLbComm() { + shutdownLbRpc(); if (lbCommChannel != null) { - lbCommChannel.shutdown(); + // The channel should have no RPCs at this point + lbCommChannel.shutdownNow(); lbCommChannel = null; } - shutdownLbRpc(); } private void shutdownLbRpc() { @@ -438,9 +470,10 @@ void shutdown() { subchannelPool.clear(); break; case PICK_FIRST: - if (!subchannels.isEmpty()) { - checkState(subchannels.size() == 1, "Excessive Subchannels: %s", subchannels); - subchannels.values().iterator().next().shutdown(); + // Shutdown the child pick_first LB which manages its own subchannels. + if (pickFirstLb != null) { + pickFirstLb.shutdown(); + pickFirstLb = null; } break; default: @@ -517,22 +550,17 @@ private void updateServerList( subchannels = Collections.unmodifiableMap(newSubchannelMap); break; case PICK_FIRST: - checkState(subchannels.size() <= 1, "Unexpected Subchannel count: %s", subchannels); - final Subchannel subchannel; + // Delegate to child pick_first LB for address management. + // Shutdown existing child LB if addresses become empty. if (newBackendAddrList.isEmpty()) { - if (subchannels.size() == 1) { - subchannel = subchannels.values().iterator().next(); - subchannel.shutdown(); - subchannels = Collections.emptyMap(); + if (pickFirstLb != null) { + pickFirstLb.shutdown(); + pickFirstLb = null; } break; } List eagList = new ArrayList<>(); - // Because for PICK_FIRST, we create a single Subchannel for all addresses, we have to - // attach the tokens to the EAG attributes and use TokenAttachingLoadRecorder to put them on - // headers. - // - // The PICK_FIRST code path doesn't cache Subchannels. + // Attach tokens to EAG attributes for TokenAttachingTracerFactory to retrieve. for (BackendAddressGroup bag : newBackendAddrList) { EquivalentAddressGroup origEag = bag.getAddresses(); Attributes eagAttrs = origEag.getAttributes(); @@ -542,30 +570,22 @@ private void updateServerList( } eagList.add(new EquivalentAddressGroup(origEag.getAddresses(), eagAttrs)); } - if (subchannels.isEmpty()) { - subchannel = - helper.createSubchannel( - CreateSubchannelArgs.newBuilder() - .setAddresses(eagList) - .setAttributes(createSubchannelAttrs()) - .build()); - subchannel.start(new SubchannelStateListener() { - @Override - public void onSubchannelState(ConnectivityStateInfo newState) { - handleSubchannelState(subchannel, newState); - } - }); - if (requestConnectionPending) { - subchannel.requestConnection(); - requestConnectionPending = false; - } - } else { - subchannel = subchannels.values().iterator().next(); - subchannel.updateAddresses(eagList); + + if (pickFirstLb == null) { + pickFirstLb = pickFirstLbProvider.newLoadBalancer(new PickFirstLbHelper()); + } + + // Pass addresses to child LB. + pickFirstLb.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(eagList) + .build()); + if (requestConnectionPending) { + pickFirstLb.requestConnection(); + requestConnectionPending = false; } - subchannels = Collections.singletonMap(eagList, subchannel); - newBackendList.add( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(loadRecorder))); + // Store the load recorder for token attachment. + currentPickFirstLoadRecorder = loadRecorder; break; default: throw new AssertionError("Missing case for " + config.getMode()); @@ -842,7 +862,11 @@ private void cleanUp() { private void maybeUpdatePicker() { List pickList; ConnectivityState state; - if (backendList.isEmpty()) { + // For PICK_FIRST mode with delegation, check if child LB exists instead of backendList. + boolean hasBackends = config.getMode() == Mode.PICK_FIRST + ? pickFirstLb != null + : !backendList.isEmpty(); + if (!hasBackends) { // Note balancer (is working) may enforce using fallback backends, and that fallback may // fail. So we should check if currently in fallback first. if (usingFallbackBackends) { @@ -894,26 +918,12 @@ private void maybeUpdatePicker() { } break; case PICK_FIRST: { - checkState(backendList.size() == 1, "Excessive backend entries: %s", backendList); - BackendEntry onlyEntry = backendList.get(0); - ConnectivityStateInfo stateInfo = - onlyEntry.subchannel.getAttributes().get(STATE_INFO).get(); - state = stateInfo.getState(); - switch (state) { - case READY: - pickList = Collections.singletonList(onlyEntry); - break; - case TRANSIENT_FAILURE: - pickList = - Collections.singletonList(new ErrorEntry(stateInfo.getStatus())); - break; - case CONNECTING: - pickList = Collections.singletonList(BUFFER_ENTRY); - break; - default: - pickList = Collections.singletonList( - new IdleSubchannelEntry(onlyEntry.subchannel, syncContext)); - } + // Use child LB's state and picker. Wrap the picker for token attachment. + state = pickFirstLbState; + TokenAttachingTracerFactory tracerFactory = + new TokenAttachingTracerFactory(currentPickFirstLoadRecorder); + pickList = Collections.singletonList( + new ChildLbPickerEntry(pickFirstLbPicker, tracerFactory)); break; } default: @@ -929,10 +939,12 @@ private void maybeUpdatePicker(ConnectivityState state, RoundRobinPicker picker) // Discard the new picker if we are sure it won't make any difference, in order to save // re-processing pending streams, and avoid unnecessary resetting of the pointer in // RoundRobinPicker. - if (picker.dropList.equals(currentPicker.dropList) + if (state.equals(currentState) + && picker.dropList.equals(currentPicker.dropList) && picker.pickList.equals(currentPicker.pickList)) { return; } + currentState = state; currentPicker = picker; helper.updateBalancingState(state, picker); } @@ -983,7 +995,7 @@ public boolean equals(Object other) { @VisibleForTesting interface RoundRobinEntry { - PickResult picked(Metadata headers); + PickResult picked(PickSubchannelArgs args); } @VisibleForTesting @@ -1024,7 +1036,8 @@ static final class BackendEntry implements RoundRobinEntry { } @Override - public PickResult picked(Metadata headers) { + public PickResult picked(PickSubchannelArgs args) { + Metadata headers = args.getHeaders(); headers.discardAll(GrpclbConstants.TOKEN_METADATA_KEY); if (token != null) { headers.put(GrpclbConstants.TOKEN_METADATA_KEY, token); @@ -1065,7 +1078,7 @@ static final class IdleSubchannelEntry implements RoundRobinEntry { } @Override - public PickResult picked(Metadata headers) { + public PickResult picked(PickSubchannelArgs args) { if (connectionRequested.compareAndSet(false, true)) { syncContext.execute(new Runnable() { @Override @@ -1108,7 +1121,7 @@ static final class ErrorEntry implements RoundRobinEntry { } @Override - public PickResult picked(Metadata headers) { + public PickResult picked(PickSubchannelArgs args) { return result; } @@ -1132,6 +1145,58 @@ public String toString() { } } + /** + * Entry that wraps a child LB's picker for PICK_FIRST mode delegation. + * Attaches TokenAttachingTracerFactory to the pick result for token propagation. + */ + @VisibleForTesting + static final class ChildLbPickerEntry implements RoundRobinEntry { + private final SubchannelPicker childPicker; + private final TokenAttachingTracerFactory tracerFactory; + + ChildLbPickerEntry(SubchannelPicker childPicker, TokenAttachingTracerFactory tracerFactory) { + this.childPicker = checkNotNull(childPicker, "childPicker"); + this.tracerFactory = checkNotNull(tracerFactory, "tracerFactory"); + } + + @Override + public PickResult picked(PickSubchannelArgs args) { + PickResult childResult = childPicker.pickSubchannel(args); + if (childResult.getSubchannel() == null) { + // No subchannel (e.g., buffer, error), return as-is. + return childResult; + } + // Wrap the pick result to attach tokens via the tracer factory. + return PickResult.withSubchannel( + childResult.getSubchannel(), tracerFactory, childResult.getAuthorityOverride()); + } + + @Override + public int hashCode() { + return Objects.hashCode(childPicker, tracerFactory); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof ChildLbPickerEntry)) { + return false; + } + ChildLbPickerEntry that = (ChildLbPickerEntry) other; + return Objects.equal(childPicker, that.childPicker) + && Objects.equal(tracerFactory, that.tracerFactory); + } + + @Override + public String toString() { + return "ChildLbPickerEntry(" + childPicker + ")"; + } + + @VisibleForTesting + SubchannelPicker getChildPicker() { + return childPicker; + } + } + @VisibleForTesting static final class RoundRobinPicker extends SubchannelPicker { @VisibleForTesting @@ -1174,7 +1239,7 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { if (pickIndex == pickList.size()) { pickIndex = 0; } - return pick.picked(args.getHeaders()); + return pick.picked(args); } } @@ -1189,4 +1254,28 @@ public String toString() { return MoreObjects.toStringHelper(RoundRobinPicker.class).toString(); } } + + /** + * Helper for the child pick_first LB in PICK_FIRST mode. Intercepts updateBalancingState() + * to store state and trigger the grpclb picker update with drops and token attachment. + */ + private final class PickFirstLbHelper extends ForwardingLoadBalancerHelper { + + @Override + protected Helper delegate() { + return helper; + } + + @Override + public void updateBalancingState(ConnectivityState newState, SubchannelPicker newPicker) { + pickFirstLbState = newState; + pickFirstLbPicker = newPicker; + // Trigger name resolution refresh on TRANSIENT_FAILURE or IDLE, similar to ROUND_ROBIN. + if (newState == TRANSIENT_FAILURE || newState == IDLE) { + helper.refreshNameResolution(); + } + maybeUseFallbackBackends(); + maybeUpdatePicker(); + } + } } diff --git a/grpclb/src/main/java/io/grpc/grpclb/SecretGrpclbNameResolverProvider.java b/grpclb/src/main/java/io/grpc/grpclb/SecretGrpclbNameResolverProvider.java index 8952ea1d8fb..f394c812b28 100644 --- a/grpclb/src/main/java/io/grpc/grpclb/SecretGrpclbNameResolverProvider.java +++ b/grpclb/src/main/java/io/grpc/grpclb/SecretGrpclbNameResolverProvider.java @@ -19,14 +19,17 @@ import com.google.common.base.Preconditions; import com.google.common.base.Stopwatch; import io.grpc.InternalServiceProviders; +import io.grpc.NameResolver; import io.grpc.NameResolver.Args; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import io.grpc.internal.GrpcUtil; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; import java.util.Collection; import java.util.Collections; +import java.util.List; /** * A provider for {@code io.grpc.grpclb.GrpclbNameResolver}. @@ -56,27 +59,47 @@ public static final class Provider extends NameResolverProvider { private static final boolean IS_ANDROID = InternalServiceProviders .isAndroid(SecretGrpclbNameResolverProvider.class.getClassLoader()); + @Override + public NameResolver newNameResolver(Uri targetUri, final NameResolver.Args args) { + if (SCHEME.equals(targetUri.getScheme())) { + List pathSegments = targetUri.getPathSegments(); + Preconditions.checkArgument( + pathSegments.size() == 1, + "expected 1 path segment in target %s but found %s", + targetUri, + pathSegments); + return newNameResolver(targetUri.getAuthority(), pathSegments.get(0), args); + } else { + return null; + } + } + @Override public GrpclbNameResolver newNameResolver(URI targetUri, Args args) { + // TODO(jdcormie): Remove once RFC 3986 migration is complete. if (SCHEME.equals(targetUri.getScheme())) { String targetPath = Preconditions.checkNotNull(targetUri.getPath(), "targetPath"); Preconditions.checkArgument( targetPath.startsWith("/"), "the path component (%s) of the target (%s) must start with '/'", targetPath, targetUri); - String name = targetPath.substring(1); - return new GrpclbNameResolver( - targetUri.getAuthority(), - name, - args, - GrpcUtil.SHARED_CHANNEL_EXECUTOR, - Stopwatch.createUnstarted(), - IS_ANDROID); + return newNameResolver(targetUri.getAuthority(), targetPath.substring(1), args); } else { return null; } } + private GrpclbNameResolver newNameResolver( + String authority, String domainNameToResolve, final NameResolver.Args args) { + return new GrpclbNameResolver( + authority, + domainNameToResolve, + args, + GrpcUtil.SHARED_CHANNEL_EXECUTOR, + Stopwatch.createUnstarted(), + IS_ANDROID); + } + @Override public String getDefaultScheme() { return SCHEME; diff --git a/grpclb/src/test/java/io/grpc/grpclb/GrpclbLoadBalancerTest.java b/grpclb/src/test/java/io/grpc/grpclb/GrpclbLoadBalancerTest.java index e489129676a..ef31b318cb5 100644 --- a/grpclb/src/test/java/io/grpc/grpclb/GrpclbLoadBalancerTest.java +++ b/grpclb/src/test/java/io/grpc/grpclb/GrpclbLoadBalancerTest.java @@ -72,6 +72,7 @@ import io.grpc.Status.Code; import io.grpc.SynchronizationContext; import io.grpc.grpclb.GrpclbState.BackendEntry; +import io.grpc.grpclb.GrpclbState.ChildLbPickerEntry; import io.grpc.grpclb.GrpclbState.DropEntry; import io.grpc.grpclb.GrpclbState.ErrorEntry; import io.grpc.grpclb.GrpclbState.IdleSubchannelEntry; @@ -779,7 +780,9 @@ public void receiveNoBackendAndBalancerAddress() { verify(helper).updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); RoundRobinPicker picker = (RoundRobinPicker) pickerCaptor.getValue(); assertThat(picker.dropList).isEmpty(); - Status error = Iterables.getOnlyElement(picker.pickList).picked(new Metadata()).getStatus(); + PickSubchannelArgs args = mock(PickSubchannelArgs.class); + when(args.getHeaders()).thenReturn(new Metadata()); + Status error = Iterables.getOnlyElement(picker.pickList).picked(args).getStatus(); assertThat(error.getCode()).isEqualTo(Code.UNAVAILABLE); assertThat(error.getDescription()).isEqualTo("No backend or balancer addresses found"); } @@ -1915,6 +1918,7 @@ public void grpclbWorking_pickFirstMode() throws Exception { lbResponseObserver.onNext(buildInitialResponse()); lbResponseObserver.onNext(buildLbResponse(backends1)); + // With delegation, the child pick_first creates the subchannel inOrder.verify(helper).createSubchannel(createSubchannelArgsCaptor.capture()); CreateSubchannelArgs createSubchannelArgs = createSubchannelArgsCaptor.getValue(); assertThat(createSubchannelArgs.getAddresses()) @@ -1922,42 +1926,41 @@ public void grpclbWorking_pickFirstMode() throws Exception { new EquivalentAddressGroup(backends1.get(0).addr, eagAttrsWithToken("token0001")), new EquivalentAddressGroup(backends1.get(1).addr, eagAttrsWithToken("token0002"))); - // Initially IDLE - inOrder.verify(helper).updateBalancingState(eq(IDLE), pickerCaptor.capture()); + // Child pick_first eagerly connects, so we start in CONNECTING + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); RoundRobinPicker picker0 = (RoundRobinPicker) pickerCaptor.getValue(); - - // Only one subchannel is created + // Only one subchannel is created by the child LB assertThat(mockSubchannels).hasSize(1); Subchannel subchannel = mockSubchannels.poll(); assertThat(picker0.dropList).containsExactly(null, null); - assertThat(picker0.pickList).containsExactly(new IdleSubchannelEntry(subchannel, syncContext)); + assertThat(picker0.pickList).hasSize(1); + assertThat(picker0.pickList.get(0)).isInstanceOf(ChildLbPickerEntry.class); - // PICK_FIRST doesn't eagerly connect - verify(subchannel, never()).requestConnection(); - - // CONNECTING - deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(CONNECTING)); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - RoundRobinPicker picker1 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker1.dropList).containsExactly(null, null); - assertThat(picker1.pickList).containsExactly(BUFFER_ENTRY); + // Child pick_first eagerly calls requestConnection() + verify(subchannel).requestConnection(); // TRANSIENT_FAILURE Status error = Status.UNAVAILABLE.withDescription("Simulated connection error"); deliverSubchannelState(subchannel, ConnectivityStateInfo.forTransientFailure(error)); - inOrder.verify(helper).updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); - RoundRobinPicker picker2 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker2.dropList).containsExactly(null, null); - assertThat(picker2.pickList).containsExactly(new ErrorEntry(error)); + // The child LB will notify our helper, which updates grpclb state + inOrder.verify(helper, atLeast(1)) + .updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); + RoundRobinPicker picker1 = (RoundRobinPicker) pickerCaptor.getValue(); + assertThat(picker1.dropList).containsExactly(null, null); + ChildLbPickerEntry failureEntry = (ChildLbPickerEntry) picker1.pickList.get(0); + PickResult failureResult = + failureEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)); + assertThat(failureResult.getStatus()).isEqualTo(error); // READY deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(READY)); - inOrder.verify(helper).updateBalancingState(eq(READY), pickerCaptor.capture()); - RoundRobinPicker picker3 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker3.dropList).containsExactly(null, null); - assertThat(picker3.pickList).containsExactly( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(getLoadRecorder()))); - + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(READY), pickerCaptor.capture()); + RoundRobinPicker picker2 = (RoundRobinPicker) pickerCaptor.getValue(); + assertThat(picker2.dropList).containsExactly(null, null); + ChildLbPickerEntry readyEntry = (ChildLbPickerEntry) picker2.pickList.get(0); + PickResult readyResult = + readyEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)); + assertThat(readyResult.getSubchannel()).isEqualTo(subchannel); // New server list with drops List backends2 = Arrays.asList( @@ -1968,37 +1971,40 @@ public void grpclbWorking_pickFirstMode() throws Exception { .updateBalancingState(any(ConnectivityState.class), any(SubchannelPicker.class)); lbResponseObserver.onNext(buildLbResponse(backends2)); - // new addresses will be updated to the existing subchannel - // createSubchannel() has ever been called only once + // Verify child LB is updated with new addresses, NOT recreated + inOrder.verify(helper, never()).createSubchannel(any(CreateSubchannelArgs.class)); verify(helper, times(1)).createSubchannel(any(CreateSubchannelArgs.class)); assertThat(mockSubchannels).isEmpty(); + + // The child LB policy internally calls updateAddresses on the subchannel verify(subchannel).updateAddresses( eq(Arrays.asList( new EquivalentAddressGroup(backends2.get(0).addr, eagAttrsWithToken("token0001")), new EquivalentAddressGroup(backends2.get(2).addr, eagAttrsWithToken("token0004"))))); - inOrder.verify(helper).updateBalancingState(eq(READY), pickerCaptor.capture()); - RoundRobinPicker picker4 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker4.dropList).containsExactly( + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(READY), pickerCaptor.capture()); + RoundRobinPicker picker3 = (RoundRobinPicker) pickerCaptor.getValue(); + assertThat(picker3.dropList).containsExactly( null, new DropEntry(getLoadRecorder(), "token0003"), null); - assertThat(picker4.pickList).containsExactly( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(getLoadRecorder()))); + ChildLbPickerEntry updatedEntry = (ChildLbPickerEntry) picker3.pickList.get(0); + PickResult updatedResult = + updatedEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)); + assertThat(updatedResult.getSubchannel()).isEqualTo(subchannel); - // Subchannel goes IDLE, but PICK_FIRST will not try to reconnect + // Subchannel goes IDLE, grpclb state should follow deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(IDLE)); inOrder.verify(helper).updateBalancingState(eq(IDLE), pickerCaptor.capture()); - RoundRobinPicker picker5 = (RoundRobinPicker) pickerCaptor.getValue(); - verify(subchannel, never()).requestConnection(); + RoundRobinPicker picker4 = (RoundRobinPicker) pickerCaptor.getValue(); - // ... until it's selected + // No new connection request should have happened yet (beyond the first eager one) + verify(subchannel, times(1)).requestConnection(); PickSubchannelArgs args = mock(PickSubchannelArgs.class); - PickResult pick = picker5.pickSubchannel(args); - assertThat(pick).isSameInstanceAs(PickResult.withNoResult()); - verify(subchannel).requestConnection(); - - // ... or requested by application - balancer.requestConnection(); + PickResult pick = picker4.pickSubchannel(args); + // Child pick_first picker returns withNoResult() when IDLE and requests connection + assertThat(pick.getSubchannel()).isNull(); verify(subchannel, times(2)).requestConnection(); + balancer.requestConnection(); + verify(subchannel, times(3)).requestConnection(); // PICK_FIRST doesn't use subchannelPool verify(subchannelPool, never()) @@ -2036,6 +2042,7 @@ public void grpclbWorking_pickFirstMode_lbSendsEmptyAddress() throws Exception { lbResponseObserver.onNext(buildInitialResponse()); lbResponseObserver.onNext(buildLbResponse(backends1)); + // The child pick_first creates the first subchannel inOrder.verify(helper).createSubchannel(createSubchannelArgsCaptor.capture()); CreateSubchannelArgs createSubchannelArgs = createSubchannelArgsCaptor.getValue(); assertThat(createSubchannelArgs.getAddresses()) @@ -2043,56 +2050,43 @@ public void grpclbWorking_pickFirstMode_lbSendsEmptyAddress() throws Exception { new EquivalentAddressGroup(backends1.get(0).addr, eagAttrsWithToken("token0001")), new EquivalentAddressGroup(backends1.get(1).addr, eagAttrsWithToken("token0002"))); - // Initially IDLE - inOrder.verify(helper).updateBalancingState(eq(IDLE), pickerCaptor.capture()); + // Child pick_first eagerly connects, so initial state is CONNECTING + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); RoundRobinPicker picker0 = (RoundRobinPicker) pickerCaptor.getValue(); - - // Only one subchannel is created + // Verify subchannel creation by child LB assertThat(mockSubchannels).hasSize(1); Subchannel subchannel = mockSubchannels.poll(); assertThat(picker0.dropList).containsExactly(null, null); - assertThat(picker0.pickList).containsExactly(new IdleSubchannelEntry(subchannel, syncContext)); - - // PICK_FIRST doesn't eagerly connect - verify(subchannel, never()).requestConnection(); + assertThat(picker0.pickList).hasSize(1); + assertThat(picker0.pickList.get(0)).isInstanceOf(ChildLbPickerEntry.class); - // CONNECTING - deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(CONNECTING)); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - RoundRobinPicker picker1 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker1.dropList).containsExactly(null, null); - assertThat(picker1.pickList).containsExactly(BUFFER_ENTRY); - - // TRANSIENT_FAILURE - Status error = Status.UNAVAILABLE.withDescription("Simulated connection error"); - deliverSubchannelState(subchannel, ConnectivityStateInfo.forTransientFailure(error)); - inOrder.verify(helper).updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); - RoundRobinPicker picker2 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker2.dropList).containsExactly(null, null); - assertThat(picker2.pickList).containsExactly(new ErrorEntry(error)); + // Child pick_first eagerly calls requestConnection() + verify(subchannel).requestConnection(); // READY deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(READY)); - inOrder.verify(helper).updateBalancingState(eq(READY), pickerCaptor.capture()); - RoundRobinPicker picker3 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker3.dropList).containsExactly(null, null); - assertThat(picker3.pickList).containsExactly( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(getLoadRecorder()))); - + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(READY), pickerCaptor.capture()); + RoundRobinPicker pickerReady = (RoundRobinPicker) pickerCaptor.getValue(); + // Verify the subchannel in the delegated picker + ChildLbPickerEntry readyEntry = (ChildLbPickerEntry) pickerReady.pickList.get(0); + assertThat( + readyEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)).getSubchannel()) + .isEqualTo(subchannel); inOrder.verify(helper, never()) .updateBalancingState(any(ConnectivityState.class), any(SubchannelPicker.class)); - // Empty addresses from LB + // Empty addresses from LB - child LB is shutdown lbResponseObserver.onNext(buildLbResponse(Collections.emptyList())); - // new addresses will be updated to the existing subchannel + // Child LB is shutdown (which shuts down its subchannel) // createSubchannel() has ever been called only once inOrder.verify(helper, never()).createSubchannel(any(CreateSubchannelArgs.class)); assertThat(mockSubchannels).isEmpty(); verify(subchannel).shutdown(); // RPC error status includes message of no backends provided by balancer - inOrder.verify(helper).updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); + inOrder.verify(helper, atLeast(1)) + .updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); RoundRobinPicker errorPicker = (RoundRobinPicker) pickerCaptor.getValue(); assertThat(errorPicker.pickList) .containsExactly(new ErrorEntry(GrpclbState.NO_AVAILABLE_BACKENDS_STATUS)); @@ -2109,18 +2103,22 @@ public void grpclbWorking_pickFirstMode_lbSendsEmptyAddress() throws Exception { .updateBalancingState(any(ConnectivityState.class), any(SubchannelPicker.class)); lbResponseObserver.onNext(buildLbResponse(backends2)); - // new addresses will be updated to the existing subchannel - inOrder.verify(helper, times(1)).createSubchannel(any(CreateSubchannelArgs.class)); - inOrder.verify(helper).updateBalancingState(eq(IDLE), pickerCaptor.capture()); - subchannel = mockSubchannels.poll(); + // A NEW child LB and NEW subchannel are created upon recovery + inOrder.verify(helper).createSubchannel(any(CreateSubchannelArgs.class)); + assertThat(mockSubchannels).hasSize(1); + Subchannel subchannel2 = mockSubchannels.poll(); + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); // Subchannel became READY - deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(CONNECTING)); - deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(READY)); - inOrder.verify(helper).updateBalancingState(eq(READY), pickerCaptor.capture()); - RoundRobinPicker picker4 = (RoundRobinPicker) pickerCaptor.getValue(); - assertThat(picker4.pickList).containsExactly( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(getLoadRecorder()))); + deliverSubchannelState(subchannel2, ConnectivityStateInfo.forNonError(READY)); + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(READY), pickerCaptor.capture()); + RoundRobinPicker pickerFinal = (RoundRobinPicker) pickerCaptor.getValue(); + assertThat(pickerFinal.dropList).containsExactly( + null, new DropEntry(getLoadRecorder(), "token0003"), null); + ChildLbPickerEntry finalEntry = (ChildLbPickerEntry) pickerFinal.pickList.get(0); + assertThat( + finalEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)).getSubchannel()) + .isEqualTo(subchannel2); } @Test @@ -2179,7 +2177,7 @@ private void pickFirstModeFallback(long timeout) throws Exception { // Fallback timer expires with no response fakeClock.forwardTime(timeout, TimeUnit.MILLISECONDS); - // Entering fallback mode + // Entering fallback mode - child LB is created for fallback backends inOrder.verify(helper).createSubchannel(createSubchannelArgsCaptor.capture()); CreateSubchannelArgs createSubchannelArgs = createSubchannelArgsCaptor.getValue(); assertThat(createSubchannelArgs.getAddresses()) @@ -2188,23 +2186,24 @@ private void pickFirstModeFallback(long timeout) throws Exception { assertThat(mockSubchannels).hasSize(1); Subchannel subchannel = mockSubchannels.poll(); - // Initially IDLE - inOrder.verify(helper).updateBalancingState(eq(IDLE), pickerCaptor.capture()); + // child pick_first eagerly connects, so initial state is CONNECTING + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); RoundRobinPicker picker0 = (RoundRobinPicker) pickerCaptor.getValue(); + assertThat(picker0.pickList.get(0)).isInstanceOf(ChildLbPickerEntry.class); - // READY + // Initial eager connection request + verify(subchannel).requestConnection(); + // READY transition in fallback deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(READY)); - inOrder.verify(helper).updateBalancingState(eq(READY), pickerCaptor.capture()); + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(READY), pickerCaptor.capture()); RoundRobinPicker picker1 = (RoundRobinPicker) pickerCaptor.getValue(); assertThat(picker1.dropList).containsExactly(null, null); - assertThat(picker1.pickList).containsExactly( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(null))); + ChildLbPickerEntry readyEntry = (ChildLbPickerEntry) picker1.pickList.get(0); + assertThat( + readyEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)).getSubchannel()) + .isEqualTo(subchannel); - assertThat(picker0.dropList).containsExactly(null, null); - assertThat(picker0.pickList).containsExactly(new IdleSubchannelEntry(subchannel, syncContext)); - - - // Finally, an LB response, which brings us out of fallback + // Finally, an LB response arrives, which brings us out of fallback List backends1 = Arrays.asList( new ServerEntry("127.0.0.1", 2000, "token0001"), new ServerEntry("127.0.0.1", 2010, "token0002")); @@ -2213,20 +2212,42 @@ private void pickFirstModeFallback(long timeout) throws Exception { lbResponseObserver.onNext(buildInitialResponse()); lbResponseObserver.onNext(buildLbResponse(backends1)); - // new addresses will be updated to the existing subchannel - // createSubchannel() has ever been called only once + // subchannel should be updated, NOT recreated inOrder.verify(helper, never()).createSubchannel(any(CreateSubchannelArgs.class)); assertThat(mockSubchannels).isEmpty(); + // The child LB internally calls updateAddresses on the existing subchannel verify(subchannel).updateAddresses( eq(Arrays.asList( new EquivalentAddressGroup(backends1.get(0).addr, eagAttrsWithToken("token0001")), new EquivalentAddressGroup(backends1.get(1).addr, eagAttrsWithToken("token0002"))))); - inOrder.verify(helper).updateBalancingState(eq(READY), pickerCaptor.capture()); + inOrder.verify(helper, atLeast(1)).updateBalancingState(eq(READY), pickerCaptor.capture()); RoundRobinPicker picker2 = (RoundRobinPicker) pickerCaptor.getValue(); assertThat(picker2.dropList).containsExactly(null, null); - assertThat(picker2.pickList).containsExactly( - new BackendEntry(subchannel, new TokenAttachingTracerFactory(getLoadRecorder()))); + + // Verify subchannel is still the same via delegated picker + ChildLbPickerEntry updatedEntry = (ChildLbPickerEntry) picker2.pickList.get(0); + assertThat( + updatedEntry.getChildPicker().pickSubchannel(mock(PickSubchannelArgs.class)) + .getSubchannel()) + .isEqualTo(subchannel); + + // Subchannel goes IDLE, grpclb follows + deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(IDLE)); + inOrder.verify(helper).updateBalancingState(eq(IDLE), pickerCaptor.capture()); + RoundRobinPicker pickerIdle = (RoundRobinPicker) pickerCaptor.getValue(); + + // Verify connection is NOT eagerly requested again yet (still only the 1st request from start) + verify(subchannel, times(1)).requestConnection(); + + // Picking while IDLE triggers a new connection request + PickSubchannelArgs args = mock(PickSubchannelArgs.class); + PickResult pick = pickerIdle.pickSubchannel(args); + assertThat(pick.getSubchannel()).isNull(); // BUFFERing while IDLE + verify(subchannel, times(2)).requestConnection(); + + balancer.requestConnection(); + verify(subchannel, times(3)).requestConnection(); // PICK_FIRST doesn't use subchannelPool verify(subchannelPool, never()) @@ -2260,6 +2281,8 @@ public void switchMode() throws Exception { List backends1 = Arrays.asList( new ServerEntry("127.0.0.1", 2000, "token0001"), new ServerEntry("127.0.0.1", 2010, "token0002")); + + // RR Mode: Ensure no updates before initial response inOrder.verify(helper, never()) .updateBalancingState(any(ConnectivityState.class), any(SubchannelPicker.class)); lbResponseObserver.onNext(buildInitialResponse()); @@ -2284,7 +2307,6 @@ public void switchMode() throws Exception { Collections.emptyList(), grpclbBalancerList, GrpclbConfig.create(Mode.PICK_FIRST)); - // GrpclbState will be shutdown, and a new one will be created assertThat(oobChannel.isShutdown()).isTrue(); verify(subchannelPool) @@ -2303,13 +2325,13 @@ public void switchMode() throws Exception { InitialLoadBalanceRequest.newBuilder().setName(SERVICE_AUTHORITY).build()) .build())); - // Simulate receiving LB response + // Simulate receiving LB response for PICK_FIRST inOrder.verify(helper, never()) .updateBalancingState(any(ConnectivityState.class), any(SubchannelPicker.class)); lbResponseObserver.onNext(buildInitialResponse()); lbResponseObserver.onNext(buildLbResponse(backends1)); - // PICK_FIRST Subchannel + // PICK_FIRST Subchannel: child LB creates it inOrder.verify(helper).createSubchannel(createSubchannelArgsCaptor.capture()); CreateSubchannelArgs createSubchannelArgs = createSubchannelArgsCaptor.getValue(); assertThat(createSubchannelArgs.getAddresses()) @@ -2317,7 +2339,9 @@ public void switchMode() throws Exception { new EquivalentAddressGroup(backends1.get(0).addr, eagAttrsWithToken("token0001")), new EquivalentAddressGroup(backends1.get(1).addr, eagAttrsWithToken("token0002"))); - inOrder.verify(helper).updateBalancingState(eq(IDLE), any(SubchannelPicker.class)); + // Child pick_first eagerly connects, so initial state is CONNECTING (not IDLE) + inOrder.verify(helper, atLeast(1)) + .updateBalancingState(eq(CONNECTING), any(SubchannelPicker.class)); } private static Attributes eagAttrsWithToken(String token) { @@ -2344,7 +2368,7 @@ public void switchMode_nullLbPolicy() throws Exception { InitialLoadBalanceRequest.newBuilder().setName(SERVICE_AUTHORITY).build()) .build())); - // Simulate receiving LB response + // Simulate receiving LB response (Initial default mode: ROUND_ROBIN) List backends1 = Arrays.asList( new ServerEntry("127.0.0.1", 2000, "token0001"), new ServerEntry("127.0.0.1", 2010, "token0002")); @@ -2391,13 +2415,13 @@ public void switchMode_nullLbPolicy() throws Exception { InitialLoadBalanceRequest.newBuilder().setName(SERVICE_AUTHORITY).build()) .build())); - // Simulate receiving LB response + // Simulate receiving LB response for PICK_FIRST inOrder.verify(helper, never()) .updateBalancingState(any(ConnectivityState.class), any(SubchannelPicker.class)); lbResponseObserver.onNext(buildInitialResponse()); lbResponseObserver.onNext(buildLbResponse(backends1)); - // PICK_FIRST Subchannel + // PICK_FIRST Subchannel: with delegation, child LB creates the subchannel inOrder.verify(helper).createSubchannel(createSubchannelArgsCaptor.capture()); CreateSubchannelArgs createSubchannelArgs = createSubchannelArgsCaptor.getValue(); assertThat(createSubchannelArgs.getAddresses()) @@ -2405,7 +2429,9 @@ public void switchMode_nullLbPolicy() throws Exception { new EquivalentAddressGroup(backends1.get(0).addr, eagAttrsWithToken("token0001")), new EquivalentAddressGroup(backends1.get(1).addr, eagAttrsWithToken("token0002"))); - inOrder.verify(helper).updateBalancingState(eq(IDLE), any(SubchannelPicker.class)); + // Child pick_first eagerly connects, so state is CONNECTING (not IDLE) + inOrder.verify(helper, atLeast(1)) + .updateBalancingState(eq(CONNECTING), any(SubchannelPicker.class)); } @Test diff --git a/grpclb/src/test/java/io/grpc/grpclb/GrpclbNameResolverTest.java b/grpclb/src/test/java/io/grpc/grpclb/GrpclbNameResolverTest.java index 1aa11ecf9af..a90556a01b0 100644 --- a/grpclb/src/test/java/io/grpc/grpclb/GrpclbNameResolverTest.java +++ b/grpclb/src/test/java/io/grpc/grpclb/GrpclbNameResolverTest.java @@ -20,7 +20,6 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -319,7 +318,7 @@ public void resolveAll_balancerLookupFails_stillLookUpServiceConfig() throws Exc } @Test - public void resolve_addressAndBalancersLookupFail_neverLookupServiceConfig() throws Exception { + public void resolve_addressAndBalancersLookupFail_stillLookupServiceConfig() throws Exception { AddressResolver mockAddressResolver = mock(AddressResolver.class); when(mockAddressResolver.resolveAddress(anyString())) .thenThrow(new UnknownHostException("I really tried")); @@ -338,7 +337,7 @@ public void resolve_addressAndBalancersLookupFail_neverLookupServiceConfig() thr Status errorStatus = resultCaptor.getValue().getAddressesOrError().getStatus(); assertThat(errorStatus.getCode()).isEqualTo(Code.UNAVAILABLE); verify(mockAddressResolver).resolveAddress(hostName); - verify(mockResourceResolver, never()).resolveTxt("_grpc_config." + hostName); + verify(mockResourceResolver).resolveTxt("_grpc_config." + hostName); verify(mockResourceResolver).resolveSrv("_grpclb._tcp." + hostName); } } diff --git a/grpclb/src/test/java/io/grpc/grpclb/SecretGrpclbNameResolverProviderTest.java b/grpclb/src/test/java/io/grpc/grpclb/SecretGrpclbNameResolverProviderTest.java index 24b1c781f58..e9ed92a54d0 100644 --- a/grpclb/src/test/java/io/grpc/grpclb/SecretGrpclbNameResolverProviderTest.java +++ b/grpclb/src/test/java/io/grpc/grpclb/SecretGrpclbNameResolverProviderTest.java @@ -17,6 +17,8 @@ package io.grpc.grpclb; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.TruthJUnit.assume; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; @@ -24,15 +26,19 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.internal.DnsNameResolverProvider; import io.grpc.internal.GrpcUtil; import java.net.URI; +import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; /** Unit tests for {@link SecretGrpclbNameResolverProvider}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class SecretGrpclbNameResolverProviderTest { private final SynchronizationContext syncContext = new SynchronizationContext( @@ -53,6 +59,13 @@ public void uncaughtException(Thread t, Throwable e) { private SecretGrpclbNameResolverProvider.Provider provider = new SecretGrpclbNameResolverProvider.Provider(); + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + @Test public void isAvailable() { assertThat(provider.isAvailable()).isTrue(); @@ -66,43 +79,65 @@ public void priority_shouldBeHigherThanDefaultDnsNameResolver() { } @Test - public void newNameResolver() { - assertThat(provider.newNameResolver(URI.create("dns:///localhost:443"), args)) + public void newNameResolverReturnsCorrectType() { + assertThat(newNameResolver("dns:///localhost:443", args)) .isInstanceOf(GrpclbNameResolver.class); - assertThat(provider.newNameResolver(URI.create("notdns:///localhost:443"), args)).isNull(); + assertThat(newNameResolver("notdns:///localhost:443", args)).isNull(); } @Test public void invalidDnsName() throws Exception { - testInvalidUri(new URI("dns", null, "/[invalid]", null)); + testInvalidUri("dns:/%5Binvalid%5D"); } @Test public void validIpv6() throws Exception { - testValidUri(new URI("dns", null, "/[::1]", null)); + testValidUri("dns:/%5B::1%5D"); } @Test public void validDnsNameWithoutPort() throws Exception { - testValidUri(new URI("dns", null, "/foo.googleapis.com", null)); + testValidUri("dns:/foo.googleapis.com"); } @Test public void validDnsNameWithPort() throws Exception { - testValidUri(new URI("dns", null, "/foo.googleapis.com:456", null)); + testValidUri("dns:/foo.googleapis.com:456"); + } + + @Test + public void newNameResolver_rejectsExtraPathSegments() { + assume().that(enableRfc3986UrisParam).isTrue(); + IllegalArgumentException iae = + assertThrows( + IllegalArgumentException.class, + () -> newNameResolver("dns:///localhost:443/extras", args)); + assertThat(iae).hasMessageThat().contains("expected 1 path segment in target"); } - private void testInvalidUri(URI uri) { + @Test + public void newNameResolver_toleratesExtraPathSegments() { + assume().that(enableRfc3986UrisParam).isFalse(); + newNameResolver("dns:///localhost:443/extras", args); + } + + private void testInvalidUri(String uri) { try { - provider.newNameResolver(uri, args); + newNameResolver(uri, args); fail("Should have failed"); } catch (IllegalArgumentException e) { // expected } } - private void testValidUri(URI uri) { - GrpclbNameResolver resolver = provider.newNameResolver(uri, args); + private void testValidUri(String uri) { + NameResolver resolver = newNameResolver(uri, args); assertThat(resolver).isNotNull(); } + + private NameResolver newNameResolver(String uriString, NameResolver.Args args) { + return enableRfc3986UrisParam + ? provider.newNameResolver(Uri.create(uriString), args) + : provider.newNameResolver(URI.create(uriString), args); + } } diff --git a/inprocess/src/main/java/io/grpc/inprocess/InProcessServerBuilder.java b/inprocess/src/main/java/io/grpc/inprocess/InProcessServerBuilder.java index 190f67603c3..b2004426aae 100644 --- a/inprocess/src/main/java/io/grpc/inprocess/InProcessServerBuilder.java +++ b/inprocess/src/main/java/io/grpc/inprocess/InProcessServerBuilder.java @@ -24,6 +24,7 @@ import io.grpc.ExperimentalApi; import io.grpc.ForwardingServerBuilder; import io.grpc.Internal; +import io.grpc.MetricRecorder; import io.grpc.ServerBuilder; import io.grpc.ServerStreamTracer; import io.grpc.internal.FixedObjectPool; @@ -120,7 +121,8 @@ private InProcessServerBuilder(SocketAddress listenAddress) { final class InProcessClientTransportServersBuilder implements ClientTransportServersBuilder { @Override public InternalServer buildClientTransportServers( - List streamTracerFactories) { + List streamTracerFactories, + MetricRecorder metricRecorder) { return buildTransportServers(streamTracerFactories); } } diff --git a/inprocess/src/main/java/io/grpc/inprocess/InProcessTransport.java b/inprocess/src/main/java/io/grpc/inprocess/InProcessTransport.java index e31696eb631..a92f10fd5c5 100644 --- a/inprocess/src/main/java/io/grpc/inprocess/InProcessTransport.java +++ b/inprocess/src/main/java/io/grpc/inprocess/InProcessTransport.java @@ -58,6 +58,7 @@ import io.grpc.internal.ServerStreamListener; import io.grpc.internal.ServerTransport; import io.grpc.internal.ServerTransportListener; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.StatsTraceContext; import io.grpc.internal.StreamListener; import java.io.ByteArrayInputStream; @@ -327,7 +328,7 @@ private synchronized void notifyShutdown(Status s) { return; } shutdown = true; - clientTransportListener.transportShutdown(s); + clientTransportListener.transportShutdown(s, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); } private synchronized void notifyTerminated() { diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java b/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java index 11455790497..51295281a90 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java @@ -1625,7 +1625,6 @@ public void unimplementedService() { } /** Start a fullDuplexCall which the server will not respond, and verify the deadline expires. */ - @SuppressWarnings("MissingFail") @Test public void timeoutOnSleepingServer() throws Exception { TestServiceGrpc.TestServiceStub stub = @@ -1635,15 +1634,10 @@ public void timeoutOnSleepingServer() throws Exception { StreamObserver requestObserver = stub.fullDuplexCall(responseObserver); - StreamingOutputCallRequest request = StreamingOutputCallRequest.newBuilder() + requestObserver.onNext(StreamingOutputCallRequest.newBuilder() .setPayload(Payload.newBuilder() .setBody(ByteString.copyFrom(new byte[27182]))) - .build(); - try { - requestObserver.onNext(request); - } catch (IllegalStateException expected) { - // This can happen if the stream has already been terminated due to deadline exceeded. - } + .build()); assertTrue(responseObserver.awaitCompletion(operationTimeoutMillis(), TimeUnit.MILLISECONDS)); assertEquals(0, responseObserver.getValues().size()); diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProvider.java b/interop-testing/src/main/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProvider.java index 65f1c46892b..f1410142bff 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProvider.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProvider.java @@ -110,6 +110,7 @@ protected LoadBalancer delegate() { return delegateLb; } + @Deprecated @Override public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { helper.setRpcBehavior( diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/TestCases.java b/interop-testing/src/main/java/io/grpc/testing/integration/TestCases.java index 2d16065254a..1a6de6c8da4 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/TestCases.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/TestCases.java @@ -59,7 +59,8 @@ public enum TestCases { RPC_SOAK("sends 'soak_iterations' large_unary rpcs in a loop, each on the same channel"), CHANNEL_SOAK("sends 'soak_iterations' large_unary rpcs in a loop, each on a new channel"), ORCA_PER_RPC("report backend metrics per query"), - ORCA_OOB("report backend metrics out-of-band"); + ORCA_OOB("report backend metrics out-of-band"), + MAX_CONCURRENT_STREAMS_CONNECTION_SCALING("max concurrent streaming connection scaling"); private final String description; diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceClient.java b/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceClient.java index 125d876b705..8a5ca05da91 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceClient.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceClient.java @@ -563,7 +563,11 @@ private void runTest(TestCases testCase) throws Exception { tester.testOrcaOob(); break; } - + + case MAX_CONCURRENT_STREAMS_CONNECTION_SCALING: { + tester.testMcs(); + break; + } default: throw new IllegalArgumentException("Unknown test case: " + testCase); } @@ -596,6 +600,7 @@ private ClientInterceptor maybeCreateAdditionalMetadataInterceptor( } private class Tester extends AbstractInteropTest { + @Override protected ManagedChannelBuilder createChannelBuilder() { boolean useGeneric = false; @@ -979,31 +984,17 @@ public void testOrcaOob() throws Exception { .build(); final int retryLimit = 5; - BlockingQueue queue = new LinkedBlockingQueue<>(); final Object lastItem = new Object(); + StreamingOutputCallResponseObserver streamingOutputCallResponseObserver = + new StreamingOutputCallResponseObserver(lastItem); StreamObserver streamObserver = - asyncStub.fullDuplexCall(new StreamObserver() { - - @Override - public void onNext(StreamingOutputCallResponse value) { - queue.add(value); - } - - @Override - public void onError(Throwable t) { - queue.add(t); - } - - @Override - public void onCompleted() { - queue.add(lastItem); - } - }); + asyncStub.fullDuplexCall(streamingOutputCallResponseObserver); streamObserver.onNext(StreamingOutputCallRequest.newBuilder() .setOrcaOobReport(answer) .addResponseParameters(ResponseParameters.newBuilder().setSize(1).build()).build()); - assertThat(queue.take()).isInstanceOf(StreamingOutputCallResponse.class); + assertThat(streamingOutputCallResponseObserver.take()) + .isInstanceOf(StreamingOutputCallResponse.class); int i = 0; for (; i < retryLimit; i++) { Thread.sleep(1000); @@ -1016,7 +1007,8 @@ public void onCompleted() { streamObserver.onNext(StreamingOutputCallRequest.newBuilder() .setOrcaOobReport(answer2) .addResponseParameters(ResponseParameters.newBuilder().setSize(1).build()).build()); - assertThat(queue.take()).isInstanceOf(StreamingOutputCallResponse.class); + assertThat(streamingOutputCallResponseObserver.take()) + .isInstanceOf(StreamingOutputCallResponse.class); for (i = 0; i < retryLimit; i++) { Thread.sleep(1000); @@ -1027,7 +1019,7 @@ public void onCompleted() { } assertThat(i).isLessThan(retryLimit); streamObserver.onCompleted(); - assertThat(queue.take()).isSameInstanceAs(lastItem); + assertThat(streamingOutputCallResponseObserver.take()).isSameInstanceAs(lastItem); } @Override @@ -1054,6 +1046,85 @@ protected ServerBuilder getHandshakerServerBuilder() { protected int operationTimeoutMillis() { return 15000; } + + class StreamingOutputCallResponseObserver implements + StreamObserver { + private final Object lastItem; + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + public StreamingOutputCallResponseObserver(Object lastItem) { + this.lastItem = lastItem; + } + + @Override + public void onNext(StreamingOutputCallResponse value) { + queue.add(value); + } + + @Override + public void onError(Throwable t) { + queue.add(t); + } + + @Override + public void onCompleted() { + queue.add(lastItem); + } + + Object take() throws InterruptedException { + return queue.take(); + } + } + + public void testMcs() throws Exception { + final Object lastItem = new Object(); + StreamingOutputCallResponseObserver responseObserver1 = + new StreamingOutputCallResponseObserver(lastItem); + StreamObserver streamObserver1 = + asyncStub.fullDuplexCall(responseObserver1); + StreamingOutputCallRequest request = StreamingOutputCallRequest.newBuilder() + .addResponseParameters(ResponseParameters.newBuilder() + .setFillPeerSocketAddress( + Messages.BoolValue.newBuilder().setValue(true).build()) + .build()) + .build(); + streamObserver1.onNext(request); + Object responseObj = responseObserver1.take(); + StreamingOutputCallResponse callResponse = (StreamingOutputCallResponse) responseObj; + String clientSocketAddressInCall1 = callResponse.getPeerSocketAddress(); + assertThat(clientSocketAddressInCall1).isNotEmpty(); + + StreamingOutputCallResponseObserver responseObserver2 = + new StreamingOutputCallResponseObserver(lastItem); + StreamObserver streamObserver2 = + asyncStub.fullDuplexCall(responseObserver2); + streamObserver2.onNext(request); + callResponse = (StreamingOutputCallResponse) responseObserver2.take(); + String clientSocketAddressInCall2 = callResponse.getPeerSocketAddress(); + + assertThat(clientSocketAddressInCall1).isEqualTo(clientSocketAddressInCall2); + + // The first connection is at max rpc call count of 2, so the 3rd rpc will cause a new + // connection to be created in the same subchannel and not get queued. + StreamingOutputCallResponseObserver responseObserver3 = + new StreamingOutputCallResponseObserver(lastItem); + StreamObserver streamObserver3 = + asyncStub.fullDuplexCall(responseObserver3); + streamObserver3.onNext(request); + callResponse = (StreamingOutputCallResponse) responseObserver3.take(); + String clientSocketAddressInCall3 = callResponse.getPeerSocketAddress(); + + // This assertion is currently failing because connection scaling when MCS limit has been + // reached is not yet implemented in gRPC Java. + assertThat(clientSocketAddressInCall3).isNotEqualTo(clientSocketAddressInCall1); + + streamObserver1.onCompleted(); + streamObserver2.onCompleted(); + streamObserver3.onCompleted(); + assertThat(responseObserver1.take()).isSameInstanceAs(lastItem); + assertThat(responseObserver2.take()).isSameInstanceAs(lastItem); + assertThat(responseObserver3.take()).isSameInstanceAs(lastItem); + } } private static String validTestCasesHelpText() { diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceImpl.java b/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceImpl.java index a9ee9382495..f331f08db02 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceImpl.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceImpl.java @@ -16,13 +16,18 @@ package io.grpc.testing.integration; +import static io.grpc.Grpc.TRANSPORT_ATTR_REMOTE_ADDR; + import com.google.common.base.Preconditions; import com.google.common.collect.Queues; import com.google.errorprone.annotations.concurrent.GuardedBy; import com.google.protobuf.ByteString; +import io.grpc.Context; +import io.grpc.Contexts; import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; import io.grpc.Metadata; import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.Status; @@ -42,15 +47,14 @@ import io.grpc.testing.integration.Messages.StreamingOutputCallResponse; import io.grpc.testing.integration.Messages.TestOrcaReport; import io.grpc.testing.integration.TestServiceGrpc.AsyncService; +import java.net.SocketAddress; import java.util.ArrayDeque; import java.util.Arrays; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.Random; -import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.Semaphore; @@ -61,8 +65,8 @@ * sent in response streams. */ public class TestServiceImpl implements io.grpc.BindableService, AsyncService { + static final Context.Key PEER_ADDRESS_CONTEXT_KEY = Context.key("peer-address"); private final Random random = new Random(); - private final ScheduledExecutorService executor; private final ByteString compressableBuffer; private final MetricRecorder metricRecorder; @@ -443,7 +447,13 @@ public Queue toChunkQueue(StreamingOutputCallRequest request) { Queue chunkQueue = new ArrayDeque<>(); int offset = 0; for (ResponseParameters params : request.getResponseParametersList()) { - chunkQueue.add(new Chunk(params.getIntervalUs(), offset, params.getSize())); + String peerSocketAddress = null; + if (params.getFillPeerSocketAddress().getValue()) { + SocketAddress peerAddress = PEER_ADDRESS_CONTEXT_KEY.get(); + peerSocketAddress = peerAddress != null ? peerAddress.toString() : ""; + } + chunkQueue.add( + new Chunk(params.getIntervalUs(), offset, params.getSize(), peerSocketAddress)); // Increment the offset past this chunk. Buffer need to be circular. offset = (offset + params.getSize()) % compressableBuffer.size(); @@ -461,11 +471,17 @@ private class Chunk { private final int delayMicroseconds; private final int offset; private final int length; + private final String peerSocketAddress; public Chunk(int delayMicroseconds, int offset, int length) { + this(delayMicroseconds, offset, length, null); + } + + public Chunk(int delayMicroseconds, int offset, int length, String peerSocketAddress) { this.delayMicroseconds = delayMicroseconds; this.offset = offset; this.length = length; + this.peerSocketAddress = peerSocketAddress; } /** @@ -474,10 +490,15 @@ public Chunk(int delayMicroseconds, int offset, int length) { private StreamingOutputCallResponse toResponse() { StreamingOutputCallResponse.Builder responseBuilder = StreamingOutputCallResponse.newBuilder(); - ByteString payload = generatePayload(compressableBuffer, offset, length); - responseBuilder.setPayload( - Payload.newBuilder() - .setBody(payload)); + if (length > 0) { + ByteString payload = generatePayload(compressableBuffer, offset, length); + responseBuilder.setPayload( + Payload.newBuilder() + .setBody(payload)); + } + if (peerSocketAddress != null) { + responseBuilder.setPeerSocketAddress(peerSocketAddress); + } return responseBuilder.build(); } } @@ -507,31 +528,35 @@ public static List interceptors() { return Arrays.asList( echoRequestHeadersInterceptor(Util.METADATA_KEY), echoRequestMetadataInHeaders(Util.ECHO_INITIAL_METADATA_KEY), - echoRequestMetadataInTrailers(Util.ECHO_TRAILING_METADATA_KEY)); + echoRequestMetadataInTrailers(Util.ECHO_TRAILING_METADATA_KEY), + new AddPeerAddressToContextInterceptor()); } /** - * Echo the request headers from a client into response headers and trailers. Useful for + * Echo a request header from a client into response headers and trailers. Useful for * testing end-to-end metadata propagation. */ - private static ServerInterceptor echoRequestHeadersInterceptor(final Metadata.Key... keys) { - final Set> keySet = new HashSet<>(Arrays.asList(keys)); + private static ServerInterceptor echoRequestHeadersInterceptor(final Metadata.Key key) { return new ServerInterceptor() { @Override public ServerCall.Listener interceptCall( ServerCall call, - final Metadata requestHeaders, + Metadata requestHeaders, ServerCallHandler next) { + if (!requestHeaders.containsKey(key)) { + return next.startCall(call, requestHeaders); + } + T value = requestHeaders.get(key); return next.startCall(new SimpleForwardingServerCall(call) { @Override public void sendHeaders(Metadata responseHeaders) { - responseHeaders.merge(requestHeaders, keySet); + responseHeaders.put(key, value); super.sendHeaders(responseHeaders); } @Override public void close(Status status, Metadata trailers) { - trailers.merge(requestHeaders, keySet); + trailers.put(key, value); super.close(status, trailers); } }, requestHeaders); @@ -539,53 +564,65 @@ public void close(Status status, Metadata trailers) { }; } + static class AddPeerAddressToContextInterceptor implements ServerInterceptor { + @Override + public Listener interceptCall(ServerCall call, + Metadata headers, ServerCallHandler next) { + SocketAddress peerAddress = call.getAttributes().get(TRANSPORT_ATTR_REMOTE_ADDR); + + // Create a new context with the peer address value + Context newContext = Context.current().withValue(PEER_ADDRESS_CONTEXT_KEY, peerAddress); + try { + return Contexts.interceptCall(newContext, call, headers, next); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + } + /** - * Echoes request headers with the specified key(s) from a client into response headers only. + * Echoes request headers with the specified key from a client into response headers only. */ - private static ServerInterceptor echoRequestMetadataInHeaders(final Metadata.Key... keys) { - final Set> keySet = new HashSet<>(Arrays.asList(keys)); + private static ServerInterceptor echoRequestMetadataInHeaders(final Metadata.Key key) { return new ServerInterceptor() { @Override public ServerCall.Listener interceptCall( ServerCall call, final Metadata requestHeaders, ServerCallHandler next) { + if (!requestHeaders.containsKey(key)) { + return next.startCall(call, requestHeaders); + } + T value = requestHeaders.get(key); return next.startCall(new SimpleForwardingServerCall(call) { @Override public void sendHeaders(Metadata responseHeaders) { - responseHeaders.merge(requestHeaders, keySet); + responseHeaders.put(key, value); super.sendHeaders(responseHeaders); } - - @Override - public void close(Status status, Metadata trailers) { - super.close(status, trailers); - } }, requestHeaders); } }; } /** - * Echoes request headers with the specified key(s) from a client into response trailers only. + * Echoes request headers with the specified key from a client into response trailers only. */ - private static ServerInterceptor echoRequestMetadataInTrailers(final Metadata.Key... keys) { - final Set> keySet = new HashSet<>(Arrays.asList(keys)); + private static ServerInterceptor echoRequestMetadataInTrailers(final Metadata.Key key) { return new ServerInterceptor() { @Override public ServerCall.Listener interceptCall( ServerCall call, final Metadata requestHeaders, ServerCallHandler next) { + if (!requestHeaders.containsKey(key)) { + return next.startCall(call, requestHeaders); + } + T value = requestHeaders.get(key); return next.startCall(new SimpleForwardingServerCall(call) { - @Override - public void sendHeaders(Metadata responseHeaders) { - super.sendHeaders(responseHeaders); - } - @Override public void close(Status status, Metadata trailers) { - trailers.merge(requestHeaders, keySet); + trailers.put(key, value); super.close(status, trailers); } }, requestHeaders); diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceServer.java b/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceServer.java index fc4cdf9178f..ee0ecff3ce1 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceServer.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/TestServiceServer.java @@ -75,6 +75,7 @@ public void run() { private int port = 8080; private boolean useTls = true; private boolean useAlts = false; + private int mcsLimit = -1; private ScheduledExecutorService executor; private Server server; @@ -118,6 +119,10 @@ void parseArgs(String[] args) { usage = true; break; } + } else if ("max_concurrent_streams_limit".equals(key)) { + mcsLimit = Integer.parseInt(value); + // TODO: Make Netty server builder usable for IPV6 as well (not limited to MCS handling) + addressType = Util.AddressType.IPV4; // To use NettyServerBuilder } else { System.err.println("Unknown argument: " + key); usage = true; @@ -141,6 +146,8 @@ void parseArgs(String[] args) { + "\n for testing. Only effective when --use_alts=true." + "\n --address_type=IPV4|IPV6|IPV4_IPV6" + "\n What type of addresses to listen on. Default IPV4_IPV6" + + "\n --max_concurrent_streams_limit=LIMIT" + + "\n Set the maximum concurrent streams limit" ); System.exit(1); } @@ -186,6 +193,9 @@ void start() throws Exception { if (v4Address != null && !v4Address.equals(localV4Address)) { ((NettyServerBuilder) serverBuilder).addListenAddress(v4Address); } + if (mcsLimit != -1) { + ((NettyServerBuilder) serverBuilder).maxConcurrentCallsPerConnection(mcsLimit); + } break; case IPV6: List v6Addresses = Util.getV6Addresses(port); diff --git a/interop-testing/src/main/proto/grpc/testing/messages.proto b/interop-testing/src/main/proto/grpc/testing/messages.proto index fbcb6b4ce9b..0a4089a7f7a 100644 --- a/interop-testing/src/main/proto/grpc/testing/messages.proto +++ b/interop-testing/src/main/proto/grpc/testing/messages.proto @@ -159,6 +159,10 @@ message ResponseParameters { // implement the full compression tests by introspecting the call to verify // the response's compression status. BoolValue compressed = 3; + + // Whether to request the server to send the requesting peer's socket + // address in the response. + BoolValue fill_peer_socket_address = 4; } // Server-streaming request. @@ -186,6 +190,9 @@ message StreamingOutputCallRequest { message StreamingOutputCallResponse { // Payload to increase response size. Payload payload = 1; + + // The peer's socket address if requested. + string peer_socket_address = 2; } // For reconnect interop test only. diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/AltsHandshakerTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/AltsHandshakerTest.java index c6c1d2b3e7e..d59512723d9 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/AltsHandshakerTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/AltsHandshakerTest.java @@ -32,7 +32,6 @@ import io.grpc.testing.integration.Messages.Payload; import io.grpc.testing.integration.Messages.SimpleRequest; import io.grpc.testing.integration.Messages.SimpleResponse; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.concurrent.DefaultThreadFactory; import org.junit.Before; @@ -71,11 +70,15 @@ public void unaryCall(SimpleRequest request, StreamObserver so) public void setup() throws Exception { // create new EventLoopGroups to avoid deadlock at server side handshake negotiation, e.g. // happens when handshakerServer and testServer child channels are on the same eventloop. + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup bossGroup = + new io.netty.channel.nio.NioEventLoopGroup(0, new DefaultThreadFactory("test-alts-boss")); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + io.netty.channel.EventLoopGroup workerGroup = + new io.netty.channel.nio.NioEventLoopGroup(0, new DefaultThreadFactory("test-alts-worker")); handshakerServer = grpcCleanup.register(NettyServerBuilder.forPort(0) - .bossEventLoopGroup( - new NioEventLoopGroup(0, new DefaultThreadFactory("test-alts-boss"))) - .workerEventLoopGroup( - new NioEventLoopGroup(0, new DefaultThreadFactory("test-alts-worker"))) + .bossEventLoopGroup(bossGroup) + .workerEventLoopGroup(workerGroup) .channelType(NioServerSocketChannel.class) .addService(new AltsHandshakerTestService()) .build()).start(); diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/Http2NettyLocalChannelTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/Http2NettyLocalChannelTest.java index 6659af68ae0..30c5bf52770 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/Http2NettyLocalChannelTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/Http2NettyLocalChannelTest.java @@ -22,7 +22,7 @@ import io.grpc.netty.NegotiationType; import io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.NettyServerBuilder; -import io.netty.channel.DefaultEventLoopGroup; +import io.netty.channel.EventLoopGroup; import io.netty.channel.local.LocalAddress; import io.netty.channel.local.LocalChannel; import io.netty.channel.local.LocalServerChannel; @@ -36,7 +36,8 @@ @RunWith(JUnit4.class) public class Http2NettyLocalChannelTest extends AbstractInteropTest { - private DefaultEventLoopGroup eventLoopGroup = new DefaultEventLoopGroup(); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + private EventLoopGroup eventLoopGroup = new io.netty.channel.DefaultEventLoopGroup(); @Override protected ServerBuilder getServerBuilder() { diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/RetryTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/RetryTest.java index 669ce1c69db..fbdba498d3d 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/RetryTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/RetryTest.java @@ -59,7 +59,6 @@ import io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.NettyServerBuilder; import io.grpc.testing.GrpcCleanupRule; -import io.netty.channel.DefaultEventLoopGroup; import io.netty.channel.EventLoopGroup; import io.netty.channel.local.LocalAddress; import io.netty.channel.local.LocalChannel; @@ -111,7 +110,8 @@ public class RetryTest { mock(ClientCall.Listener.class, delegatesTo(testCallListener)); private CountDownLatch backoffLatch = new CountDownLatch(1); - private final EventLoopGroup clientGroup = new DefaultEventLoopGroup(1) { + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + private final EventLoopGroup clientGroup = new io.netty.channel.DefaultEventLoopGroup(1) { @SuppressWarnings("FutureReturnValueIgnored") @Override public ScheduledFuture schedule( @@ -138,7 +138,8 @@ public void run() {} // no-op TimeUnit.NANOSECONDS); } }; - private final EventLoopGroup serverGroup = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + private final EventLoopGroup serverGroup = new io.netty.channel.DefaultEventLoopGroup(1); private final FakeStatsRecorder clientStatsRecorder = new FakeStatsRecorder(); private final ClientInterceptor statsInterceptor = InternalCensusStatsAccessor.getClientInterceptor( diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProviderTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProviderTest.java index f17ea059211..4a43af67ac8 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProviderTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/RpcBehaviorLoadBalancerProviderTest.java @@ -78,6 +78,7 @@ public void parseInvalidConfig() { assertThat(status.getDescription()).contains("rpcBehavior"); } + @Deprecated @Test public void handleResolvedAddressesDelegated() { RpcBehaviorLoadBalancer lb = new RpcBehaviorLoadBalancer(new RpcBehaviorHelper(mockHelper), diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/StressTestClientTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/StressTestClientTest.java index c09a0cfeab9..a1a2cb9b5ea 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/StressTestClientTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/StressTestClientTest.java @@ -44,13 +44,13 @@ public class StressTestClientTest { @Rule - public final Timeout globalTimeout = Timeout.seconds(10); + public final Timeout globalTimeout = Timeout.seconds(15); @Test public void ipv6AddressesShouldBeSupported() { StressTestClient client = new StressTestClient(); - client.parseArgs(new String[] {"--server_addresses=[0:0:0:0:0:0:0:1]:8080," - + "[1:2:3:4:f:e:a:b]:8083"}); + client.parseArgs(new String[] { + "--server_addresses=[0:0:0:0:0:0:0:1]:8080,[1:2:3:4:f:e:a:b]:8083"}); assertEquals(2, client.addresses().size()); assertEquals(new InetSocketAddress("0:0:0:0:0:0:0:1", 8080), client.addresses().get(0)); diff --git a/interop-testing/src/test/java/io/grpc/testing/integration/TestCasesTest.java b/interop-testing/src/test/java/io/grpc/testing/integration/TestCasesTest.java index ab32d584e7c..51099ea2498 100644 --- a/interop-testing/src/test/java/io/grpc/testing/integration/TestCasesTest.java +++ b/interop-testing/src/test/java/io/grpc/testing/integration/TestCasesTest.java @@ -67,7 +67,8 @@ public void testCaseNamesShouldMapToEnums() { "cancel_after_first_response", "timeout_on_sleeping_server", "orca_per_rpc", - "orca_oob" + "orca_oob", + "max_concurrent_streams_connection_scaling", }; // additional test cases diff --git a/java_grpc_library.bzl b/java_grpc_library.bzl index aaebfe3f933..e6afc028883 100644 --- a/java_grpc_library.bzl +++ b/java_grpc_library.bzl @@ -1,5 +1,6 @@ """Build rule for java_grpc_library.""" -load("@rules_proto//proto:defs.bzl", "ProtoInfo") + +load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") load("@rules_java//java:defs.bzl", "JavaInfo", "JavaPluginInfo", "java_common") _JavaRpcToolchainInfo = provider( diff --git a/netty/BUILD.bazel b/netty/BUILD.bazel index 8253d1f5bff..1d7c44e24f5 100644 --- a/netty/BUILD.bazel +++ b/netty/BUILD.bazel @@ -17,7 +17,7 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("io.netty:netty-buffer"), - artifact("io.netty:netty-codec"), + artifact("io.netty:netty-codec-base"), artifact("io.netty:netty-codec-http"), artifact("io.netty:netty-codec-http2"), artifact("io.netty:netty-codec-socks"), diff --git a/netty/shaded/build.gradle b/netty/shaded/build.gradle index f805a4f4a1f..27816f9380b 100644 --- a/netty/shaded/build.gradle +++ b/netty/shaded/build.gradle @@ -153,7 +153,11 @@ class NettyResourceTransformer implements Transformer { @Override boolean canTransformResource(FileTreeElement fileTreeElement) { - fileTreeElement.name.startsWith("META-INF/native-image/io.netty") + // io.netty.versions.properties can't actually be shaded successfully, + // as io.netty.util.Version still looks for the unshaded name. But we + // keep the file for manual inspection. + fileTreeElement.name.startsWith("META-INF/native-image/io.netty") || + fileTreeElement.name.startsWith("META-INF/io.netty.versions.properties") } @Override diff --git a/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java b/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java index b4e53d5568c..01e7bc3ed12 100644 --- a/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java +++ b/netty/src/main/java/io/grpc/netty/ClientTransportLifecycleManager.java @@ -19,6 +19,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.grpc.Attributes; import io.grpc.Status; +import io.grpc.internal.DisconnectError; import io.grpc.internal.ManagedClientTransport; /** Maintainer of transport lifecycle status. */ @@ -55,18 +56,18 @@ public void notifyReady() { * Marks transport as shutdown, but does not set the error status. This must eventually be * followed by a call to notifyShutdown. */ - public void notifyGracefulShutdown(Status s) { + public void notifyGracefulShutdown(Status s, DisconnectError disconnectError) { if (transportShutdown) { return; } transportShutdown = true; - listener.transportShutdown(s); + listener.transportShutdown(s, disconnectError); } /** Returns {@code true} if was the first shutdown. */ @CanIgnoreReturnValue - public boolean notifyShutdown(Status s) { - notifyGracefulShutdown(s); + public boolean notifyShutdown(Status s, DisconnectError disconnectError) { + notifyGracefulShutdown(s, disconnectError); if (shutdownStatus != null) { return false; } @@ -82,12 +83,12 @@ public void notifyInUse(boolean inUse) { listener.transportInUse(inUse); } - public void notifyTerminated(Status s) { + public void notifyTerminated(Status s, DisconnectError disconnectError) { if (transportTerminated) { return; } transportTerminated = true; - notifyShutdown(s); + notifyShutdown(s, disconnectError); listener.transportTerminated(); } diff --git a/netty/src/main/java/io/grpc/netty/GrpcHttp2ConnectionHandler.java b/netty/src/main/java/io/grpc/netty/GrpcHttp2ConnectionHandler.java index a463cf01d95..ee5227484fb 100644 --- a/netty/src/main/java/io/grpc/netty/GrpcHttp2ConnectionHandler.java +++ b/netty/src/main/java/io/grpc/netty/GrpcHttp2ConnectionHandler.java @@ -18,7 +18,6 @@ import static com.google.common.base.Preconditions.checkState; -import com.google.common.annotations.VisibleForTesting; import io.grpc.Attributes; import io.grpc.ChannelLogger; import io.grpc.Internal; @@ -28,7 +27,6 @@ import io.netty.handler.codec.http2.Http2ConnectionEncoder; import io.netty.handler.codec.http2.Http2ConnectionHandler; import io.netty.handler.codec.http2.Http2Settings; -import io.netty.util.Version; import javax.annotation.Nullable; /** @@ -36,37 +34,9 @@ */ @Internal public abstract class GrpcHttp2ConnectionHandler extends Http2ConnectionHandler { - static final int ADAPTIVE_CUMULATOR_COMPOSE_MIN_SIZE_DEFAULT = 1024; - static final Cumulator ADAPTIVE_CUMULATOR = - new NettyAdaptiveCumulator(ADAPTIVE_CUMULATOR_COMPOSE_MIN_SIZE_DEFAULT); - @Nullable protected final ChannelPromise channelUnused; private final ChannelLogger negotiationLogger; - private static final boolean usingPre4_1_111_Netty; - - static { - // Netty 4.1.111 introduced a change in the behavior of duplicate() method - // that breaks the assumption of the cumulator. We need to detect this version - // and adjust the behavior accordingly. - - boolean identifiedOldVersion = false; - try { - Version version = Version.identify().get("netty-buffer"); - if (version != null) { - String[] split = version.artifactVersion().split("\\."); - if (split.length >= 3 - && Integer.parseInt(split[0]) == 4 - && Integer.parseInt(split[1]) <= 1 - && Integer.parseInt(split[2]) < 111) { - identifiedOldVersion = true; - } - } - } catch (Exception e) { - // Ignore, we'll assume it's a new version. - } - usingPre4_1_111_Netty = identifiedOldVersion; - } @SuppressWarnings("this-escape") protected GrpcHttp2ConnectionHandler( @@ -78,16 +48,6 @@ protected GrpcHttp2ConnectionHandler( super(decoder, encoder, initialSettings); this.channelUnused = channelUnused; this.negotiationLogger = negotiationLogger; - if (usingPre4_1_111_Netty()) { - // We need to use the adaptive cumulator only if we're using a version of Netty that - // doesn't have the behavior that breaks it. - setCumulator(ADAPTIVE_CUMULATOR); - } - } - - @VisibleForTesting - static boolean usingPre4_1_111_Netty() { - return usingPre4_1_111_Netty; } /** diff --git a/netty/src/main/java/io/grpc/netty/InternalProtocolNegotiators.java b/netty/src/main/java/io/grpc/netty/InternalProtocolNegotiators.java index 039ea6c4f24..35dc1bbc2e8 100644 --- a/netty/src/main/java/io/grpc/netty/InternalProtocolNegotiators.java +++ b/netty/src/main/java/io/grpc/netty/InternalProtocolNegotiators.java @@ -26,6 +26,7 @@ import io.netty.handler.ssl.SslContext; import io.netty.util.AsciiString; import java.util.concurrent.Executor; +import javax.net.ssl.X509TrustManager; /** * Internal accessor for {@link ProtocolNegotiators}. @@ -42,9 +43,11 @@ private InternalProtocolNegotiators() {} */ public static InternalProtocolNegotiator.ProtocolNegotiator tls(SslContext sslContext, ObjectPool executorPool, - Optional handshakeCompleteRunnable) { + Optional handshakeCompleteRunnable, + X509TrustManager extendedX509TrustManager, + String sni) { final io.grpc.netty.ProtocolNegotiator negotiator = ProtocolNegotiators.tls(sslContext, - executorPool, handshakeCompleteRunnable, null); + executorPool, handshakeCompleteRunnable, extendedX509TrustManager, sni); final class TlsNegotiator implements InternalProtocolNegotiator.ProtocolNegotiator { @Override @@ -62,17 +65,19 @@ public void close() { negotiator.close(); } } - + return new TlsNegotiator(); } - + /** * Returns a {@link ProtocolNegotiator} that ensures the pipeline is set up so that TLS will * be negotiated, the {@code handler} is added and writes to the {@link io.netty.channel.Channel} * may happen immediately, even before the TLS Handshake is complete. */ - public static InternalProtocolNegotiator.ProtocolNegotiator tls(SslContext sslContext) { - return tls(sslContext, null, Optional.absent()); + public static InternalProtocolNegotiator.ProtocolNegotiator tls( + SslContext sslContext, String sni, + X509TrustManager extendedX509TrustManager) { + return tls(sslContext, null, Optional.absent(), extendedX509TrustManager, sni); } /** diff --git a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java index 46566eaca1a..8ad67f8f14e 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -38,6 +38,8 @@ import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.Internal; import io.grpc.ManagedChannelBuilder; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; import io.grpc.internal.AtomicBackoff; import io.grpc.internal.ClientTransportFactory; import io.grpc.internal.ConnectionClientTransport; @@ -207,10 +209,20 @@ public int getDefaultPort() { NettyChannelBuilder( String target, ChannelCredentials channelCreds, CallCredentials callCreds, ProtocolNegotiator.ClientFactory negotiator) { + this(target, channelCreds, callCreds, negotiator, null, null); + } + + NettyChannelBuilder( + String target, ChannelCredentials channelCreds, CallCredentials callCreds, + ProtocolNegotiator.ClientFactory negotiator, + NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { managedChannelImplBuilder = new ManagedChannelImplBuilder( target, channelCreds, callCreds, new NettyChannelTransportFactoryBuilder(), - new NettyChannelDefaultPortProvider()); + new NettyChannelDefaultPortProvider(), + nameResolverRegistry, + nameResolverProvider); this.protocolNegotiatorFactory = checkNotNull(negotiator, "negotiator"); this.freezeProtocolNegotiatorFactory = true; } @@ -652,7 +664,7 @@ static ProtocolNegotiator createProtocolNegotiatorByType( case PLAINTEXT_UPGRADE: return ProtocolNegotiators.plaintextUpgrade(); case TLS: - return ProtocolNegotiators.tls(sslContext, executorPool, Optional.absent(), null); + return ProtocolNegotiators.tls(sslContext, executorPool, Optional.absent(), null, null); default: throw new IllegalArgumentException("Unsupported negotiationType: " + negotiationType); } @@ -708,6 +720,8 @@ NettyChannelBuilder setTransportTracerFactory(TransportTracer.Factory transportT return this; } + + static Collection> getSupportedSocketAddressTypes() { return Collections.singleton(InetSocketAddress.class); } @@ -818,6 +832,7 @@ public ConnectionClientTransport newClientTransport( serverAddress = proxiedAddr.getTargetAddress(); localNegotiator = ProtocolNegotiators.httpProxy( proxiedAddr.getProxyAddress(), + proxiedAddr.getHeaders(), proxiedAddr.getUsername(), proxiedAddr.getPassword(), protocolNegotiator); @@ -855,6 +870,7 @@ public void run() { localSocketPicker, channelLogger, useGetForSafeMethods, + options.getMetricRecorder(), Ticker.systemTicker()); return transport; } diff --git a/netty/src/main/java/io/grpc/netty/NettyChannelProvider.java b/netty/src/main/java/io/grpc/netty/NettyChannelProvider.java index 1b22a95a44b..7b2b747a1c4 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelProvider.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelProvider.java @@ -19,6 +19,8 @@ import io.grpc.ChannelCredentials; import io.grpc.Internal; import io.grpc.ManagedChannelProvider; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; import java.net.SocketAddress; import java.util.Collection; @@ -55,6 +57,19 @@ public NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentia new NettyChannelBuilder(target, creds, result.callCredentials, result.negotiator)); } + @Override + public NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentials creds, + NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { + ProtocolNegotiators.FromChannelCredentialsResult result = ProtocolNegotiators.from(creds); + if (result.error != null) { + return NewChannelBuilderResult.error(result.error); + } + NettyChannelBuilder builder = new NettyChannelBuilder(target, creds, + result.callCredentials, result.negotiator, nameResolverRegistry, nameResolverProvider); + return NewChannelBuilderResult.channelBuilder(builder); + } + @Override protected Collection> getSupportedSocketAddressTypes() { return NettyChannelBuilder.getSupportedSocketAddressTypes(); diff --git a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java index d6bb3790433..14a1d7535ad 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java @@ -30,15 +30,19 @@ import io.grpc.InternalChannelz; import io.grpc.InternalStatus; import io.grpc.Metadata; +import io.grpc.MetricRecorder; import io.grpc.Status; import io.grpc.StatusException; import io.grpc.internal.ClientStreamListener.RpcProgress; import io.grpc.internal.ClientTransport.PingCallback; +import io.grpc.internal.DisconnectError; +import io.grpc.internal.GoAwayDisconnectError; import io.grpc.internal.GrpcAttributes; import io.grpc.internal.GrpcUtil; import io.grpc.internal.Http2Ping; import io.grpc.internal.InUseStateAggregator; import io.grpc.internal.KeepAliveManager; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.TransportTracer; import io.grpc.netty.GrpcHttp2HeadersUtils.GrpcHttp2ClientHeadersDecoder; import io.netty.buffer.ByteBuf; @@ -120,6 +124,7 @@ class NettyClientHandler extends AbstractNettyHandler { private final Supplier stopwatchFactory; private final TransportTracer transportTracer; private final Attributes eagAttributes; + private final TcpMetrics tcpMetrics; private final String authority; private final InUseStateAggregator inUseState = new InUseStateAggregator() { @@ -161,7 +166,8 @@ static NettyClientHandler newHandler( Attributes eagAttributes, String authority, ChannelLogger negotiationLogger, - Ticker ticker) { + Ticker ticker, + MetricRecorder metricRecorder) { Preconditions.checkArgument(maxHeaderListSize > 0, "maxHeaderListSize must be positive"); Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder); @@ -191,7 +197,8 @@ static NettyClientHandler newHandler( eagAttributes, authority, negotiationLogger, - ticker); + ticker, + metricRecorder); } @VisibleForTesting @@ -211,7 +218,8 @@ static NettyClientHandler newHandler( Attributes eagAttributes, String authority, ChannelLogger negotiationLogger, - Ticker ticker) { + Ticker ticker, + MetricRecorder metricRecorder) { Preconditions.checkNotNull(connection, "connection"); Preconditions.checkNotNull(frameReader, "frameReader"); Preconditions.checkNotNull(lifecycleManager, "lifecycleManager"); @@ -266,7 +274,8 @@ static NettyClientHandler newHandler( pingCounter, ticker, maxHeaderListSize, - softLimitHeaderListSize); + softLimitHeaderListSize, + metricRecorder); } private NettyClientHandler( @@ -285,7 +294,8 @@ private NettyClientHandler( PingLimiter pingLimiter, Ticker ticker, int maxHeaderListSize, - int softLimitHeaderListSize) { + int softLimitHeaderListSize, + MetricRecorder metricRecorder) { super( /* channelUnused= */ null, decoder, @@ -305,6 +315,7 @@ private NettyClientHandler( this.authority = authority; this.attributes = Attributes.newBuilder() .set(GrpcAttributes.ATTR_CLIENT_EAG_ATTRS, eagAttributes).build(); + this.tcpMetrics = new TcpMetrics(metricRecorder); // Set the frame listener on the decoder. decoder().frameListener(new FrameListener()); @@ -475,10 +486,12 @@ private void onRstStreamRead(int streamId, long errorCode) { @Override public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { + tcpMetrics.recordTcpInfo(ctx.channel()); logger.fine("Network channel being closed by the application."); if (ctx.channel().isActive()) { // Ignore notification that the socket was closed lifecycleManager.notifyShutdown( - Status.UNAVAILABLE.withDescription("Transport closed for unknown reason")); + Status.UNAVAILABLE.withDescription("Transport closed for unknown reason"), + SimpleDisconnectError.UNKNOWN); } super.close(ctx, promise); } @@ -486,12 +499,19 @@ public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exce /** * Handler for the Channel shutting down. */ + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + tcpMetrics.channelActive(ctx.channel()); + super.channelActive(ctx); + } + @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { try { logger.fine("Network channel is closed"); + tcpMetrics.channelInactive(ctx.channel()); Status status = Status.UNAVAILABLE.withDescription("Network closed for unknown reason"); - lifecycleManager.notifyShutdown(status); + lifecycleManager.notifyShutdown(status, SimpleDisconnectError.UNKNOWN); final Status streamStatus; if (channelInactiveReason != null) { streamStatus = channelInactiveReason; @@ -512,7 +532,7 @@ public boolean visit(Http2Stream stream) throws Http2Exception { } }); } finally { - lifecycleManager.notifyTerminated(status); + lifecycleManager.notifyTerminated(status, SimpleDisconnectError.UNKNOWN); } } finally { // Close any open streams @@ -560,7 +580,8 @@ InternalChannelz.Security getSecurityInfo() { protected void onConnectionError(ChannelHandlerContext ctx, boolean outbound, Throwable cause, Http2Exception http2Ex) { logger.log(Level.FINE, "Caught a connection error", cause); - lifecycleManager.notifyShutdown(Utils.statusFromThrowable(cause)); + lifecycleManager.notifyShutdown(Utils.statusFromThrowable(cause), + SimpleDisconnectError.SOCKET_ERROR); // Parent class will shut down the Channel super.onConnectionError(ctx, outbound, cause, http2Ex); } @@ -667,7 +688,7 @@ private void createStream(CreateStreamCommand command, ChannelPromise promise) if (!connection().goAwaySent()) { logger.fine("Stream IDs have been exhausted for this connection. " + "Initiating graceful shutdown of the connection."); - lifecycleManager.notifyShutdown(e.getStatus()); + lifecycleManager.notifyShutdown(e.getStatus(), SimpleDisconnectError.UNKNOWN); close(ctx(), ctx().newPromise()); } return; @@ -893,7 +914,7 @@ public void operationComplete(ChannelFuture future) throws Exception { private void gracefulClose(ChannelHandlerContext ctx, GracefulCloseCommand msg, ChannelPromise promise) throws Exception { - lifecycleManager.notifyShutdown(msg.getStatus()); + lifecycleManager.notifyShutdown(msg.getStatus(), SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // Explicitly flush to create any buffered streams before sending GOAWAY. // TODO(ejona): determine if the need to flush is a bug in Netty flush(ctx); @@ -929,13 +950,15 @@ public boolean visit(Http2Stream stream) throws Http2Exception { private void goingAway(long errorCode, byte[] debugData) { Status finalStatus = statusFromH2Error( Status.Code.UNAVAILABLE, "GOAWAY shut down transport", errorCode, debugData); - lifecycleManager.notifyGracefulShutdown(finalStatus); + DisconnectError disconnectError = new GoAwayDisconnectError( + GrpcUtil.Http2Error.forCode(errorCode)); + lifecycleManager.notifyGracefulShutdown(finalStatus, disconnectError); abruptGoAwayStatus = statusFromH2Error( Status.Code.UNAVAILABLE, "Abrupt GOAWAY closed unsent stream", errorCode, debugData); // While this _should_ be UNAVAILABLE, Netty uses the wrong stream id in the GOAWAY when it // fails streams due to HPACK failures (e.g., header list too large). To be more conservative, // we assume any sent streams may be related to the GOAWAY. This should rarely impact users - // since the main time servers should use abrupt GOAWAYs is if there is a protocol error, and if + // since the main time servers should use abrupt GOAWAYs if there is a protocol error, and if // there wasn't a protocol error the error code was probably NO_ERROR which is mapped to // UNAVAILABLE. https://github.com/netty/netty/issues/10670 final Status abruptGoAwayStatusConservative = statusFromH2Error( @@ -950,7 +973,7 @@ private void goingAway(long errorCode, byte[] debugData) { // This can cause reentrancy, but should be minor since it is normal to handle writes in // response to a read. Also, the call stack is rather shallow at this point clientWriteQueue.drainNow(); - if (lifecycleManager.notifyShutdown(finalStatus)) { + if (lifecycleManager.notifyShutdown(finalStatus, disconnectError)) { // This is for the only RPCs that are actually covered by the GOAWAY error code. All other // RPCs were not observed by the remote and so should be UNAVAILABLE. channelInactiveReason = statusFromH2Error( diff --git a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java index e03989e9906..6585df42df3 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -34,14 +34,17 @@ import io.grpc.InternalLogId; import io.grpc.Metadata; import io.grpc.MethodDescriptor; +import io.grpc.MetricRecorder; import io.grpc.Status; import io.grpc.internal.ClientStream; import io.grpc.internal.ConnectionClientTransport; +import io.grpc.internal.DisconnectError; import io.grpc.internal.FailingClientStream; import io.grpc.internal.GrpcUtil; import io.grpc.internal.Http2Ping; import io.grpc.internal.KeepAliveManager; import io.grpc.internal.KeepAliveManager.ClientKeepAlivePinger; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.StatsTraceContext; import io.grpc.internal.TransportTracer; import io.grpc.netty.NettyChannelBuilder.LocalSocketPicker; @@ -68,7 +71,8 @@ /** * A Netty-based {@link ConnectionClientTransport} implementation. */ -class NettyClientTransport implements ConnectionClientTransport { +class NettyClientTransport implements ConnectionClientTransport, + ClientKeepAlivePinger.TransportWithDisconnectReason { private final InternalLogId logId; private final Map, ?> channelOptions; @@ -105,6 +109,7 @@ class NettyClientTransport implements ConnectionClientTransport { private final ChannelLogger channelLogger; private final boolean useGetForSafeMethods; private final Ticker ticker; + private final MetricRecorder metricRecorder; NettyClientTransport( @@ -129,6 +134,7 @@ class NettyClientTransport implements ConnectionClientTransport { LocalSocketPicker localSocketPicker, ChannelLogger channelLogger, boolean useGetForSafeMethods, + MetricRecorder metricRecorder, Ticker ticker) { this.negotiator = Preconditions.checkNotNull(negotiator, "negotiator"); @@ -156,6 +162,7 @@ class NettyClientTransport implements ConnectionClientTransport { this.logId = InternalLogId.allocate(getClass(), remoteAddress.toString()); this.channelLogger = Preconditions.checkNotNull(channelLogger, "channelLogger"); this.useGetForSafeMethods = useGetForSafeMethods; + this.metricRecorder = metricRecorder; this.ticker = Preconditions.checkNotNull(ticker, "ticker"); } @@ -231,8 +238,8 @@ public Runnable start(Listener transportListener) { EventLoop eventLoop = group.next(); if (keepAliveTimeNanos != KEEPALIVE_TIME_NANOS_DISABLED) { keepAliveManager = new KeepAliveManager( - new ClientKeepAlivePinger(this), eventLoop, keepAliveTimeNanos, keepAliveTimeoutNanos, - keepAliveWithoutCalls); + new ClientKeepAlivePinger(this), eventLoop, keepAliveTimeNanos, + keepAliveTimeoutNanos, keepAliveWithoutCalls); } handler = NettyClientHandler.newHandler( @@ -248,7 +255,8 @@ public Runnable start(Listener transportListener) { eagAttributes, authorityString, channelLogger, - ticker); + ticker, + metricRecorder); ChannelHandler negotiationHandler = negotiator.newHandler(handler); @@ -291,7 +299,8 @@ public void run() { // could use GlobalEventExecutor (which is what regFuture would use for notifying // listeners in this case), but avoiding on-demand thread creation in an error case seems // a good idea and is probably clearer threading. - lifecycleManager.notifyTerminated(statusExplainingWhyTheChannelIsNull); + lifecycleManager.notifyTerminated(statusExplainingWhyTheChannelIsNull, + SimpleDisconnectError.UNKNOWN); } }; } @@ -323,7 +332,8 @@ public void operationComplete(ChannelFuture future) throws Exception { if (!future.isSuccess()) { // Need to notify of this failure, because NettyClientHandler may not have been added to // the pipeline before the error occurred. - lifecycleManager.notifyTerminated(Utils.statusFromThrowable(future.cause())); + lifecycleManager.notifyTerminated(Utils.statusFromThrowable(future.cause()), + SimpleDisconnectError.UNKNOWN); } } }); @@ -357,12 +367,17 @@ public void shutdown(Status reason) { @Override public void shutdownNow(final Status reason) { + shutdownNow(reason, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); + } + + @Override + public void shutdownNow(final Status reason, DisconnectError disconnectError) { // Notifying of termination is automatically done when the channel closes. if (channel != null && channel.isOpen()) { handler.getWriteQueue().enqueue(new Runnable() { @Override public void run() { - lifecycleManager.notifyShutdown(reason); + lifecycleManager.notifyShutdown(reason, disconnectError); channel.write(new ForcefulCloseCommand(reason)); } }, true); diff --git a/netty/src/main/java/io/grpc/netty/NettyReadableBuffer.java b/netty/src/main/java/io/grpc/netty/NettyReadableBuffer.java index 7e180544de4..af5ec8d8bad 100644 --- a/netty/src/main/java/io/grpc/netty/NettyReadableBuffer.java +++ b/netty/src/main/java/io/grpc/netty/NettyReadableBuffer.java @@ -60,11 +60,6 @@ public void readBytes(byte[] dest, int index, int length) { buffer.readBytes(dest, index, length); } - @Override - public void readBytes(ByteBuffer dest) { - buffer.readBytes(dest); - } - @Override public void readBytes(OutputStream dest, int length) { try { diff --git a/netty/src/main/java/io/grpc/netty/NettyServer.java b/netty/src/main/java/io/grpc/netty/NettyServer.java index 1cf67ea25ca..2bb6b2c5921 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServer.java +++ b/netty/src/main/java/io/grpc/netty/NettyServer.java @@ -31,6 +31,7 @@ import io.grpc.InternalInstrumented; import io.grpc.InternalLogId; import io.grpc.InternalWithLogId; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.internal.InternalServer; import io.grpc.internal.ObjectPool; @@ -93,6 +94,7 @@ class NettyServer implements InternalServer, InternalWithLogId { private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; + private MetricRecorder metricRecorder; private final long keepAliveTimeInNanos; private final long keepAliveTimeoutInNanos; private final long maxConnectionIdleInNanos; @@ -136,8 +138,10 @@ class NettyServer implements InternalServer, InternalWithLogId { long maxConnectionAgeInNanos, long maxConnectionAgeGraceInNanos, boolean permitKeepAliveWithoutCalls, long permitKeepAliveTimeInNanos, int maxRstCount, long maxRstPeriodNanos, - Attributes eagAttributes, InternalChannelz channelz) { + Attributes eagAttributes, InternalChannelz channelz, + MetricRecorder metricRecorder) { this.addresses = checkNotNull(addresses, "addresses"); + this.metricRecorder = metricRecorder; this.channelFactory = checkNotNull(channelFactory, "channelFactory"); checkNotNull(channelOptions, "channelOptions"); this.channelOptions = new HashMap, Object>(channelOptions); @@ -174,6 +178,7 @@ class NettyServer implements InternalServer, InternalWithLogId { String.valueOf(addresses)); } + @Override public SocketAddress getListenSocketAddress() { Iterator it = channelGroup.iterator(); @@ -272,7 +277,8 @@ public void initChannel(Channel ch) { permitKeepAliveTimeInNanos, maxRstCount, maxRstPeriodNanos, - eagAttributes); + eagAttributes, + metricRecorder); ServerTransportListener transportListener; // This is to order callbacks on the listener, not to guard access to channel. synchronized (NettyServer.this) { diff --git a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java index eb3a6d9b538..4ef14b0e933 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerBuilder.java @@ -22,6 +22,7 @@ import static io.grpc.internal.GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; import static io.grpc.internal.GrpcUtil.DEFAULT_SERVER_KEEPALIVE_TIMEOUT_NANOS; import static io.grpc.internal.GrpcUtil.DEFAULT_SERVER_KEEPALIVE_TIME_NANOS; +import static io.grpc.internal.GrpcUtil.DEFAULT_SERVER_PERMIT_KEEPALIVE_TIME_NANOS; import static io.grpc.internal.GrpcUtil.SERVER_KEEPALIVE_TIME_NANOS_DISABLED; import com.google.common.annotations.VisibleForTesting; @@ -32,6 +33,7 @@ import io.grpc.ExperimentalApi; import io.grpc.ForwardingServerBuilder; import io.grpc.Internal; +import io.grpc.MetricRecorder; import io.grpc.ServerBuilder; import io.grpc.ServerCredentials; import io.grpc.ServerStreamTracer; @@ -112,7 +114,7 @@ public final class NettyServerBuilder extends ForwardingServerBuilder streamTracerFactories) { - return buildTransportServers(streamTracerFactories); + List streamTracerFactories, + MetricRecorder metricRecorder) { + return buildTransportServers(streamTracerFactories, metricRecorder); } } @@ -703,8 +706,10 @@ void eagAttributes(Attributes eagAttributes) { this.eagAttributes = checkNotNull(eagAttributes, "eagAttributes"); } + @VisibleForTesting NettyServer buildTransportServers( - List streamTracerFactories) { + List streamTracerFactories, + MetricRecorder metricRecorder) { assertEventLoopsAndChannelType(); ProtocolNegotiator negotiator = protocolNegotiatorFactory.newNegotiator( @@ -737,7 +742,8 @@ NettyServer buildTransportServers( maxRstCount, maxRstPeriodNanos, eagAttributes, - this.serverImplBuilder.getChannelz()); + this.serverImplBuilder.getChannelz(), + metricRecorder); } @VisibleForTesting diff --git a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java index 036fde55e2c..79715ca2996 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerHandler.java @@ -42,6 +42,7 @@ import io.grpc.InternalMetadata; import io.grpc.InternalStatus; import io.grpc.Metadata; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.internal.GrpcUtil; @@ -127,6 +128,7 @@ class NettyServerHandler extends AbstractNettyHandler { private final Http2Connection.PropertyKey streamKey; private final ServerTransportListener transportListener; private final int maxMessageSize; + private final TcpMetrics tcpMetrics; private final long keepAliveTimeInNanos; private final long keepAliveTimeoutInNanos; private final long maxConnectionAgeInNanos; @@ -174,7 +176,8 @@ static NettyServerHandler newHandler( long permitKeepAliveTimeInNanos, int maxRstCount, long maxRstPeriodNanos, - Attributes eagAttributes) { + Attributes eagAttributes, + MetricRecorder metricRecorder) { Preconditions.checkArgument(maxHeaderListSize > 0, "maxHeaderListSize must be positive: %s", maxHeaderListSize); Http2FrameLogger frameLogger = new Http2FrameLogger(LogLevel.DEBUG, NettyServerHandler.class); @@ -208,7 +211,8 @@ static NettyServerHandler newHandler( maxRstCount, maxRstPeriodNanos, eagAttributes, - Ticker.systemTicker()); + Ticker.systemTicker(), + metricRecorder); } static NettyServerHandler newHandler( @@ -234,7 +238,8 @@ static NettyServerHandler newHandler( int maxRstCount, long maxRstPeriodNanos, Attributes eagAttributes, - Ticker ticker) { + Ticker ticker, + MetricRecorder metricRecorder) { Preconditions.checkArgument(maxStreams > 0, "maxStreams must be positive: %s", maxStreams); Preconditions.checkArgument(flowControlWindow > 0, "flowControlWindow must be positive: %s", flowControlWindow); @@ -294,7 +299,8 @@ static NettyServerHandler newHandler( keepAliveEnforcer, autoFlowControl, rstStreamCounter, - eagAttributes, ticker); + eagAttributes, ticker, + metricRecorder); } private NettyServerHandler( @@ -318,7 +324,8 @@ private NettyServerHandler( boolean autoFlowControl, RstStreamCounter rstStreamCounter, Attributes eagAttributes, - Ticker ticker) { + Ticker ticker, + MetricRecorder metricRecorder) { super( channelUnused, decoder, @@ -362,6 +369,7 @@ public void onStreamClosed(Http2Stream stream) { checkArgument(maxMessageSize >= 0, "maxMessageSize must be non-negative: %s", maxMessageSize); this.maxMessageSize = maxMessageSize; + this.tcpMetrics = new TcpMetrics(metricRecorder); this.keepAliveTimeInNanos = keepAliveTimeInNanos; this.keepAliveTimeoutInNanos = keepAliveTimeoutInNanos; this.maxConnectionIdleManager = maxConnectionIdleManager; @@ -661,9 +669,16 @@ void setKeepAliveManagerForTest(KeepAliveManager keepAliveManager) { /** * Handler for the Channel shutting down. */ + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + tcpMetrics.channelActive(ctx.channel()); + super.channelActive(ctx); + } + @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { try { + tcpMetrics.channelInactive(ctx.channel()); if (keepAliveManager != null) { keepAliveManager.onTransportTermination(); } diff --git a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java index 758ffeee5b1..c0e52b75876 100644 --- a/netty/src/main/java/io/grpc/netty/NettyServerTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyServerTransport.java @@ -25,6 +25,7 @@ import io.grpc.Attributes; import io.grpc.InternalChannelz.SocketStats; import io.grpc.InternalLogId; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.internal.ServerTransport; @@ -81,6 +82,7 @@ class NettyServerTransport implements ServerTransport { private final int maxRstCount; private final long maxRstPeriodNanos; private final Attributes eagAttributes; + private final MetricRecorder metricRecorder; private final List streamTracerFactories; private final TransportTracer transportTracer; @@ -105,7 +107,8 @@ class NettyServerTransport implements ServerTransport { long permitKeepAliveTimeInNanos, int maxRstCount, long maxRstPeriodNanos, - Attributes eagAttributes) { + Attributes eagAttributes, + MetricRecorder metricRecorder) { this.channel = Preconditions.checkNotNull(channel, "channel"); this.channelUnused = channelUnused; this.protocolNegotiator = Preconditions.checkNotNull(protocolNegotiator, "protocolNegotiator"); @@ -128,6 +131,7 @@ class NettyServerTransport implements ServerTransport { this.maxRstCount = maxRstCount; this.maxRstPeriodNanos = maxRstPeriodNanos; this.eagAttributes = Preconditions.checkNotNull(eagAttributes, "eagAttributes"); + this.metricRecorder = metricRecorder; SocketAddress remote = channel.remoteAddress(); this.logId = InternalLogId.allocate(getClass(), remote != null ? remote.toString() : null); } @@ -289,6 +293,7 @@ private NettyServerHandler createHandler( permitKeepAliveTimeInNanos, maxRstCount, maxRstPeriodNanos, - eagAttributes); + eagAttributes, + metricRecorder); } } diff --git a/netty/src/main/java/io/grpc/netty/ProtocolNegotiators.java b/netty/src/main/java/io/grpc/netty/ProtocolNegotiators.java index 77308c76ace..8faf3d0fae8 100644 --- a/netty/src/main/java/io/grpc/netty/ProtocolNegotiators.java +++ b/netty/src/main/java/io/grpc/netty/ProtocolNegotiators.java @@ -51,10 +51,12 @@ import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.DefaultHttpRequest; import io.netty.handler.codec.http.HttpClientCodec; import io.netty.handler.codec.http.HttpClientUpgradeHandler; import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http2.Http2ClientUpgradeCodec; @@ -77,6 +79,7 @@ import java.util.Arrays; import java.util.EnumSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.Executor; import java.util.logging.Level; @@ -102,15 +105,6 @@ final class ProtocolNegotiators { private static final EnumSet understoodServerTlsFeatures = EnumSet.of( TlsServerCredentials.Feature.MTLS, TlsServerCredentials.Feature.CUSTOM_MANAGERS); - private static Class x509ExtendedTrustManagerClass; - - static { - try { - x509ExtendedTrustManagerClass = Class.forName("javax.net.ssl.X509ExtendedTrustManager"); - } catch (ClassNotFoundException e) { - // Will disallow per-rpc authority override via call option. - } - } private ProtocolNegotiators() { } @@ -147,15 +141,8 @@ public static FromChannelCredentialsResult from(ChannelCredentials creds) { trustManagers = Arrays.asList(tmf.getTrustManagers()); } builder.trustManager(new FixedTrustManagerFactory(trustManagers)); - TrustManager x509ExtendedTrustManager = null; - if (x509ExtendedTrustManagerClass != null) { - for (TrustManager trustManager : trustManagers) { - if (x509ExtendedTrustManagerClass.isInstance(trustManager)) { - x509ExtendedTrustManager = trustManager; - break; - } - } - } + TrustManager x509ExtendedTrustManager = + CertificateUtils.getX509ExtendedTrustManager(trustManagers); return FromChannelCredentialsResult.negotiator(tlsClientFactory(builder.build(), (X509TrustManager) x509ExtendedTrustManager)); } catch (SSLException | GeneralSecurityException ex) { @@ -500,7 +487,8 @@ private void fireProtocolNegotiationEvent(ChannelHandlerContext ctx, SSLSession * Returns a {@link ProtocolNegotiator} that does HTTP CONNECT proxy negotiation. */ public static ProtocolNegotiator httpProxy(final SocketAddress proxyAddress, - final @Nullable String proxyUsername, final @Nullable String proxyPassword, + final @Nullable Map headers, final @Nullable String proxyUsername, + final @Nullable String proxyPassword, final ProtocolNegotiator negotiator) { Preconditions.checkNotNull(negotiator, "negotiator"); Preconditions.checkNotNull(proxyAddress, "proxyAddress"); @@ -510,8 +498,9 @@ class ProxyNegotiator implements ProtocolNegotiator { public ChannelHandler newHandler(GrpcHttp2ConnectionHandler http2Handler) { ChannelHandler protocolNegotiationHandler = negotiator.newHandler(http2Handler); ChannelLogger negotiationLogger = http2Handler.getNegotiationLogger(); + HttpHeaders httpHeaders = toHttpHeaders(headers); return new ProxyProtocolNegotiationHandler( - proxyAddress, proxyUsername, proxyPassword, protocolNegotiationHandler, + proxyAddress, httpHeaders, proxyUsername, proxyPassword, protocolNegotiationHandler, negotiationLogger); } @@ -531,6 +520,22 @@ public void close() { return new ProxyNegotiator(); } + /** + * Converts generic Map of headers to Netty's HttpHeaders. + * Returns null if the map is null or empty. + */ + @Nullable + private static HttpHeaders toHttpHeaders(@Nullable Map headers) { + if (headers == null || headers.isEmpty()) { + return null; + } + HttpHeaders httpHeaders = new DefaultHttpHeaders(); + for (Map.Entry entry : headers.entrySet()) { + httpHeaders.add(entry.getKey(), entry.getValue()); + } + return httpHeaders; + } + /** * A Proxy handler follows {@link ProtocolNegotiationHandler} pattern. Upon successful proxy * connection, this handler will install {@code next} handler which should be a handler from @@ -539,17 +544,20 @@ public void close() { static final class ProxyProtocolNegotiationHandler extends ProtocolNegotiationHandler { private final SocketAddress address; + @Nullable private final HttpHeaders httpHeaders; @Nullable private final String userName; @Nullable private final String password; public ProxyProtocolNegotiationHandler( SocketAddress address, + @Nullable HttpHeaders httpHeaders, @Nullable String userName, @Nullable String password, ChannelHandler next, ChannelLogger negotiationLogger) { super(next, negotiationLogger); this.address = Preconditions.checkNotNull(address, "address"); + this.httpHeaders = httpHeaders; this.userName = userName; this.password = password; } @@ -558,9 +566,9 @@ public ProxyProtocolNegotiationHandler( protected void protocolNegotiationEventTriggered(ChannelHandlerContext ctx) { HttpProxyHandler nettyProxyHandler; if (userName == null || password == null) { - nettyProxyHandler = new HttpProxyHandler(address); + nettyProxyHandler = new HttpProxyHandler(address, httpHeaders); } else { - nettyProxyHandler = new HttpProxyHandler(address, userName, password); + nettyProxyHandler = new HttpProxyHandler(address, userName, password, httpHeaders); } ctx.pipeline().addBefore(ctx.name(), /* name= */ null, nettyProxyHandler); } @@ -579,7 +587,7 @@ static final class ClientTlsProtocolNegotiator implements ProtocolNegotiator { public ClientTlsProtocolNegotiator(SslContext sslContext, ObjectPool executorPool, Optional handshakeCompleteRunnable, - X509TrustManager x509ExtendedTrustManager) { + X509TrustManager x509ExtendedTrustManager, String sni) { this.sslContext = Preconditions.checkNotNull(sslContext, "sslContext"); this.executorPool = executorPool; if (this.executorPool != null) { @@ -587,12 +595,14 @@ public ClientTlsProtocolNegotiator(SslContext sslContext, } this.handshakeCompleteRunnable = handshakeCompleteRunnable; this.x509ExtendedTrustManager = x509ExtendedTrustManager; + this.sni = sni; } private final SslContext sslContext; private final ObjectPool executorPool; private final Optional handshakeCompleteRunnable; private final X509TrustManager x509ExtendedTrustManager; + private final String sni; private Executor executor; @Override @@ -604,9 +614,17 @@ public AsciiString scheme() { public ChannelHandler newHandler(GrpcHttp2ConnectionHandler grpcHandler) { ChannelHandler gnh = new GrpcNegotiationHandler(grpcHandler); ChannelLogger negotiationLogger = grpcHandler.getNegotiationLogger(); - ChannelHandler cth = new ClientTlsHandler(gnh, sslContext, grpcHandler.getAuthority(), - this.executor, negotiationLogger, handshakeCompleteRunnable, this, - x509ExtendedTrustManager); + String authority; + if ("".equals(sni)) { + authority = null; + } else if (sni != null) { + authority = sni; + } else { + authority = grpcHandler.getAuthority(); + } + ChannelHandler cth = new ClientTlsHandler(gnh, sslContext, + authority, this.executor, negotiationLogger, handshakeCompleteRunnable, this, + x509ExtendedTrustManager); return new WaitUntilActiveHandler(cth, negotiationLogger); } @@ -630,28 +648,37 @@ static final class ClientTlsHandler extends ProtocolNegotiationHandler { private final int port; private Executor executor; private final Optional handshakeCompleteRunnable; - private final X509TrustManager x509ExtendedTrustManager; + private final X509TrustManager x509TrustManager; private SSLEngine sslEngine; ClientTlsHandler(ChannelHandler next, SslContext sslContext, String authority, Executor executor, ChannelLogger negotiationLogger, Optional handshakeCompleteRunnable, ClientTlsProtocolNegotiator clientTlsProtocolNegotiator, - X509TrustManager x509ExtendedTrustManager) { + X509TrustManager x509TrustManager) { super(next, negotiationLogger); this.sslContext = Preconditions.checkNotNull(sslContext, "sslContext"); - HostPort hostPort = parseAuthority(authority); - this.host = hostPort.host; - this.port = hostPort.port; + if (authority != null) { + HostPort hostPort = parseAuthority(authority); + this.host = hostPort.host; + this.port = hostPort.port; + } else { + this.host = null; + this.port = 0; + } this.executor = executor; this.handshakeCompleteRunnable = handshakeCompleteRunnable; - this.x509ExtendedTrustManager = x509ExtendedTrustManager; + this.x509TrustManager = x509TrustManager; } @Override @IgnoreJRERequirement protected void handlerAdded0(ChannelHandlerContext ctx) { - sslEngine = sslContext.newEngine(ctx.alloc(), host, port); + if (host != null) { + sslEngine = sslContext.newEngine(ctx.alloc(), host, port); + } else { + sslEngine = sslContext.newEngine(ctx.alloc()); + } SSLParameters sslParams = sslEngine.getSSLParameters(); sslParams.setEndpointIdentificationAlgorithm("HTTPS"); sslEngine.setSSLParameters(sslParams); @@ -675,9 +702,6 @@ protected void userEventTriggered0(ChannelHandlerContext ctx, Object evt) throws Exception ex = unavailableException("Failed ALPN negotiation: Unable to find compatible protocol"); logSslEngineDetails(Level.FINE, ctx, "TLS negotiation failed.", ex); - if (handshakeCompleteRunnable.isPresent()) { - handshakeCompleteRunnable.get().run(); - } ctx.fireExceptionCaught(ex); } } else { @@ -692,11 +716,11 @@ protected void userEventTriggered0(ChannelHandlerContext ctx, Object evt) throws .withCause(t) .asRuntimeException(); } - if (handshakeCompleteRunnable.isPresent()) { - handshakeCompleteRunnable.get().run(); - } ctx.fireExceptionCaught(t); } + if (handshakeCompleteRunnable.isPresent()) { + handshakeCompleteRunnable.get().run(); + } } else { super.userEventTriggered0(ctx, evt); } @@ -709,7 +733,7 @@ private void propagateTlsComplete(ChannelHandlerContext ctx, SSLSession session) .set(GrpcAttributes.ATTR_SECURITY_LEVEL, SecurityLevel.PRIVACY_AND_INTEGRITY) .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, session) .set(GrpcAttributes.ATTR_AUTHORITY_VERIFIER, new X509AuthorityVerifier( - sslEngine, x509ExtendedTrustManager)) + sslEngine, x509TrustManager)) .build(); replaceProtocolNegotiationEvent(existingPne.withAttributes(attrs).withSecurity(security)); if (handshakeCompleteRunnable.isPresent()) { @@ -746,13 +770,14 @@ static HostPort parseAuthority(String authority) { * Returns a {@link ProtocolNegotiator} that ensures the pipeline is set up so that TLS will * be negotiated, the {@code handler} is added and writes to the {@link io.netty.channel.Channel} * may happen immediately, even before the TLS Handshake is complete. + * * @param executorPool a dedicated {@link Executor} pool for time-consuming TLS tasks */ public static ProtocolNegotiator tls(SslContext sslContext, ObjectPool executorPool, Optional handshakeCompleteRunnable, - X509TrustManager x509ExtendedTrustManager) { + X509TrustManager x509ExtendedTrustManager, String sni) { return new ClientTlsProtocolNegotiator(sslContext, executorPool, handshakeCompleteRunnable, - x509ExtendedTrustManager); + x509ExtendedTrustManager, sni); } /** @@ -762,7 +787,7 @@ public static ProtocolNegotiator tls(SslContext sslContext, */ public static ProtocolNegotiator tls(SslContext sslContext, X509TrustManager x509ExtendedTrustManager) { - return tls(sslContext, null, Optional.absent(), x509ExtendedTrustManager); + return tls(sslContext, null, Optional.absent(), x509ExtendedTrustManager, null); } public static ProtocolNegotiator.ClientFactory tlsClientFactory(SslContext sslContext, diff --git a/netty/src/main/java/io/grpc/netty/TcpMetrics.java b/netty/src/main/java/io/grpc/netty/TcpMetrics.java new file mode 100644 index 00000000000..10f3a55014d --- /dev/null +++ b/netty/src/main/java/io/grpc/netty/TcpMetrics.java @@ -0,0 +1,227 @@ +/* + * 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.netty; + +import com.google.common.annotations.VisibleForTesting; +import io.grpc.InternalTcpMetrics; +import io.grpc.MetricRecorder; +import io.netty.channel.Channel; +import io.netty.util.concurrent.ScheduledFuture; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility for collecting TCP metrics from Netty channels. + */ +final class TcpMetrics { + private static final Logger log = Logger.getLogger(TcpMetrics.class.getName()); + + static EpollInfo epollInfo = loadEpollInfo(); + + static final class EpollInfo { + final Class channelClass; + final java.lang.reflect.Constructor infoConstructor; + final Method tcpInfo; + final Method totalRetrans; + final Method retransmits; + final Method rtt; + + EpollInfo( + Class channelClass, + java.lang.reflect.Constructor infoConstructor, + Method tcpInfo, + Method totalRetrans, + Method retransmits, + Method rtt) { + this.channelClass = channelClass; + this.infoConstructor = infoConstructor; + this.tcpInfo = tcpInfo; + this.totalRetrans = totalRetrans; + this.retransmits = retransmits; + this.rtt = rtt; + } + } + + static EpollInfo loadEpollInfo() { + boolean epollAvailable = false; + try { + Class epollClass = Class.forName("io.netty.channel.epoll.Epoll"); + Method isAvailableMethod = epollClass.getDeclaredMethod("isAvailable"); + epollAvailable = (Boolean) isAvailableMethod.invoke(null); + if (epollAvailable) { + Class channelClass = Class.forName("io.netty.channel.epoll.EpollSocketChannel"); + Class infoClass = Class.forName("io.netty.channel.epoll.EpollTcpInfo"); + return new EpollInfo( + channelClass, + infoClass.getDeclaredConstructor(), + channelClass.getMethod("tcpInfo", infoClass), + infoClass.getMethod("totalRetrans"), + infoClass.getMethod("retrans"), + infoClass.getMethod("rtt")); + } + } catch (ReflectiveOperationException e) { + log.log(Level.FINE, "Failed to initialize Epoll tcp_info reflection", e); + } finally { + log.log(Level.FINE, "Epoll available during static init of TcpMetrics:" + + "{0}", epollAvailable); + } + return null; + } + + private static final long RECORD_INTERVAL_MILLIS = TimeUnit.MINUTES.toMillis(5); + private final MetricRecorder metricRecorder; + private final Object tcpInfo; + private long lastTotalRetrans = 0; + private ScheduledFuture reportTimer; + + TcpMetrics(MetricRecorder metricRecorder) { + this.metricRecorder = metricRecorder; + + Object tcpInfo = null; + if (epollInfo != null) { + try { + tcpInfo = epollInfo.infoConstructor.newInstance(); + } catch (ReflectiveOperationException e) { + log.log(Level.FINE, "Failed to instantiate EpollTcpInfo", e); + } + } + this.tcpInfo = tcpInfo; + } + + void channelActive(Channel channel) { + List labelValues = getLabelValues(channel); + metricRecorder.addLongCounter(InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT, 1, + Collections.emptyList(), labelValues); + metricRecorder.addLongUpDownCounter(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT, 1, + Collections.emptyList(), labelValues); + scheduleNextReport(channel, true); + } + + private void scheduleNextReport(final Channel channel, boolean isInitial) { + if (epollInfo == null || !epollInfo.channelClass.isInstance(channel) || !channel.isActive()) { + return; + } + + // Initial report has a larger jitter range to spread out initial connections. + // Subsequent reports have a smaller jitter range to avoid drift. + double jitter = isInitial + ? 0.1 + ThreadLocalRandom.current().nextDouble() // 10% to 110% + : 0.9 + ThreadLocalRandom.current().nextDouble() * 0.2; // 90% to 110% + long rearmingDelay = (long) (RECORD_INTERVAL_MILLIS * jitter); + + reportTimer = channel.eventLoop().schedule(() -> { + if (channel.isActive()) { + recordTcpInfo(channel, false); + scheduleNextReport(channel, false); // Re-arm + } + }, rearmingDelay, TimeUnit.MILLISECONDS); + } + + void channelInactive(Channel channel) { + if (reportTimer != null) { + reportTimer.cancel(false); + } + List labelValues = getLabelValues(channel); + metricRecorder.addLongUpDownCounter(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT, -1, + Collections.emptyList(), labelValues); + // Final collection on close + if (epollInfo != null && epollInfo.channelClass.isInstance(channel)) { + recordTcpInfo(channel, true); + } + } + + void recordTcpInfo(Channel channel) { + recordTcpInfo(channel, false); + } + + private void recordTcpInfo(Channel channel, boolean isClose) { + if (epollInfo == null || !epollInfo.channelClass.isInstance(channel)) { + return; + } + List labelValues = getLabelValues(channel); + long totalRetrans; + long retransmits; + long rtt; + try { + epollInfo.tcpInfo.invoke(channel, tcpInfo); + totalRetrans = (Long) epollInfo.totalRetrans.invoke(tcpInfo); + retransmits = (Long) epollInfo.retransmits.invoke(tcpInfo); + rtt = (Long) epollInfo.rtt.invoke(tcpInfo); + } catch (ReflectiveOperationException e) { + log.log(Level.FINE, "Error computing TCP metrics", e); + return; + } + + long deltaTotal = totalRetrans - lastTotalRetrans; + if (deltaTotal > 0) { + metricRecorder.addLongCounter(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT, + deltaTotal, Collections.emptyList(), labelValues); + lastTotalRetrans = totalRetrans; + } + if (isClose && retransmits > 0) { + metricRecorder.addLongCounter(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT, + retransmits, Collections.emptyList(), labelValues); + } + metricRecorder.recordDoubleHistogram(InternalTcpMetrics.MIN_RTT_INSTRUMENT, + rtt / 1000000.0, // Convert microseconds to seconds + Collections.emptyList(), labelValues); + } + + @VisibleForTesting + ScheduledFuture getReportTimer() { + return reportTimer; + } + + private static List getLabelValues(Channel channel) { + String localAddress = ""; + String localPort = ""; + String peerAddress = ""; + String peerPort = ""; + + SocketAddress local = channel.localAddress(); + if (local instanceof InetSocketAddress) { + InetSocketAddress inetLocal = (InetSocketAddress) local; + if (inetLocal.getAddress() != null) { + localAddress = inetLocal.getAddress().getHostAddress(); + } else if (inetLocal.getHostString() != null) { + localAddress = inetLocal.getHostString(); + } + localPort = String.valueOf(inetLocal.getPort()); + } + + SocketAddress remote = channel.remoteAddress(); + if (remote instanceof InetSocketAddress) { + InetSocketAddress inetRemote = (InetSocketAddress) remote; + if (inetRemote.getAddress() != null) { + peerAddress = inetRemote.getAddress().getHostAddress(); + } else if (inetRemote.getHostString() != null) { + peerAddress = inetRemote.getHostString(); + } + peerPort = String.valueOf(inetRemote.getPort()); + } + + return Arrays.asList(localAddress, localPort, peerAddress, peerPort); + } +} diff --git a/netty/src/main/java/io/grpc/netty/UdsNameResolver.java b/netty/src/main/java/io/grpc/netty/UdsNameResolver.java index de14dc8b460..3477a458933 100644 --- a/netty/src/main/java/io/grpc/netty/UdsNameResolver.java +++ b/netty/src/main/java/io/grpc/netty/UdsNameResolver.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Strings.isNullOrEmpty; import com.google.common.base.Preconditions; import io.grpc.EquivalentAddressGroup; @@ -31,8 +32,18 @@ final class UdsNameResolver extends NameResolver { private NameResolver.Listener2 listener; private final String authority; + /** + * Constructs a new instance of UdsNameResolver. + * + * @param authority authority of the 'unix:' URI to resolve, or null if target has no authority + * @param targetPath path of the 'unix:' URI to resolve + */ UdsNameResolver(String authority, String targetPath, Args args) { - checkArgument(authority == null, "non-null authority not supported"); + // UDS is inherently local. According to https://github.com/grpc/grpc/blob/master/doc/naming.md, + // this is expressed in the target URI either by using a blank authority, like "unix:///sock", + // or by omitting authority completely, e.g. "unix:/sock". + // TODO(jdcormie): Allow the explicit authority string "localhost"? + checkArgument(isNullOrEmpty(authority), "authority not supported: %s", authority); this.authority = targetPath; } diff --git a/netty/src/main/java/io/grpc/netty/UdsNameResolverProvider.java b/netty/src/main/java/io/grpc/netty/UdsNameResolverProvider.java index fe6300057fd..baf18e3d7de 100644 --- a/netty/src/main/java/io/grpc/netty/UdsNameResolverProvider.java +++ b/netty/src/main/java/io/grpc/netty/UdsNameResolverProvider.java @@ -20,6 +20,7 @@ import io.grpc.Internal; import io.grpc.NameResolver; import io.grpc.NameResolverProvider; +import io.grpc.Uri; import io.netty.channel.unix.DomainSocketAddress; import java.net.SocketAddress; import java.net.URI; @@ -31,9 +32,21 @@ public final class UdsNameResolverProvider extends NameResolverProvider { private static final String SCHEME = "unix"; + @Override + public NameResolver newNameResolver(Uri targetUri, NameResolver.Args args) { + if (SCHEME.equals(targetUri.getScheme())) { + return new UdsNameResolver(targetUri.getAuthority(), targetUri.getPath(), args); + } else { + return null; + } + } + @Override public UdsNameResolver newNameResolver(URI targetUri, NameResolver.Args args) { if (SCHEME.equals(targetUri.getScheme())) { + // TODO(jdcormie): java.net.URI has a bug where getAuthority() returns null for both the + // undefined and zero-length authority. Doesn't matter for now because UdsNameResolver doesn't + // distinguish these cases. return new UdsNameResolver(targetUri.getAuthority(), getTargetPathFromUri(targetUri), args); } else { return null; @@ -44,6 +57,10 @@ static String getTargetPathFromUri(URI targetUri) { Preconditions.checkArgument(SCHEME.equals(targetUri.getScheme()), "scheme must be " + SCHEME); String targetPath = targetUri.getPath(); if (targetPath == null) { + // TODO(jdcormie): This incorrectly includes '?' and any characters that follow. In the + // hierarchical case ('unix:///path'), java.net.URI parses these into a query component that's + // distinct from the path. But in the present "opaque" case ('unix:/path'), what may look like + // a query is considered part of the SSP. targetPath = Preconditions.checkNotNull(targetUri.getSchemeSpecificPart(), "targetPath"); } return targetPath; diff --git a/netty/src/main/java/io/grpc/netty/UdsNettyChannelProvider.java b/netty/src/main/java/io/grpc/netty/UdsNettyChannelProvider.java index 4e9895da0a8..23a09889de0 100644 --- a/netty/src/main/java/io/grpc/netty/UdsNettyChannelProvider.java +++ b/netty/src/main/java/io/grpc/netty/UdsNettyChannelProvider.java @@ -20,6 +20,8 @@ import io.grpc.ChannelCredentials; import io.grpc.Internal; import io.grpc.ManagedChannelProvider; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; import io.grpc.internal.SharedResourcePool; import io.netty.channel.unix.DomainSocketAddress; import java.net.SocketAddress; @@ -62,6 +64,21 @@ public NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentia return result; } + @Override + public NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentials creds, + NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { + Preconditions.checkState(isAvailable()); + NewChannelBuilderResult result = new NettyChannelProvider().newChannelBuilder( + target, creds, nameResolverRegistry, nameResolverProvider); + if (result.getChannelBuilder() != null) { + ((NettyChannelBuilder) result.getChannelBuilder()) + .eventLoopGroupPool(SharedResourcePool.forResource(Utils.DEFAULT_WORKER_EVENT_LOOP_GROUP)) + .channelType(Utils.EPOLL_DOMAIN_CLIENT_CHANNEL_TYPE, DomainSocketAddress.class); + } + return result; + } + @Override protected Collection> getSupportedSocketAddressTypes() { return Collections.singleton(DomainSocketAddress.class); diff --git a/netty/src/main/java/io/grpc/netty/Utils.java b/netty/src/main/java/io/grpc/netty/Utils.java index c0981f5b219..1d30a19c727 100644 --- a/netty/src/main/java/io/grpc/netty/Utils.java +++ b/netty/src/main/java/io/grpc/netty/Utils.java @@ -47,7 +47,6 @@ import io.netty.channel.EventLoopGroup; import io.netty.channel.ReflectiveChannelFactory; import io.netty.channel.ServerChannel; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.DecoderException; @@ -122,10 +121,10 @@ private static final class ByteBufAllocatorPreferHeapHolder { EPOLL_DOMAIN_CLIENT_CHANNEL_TYPE = epollDomainSocketChannelType(); DEFAULT_SERVER_CHANNEL_FACTORY = new ReflectiveChannelFactory<>(epollServerChannelType()); EPOLL_EVENT_LOOP_GROUP_CONSTRUCTOR = epollEventLoopGroupConstructor(); - DEFAULT_BOSS_EVENT_LOOP_GROUP - = new DefaultEventLoopGroupResource(1, "grpc-default-boss-ELG", EventLoopGroupType.EPOLL); - DEFAULT_WORKER_EVENT_LOOP_GROUP - = new DefaultEventLoopGroupResource(0,"grpc-default-worker-ELG", EventLoopGroupType.EPOLL); + DEFAULT_BOSS_EVENT_LOOP_GROUP = new DefaultEventLoopGroupResource( + 1, "grpc-default-boss-ELG", EventLoopGroupType.EPOLL); + DEFAULT_WORKER_EVENT_LOOP_GROUP = new DefaultEventLoopGroupResource( + 0, "grpc-default-worker-ELG", EventLoopGroupType.EPOLL); } else { logger.log(Level.FINE, "Epoll is not available, using Nio.", getEpollUnavailabilityCause()); DEFAULT_SERVER_CHANNEL_FACTORY = nioServerChannelFactory(); @@ -513,7 +512,10 @@ public EventLoopGroup create() { ThreadFactory threadFactory = new DefaultThreadFactory(name, /* daemon= */ true); switch (eventLoopGroupType) { case NIO: - return new NioEventLoopGroup(numEventLoops, threadFactory); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup group = + new io.netty.channel.nio.NioEventLoopGroup(numEventLoops, threadFactory); + return group; case EPOLL: return createEpollEventLoopGroup(numEventLoops, threadFactory); default: diff --git a/netty/src/main/java/io/grpc/netty/X509AuthorityVerifier.java b/netty/src/main/java/io/grpc/netty/X509AuthorityVerifier.java index 8a8d426662f..a2df3dbc431 100644 --- a/netty/src/main/java/io/grpc/netty/X509AuthorityVerifier.java +++ b/netty/src/main/java/io/grpc/netty/X509AuthorityVerifier.java @@ -103,6 +103,6 @@ private void verifyAuthorityAllowedForPeerCert(String authority) throw new IllegalStateException("checkServerTrustedMethod not found"); } checkServerTrustedMethod.invoke( - x509ExtendedTrustManager, x509PeerCertificates, "RSA", sslEngineWrapper); + x509ExtendedTrustManager, x509PeerCertificates, "UNKNOWN", sslEngineWrapper); } } diff --git a/netty/src/test/java/io/grpc/netty/AdvancedTlsTest.java b/netty/src/test/java/io/grpc/netty/AdvancedTlsTest.java index f34e336553b..66591cda153 100644 --- a/netty/src/test/java/io/grpc/netty/AdvancedTlsTest.java +++ b/netty/src/test/java/io/grpc/netty/AdvancedTlsTest.java @@ -436,16 +436,13 @@ public void onFileReloadingTrustManagerBadInitialContentTest() throws Exception } @Test - public void keyManagerAliasesTest() { + public void keyManagerAliasesTest() throws Exception { AdvancedTlsX509KeyManager km = new AdvancedTlsX509KeyManager(); - assertArrayEquals( - new String[] {"default"}, km.getClientAliases("", null)); - assertEquals( - "default", km.chooseClientAlias(new String[] {"default"}, null, null)); - assertArrayEquals( - new String[] {"default"}, km.getServerAliases("", null)); - assertEquals( - "default", km.chooseServerAlias("default", null, null)); + km.updateIdentityCredentials(serverCert0, serverKey0); + assertArrayEquals(new String[] {"key-1"}, km.getClientAliases("", null)); + assertEquals("key-1", km.chooseClientAlias(new String[] {"key-1"}, null, null)); + assertArrayEquals(new String[] {"key-1"}, km.getServerAliases("", null)); + assertEquals("key-1", km.chooseServerAlias("key-1", null, null)); } @Test diff --git a/netty/src/test/java/io/grpc/netty/NettyAdaptiveCumulatorTest.java b/netty/src/test/java/io/grpc/netty/NettyAdaptiveCumulatorTest.java index 68dc6dc0c80..b19f247b5cf 100644 --- a/netty/src/test/java/io/grpc/netty/NettyAdaptiveCumulatorTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyAdaptiveCumulatorTest.java @@ -52,6 +52,9 @@ @RunWith(Enclosed.class) public class NettyAdaptiveCumulatorTest { + private static boolean usingPre4_1_111_Netty() { + return false; // Disabled detection because it was unreliable + } private static Collection cartesianProductParams(List... lists) { return Lists.transform(Lists.cartesianProduct(lists), List::toArray); @@ -385,9 +388,8 @@ public void mergeWithCompositeTail_tailExpandable_reallocateInMemory() { } private void assertTailExpanded(String expectedTailReadableData, int expectedNewTailCapacity) { - if (!GrpcHttp2ConnectionHandler.usingPre4_1_111_Netty()) { - return; // Netty 4.1.111 doesn't work with NettyAdaptiveCumulator - } + assume().withMessage("Netty 4.1.111 doesn't work with NettyAdaptiveCumulator") + .that(usingPre4_1_111_Netty()).isTrue(); int originalNumComponents = composite.numComponents(); // Handle the case when reader index is beyond all readable bytes of the cumulation. @@ -628,9 +630,8 @@ public void mergeWithCompositeTail_outOfSyncComposite() { alloc.compositeBuffer(8).addFlattenedComponents(true, composite1); assertThat(composite2.toString(US_ASCII)).isEqualTo("01234"); - if (!GrpcHttp2ConnectionHandler.usingPre4_1_111_Netty()) { - return; // Netty 4.1.111 doesn't work with NettyAdaptiveCumulator - } + assume().withMessage("Netty 4.1.111 doesn't work with NettyAdaptiveCumulator") + .that(usingPre4_1_111_Netty()).isTrue(); // The previous operation does not adjust the read indexes of the underlying buffers, // only the internal Component offsets. When the cumulator attempts to append the input to diff --git a/netty/src/test/java/io/grpc/netty/NettyChannelProviderTest.java b/netty/src/test/java/io/grpc/netty/NettyChannelProviderTest.java index 86c1389f002..fe5a45848e1 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelProviderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelProviderTest.java @@ -87,4 +87,164 @@ public void newChannelBuilder_fail() { TlsChannelCredentials.newBuilder().requireFakeFeature().build()); assertThat(result.getError()).contains("FAKE"); } + + @Test + public void newChannelBuilder_withRegistry() { + io.grpc.NameResolverRegistry registry = new io.grpc.NameResolverRegistry(); + NewChannelBuilderResult result = provider.newChannelBuilder( + "localhost:443", TlsChannelCredentials.create(), registry, null); + assertThat(result.getChannelBuilder()).isInstanceOf(NettyChannelBuilder.class); + } + + @Test + public void newChannelBuilder_withProvider() { + io.grpc.NameResolverProvider resolverProvider = new io.grpc.NameResolverProvider() { + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + + @Override + public String getDefaultScheme() { + return "dns"; + } + + @Override + public io.grpc.NameResolver newNameResolver(java.net.URI targetUri, + io.grpc.NameResolver.Args args) { + return null; + } + }; + NewChannelBuilderResult result = provider.newChannelBuilder( + "localhost:443", TlsChannelCredentials.create(), null, + resolverProvider); + assertThat(result.getChannelBuilder()).isInstanceOf(NettyChannelBuilder.class); + } + + @Test + public void newChannelBuilder_registryPropagation_e2e() { + String scheme = "testscheme"; + final io.grpc.NameResolverRegistry registry = new io.grpc.NameResolverRegistry(); + final java.util.concurrent.atomic.AtomicReference + capturedRegistry = new java.util.concurrent.atomic.AtomicReference<>(); + + final io.grpc.NameResolverProvider resolverProvider = new io.grpc.NameResolverProvider() { + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + + @Override + public String getDefaultScheme() { + return scheme; + } + + @Override + public io.grpc.NameResolver newNameResolver(java.net.URI targetUri, + io.grpc.NameResolver.Args args) { + capturedRegistry.set(args.getNameResolverRegistry()); + return new io.grpc.NameResolver() { + @Override + public String getServiceAuthority() { + return "authority"; + } + + @Override + public void start(Listener2 listener) { + } + + @Override + public void shutdown() { + } + }; + } + }; + registry.register(resolverProvider); + + NewChannelBuilderResult result = provider.newChannelBuilder( + scheme + ":///target", TlsChannelCredentials.create(), registry, + null); + assertThat(result.getChannelBuilder()).isInstanceOf(NettyChannelBuilder.class); + // Verify build() succeeds + result.getChannelBuilder().build(); + + // Verify the registry passed to args is the exact same instance + assertSame("Registry should be propagated to NameResolver.Args", registry, + capturedRegistry.get()); + + // Verify default registry (empty) fails + NewChannelBuilderResult defaultResult = provider.newChannelBuilder( + scheme + ":///target", TlsChannelCredentials.create(), + new io.grpc.NameResolverRegistry(), null); + // The provider might still return a builder, but build() should fail if it + // can't find the resolver. + // However, NettyChannelProvider just delegates to NettyChannelBuilder. + // NettyChannelBuilder delegates to ManagedChannelImplBuilder. + // ManagedChannelImplBuilder.build() calls getNameResolverProvider(), which + // throws if not found. + try { + defaultResult.getChannelBuilder().build(); + fail("Should have failed to build() without correct registry"); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + public void newChannelBuilder_providerPropagation_e2e() { + String scheme = "otherscheme"; + final io.grpc.NameResolverProvider resolverProvider = new io.grpc.NameResolverProvider() { + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + + @Override + public String getDefaultScheme() { + return scheme; + } + + @Override + public io.grpc.NameResolver newNameResolver(java.net.URI targetUri, + io.grpc.NameResolver.Args args) { + return new io.grpc.NameResolver() { + @Override + public String getServiceAuthority() { + return "authority"; + } + + @Override + public void start(Listener2 listener) { + } + + @Override + public void shutdown() { + } + }; + } + }; + + // Pass explicit provider, null registry + NewChannelBuilderResult result = provider.newChannelBuilder( + scheme + ":///target", TlsChannelCredentials.create(), + null, resolverProvider); + assertThat(result.getChannelBuilder()).isInstanceOf(NettyChannelBuilder.class); + // Should succeed because we passed the specific provider + result.getChannelBuilder().build(); + } } diff --git a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java index c5289296ed0..9f6be9a2f3e 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerTest.java @@ -57,6 +57,7 @@ import io.grpc.Attributes; import io.grpc.CallOptions; import io.grpc.Metadata; +import io.grpc.MetricRecorder; import io.grpc.Status; import io.grpc.internal.AbstractStream; import io.grpc.internal.ClientStreamListener; @@ -67,6 +68,7 @@ import io.grpc.internal.GrpcUtil; import io.grpc.internal.KeepAliveManager; import io.grpc.internal.ManagedClientTransport; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.StatsTraceContext; import io.grpc.internal.StreamListener; import io.grpc.internal.TransportTracer; @@ -764,7 +766,7 @@ public void exhaustedStreamsShouldFail() throws Exception { public void nonExistentStream() throws Exception { Status status = Status.INTERNAL.withDescription("zz"); - lifecycleManager.notifyShutdown(status); + lifecycleManager.notifyShutdown(status, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); // Stream creation can race with the transport shutting down, with the create command already // enqueued. ChannelFuture future1 = createStream(); @@ -1164,7 +1166,8 @@ public Stopwatch get() { Attributes.EMPTY, "someauthority", null, - fakeClock().getTicker()); + fakeClock().getTicker(), + new MetricRecorder() {}); } @Override diff --git a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java index 55abe29e93a..ef8d2e5efda 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -46,6 +46,7 @@ import com.google.common.base.Ticker; import com.google.common.io.ByteStreams; import com.google.common.util.concurrent.SettableFuture; +import com.google.errorprone.annotations.concurrent.GuardedBy; import io.grpc.Attributes; import io.grpc.CallOptions; import io.grpc.ChannelLogger; @@ -55,6 +56,7 @@ import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.grpc.MethodDescriptor.Marshaller; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.Status.Code; @@ -63,6 +65,7 @@ import io.grpc.internal.ClientStream; import io.grpc.internal.ClientStreamListener; import io.grpc.internal.ClientTransport; +import io.grpc.internal.DisconnectError; import io.grpc.internal.FakeClock; import io.grpc.internal.FixedObjectPool; import io.grpc.internal.GrpcUtil; @@ -87,14 +90,13 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPromise; -import io.netty.channel.DefaultEventLoopGroup; import io.netty.channel.EventLoopGroup; import io.netty.channel.ReflectiveChannelFactory; import io.netty.channel.local.LocalChannel; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannelConfig; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http2.Http2Exception; import io.netty.handler.codec.http2.StreamBufferingEncoder; import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; @@ -122,7 +124,6 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nullable; -import javax.annotation.concurrent.GuardedBy; import javax.net.ssl.SSLException; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.TrustManager; @@ -160,7 +161,8 @@ public class NettyClientTransportTest { private final List transports = new ArrayList<>(); private final LinkedBlockingQueue serverTransportAttributesList = new LinkedBlockingQueue<>(); - private final NioEventLoopGroup group = new NioEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + private final EventLoopGroup group = new io.netty.channel.nio.NioEventLoopGroup(1); private final EchoServerListener serverListener = new EchoServerListener(); private final InternalChannelz channelz = new InternalChannelz(); private Runnable tooManyPingsRunnable = new Runnable() { @@ -227,30 +229,31 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept // set SO_LINGER option int soLinger = 123; channelOptions.put(ChannelOption.SO_LINGER, soLinger); - NettyClientTransport transport = - new NettyClientTransport( - address, - new ReflectiveChannelFactory<>(NioSocketChannel.class), - channelOptions, - group, - newNegotiator(), - false, - DEFAULT_WINDOW_SIZE, - DEFAULT_MAX_MESSAGE_SIZE, - GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, - GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, - KEEPALIVE_TIME_NANOS_DISABLED, - 1L, - false, - authority, - null /* user agent */, - tooManyPingsRunnable, - new TransportTracer(), - Attributes.EMPTY, - new SocketPicker(), - new FakeChannelLogger(), - false, - Ticker.systemTicker()); + NettyClientTransport transport = new NettyClientTransport( + address, + new ReflectiveChannelFactory<>(NioSocketChannel.class), + channelOptions, + group, + newNegotiator(), + false, + DEFAULT_WINDOW_SIZE, + DEFAULT_MAX_MESSAGE_SIZE, + GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, + GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, + KEEPALIVE_TIME_NANOS_DISABLED, + 1L, + false, + authority, + null /* user agent */, + tooManyPingsRunnable, + new TransportTracer(), + Attributes.EMPTY, + new SocketPicker(), + new FakeChannelLogger(), + false, + new MetricRecorder() { + }, + Ticker.systemTicker()); transports.add(transport); callMeMaybe(transport.start(clientTransportListener)); @@ -502,30 +505,31 @@ private static class CantConstructChannelError extends Error {} public void failingToConstructChannelShouldFailGracefully() throws Exception { address = TestUtils.testServerAddress(new InetSocketAddress(12345)); authority = GrpcUtil.authorityFromHostAndPort(address.getHostString(), address.getPort()); - NettyClientTransport transport = - new NettyClientTransport( - address, - new ReflectiveChannelFactory<>(CantConstructChannel.class), - new HashMap, Object>(), - group, - newNegotiator(), - false, - DEFAULT_WINDOW_SIZE, - DEFAULT_MAX_MESSAGE_SIZE, - GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, - GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, - KEEPALIVE_TIME_NANOS_DISABLED, - 1, - false, - authority, - null, - tooManyPingsRunnable, - new TransportTracer(), - Attributes.EMPTY, - new SocketPicker(), - new FakeChannelLogger(), - false, - Ticker.systemTicker()); + NettyClientTransport transport = new NettyClientTransport( + address, + new ReflectiveChannelFactory<>(CantConstructChannel.class), + new HashMap, Object>(), + group, + newNegotiator(), + false, + DEFAULT_WINDOW_SIZE, + DEFAULT_MAX_MESSAGE_SIZE, + GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, + GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, + KEEPALIVE_TIME_NANOS_DISABLED, + 1, + false, + authority, + null, + tooManyPingsRunnable, + new TransportTracer(), + Attributes.EMPTY, + new SocketPicker(), + new FakeChannelLogger(), + false, + new MetricRecorder() { + }, + Ticker.systemTicker()); transports.add(transport); // Should not throw @@ -586,7 +590,8 @@ public void channelFactoryShouldSetSocketOptionKeepAlive() throws Exception { @Test public void channelFactoryShouldNNotSetSocketOptionKeepAlive() throws Exception { startServer(); - DefaultEventLoopGroup group = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup group = new io.netty.channel.DefaultEventLoopGroup(1); try { NettyClientTransport transport = newTransport(newNegotiator(), DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, "testUserAgent", true, @@ -606,8 +611,12 @@ public void channelFactoryShouldNNotSetSocketOptionKeepAlive() throws Exception public void maxHeaderListSizeShouldBeEnforcedOnClient() throws Exception { startServer(); + // We want the header list size limit to be half-way close to the RPC's header size, otherwise + // Netty will kill the connection instead of just the stream. While we can kill the connection, + // we want to make sure there's a more graceful failure first. We don't want a cliff where if an + // app adds one byte suddenly RPCs kill the connection. NettyClientTransport transport = - newTransport(newNegotiator(), DEFAULT_MAX_MESSAGE_SIZE, 1, null, true); + newTransport(newNegotiator(), DEFAULT_MAX_MESSAGE_SIZE, 75, null, true); callMeMaybe(transport.start(clientTransportListener)); verify(clientTransportListener, timeout(5000)).transportReady(); @@ -617,11 +626,17 @@ public void maxHeaderListSizeShouldBeEnforcedOnClient() throws Exception { fail("The stream should have been failed due to client received header exceeds header list" + " size limit!"); } catch (Exception e) { - Throwable rootCause = getRootCause(e); - Status status = ((StatusException) rootCause).getStatus(); + Status status = ((StatusException) e.getCause()).getStatus(); assertEquals(Status.Code.INTERNAL, status.getCode()); - assertEquals("RST_STREAM closed stream. HTTP/2 error code: PROTOCOL_ERROR", - status.getDescription()); + if (status.getCause() instanceof Http2Exception.StreamException) { + // Netty 4.1.135+ stream-level error that is generated by the client. + assertThat(status.getCause()).hasMessageThat() + .contains("Header size exceeded max allowed size"); + } else { + // Older Netty failed the stream on the the server-side. + assertEquals("RST_STREAM closed stream. HTTP/2 error code: PROTOCOL_ERROR", + status.getDescription()); + } } } @@ -877,7 +892,7 @@ public void tlsNegotiationServerExecutorShouldSucceed() throws Exception { .keyManager(clientCert, clientKey) .build(); ProtocolNegotiator negotiator = ProtocolNegotiators.tls(clientContext, clientExecutorPool, - Optional.absent(), null); + Optional.absent(), null, null); // after starting the client, the Executor in the client pool should be used assertEquals(true, clientExecutorPool.isInUse()); final NettyClientTransport transport = newTransport(negotiator); @@ -988,7 +1003,7 @@ public void authorityOverrideInCallOptions_matchesServerPeerHost_newStreamCreati new Rpc(transport, new Metadata(), "foo.test.google.fr").waitForResponse(); } finally { - NettyClientHandler.enablePerRpcAuthorityCheck = false;; + NettyClientHandler.enablePerRpcAuthorityCheck = false; } } @@ -1011,7 +1026,7 @@ public void authorityOverrideInCallOptions_portNumberInAuthority_isStrippedForPe new Rpc(transport, new Metadata(), "foo.test.google.fr:12345").waitForResponse(); } finally { - NettyClientHandler.enablePerRpcAuthorityCheck = false;; + NettyClientHandler.enablePerRpcAuthorityCheck = false; } } @@ -1045,7 +1060,7 @@ public void authorityOverrideInCallOptions_portNumberAndIpv6_isStrippedForPeerVe "No subject alternative names matching IP address 2001:db8:3333:4444:5555:6666:1.2.3.4 " + "found"); } finally { - NettyClientHandler.enablePerRpcAuthorityCheck = false;; + NettyClientHandler.enablePerRpcAuthorityCheck = false; } } @@ -1124,30 +1139,31 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max if (!enableKeepAlive) { keepAliveTimeNano = KEEPALIVE_TIME_NANOS_DISABLED; } - NettyClientTransport transport = - new NettyClientTransport( - address, - channelFactory, - new HashMap, Object>(), - group, - negotiator, - false, - DEFAULT_WINDOW_SIZE, - maxMsgSize, - maxHeaderListSize, - maxHeaderListSize, - keepAliveTimeNano, - keepAliveTimeoutNano, - false, - authority, - userAgent, - tooManyPingsRunnable, - new TransportTracer(), - eagAttributes, - new SocketPicker(), - new FakeChannelLogger(), - false, - Ticker.systemTicker()); + NettyClientTransport transport = new NettyClientTransport( + address, + channelFactory, + new HashMap, Object>(), + group, + negotiator, + false, + DEFAULT_WINDOW_SIZE, + maxMsgSize, + maxHeaderListSize, + maxHeaderListSize, + keepAliveTimeNano, + keepAliveTimeoutNano, + false, + authority, + userAgent, + tooManyPingsRunnable, + new TransportTracer(), + eagAttributes, + new SocketPicker(), + new FakeChannelLogger(), + false, + new MetricRecorder() { + }, + Ticker.systemTicker()); transports.add(transport); return transport; } @@ -1166,35 +1182,35 @@ private void startServer(int maxStreamsPerConnection, int maxHeaderListSize) thr private void startServer(int maxStreamsPerConnection, int maxHeaderListSize, ServerListener serverListener) throws IOException { - server = - new NettyServer( - TestUtils.testServerAddresses(new InetSocketAddress(0)), - new ReflectiveChannelFactory<>(NioServerSocketChannel.class), - new HashMap, Object>(), - new HashMap, Object>(), - new FixedObjectPool<>(group), - new FixedObjectPool<>(group), - false, - negotiator, - Collections.emptyList(), - TransportTracer.getDefaultFactory(), - maxStreamsPerConnection, - false, - DEFAULT_WINDOW_SIZE, - DEFAULT_MAX_MESSAGE_SIZE, - maxHeaderListSize, - maxHeaderListSize, - DEFAULT_SERVER_KEEPALIVE_TIME_NANOS, - DEFAULT_SERVER_KEEPALIVE_TIMEOUT_NANOS, - MAX_CONNECTION_IDLE_NANOS_DISABLED, - MAX_CONNECTION_AGE_NANOS_DISABLED, - MAX_CONNECTION_AGE_GRACE_NANOS_INFINITE, - true, - 0, - MAX_RST_COUNT_DISABLED, - 0, - Attributes.EMPTY, - channelz); + server = new NettyServer( + TestUtils.testServerAddresses(new InetSocketAddress(0)), + new ReflectiveChannelFactory<>(NioServerSocketChannel.class), + new HashMap, Object>(), + new HashMap, Object>(), + new FixedObjectPool<>(group), + new FixedObjectPool<>(group), + false, + negotiator, + Collections.emptyList(), + TransportTracer.getDefaultFactory(), + maxStreamsPerConnection, + false, + DEFAULT_WINDOW_SIZE, + DEFAULT_MAX_MESSAGE_SIZE, + maxHeaderListSize, + maxHeaderListSize, + DEFAULT_SERVER_KEEPALIVE_TIME_NANOS, + DEFAULT_SERVER_KEEPALIVE_TIMEOUT_NANOS, + MAX_CONNECTION_IDLE_NANOS_DISABLED, + MAX_CONNECTION_AGE_NANOS_DISABLED, + MAX_CONNECTION_AGE_GRACE_NANOS_INFINITE, + true, + 0, + MAX_RST_COUNT_DISABLED, + 0, + Attributes.EMPTY, + channelz, + new MetricRecorder() {}); server.start(serverListener); address = TestUtils.testServerAddress((InetSocketAddress) server.getListenSocketAddress()); authority = GrpcUtil.authorityFromHostAndPort(address.getHostString(), address.getPort()); @@ -1462,7 +1478,7 @@ public FakeClientTransportListener(SettableFuture connected) { } @Override - public void transportShutdown(Status s) {} + public void transportShutdown(Status s, DisconnectError e) {} @Override public void transportTerminated() {} diff --git a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java index 797cfa95c0e..f3b73a515b5 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerBuilderTest.java @@ -22,7 +22,7 @@ import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; -import io.grpc.ServerStreamTracer; +import io.grpc.MetricRecorder; import io.netty.channel.EventLoopGroup; import io.netty.channel.local.LocalServerChannel; import io.netty.handler.ssl.SslContext; @@ -43,8 +43,9 @@ public class NettyServerBuilderTest { @Test public void addMultipleListenAddresses() { builder.addListenAddress(new InetSocketAddress(8081)); - NettyServer server = - builder.buildTransportServers(ImmutableList.of()); + NettyServer server = builder.buildTransportServers( + ImmutableList.of(), + new MetricRecorder() {}); assertThat(server.getListenSocketAddresses()).hasSize(2); } @@ -189,4 +190,5 @@ public void useNioTransport_shouldNotThrow() { builder.assertEventLoopsAndChannelType(); } + } diff --git a/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java b/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java index 0d5a9bab176..1c8d2b5479d 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerHandlerTest.java @@ -59,6 +59,7 @@ import io.grpc.Attributes; import io.grpc.InternalStatus; import io.grpc.Metadata; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.Status.Code; @@ -129,6 +130,7 @@ public class NettyServerHandlerTest extends NettyHandlerTestBase streamListenerMessageQueue = new LinkedList<>(); @@ -205,6 +207,18 @@ protected void manualSetUp() throws Exception { channel().releaseOutbound(); } + @Test + public void tcpMetrics_recorded() throws Exception { + manualSetUp(); + handler().channelActive(ctx()); + // Verify that channelActive triggered TcpMetrics + verify(metricRecorder, atLeastOnce()).addLongCounter( + eq(io.grpc.InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT), + eq(1L), + any(), + any()); + } + @Test public void transportReadyDelayedUntilConnectionPreface() throws Exception { initChannel(new GrpcHttp2ServerHeadersDecoder(GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE)); @@ -1416,7 +1430,8 @@ protected NettyServerHandler newHandler() { maxRstCount, maxRstPeriodNanos, Attributes.EMPTY, - fakeClock().getTicker()); + fakeClock().getTicker(), + metricRecorder); } @Override diff --git a/netty/src/test/java/io/grpc/netty/NettyServerTest.java b/netty/src/test/java/io/grpc/netty/NettyServerTest.java index f9bda4c5af1..e81008d029e 100644 --- a/netty/src/test/java/io/grpc/netty/NettyServerTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyServerTest.java @@ -37,6 +37,7 @@ import io.grpc.InternalChannelz.SocketStats; import io.grpc.InternalInstrumented; import io.grpc.Metadata; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.internal.FixedObjectPool; import io.grpc.internal.ServerListener; @@ -55,7 +56,6 @@ import io.netty.channel.EventLoopGroup; import io.netty.channel.ReflectiveChannelFactory; import io.netty.channel.WriteBufferWaterMark; -import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.util.AsciiString; import io.netty.util.concurrent.Future; @@ -87,7 +87,8 @@ public class NettyServerTest { @Rule public final MockitoRule mocks = MockitoJUnit.rule(); private final InternalChannelz channelz = new InternalChannelz(); - private final NioEventLoopGroup eventLoop = new NioEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + private final EventLoopGroup eventLoop = new io.netty.channel.nio.NioEventLoopGroup(1); private final ChannelFactory channelFactory = new ReflectiveChannelFactory<>(NioServerSocketChannel.class); @@ -161,7 +162,7 @@ class NoHandlerProtocolNegotiator implements ProtocolNegotiator { 0, 0, // ignore Attributes.EMPTY, - channelz); + channelz, mock(MetricRecorder.class)); final SettableFuture serverShutdownCalled = SettableFuture.create(); ns.start(new ServerListener() { @Override @@ -218,7 +219,7 @@ public void multiPortStartStopGet() throws Exception { 0, 0, // ignore Attributes.EMPTY, - channelz); + channelz, mock(MetricRecorder.class)); final SettableFuture shutdownCompleted = SettableFuture.create(); ns.start(new ServerListener() { @Override @@ -298,7 +299,7 @@ public void multiPortConnections() throws Exception { 0, 0, // ignore Attributes.EMPTY, - channelz); + channelz, mock(MetricRecorder.class)); final SettableFuture shutdownCompleted = SettableFuture.create(); ns.start(new ServerListener() { @Override @@ -366,7 +367,7 @@ public void getPort_notStarted() { 0, 0, // ignore Attributes.EMPTY, - channelz); + channelz, mock(MetricRecorder.class)); assertThat(ns.getListenSocketAddress()).isEqualTo(addr); assertThat(ns.getListenSocketAddresses()).isEqualTo(addresses); @@ -447,7 +448,7 @@ class TestProtocolNegotiator implements ProtocolNegotiator { 0, 0, // ignore eagAttributes, - channelz); + channelz, mock(MetricRecorder.class)); ns.start(new ServerListener() { @Override public ServerTransportListener transportCreated(ServerTransport transport) { @@ -501,7 +502,7 @@ public void channelzListenSocket() throws Exception { 0, 0, // ignore Attributes.EMPTY, - channelz); + channelz, mock(MetricRecorder.class)); final SettableFuture shutdownCompleted = SettableFuture.create(); ns.start(new ServerListener() { @Override @@ -649,7 +650,7 @@ private NettyServer getServer(List addr, EventLoopGroup ev) { 0, 0, // ignore Attributes.EMPTY, - channelz); + channelz, mock(MetricRecorder.class)); } private static class NoopServerTransportListener implements ServerTransportListener { diff --git a/netty/src/test/java/io/grpc/netty/NettyTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyTransportTest.java index b1c89e22f93..22758a8b727 100644 --- a/netty/src/test/java/io/grpc/netty/NettyTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyTransportTest.java @@ -22,10 +22,12 @@ import com.google.common.util.concurrent.SettableFuture; import io.grpc.Attributes; import io.grpc.ChannelLogger; +import io.grpc.MetricRecorder; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.internal.AbstractTransportTest; import io.grpc.internal.ClientTransportFactory; +import io.grpc.internal.DisconnectError; import io.grpc.internal.FakeClock; import io.grpc.internal.InternalServer; import io.grpc.internal.ManagedClientTransport; @@ -70,7 +72,7 @@ protected InternalServer newServer( .forAddress(new InetSocketAddress("localhost", 0)) .flowControlWindow(AbstractTransportTest.TEST_FLOW_CONTROL_WINDOW) .setTransportTracerFactory(fakeClockTransportTracer) - .buildTransportServers(streamTracerFactories); + .buildTransportServers(streamTracerFactories, new MetricRecorder() {}); } @Override @@ -80,7 +82,7 @@ protected InternalServer newServer( .forAddress(new InetSocketAddress("localhost", port)) .flowControlWindow(AbstractTransportTest.TEST_FLOW_CONTROL_WINDOW) .setTransportTracerFactory(fakeClockTransportTracer) - .buildTransportServers(streamTracerFactories); + .buildTransportServers(streamTracerFactories, new MetricRecorder() {}); } @Override @@ -127,7 +129,7 @@ public void channelHasUnresolvedHostname() throws Exception { .setChannelLogger(logger), logger); Runnable runnable = transport.start(new ManagedClientTransport.Listener() { @Override - public void transportShutdown(Status s) { + public void transportShutdown(Status s, DisconnectError e) { future.set(s); } diff --git a/netty/src/test/java/io/grpc/netty/ProtocolNegotiatorsTest.java b/netty/src/test/java/io/grpc/netty/ProtocolNegotiatorsTest.java index 638fe960a32..8845a3986b9 100644 --- a/netty/src/test/java/io/grpc/netty/ProtocolNegotiatorsTest.java +++ b/netty/src/test/java/io/grpc/netty/ProtocolNegotiatorsTest.java @@ -46,6 +46,7 @@ import io.grpc.InternalChannelz; import io.grpc.InternalChannelz.Security; import io.grpc.Metadata; +import io.grpc.MetricRecorder; import io.grpc.SecurityLevel; import io.grpc.ServerCredentials; import io.grpc.ServerStreamTracer; @@ -55,6 +56,7 @@ import io.grpc.TlsChannelCredentials; import io.grpc.TlsServerCredentials; import io.grpc.internal.ClientTransportFactory; +import io.grpc.internal.DisconnectError; import io.grpc.internal.GrpcAttributes; import io.grpc.internal.InternalServer; import io.grpc.internal.ManagedClientTransport; @@ -88,7 +90,6 @@ import io.netty.channel.ChannelPipeline; import io.netty.channel.ChannelPromise; import io.netty.channel.DefaultEventLoop; -import io.netty.channel.DefaultEventLoopGroup; import io.netty.channel.EventLoopGroup; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.channel.local.LocalAddress; @@ -126,6 +127,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -387,7 +389,9 @@ private Object expectHandshake( .buildTransportFactory(); InternalServer server = NettyServerBuilder .forPort(0, serverCreds) - .buildTransportServers(Collections.emptyList()); + .buildTransportServers( + Collections.emptyList(), + new MetricRecorder() {}); server.start(serverListener); ManagedClientTransport.Listener clientTransportListener = @@ -408,7 +412,7 @@ private Object expectHandshake( } else { ArgumentCaptor captor = ArgumentCaptor.forClass(Status.class); verify(clientTransportListener, timeout(TIMEOUT_SECONDS * 1000)) - .transportShutdown(captor.capture()); + .transportShutdown(captor.capture(), any(DisconnectError.class)); result = captor.getValue(); } @@ -914,7 +918,8 @@ public String applicationProtocol() { return "h2"; } }; - DefaultEventLoopGroup elg = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); ClientTlsHandler handler = new ClientTlsHandler(grpcHandler, sslContext, "authority", elg, noopLogger, Optional.absent(), @@ -940,7 +945,8 @@ public String applicationProtocol() { return "managed_mtls"; } }; - DefaultEventLoopGroup elg = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); InputStream clientCert = TlsTesting.loadCert("client.pem"); InputStream key = TlsTesting.loadCert("client.key"); @@ -978,7 +984,8 @@ public String applicationProtocol() { return "badproto"; } }; - DefaultEventLoopGroup elg = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); ClientTlsHandler handler = new ClientTlsHandler(grpcHandler, sslContext, "authority", elg, noopLogger, Optional.absent(), @@ -1026,7 +1033,7 @@ public void clientTlsHandler_closeDuringNegotiation() throws Exception { private ClientTlsProtocolNegotiator getClientTlsProtocolNegotiator() throws SSLException { return new ClientTlsProtocolNegotiator(GrpcSslContexts.forClient().trustManager( TlsTesting.loadCert("ca.pem")).build(), - null, Optional.absent(), null); + null, Optional.absent(), null, ""); } @Test @@ -1088,26 +1095,28 @@ public void tls_invalidHost() throws SSLException { @Test public void httpProxy_nullAddressNpe() { assertThrows(NullPointerException.class, - () -> ProtocolNegotiators.httpProxy(null, "user", "pass", ProtocolNegotiators.plaintext())); + () -> ProtocolNegotiators.httpProxy(null, null, "user", "pass", + ProtocolNegotiators.plaintext())); } @Test public void httpProxy_nullNegotiatorNpe() { assertThrows(NullPointerException.class, () -> ProtocolNegotiators.httpProxy( - InetSocketAddress.createUnresolved("localhost", 80), "user", "pass", null)); + InetSocketAddress.createUnresolved("localhost", 80), null, "user", "pass", null)); } @Test public void httpProxy_nullUserPassNoException() throws Exception { assertNotNull(ProtocolNegotiators.httpProxy( - InetSocketAddress.createUnresolved("localhost", 80), null, null, + InetSocketAddress.createUnresolved("localhost", 80), null, null, null, ProtocolNegotiators.plaintext())); } @Test public void httpProxy_completes() throws Exception { - DefaultEventLoopGroup elg = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); // ProxyHandler is incompatible with EmbeddedChannel because when channelRegistered() is called // the channel is already active. LocalAddress proxy = new LocalAddress("httpProxy_completes"); @@ -1119,7 +1128,7 @@ public void httpProxy_completes() throws Exception { .bind(proxy).sync().channel(); ProtocolNegotiator nego = - ProtocolNegotiators.httpProxy(proxy, null, null, ProtocolNegotiators.plaintext()); + ProtocolNegotiators.httpProxy(proxy, null, null, null, ProtocolNegotiators.plaintext()); // normally NettyClientTransport will add WBAEH which kick start the ProtocolNegotiation, // mocking the behavior using KickStartHandler. ChannelHandler handler = @@ -1170,7 +1179,8 @@ public void httpProxy_completes() throws Exception { @Test public void httpProxy_500() throws Exception { - DefaultEventLoopGroup elg = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); // ProxyHandler is incompatible with EmbeddedChannel because when channelRegistered() is called // the channel is already active. LocalAddress proxy = new LocalAddress("httpProxy_500"); @@ -1182,7 +1192,7 @@ public void httpProxy_500() throws Exception { .bind(proxy).sync().channel(); ProtocolNegotiator nego = - ProtocolNegotiators.httpProxy(proxy, null, null, ProtocolNegotiators.plaintext()); + ProtocolNegotiators.httpProxy(proxy, null, null, null, ProtocolNegotiators.plaintext()); // normally NettyClientTransport will add WBAEH which kick start the ProtocolNegotiation, // mocking the behavior using KickStartHandler. ChannelHandler handler = @@ -1220,9 +1230,82 @@ public void httpProxy_500() throws Exception { } } + @Test + public void httpProxy_customHeaders() throws Exception { + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); + // ProxyHandler is incompatible with EmbeddedChannel because when channelRegistered() is called + // the channel is already active. + LocalAddress proxy = new LocalAddress("httpProxy_customHeaders"); + SocketAddress host = InetSocketAddress.createUnresolved("example.com", 443); + + ChannelInboundHandler mockHandler = mock(ChannelInboundHandler.class); + Channel serverChannel = new ServerBootstrap().group(elg).channel(LocalServerChannel.class) + .childHandler(mockHandler) + .bind(proxy).sync().channel(); + + Map headers = new java.util.HashMap<>(); + headers.put("X-Custom-Header", "custom-value"); + headers.put("Proxy-Authorization", "Bearer token123"); + + ProtocolNegotiator nego = ProtocolNegotiators.httpProxy( + proxy, headers, null, null, ProtocolNegotiators.plaintext()); + // normally NettyClientTransport will add WBAEH which kick start the ProtocolNegotiation, + // mocking the behavior using KickStartHandler. + ChannelHandler handler = + new KickStartHandler(nego.newHandler(FakeGrpcHttp2ConnectionHandler.noopHandler())); + Channel channel = new Bootstrap().group(elg).channel(LocalChannel.class).handler(handler) + .register().sync().channel(); + pipeline = channel.pipeline(); + // Wait for initialization to complete + channel.eventLoop().submit(NOOP_RUNNABLE).sync(); + channel.connect(host).sync(); + serverChannel.close(); + ArgumentCaptor contextCaptor = + ArgumentCaptor.forClass(ChannelHandlerContext.class); + Mockito.verify(mockHandler).channelActive(contextCaptor.capture()); + ChannelHandlerContext serverContext = contextCaptor.getValue(); + + final String golden = "testData"; + ChannelFuture negotiationFuture = channel.writeAndFlush(bb(golden, channel)); + + // Wait for sending initial request to complete + channel.eventLoop().submit(NOOP_RUNNABLE).sync(); + ArgumentCaptor objectCaptor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(mockHandler) + .channelRead(ArgumentMatchers.any(), objectCaptor.capture()); + ByteBuf b = (ByteBuf) objectCaptor.getValue(); + String request = b.toString(UTF_8); + b.release(); + + // Verify custom headers are present in the CONNECT request + assertTrue("No trailing newline: " + request, request.endsWith("\r\n\r\n")); + assertTrue("No CONNECT: " + request, request.startsWith("CONNECT example.com:443 ")); + assertTrue("No custom header: " + request, + request.contains("X-Custom-Header: custom-value")); + assertTrue("No proxy authorization: " + request, + request.contains("Proxy-Authorization: Bearer token123")); + + assertFalse(negotiationFuture.isDone()); + serverContext.writeAndFlush(bb("HTTP/1.1 200 OK\r\n\r\n", serverContext.channel())).sync(); + negotiationFuture.sync(); + + channel.eventLoop().submit(NOOP_RUNNABLE).sync(); + objectCaptor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(mockHandler, times(2)) + .channelRead(ArgumentMatchers.any(), objectCaptor.capture()); + b = (ByteBuf) objectCaptor.getAllValues().get(1); + String preface = b.toString(UTF_8); + b.release(); + assertEquals(golden, preface); + + channel.close(); + } + @Test public void waitUntilActiveHandler_firesNegotiation() throws Exception { - EventLoopGroup elg = new DefaultEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup elg = new io.netty.channel.DefaultEventLoopGroup(1); SocketAddress addr = new LocalAddress("addr"); final AtomicReference event = new AtomicReference<>(); ChannelHandler next = new ChannelInboundHandlerAdapter() { @@ -1277,7 +1360,7 @@ public void clientTlsHandler_firesNegotiation() throws Exception { } FakeGrpcHttp2ConnectionHandler gh = FakeGrpcHttp2ConnectionHandler.newHandler(); ClientTlsProtocolNegotiator pn = new ClientTlsProtocolNegotiator(clientSslContext, - null, Optional.absent(), null); + null, Optional.absent(), null, null); WriteBufferingAndExceptionHandler clientWbaeh = new WriteBufferingAndExceptionHandler(pn.newHandler(gh)); diff --git a/netty/src/test/java/io/grpc/netty/TcpMetricsTest.java b/netty/src/test/java/io/grpc/netty/TcpMetricsTest.java new file mode 100644 index 00000000000..f75a98b46df --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/TcpMetricsTest.java @@ -0,0 +1,616 @@ +/* + * 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.netty; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import io.grpc.InternalTcpMetrics; +import io.grpc.MetricRecorder; +import io.netty.util.concurrent.ScheduledFuture; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import org.junit.After; +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.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public class TcpMetricsTest { + + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + private MetricRecorder metricRecorder; + + private ConfigurableFakeWithTcpInfo channel; + private TcpMetrics metrics; + + @Before + public void setUp() throws Exception { + FakeEpollTcpInfo dummyInfo = new FakeEpollTcpInfo(); + channel = new ConfigurableFakeWithTcpInfo(dummyInfo); + metrics = new TcpMetrics(metricRecorder); + } + + @After + public void tearDown() throws Exception { + TcpMetrics.epollInfo = TcpMetrics.loadEpollInfo(); + } + + @Test + public void metricsInitialization() { + + assertNotNull(InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT); + assertNotNull(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT); + assertNotNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT); + assertNotNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT); + assertNotNull(InternalTcpMetrics.MIN_RTT_INSTRUMENT); + } + + public static class FakeEpollTcpInfo { + long totalRetrans; + long retransmits; + long rtt; + + public void setValues(long totalRetrans, long retransmits, long rtt) { + this.totalRetrans = totalRetrans; + this.retransmits = retransmits; + this.rtt = rtt; + } + + @SuppressWarnings("unused") + public long totalRetrans() { + return totalRetrans; + } + + @SuppressWarnings("unused") + public long retrans() { + return retransmits; + } + + @SuppressWarnings("unused") + public long rtt() { + return rtt; + } + } + + @Test + public void tracker_recordTcpInfo_reflectionSuccess() throws Exception { + MetricRecorder recorder = mock(MetricRecorder.class); + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + TcpMetrics tracker = new TcpMetrics(recorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + infoSource.setValues(123, 4, 5000); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + channel.writeInbound("dummy"); + + tracker.channelInactive(channel); + + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(123L), any(), any()); + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT)), + eq(4L), any(), any()); + verify(recorder).recordDoubleHistogram( + eq(Objects.requireNonNull(InternalTcpMetrics.MIN_RTT_INSTRUMENT)), + eq(0.005), any(), any()); + } + + @Test + public void tracker_periodicRecord_doesNotRecordRecurringRetransmits() throws Exception { + MetricRecorder recorder = mock(MetricRecorder.class); + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + TcpMetrics tracker = new TcpMetrics(recorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + infoSource.setValues(123, 4, 5000); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + tracker.channelActive(channel); + + ScheduledFuture timer = tracker.getReportTimer(); + assertNotNull("Timer should be scheduled", timer); + + long delay = timer.getDelay(TimeUnit.MILLISECONDS); + channel.advanceTimeBy(delay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(123L), any(), any()); + verify(recorder).recordDoubleHistogram( + eq(Objects.requireNonNull(InternalTcpMetrics.MIN_RTT_INSTRUMENT)), + eq(0.005), any(), any()); + // Should NOT record recurring retransmits during periodic polling + verify(recorder, org.mockito.Mockito.never()) + .addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT)), + anyLong(), any(), any()); + } + + @Test + public void tracker_channelInactive_recordsRecurringRetransmits_raw_notDelta() throws Exception { + MetricRecorder recorder = mock(MetricRecorder.class); + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + TcpMetrics tracker = new TcpMetrics(recorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + infoSource.setValues(123, 4, 5000); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + tracker.channelActive(channel); + + ScheduledFuture timer = tracker.getReportTimer(); + assertNotNull("Timer should be scheduled", timer); + + long delay = timer.getDelay(TimeUnit.MILLISECONDS); + channel.advanceTimeBy(delay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + org.mockito.Mockito.clearInvocations(recorder); + + // Let's just create a new channel instance where tcpInfo sets retrans=5. + FakeEpollTcpInfo infoSource2 = new FakeEpollTcpInfo(); + infoSource2.setValues(130, 5, 5000); + ConfigurableFakeWithTcpInfo channel2 = new ConfigurableFakeWithTcpInfo(infoSource2); + + tracker.channelInactive(channel2); + + // It should record delta for totalRetrans (130 - 123 = 7) + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(7L), any(), any()); + // But for recurringRetransmits it MUST record the raw value 5, not the delta! + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT)), + eq(5L), any(), any()); + } + + @Test + public void tracker_periodicRecord_reportsDeltaForTotalRetrans() throws Exception { + MetricRecorder recorder = mock(MetricRecorder.class); + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + TcpMetrics tracker = new TcpMetrics(recorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + infoSource.setValues(123, 4, 5000); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + tracker.channelActive(channel); + + ScheduledFuture timer = tracker.getReportTimer(); + assertNotNull("Timer should be scheduled", timer); + + long delay = timer.getDelay(TimeUnit.MILLISECONDS); + channel.advanceTimeBy(delay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(123L), any(), any()); + + org.mockito.Mockito.clearInvocations(recorder); + + // Change tcpInfo for second periodic record + infoSource.setValues(150, 2, 6000); // 150 - 123 = 27 + + ScheduledFuture newTimer = tracker.getReportTimer(); + assertNotNull("New timer should be scheduled", newTimer); + assertNotSame("Timer should be a new instance", timer, newTimer); + long newDelay = newTimer.getDelay(TimeUnit.MILLISECONDS); + channel.advanceTimeBy(newDelay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + // Only the delta (150 - 123 = 27) should be recorded + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(27L), any(), any()); + verify(recorder).recordDoubleHistogram( + eq(Objects.requireNonNull(InternalTcpMetrics.MIN_RTT_INSTRUMENT)), + eq(0.006), any(), any()); + verify(recorder, org.mockito.Mockito.never()) + .addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT)), + anyLong(), any(), any()); + } + + @Test + public void tracker_periodicRecord_doesNotReportZeroDeltaForTotalRetrans() throws Exception { + MetricRecorder recorder = mock(MetricRecorder.class); + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + TcpMetrics tracker = new TcpMetrics(recorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + infoSource.setValues(123, 4, 5000); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + tracker.channelActive(channel); + + ScheduledFuture timer = tracker.getReportTimer(); + assertNotNull("Timer should be scheduled", timer); + + long delay = timer.getDelay(TimeUnit.MILLISECONDS); + channel.advanceTimeBy(delay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(123L), any(), any()); + + org.mockito.Mockito.clearInvocations(recorder); + + // Keep tcpInfo the same for second periodic record + ScheduledFuture newTimer = tracker.getReportTimer(); + assertNotNull("New timer should be scheduled", newTimer); + assertNotSame("Timer should be a new instance", timer, newTimer); + long newDelay = newTimer.getDelay(TimeUnit.MILLISECONDS); + channel.advanceTimeBy(newDelay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + // NO delta (123 - 123 = 0), so it should not be recorded + verify(recorder, org.mockito.Mockito.never()) + .addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + anyLong(), any(), any()); + + // MIN_RTT should be recorded again! + verify(recorder).recordDoubleHistogram( + eq(Objects.requireNonNull(InternalTcpMetrics.MIN_RTT_INSTRUMENT)), + eq(0.005), any(), any()); + } + + public static class ConfigurableFakeWithTcpInfo extends + io.netty.channel.embedded.EmbeddedChannel { + private final FakeEpollTcpInfo infoToCopy; + + public ConfigurableFakeWithTcpInfo(FakeEpollTcpInfo infoToCopy) { + this.infoToCopy = infoToCopy; + } + + public void tcpInfo(FakeEpollTcpInfo info) { + info.totalRetrans = infoToCopy.totalRetrans; + info.retransmits = infoToCopy.retransmits; + info.rtt = infoToCopy.rtt; + } + } + + private static class AddressOverrideEmbeddedChannel extends + io.netty.channel.embedded.EmbeddedChannel { + private final SocketAddress local; + private final SocketAddress remote; + + public AddressOverrideEmbeddedChannel(SocketAddress local, SocketAddress remote) { + this.local = local; + this.remote = remote; + } + + @Override + public SocketAddress localAddress() { + return local; + } + + @Override + public SocketAddress remoteAddress() { + return remote; + } + } + + @Test + public void tracker_reportsDeltas_correctly() throws Exception { + MetricRecorder recorder = mock(MetricRecorder.class); + + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + TcpMetrics tracker = new TcpMetrics(recorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + // 10 retransmits total + infoSource.setValues(10, 2, 1000); + tracker.recordTcpInfo(channel); + + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(10L), any(), any()); + + // 15 retransmits total (delta 5) + infoSource.setValues(15, 0, 1000); + tracker.recordTcpInfo(channel); + + verify(recorder).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(5L), any(), any()); + + // 15 retransmits total (delta 0) - should NOT report + // also set retransmits to 1 + infoSource.setValues(15, 1, 1000); + tracker.recordTcpInfo(channel); + // Verify no new interactions with this specific metric and value + // We can't easily verify "no interaction" for specific value without capturing. + verify(recorder, org.mockito.Mockito.times(1)).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(10L), any(), any()); + verify(recorder, org.mockito.Mockito.times(1)).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + eq(5L), any(), any()); + // Total interactions for packetsRetransmitted should be 2 + verify(recorder, org.mockito.Mockito.times(2)).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT)), + anyLong(), any(), any()); + + // recurringRetransmits should NOT have been reported yet (periodic calls) + verify(recorder, org.mockito.Mockito.times(0)).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT)), + anyLong(), any(), any()); + + // Close channel - should report recurringRetransmits + tracker.channelInactive(channel); + verify(recorder, org.mockito.Mockito.times(1)).addLongCounter( + eq(Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT)), + eq(1L), // From last infoSource setValues(15, 1, 1000) + any(), any()); + } + + @Test + public void tracker_recordTcpInfo_reflectionFailure() { + MetricRecorder recorder = mock(MetricRecorder.class); + + TcpMetrics.epollInfo = null; + TcpMetrics tracker = new TcpMetrics(recorder); + + io.netty.channel.embedded.EmbeddedChannel channel = new + io.netty.channel.embedded.EmbeddedChannel(); + + // Should catch exception and ignore + tracker.channelInactive(channel); + } + + @Test + public void registeredMetrics_haveCorrectOptionalLabels() { + List expectedOptionalLabels = Arrays.asList( + "network.local.address", + "network.local.port", + "network.peer.address", + "network.peer.port"); + + assertEquals( + expectedOptionalLabels, + InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT.getOptionalLabelKeys()); + assertEquals( + expectedOptionalLabels, + InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT.getOptionalLabelKeys()); + + assertEquals( + expectedOptionalLabels, + Objects.requireNonNull(InternalTcpMetrics.PACKETS_RETRANSMITTED_INSTRUMENT) + .getOptionalLabelKeys()); + assertEquals( + expectedOptionalLabels, + Objects.requireNonNull(InternalTcpMetrics.RECURRING_RETRANSMITS_INSTRUMENT) + .getOptionalLabelKeys()); + assertEquals( + expectedOptionalLabels, + Objects.requireNonNull(InternalTcpMetrics.MIN_RTT_INSTRUMENT).getOptionalLabelKeys()); + } + + @Test + public void channelActive_extractsLabels_ipv4() throws Exception { + InetAddress localInet = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); + InetAddress remoteInet = InetAddress.getByAddress(new byte[] {127, 0, 0, 2}); + + AddressOverrideEmbeddedChannel channel = new AddressOverrideEmbeddedChannel( + new InetSocketAddress(localInet, 8080), + new InetSocketAddress(remoteInet, 443)); + + metrics.channelActive(channel); + + verify(metricRecorder).addLongCounter( + eq(InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT), eq(1L), + eq(Collections.emptyList()), + eq(Arrays.asList("127.0.0.1", "8080", "127.0.0.2", "443"))); + verify(metricRecorder).addLongUpDownCounter( + eq(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT), eq(1L), + eq(Collections.emptyList()), + eq(Arrays.asList("127.0.0.1", "8080", "127.0.0.2", "443"))); + verifyNoMoreInteractions(metricRecorder); + } + + @Test + public void channelInactive_extractsLabels_ipv6() throws Exception { + InetAddress localInet = InetAddress.getByAddress(new byte[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}); + InetAddress remoteInet = InetAddress.getByAddress(new byte[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2}); + + AddressOverrideEmbeddedChannel channel = new AddressOverrideEmbeddedChannel( + new InetSocketAddress(localInet, 8080), + new InetSocketAddress(remoteInet, 443)); + + metrics.channelInactive(channel); + + verify(metricRecorder).addLongUpDownCounter( + eq(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT), eq(-1L), + eq(Collections.emptyList()), + eq(Arrays.asList("0:0:0:0:0:0:0:1", "8080", "0:0:0:0:0:0:0:2", "443"))); + verifyNoMoreInteractions(metricRecorder); + } + + @Test + public void channelActive_extractsLabels_nonInetAddress() { + SocketAddress dummyAddress = new SocketAddress() { + }; + AddressOverrideEmbeddedChannel channel = new AddressOverrideEmbeddedChannel( + dummyAddress, dummyAddress); + + metrics.channelActive(channel); + + verify(metricRecorder).addLongCounter( + eq(InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT), eq(1L), + eq(Collections.emptyList()), + eq(Arrays.asList("", "", "", ""))); + verify(metricRecorder).addLongUpDownCounter( + eq(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT), eq(1L), + eq(Collections.emptyList()), + eq(Arrays.asList("", "", "", ""))); + verifyNoMoreInteractions(metricRecorder); + } + + @Test + public void channelActive_incrementsCounts() { + metrics.channelActive(channel); + verify(metricRecorder).addLongCounter( + eq(InternalTcpMetrics.CONNECTIONS_CREATED_INSTRUMENT), eq(1L), + eq(Collections.emptyList()), + eq(Arrays.asList("", "", "", ""))); + verify(metricRecorder).addLongUpDownCounter( + eq(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT), eq(1L), + eq(Collections.emptyList()), + eq(Arrays.asList("", "", "", ""))); + verifyNoMoreInteractions(metricRecorder); + } + + @Test + public void channelInactive_decrementsCount_noEpoll_noError() { + metrics.channelInactive(channel); + verify(metricRecorder).addLongUpDownCounter( + eq(InternalTcpMetrics.CONNECTION_COUNT_INSTRUMENT), eq(-1L), + eq(Collections.emptyList()), + eq(Arrays.asList("", "", "", ""))); + verifyNoMoreInteractions(metricRecorder); + } + + @Test + public void channelActive_schedulesReportTimer() throws Exception { + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + + metrics = new TcpMetrics(metricRecorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + metrics.channelActive(channel); + + ScheduledFuture timer = metrics.getReportTimer(); + assertNotNull("Timer should be scheduled", timer); + + long delay = timer.getDelay(TimeUnit.MILLISECONDS); + assertTrue("Delay should be >= 30000 but was " + delay, delay >= 30_000); + assertTrue("Delay should be <= 330000 but was " + delay, delay <= 330_000); + + // Advance time to trigger the task + channel.advanceTimeBy(delay + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + + // Verify rescheduling + ScheduledFuture newTimer = metrics.getReportTimer(); + assertNotNull("New timer should be scheduled", newTimer); + assertNotSame("Timer should be a new instance", timer, newTimer); + + long newDelay = newTimer.getDelay(TimeUnit.MILLISECONDS); + // Re-arming jitter is 90% to 110%, so 270,000 ms to 330,000 ms + assertTrue("Delay should be >= 270000 but was " + newDelay, newDelay >= 270_000); + assertTrue("Delay should be <= 330000 but was " + newDelay, newDelay <= 330_000); + } + + @Test + public void channelInactive_cancelsReportTimer() throws Exception { + TcpMetrics.epollInfo = new TcpMetrics.EpollInfo( + ConfigurableFakeWithTcpInfo.class, + FakeEpollTcpInfo.class.getConstructor(), + ConfigurableFakeWithTcpInfo.class.getMethod("tcpInfo", FakeEpollTcpInfo.class), + FakeEpollTcpInfo.class.getMethod("totalRetrans"), + FakeEpollTcpInfo.class.getMethod("retrans"), + FakeEpollTcpInfo.class.getMethod("rtt")); + + metrics = new TcpMetrics(metricRecorder); + + FakeEpollTcpInfo infoSource = new FakeEpollTcpInfo(); + ConfigurableFakeWithTcpInfo channel = new ConfigurableFakeWithTcpInfo(infoSource); + + metrics.channelActive(channel); + + ScheduledFuture timer = metrics.getReportTimer(); + assertNotNull("Timer should be scheduled", timer); + + metrics.channelInactive(channel); + + assertTrue("Timer should be cancelled", timer.isCancelled()); + } +} diff --git a/netty/src/test/java/io/grpc/netty/UdsNameResolverProviderTest.java b/netty/src/test/java/io/grpc/netty/UdsNameResolverProviderTest.java index 9dacf00cfad..1766a8e4134 100644 --- a/netty/src/test/java/io/grpc/netty/UdsNameResolverProviderTest.java +++ b/netty/src/test/java/io/grpc/netty/UdsNameResolverProviderTest.java @@ -17,6 +17,7 @@ package io.grpc.netty; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.TruthJUnit.assume; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -26,16 +27,20 @@ import io.grpc.NameResolver; import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; import io.netty.channel.unix.DomainSocketAddress; import java.net.SocketAddress; import java.net.URI; +import java.util.Arrays; import java.util.List; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; @@ -43,9 +48,17 @@ import org.mockito.junit.MockitoRule; /** Unit tests for {@link UdsNameResolverProvider}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class UdsNameResolverProviderTest { private static final int DEFAULT_PORT = 887; + + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); @@ -73,48 +86,60 @@ public class UdsNameResolverProviderTest { @Test public void testUnixRelativePath() { - UdsNameResolver udsNameResolver = - udsNameResolverProvider.newNameResolver(URI.create("unix:sock.sock"), args); - assertThat(udsNameResolver).isNotNull(); - udsNameResolver.start(mockListener); - verify(mockListener).onResult2(resultCaptor.capture()); - NameResolver.ResolutionResult result = resultCaptor.getValue(); - List list = result.getAddressesOrError().getValue(); - assertThat(list).isNotNull(); - assertThat(list).hasSize(1); - EquivalentAddressGroup eag = list.get(0); - assertThat(eag).isNotNull(); - List addresses = eag.getAddresses(); - assertThat(addresses).hasSize(1); - assertThat(addresses.get(0)).isInstanceOf(DomainSocketAddress.class); - DomainSocketAddress domainSocketAddress = (DomainSocketAddress) addresses.get(0); + UdsNameResolver udsNameResolver = newNameResolver("unix:sock.sock", args); + DomainSocketAddress domainSocketAddress = startAndGetUniqueResolvedAddress(udsNameResolver); assertThat(domainSocketAddress.path()).isEqualTo("sock.sock"); } @Test public void testUnixAbsolutePath() { - UdsNameResolver udsNameResolver = - udsNameResolverProvider.newNameResolver(URI.create("unix:/sock.sock"), args); - assertThat(udsNameResolver).isNotNull(); - udsNameResolver.start(mockListener); - verify(mockListener).onResult2(resultCaptor.capture()); - NameResolver.ResolutionResult result = resultCaptor.getValue(); - List list = result.getAddressesOrError().getValue(); - assertThat(list).isNotNull(); - assertThat(list).hasSize(1); - EquivalentAddressGroup eag = list.get(0); - assertThat(eag).isNotNull(); - List addresses = eag.getAddresses(); - assertThat(addresses).hasSize(1); - assertThat(addresses.get(0)).isInstanceOf(DomainSocketAddress.class); - DomainSocketAddress domainSocketAddress = (DomainSocketAddress) addresses.get(0); + UdsNameResolver udsNameResolver = newNameResolver("unix:/sock.sock", args); + DomainSocketAddress domainSocketAddress = startAndGetUniqueResolvedAddress(udsNameResolver); assertThat(domainSocketAddress.path()).isEqualTo("/sock.sock"); } @Test public void testUnixAbsoluteAlternatePath() { - UdsNameResolver udsNameResolver = - udsNameResolverProvider.newNameResolver(URI.create("unix:///sock.sock"), args); + UdsNameResolver udsNameResolver = newNameResolver("unix:///sock.sock", args); + DomainSocketAddress domainSocketAddress = startAndGetUniqueResolvedAddress(udsNameResolver); + assertThat(domainSocketAddress.path()).isEqualTo("/sock.sock"); + } + + @Test + public void testUnixPathWithAuthority() { + try { + newNameResolver("unix://localhost/sock.sock", args); + fail("exception expected"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().isEqualTo("authority not supported: localhost"); + } + } + + @Test + public void testUnixAbsolutePathDoesNotIncludeQueryOrFragment() { + UdsNameResolver udsNameResolver = newNameResolver("unix:///sock.sock?query#fragment", args); + DomainSocketAddress domainSocketAddress = startAndGetUniqueResolvedAddress(udsNameResolver); + assertThat(domainSocketAddress.path()).isEqualTo("/sock.sock"); + } + + @Test + public void testUnixRelativePathDoesNotIncludeQueryOrFragment() { + // This test fails without RFC 3986 support because of a bug in the legacy java.net.URI-based + // NRP implementation. + assume().that(enableRfc3986UrisParam).isTrue(); + + UdsNameResolver udsNameResolver = newNameResolver("unix:sock.sock?query#fragment", args); + DomainSocketAddress domainSocketAddress = startAndGetUniqueResolvedAddress(udsNameResolver); + assertThat(domainSocketAddress.path()).isEqualTo("sock.sock"); + } + + private UdsNameResolver newNameResolver(String uriString, NameResolver.Args args) { + return enableRfc3986UrisParam + ? (UdsNameResolver) udsNameResolverProvider.newNameResolver(Uri.create(uriString), args) + : udsNameResolverProvider.newNameResolver(URI.create(uriString), args); + } + + private DomainSocketAddress startAndGetUniqueResolvedAddress(UdsNameResolver udsNameResolver) { assertThat(udsNameResolver).isNotNull(); udsNameResolver.start(mockListener); verify(mockListener).onResult2(resultCaptor.capture()); @@ -127,17 +152,6 @@ public void testUnixAbsoluteAlternatePath() { List addresses = eag.getAddresses(); assertThat(addresses).hasSize(1); assertThat(addresses.get(0)).isInstanceOf(DomainSocketAddress.class); - DomainSocketAddress domainSocketAddress = (DomainSocketAddress) addresses.get(0); - assertThat(domainSocketAddress.path()).isEqualTo("/sock.sock"); - } - - @Test - public void testUnixPathWithAuthority() { - try { - udsNameResolverProvider.newNameResolver(URI.create("unix://localhost/sock.sock"), args); - fail("exception expected"); - } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().isEqualTo("non-null authority not supported"); - } + return (DomainSocketAddress) addresses.get(0); } } diff --git a/netty/src/test/java/io/grpc/netty/UdsNameResolverTest.java b/netty/src/test/java/io/grpc/netty/UdsNameResolverTest.java index 22790a41c77..7bf808c18ce 100644 --- a/netty/src/test/java/io/grpc/netty/UdsNameResolverTest.java +++ b/netty/src/test/java/io/grpc/netty/UdsNameResolverTest.java @@ -91,10 +91,10 @@ public void testValidTargetPath() { @Test public void testNonNullAuthority() { try { - udsNameResolver = new UdsNameResolver("authority", "sock.sock", args); + udsNameResolver = new UdsNameResolver("somehost", "sock.sock", args); fail("exception expected"); } catch (IllegalArgumentException e) { - assertThat(e).hasMessageThat().isEqualTo("non-null authority not supported"); + assertThat(e).hasMessageThat().isEqualTo("authority not supported: somehost"); } } } diff --git a/netty/src/test/java/io/grpc/netty/UdsNettyChannelProviderTest.java b/netty/src/test/java/io/grpc/netty/UdsNettyChannelProviderTest.java index e0c3d5a8525..702ed4c81f7 100644 --- a/netty/src/test/java/io/grpc/netty/UdsNettyChannelProviderTest.java +++ b/netty/src/test/java/io/grpc/netty/UdsNettyChannelProviderTest.java @@ -35,7 +35,6 @@ import io.grpc.testing.protobuf.SimpleResponse; import io.grpc.testing.protobuf.SimpleServiceGrpc; import io.netty.channel.EventLoopGroup; -import io.netty.channel.epoll.EpollEventLoopGroup; import io.netty.channel.epoll.EpollServerDomainSocketChannel; import io.netty.channel.unix.DomainSocketAddress; import java.io.IOException; @@ -108,6 +107,16 @@ public void newChannelBuilder_success() { assertThat(result.getChannelBuilder()).isInstanceOf(NettyChannelBuilder.class); } + @Test + public void newChannelBuilder_withRegistry_success() { + Assume.assumeTrue(Utils.isEpollAvailable()); + NewChannelBuilderResult result = provider.newChannelBuilder("unix:sock.sock", + TlsChannelCredentials.create(), + io.grpc.NameResolverRegistry.getDefaultRegistry(), + new io.grpc.internal.DnsNameResolverProvider()); + assertThat(result.getChannelBuilder()).isInstanceOf(NettyChannelBuilder.class); + } + @Test public void managedChannelRegistry_newChannelBuilder() { Assume.assumeTrue(Utils.isEpollAvailable()); @@ -141,8 +150,12 @@ private static String unaryRpc( } private void createUdsServer(String name) throws IOException { - elg = new EpollEventLoopGroup(); - boss = new EpollEventLoopGroup(1); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup epollElg = new io.netty.channel.epoll.EpollEventLoopGroup(); + @SuppressWarnings("deprecation") // Wait a bit before migrating to the Netty 4.2 API + EventLoopGroup epollBoss = new io.netty.channel.epoll.EpollEventLoopGroup(1); + elg = epollElg; + boss = epollBoss; cleanupRule.register( NettyServerBuilder.forAddress(new DomainSocketAddress(name)) .bossEventLoopGroup(boss) diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelBuilder.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelBuilder.java index 98f764132fe..89176ce01f5 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelBuilder.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelBuilder.java @@ -35,6 +35,8 @@ import io.grpc.InsecureChannelCredentials; import io.grpc.Internal; import io.grpc.ManagedChannelBuilder; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; import io.grpc.TlsChannelCredentials; import io.grpc.internal.AtomicBackoff; import io.grpc.internal.ClientTransportFactory; @@ -116,17 +118,26 @@ private enum NegotiationType { CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, - CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 - - // TLS 1.3 does not work so far. See issues: - // https://github.com/grpc/grpc-java/issues/7765 - // - // TLS 1.3 - //CipherSuite.TLS_AES_128_GCM_SHA256, - //CipherSuite.TLS_AES_256_GCM_SHA384, - //CipherSuite.TLS_CHACHA20_POLY1305_SHA256 - ) - .tlsVersions(/*TlsVersion.TLS_1_3,*/ TlsVersion.TLS_1_2) + CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + CipherSuite.TLS_AES_128_GCM_SHA256, + CipherSuite.TLS_AES_256_GCM_SHA384, + CipherSuite.TLS_CHACHA20_POLY1305_SHA256) + .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) + .supportsTlsExtensions(true) + .build(); + + // @VisibleForTesting + static final ConnectionSpec INTERNAL_LEGACY_CONNECTION_SPEC = + new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .cipherSuites( + // The following items should be sync with Netty's Http2SecurityUtil.CIPHERS. + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256) + .tlsVersions(TlsVersion.TLS_1_2) .supportsTlsExtensions(true) .build(); @@ -184,7 +195,7 @@ public static OkHttpChannelBuilder forTarget(String target, ChannelCredentials c private SSLSocketFactory sslSocketFactory; private final boolean freezeSecurityConfiguration; private HostnameVerifier hostnameVerifier; - private ConnectionSpec connectionSpec = INTERNAL_DEFAULT_CONNECTION_SPEC; + private ConnectionSpec connectionSpec = initialConnectionSpec(); private NegotiationType negotiationType = NegotiationType.TLS; private long keepAliveTimeNanos = KEEPALIVE_TIME_NANOS_DISABLED; private long keepAliveTimeoutNanos = DEFAULT_KEEPALIVE_TIMEOUT_NANOS; @@ -199,6 +210,12 @@ public static OkHttpChannelBuilder forTarget(String target, ChannelCredentials c */ private final boolean useGetForSafeMethods = false; + static ConnectionSpec initialConnectionSpec() { + return (OkHttpProtocolNegotiator.get() instanceof OkHttpProtocolNegotiator.AndroidNegotiator) + ? INTERNAL_DEFAULT_CONNECTION_SPEC + : INTERNAL_LEGACY_CONNECTION_SPEC; + } + private OkHttpChannelBuilder(String host, int port) { this(GrpcUtil.authorityFromHostAndPort(host, port)); } @@ -214,10 +231,20 @@ private OkHttpChannelBuilder(String target) { OkHttpChannelBuilder( String target, ChannelCredentials channelCreds, CallCredentials callCreds, SSLSocketFactory factory) { + this(target, channelCreds, callCreds, factory, null, null); + } + + OkHttpChannelBuilder( + String target, ChannelCredentials channelCreds, CallCredentials callCreds, + SSLSocketFactory factory, + NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { managedChannelImplBuilder = new ManagedChannelImplBuilder( target, channelCreds, callCreds, new OkHttpChannelTransportFactoryBuilder(), - new OkHttpChannelDefaultPortProvider()); + new OkHttpChannelDefaultPortProvider(), + nameResolverRegistry, + nameResolverProvider); this.sslSocketFactory = factory; this.negotiationType = factory == null ? NegotiationType.PLAINTEXT : NegotiationType.TLS; this.freezeSecurityConfiguration = true; @@ -588,6 +615,8 @@ SSLSocketFactory createSslSocketFactory() { } } + + private static final EnumSet understoodTlsFeatures = EnumSet.of( TlsChannelCredentials.Feature.MTLS, TlsChannelCredentials.Feature.CUSTOM_MANAGERS); diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelProvider.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelProvider.java index bf2a9be6fee..f4485d237a3 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelProvider.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpChannelProvider.java @@ -20,6 +20,8 @@ import io.grpc.Internal; import io.grpc.InternalServiceProviders; import io.grpc.ManagedChannelProvider; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; import java.net.SocketAddress; import java.util.Collection; @@ -60,6 +62,21 @@ public NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentia target, creds, result.callCredentials, result.factory)); } + @Override + public NewChannelBuilderResult newChannelBuilder(String target, ChannelCredentials creds, + NameResolverRegistry nameResolverRegistry, + NameResolverProvider nameResolverProvider) { + OkHttpChannelBuilder.SslSocketFactoryResult result = + OkHttpChannelBuilder.sslSocketFactoryFrom(creds); + if (result.error != null) { + return NewChannelBuilderResult.error(result.error); + } + OkHttpChannelBuilder builder = new OkHttpChannelBuilder( + target, creds, result.callCredentials, result.factory, + nameResolverRegistry, nameResolverProvider); + return NewChannelBuilderResult.channelBuilder(builder); + } + @Override protected Collection> getSupportedSocketAddressTypes() { return OkHttpChannelBuilder.getSupportedSocketAddressTypes(); diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpClientTransport.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpClientTransport.java index e76799845c7..4764a6a1387 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpClientTransport.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpClientTransport.java @@ -48,6 +48,8 @@ import io.grpc.internal.CertificateUtils; import io.grpc.internal.ClientStreamListener.RpcProgress; import io.grpc.internal.ConnectionClientTransport; +import io.grpc.internal.DisconnectError; +import io.grpc.internal.GoAwayDisconnectError; import io.grpc.internal.GrpcAttributes; import io.grpc.internal.GrpcUtil; import io.grpc.internal.Http2Ping; @@ -56,6 +58,7 @@ import io.grpc.internal.KeepAliveManager.ClientKeepAlivePinger; import io.grpc.internal.NoopSslSession; import io.grpc.internal.SerializingExecutor; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.internal.StatsTraceContext; import io.grpc.internal.TransportTracer; import io.grpc.okhttp.ExceptionHandlingFrameWriter.TransportExceptionHandler; @@ -129,7 +132,7 @@ * A okhttp-based {@link ConnectionClientTransport} implementation. */ class OkHttpClientTransport implements ConnectionClientTransport, TransportExceptionHandler, - OutboundFlowController.Transport { + OutboundFlowController.Transport, ClientKeepAlivePinger.TransportWithDisconnectReason { private static final Map ERROR_CODE_TO_STATUS = buildErrorCodeToStatusMap(); private static final Logger log = Logger.getLogger(OkHttpClientTransport.class.getName()); private static final String GRPC_ENABLE_PER_RPC_AUTHORITY_CHECK = @@ -337,7 +340,7 @@ private OkHttpClientTransport( ? SocketFactory.getDefault() : transportFactory.socketFactory; this.sslSocketFactory = transportFactory.sslSocketFactory; this.hostnameVerifier = transportFactory.hostnameVerifier != null - ? transportFactory.hostnameVerifier : OkHostnameVerifier.INSTANCE; + ? transportFactory.hostnameVerifier : OkHostnameVerifier.INSTANCE; this.connectionSpec = Preconditions.checkNotNull( transportFactory.connectionSpec, "connectionSpec"); this.stopwatchFactory = Preconditions.checkNotNull(stopwatchFactory, "stopwatchFactory"); @@ -800,13 +803,15 @@ public void run() { if (connectingCallback != null) { connectingCallback.run(); } - // ClientFrameHandler need to be started after connectionPreface / settings, otherwise it - // may send goAway immediately. - executor.execute(clientFrameHandler); synchronized (lock) { maxConcurrentStreams = Integer.MAX_VALUE; - startPendingStreams(); + checkState(pendingStreams.isEmpty(), + "Pending streams detected during transport start." + + " RPCs should not be started before transport is ready."); } + // ClientFrameHandler need to be started after connectionPreface / settings, otherwise it + // may send goAway immediately. + executor.execute(clientFrameHandler); if (connectedFuture != null) { connectedFuture.set(null); } @@ -990,13 +995,18 @@ public void shutdown(Status reason) { } goAwayStatus = reason; - listener.transportShutdown(goAwayStatus); + listener.transportShutdown(goAwayStatus, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); stopIfNecessary(); } } @Override public void shutdownNow(Status reason) { + shutdownNow(reason, SimpleDisconnectError.SUBCHANNEL_SHUTDOWN); + } + + @Override + public void shutdownNow(Status reason, DisconnectError disconnectError) { shutdown(reason); synchronized (lock) { Iterator> it = streams.entrySet().iterator(); @@ -1086,7 +1096,13 @@ private void startGoAway(int lastKnownStreamId, ErrorCode errorCode, Status stat synchronized (lock) { if (goAwayStatus == null) { goAwayStatus = status; - listener.transportShutdown(status); + GrpcUtil.Http2Error http2Error; + if (errorCode == null) { + http2Error = GrpcUtil.Http2Error.NO_ERROR; + } else { + http2Error = GrpcUtil.Http2Error.forCode(errorCode.httpCode); + } + listener.transportShutdown(status, new GoAwayDisconnectError(http2Error)); } if (errorCode != null && !goAwaySent) { // Send GOAWAY with lastGoodStreamId of 0, since we don't expect any server-initiated diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java index 0706a39d028..3f5a4d8cb2b 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpProtocolNegotiator.java @@ -122,7 +122,6 @@ public String getSelectedProtocol(SSLSocket socket) { return platform.getSelectedProtocol(socket); } - @VisibleForTesting static final class AndroidNegotiator extends OkHttpProtocolNegotiator { // setUseSessionTickets(boolean) private static final OptionalMethod SET_USE_SESSION_TICKETS = diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpReadableBuffer.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpReadableBuffer.java index 136ee8954a2..d65453722f0 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpReadableBuffer.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpReadableBuffer.java @@ -21,7 +21,6 @@ import java.io.EOFException; import java.io.IOException; import java.io.OutputStream; -import java.nio.ByteBuffer; /** * A {@link ReadableBuffer} implementation that is backed by an {@link okio.Buffer}. @@ -71,12 +70,6 @@ public void readBytes(byte[] dest, int destOffset, int length) { } } - @Override - public void readBytes(ByteBuffer dest) { - // We are not using it. - throw new UnsupportedOperationException(); - } - @Override public void readBytes(OutputStream dest, int length) throws IOException { buffer.writeTo(dest, length); diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerBuilder.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerBuilder.java index 8daeed42a8c..50097e1922e 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerBuilder.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerBuilder.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static io.grpc.internal.CertificateUtils.createTrustManager; +import static io.grpc.internal.GrpcUtil.DEFAULT_SERVER_PERMIT_KEEPALIVE_TIME_NANOS; import com.google.common.base.Preconditions; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -27,6 +28,7 @@ import io.grpc.ForwardingServerBuilder; import io.grpc.InsecureServerCredentials; import io.grpc.Internal; +import io.grpc.MetricRecorder; import io.grpc.ServerBuilder; import io.grpc.ServerCredentials; import io.grpc.ServerStreamTracer; @@ -111,7 +113,15 @@ public static OkHttpServerBuilder forPort(SocketAddress address, ServerCredentia return new OkHttpServerBuilder(address, result.factory); } - final ServerImplBuilder serverImplBuilder = new ServerImplBuilder(this::buildTransportServers); + final ServerImplBuilder serverImplBuilder = new ServerImplBuilder( + new ServerImplBuilder.ClientTransportServersBuilder() { + @Override + public InternalServer buildClientTransportServers( + List streamTracerFactories, + MetricRecorder metricRecorder) { + return buildTransportServers(streamTracerFactories); + } + }); final SocketAddress listenAddress; final HandshakerSocketFactory handshakerSocketFactory; TransportTracer.Factory transportTracerFactory = TransportTracer.getDefaultFactory(); @@ -128,7 +138,7 @@ public static OkHttpServerBuilder forPort(SocketAddress address, ServerCredentia int maxInboundMessageSize = GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; long maxConnectionIdleInNanos = MAX_CONNECTION_IDLE_NANOS_DISABLED; boolean permitKeepAliveWithoutCalls; - long permitKeepAliveTimeInNanos = TimeUnit.MINUTES.toNanos(5); + long permitKeepAliveTimeInNanos = DEFAULT_SERVER_PERMIT_KEEPALIVE_TIME_NANOS; long maxConnectionAgeInNanos = MAX_CONNECTION_AGE_NANOS_DISABLED; long maxConnectionAgeGraceInNanos = MAX_CONNECTION_AGE_GRACE_NANOS_INFINITE; int maxConcurrentCallsPerConnection = MAX_CONCURRENT_STREAMS; diff --git a/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerTransport.java b/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerTransport.java index b744bca3116..7d192b16943 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerTransport.java +++ b/okhttp/src/main/java/io/grpc/okhttp/OkHttpServerTransport.java @@ -951,13 +951,13 @@ public void settings(boolean clearPrevious, Settings settings) { @Override public void ping(boolean ack, int payload1, int payload2) { - if (!keepAliveEnforcer.pingAcceptable()) { - abruptShutdown(ErrorCode.ENHANCE_YOUR_CALM, "too_many_pings", - Status.RESOURCE_EXHAUSTED.withDescription("Too many pings from client"), false); - return; - } long payload = (((long) payload1) << 32) | (payload2 & 0xffffffffL); if (!ack) { + if (!keepAliveEnforcer.pingAcceptable()) { + abruptShutdown(ErrorCode.ENHANCE_YOUR_CALM, "too_many_pings", + Status.RESOURCE_EXHAUSTED.withDescription("Too many pings from client"), false); + return; + } frameLogger.logPing(OkHttpFrameLogger.Direction.INBOUND, payload); synchronized (lock) { frameWriter.ping(true, payload1, payload2); diff --git a/okhttp/src/main/java/io/grpc/okhttp/SslSocketFactoryServerCredentials.java b/okhttp/src/main/java/io/grpc/okhttp/SslSocketFactoryServerCredentials.java index 63c6f33ff79..0c04aa63fad 100644 --- a/okhttp/src/main/java/io/grpc/okhttp/SslSocketFactoryServerCredentials.java +++ b/okhttp/src/main/java/io/grpc/okhttp/SslSocketFactoryServerCredentials.java @@ -41,7 +41,7 @@ static final class ServerCredentials extends io.grpc.ServerCredentials { private final ConnectionSpec connectionSpec; ServerCredentials(SSLSocketFactory factory) { - this(factory, OkHttpChannelBuilder.INTERNAL_DEFAULT_CONNECTION_SPEC); + this(factory, OkHttpChannelBuilder.initialConnectionSpec()); } ServerCredentials(SSLSocketFactory factory, ConnectionSpec connectionSpec) { diff --git a/okhttp/src/test/java/io/grpc/okhttp/OkHttpChannelProviderTest.java b/okhttp/src/test/java/io/grpc/okhttp/OkHttpChannelProviderTest.java index 363f11e71eb..40cf0a41449 100644 --- a/okhttp/src/test/java/io/grpc/okhttp/OkHttpChannelProviderTest.java +++ b/okhttp/src/test/java/io/grpc/okhttp/OkHttpChannelProviderTest.java @@ -85,4 +85,13 @@ public void newChannelBuilder_fail() { TlsChannelCredentials.newBuilder().requireFakeFeature().build()); assertThat(result.getError()).contains("FAKE"); } + + @Test + public void newChannelBuilder_withRegistry_success() { + NewChannelBuilderResult result = provider.newChannelBuilder("localhost:443", + TlsChannelCredentials.create(), + io.grpc.NameResolverRegistry.getDefaultRegistry(), + new io.grpc.internal.DnsNameResolverProvider()); + assertThat(result.getChannelBuilder()).isInstanceOf(OkHttpChannelBuilder.class); + } } diff --git a/okhttp/src/test/java/io/grpc/okhttp/OkHttpClientTransportTest.java b/okhttp/src/test/java/io/grpc/okhttp/OkHttpClientTransportTest.java index 99f430be009..0b571530db4 100644 --- a/okhttp/src/test/java/io/grpc/okhttp/OkHttpClientTransportTest.java +++ b/okhttp/src/test/java/io/grpc/okhttp/OkHttpClientTransportTest.java @@ -71,9 +71,12 @@ import io.grpc.internal.ClientStream; import io.grpc.internal.ClientStreamListener; import io.grpc.internal.ClientTransport; +import io.grpc.internal.DisconnectError; import io.grpc.internal.FakeClock; +import io.grpc.internal.GoAwayDisconnectError; import io.grpc.internal.GrpcUtil; import io.grpc.internal.ManagedClientTransport; +import io.grpc.internal.SimpleDisconnectError; import io.grpc.okhttp.OkHttpClientTransport.ClientFrameHandler; import io.grpc.okhttp.OkHttpFrameLogger.Direction; import io.grpc.okhttp.internal.Protocol; @@ -189,7 +192,13 @@ public class OkHttpClientTransportTest { @After public void tearDown() { - executor.shutdownNow(); + try { + executor.shutdownNow(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // Ignore in a test and continue on as normal. + Thread.currentThread().interrupt(); + } } private void initTransport() throws Exception { @@ -280,7 +289,8 @@ public void testTransportExecutorWithTooFewThreads() throws Exception { null); clientTransport.start(transportListener); ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(statusCaptor.capture()); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(statusCaptor.capture(), + eq(new GoAwayDisconnectError(GrpcUtil.Http2Error.INTERNAL_ERROR))); Status capturedStatus = statusCaptor.getValue(); assertEquals("Timed out waiting for second handshake thread. " + "The transport executor pool may have run out of threads", @@ -481,7 +491,8 @@ public void nextFrameThrowIoException() throws Exception { assertEquals(NETWORK_ISSUE_MESSAGE, listener1.status.getCause().getMessage()); assertEquals(Status.INTERNAL.getCode(), listener2.status.getCode()); assertEquals(NETWORK_ISSUE_MESSAGE, listener2.status.getCause().getMessage()); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class)); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -507,7 +518,8 @@ public void nextFrameThrowsError() throws Exception { assertEquals(0, activeStreamCount()); assertEquals(Status.INTERNAL.getCode(), listener.status.getCode()); assertEquals(ERROR_MESSAGE, listener.status.getCause().getMessage()); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class)); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -523,7 +535,8 @@ public void nextFrameReturnFalse() throws Exception { frameReader.nextFrameAtEndOfStream(); listener.waitUntilStreamClosed(); assertEquals(Status.UNAVAILABLE.getCode(), listener.status.getCode()); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class)); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -565,7 +578,8 @@ public void receivedHeadersForInvalidStreamShouldKillConnection() throws Excepti HeadersMode.HTTP_20_HEADERS); verify(frameWriter, timeout(TIME_OUT_MS)) .goAway(eq(0), eq(ErrorCode.PROTOCOL_ERROR), any(byte[].class)); - verify(transportListener).transportShutdown(isA(Status.class)); + verify(transportListener).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -577,7 +591,8 @@ public void receivedDataForInvalidStreamShouldKillConnection() throws Exception 1000, 1000); verify(frameWriter, timeout(TIME_OUT_MS)) .goAway(eq(0), eq(ErrorCode.PROTOCOL_ERROR), any(byte[].class)); - verify(transportListener).transportShutdown(isA(Status.class)); + verify(transportListener).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -1173,7 +1188,8 @@ public void stopNormally() throws Exception { clientTransport.shutdown(SHUTDOWN_REASON); assertEquals(2, activeStreamCount()); - verify(transportListener).transportShutdown(same(SHUTDOWN_REASON)); + verify(transportListener).transportShutdown(same(SHUTDOWN_REASON), + eq(SimpleDisconnectError.SUBCHANNEL_SHUTDOWN)); stream1.cancel(Status.CANCELLED); stream2.cancel(Status.CANCELLED); @@ -1207,7 +1223,8 @@ public void receiveGoAway() throws Exception { frameHandler().goAway(3, ErrorCode.CANCEL, ByteString.EMPTY); // Transport should be in STOPPING state. - verify(transportListener).transportShutdown(isA(Status.class)); + verify(transportListener).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, never()).transportTerminated(); // Stream 2 should be closed. @@ -1277,7 +1294,8 @@ public void streamIdExhausted() throws Exception { // Should only have the first message delivered. assertEquals(message, listener.messages.get(0)); verify(frameWriter, timeout(TIME_OUT_MS)).rstStream(eq(startId), eq(ErrorCode.CANCEL)); - verify(transportListener).transportShutdown(isA(Status.class)); + verify(transportListener).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -1588,7 +1606,8 @@ public void receiveDataForUnknownStreamUpdateConnectionWindow() throws Exception (int) buffer.size()); verify(frameWriter, timeout(TIME_OUT_MS)) .goAway(eq(0), eq(ErrorCode.PROTOCOL_ERROR), any(byte[].class)); - verify(transportListener).transportShutdown(isA(Status.class)); + verify(transportListener).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -1608,7 +1627,8 @@ public void receiveWindowUpdateForUnknownStream() throws Exception { frameHandler().windowUpdate(5, 73); verify(frameWriter, timeout(TIME_OUT_MS)) .goAway(eq(0), eq(ErrorCode.PROTOCOL_ERROR), any(byte[].class)); - verify(transportListener).transportShutdown(isA(Status.class)); + verify(transportListener).transportShutdown(isA(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); shutdownAndVerify(); } @@ -1821,9 +1841,10 @@ public void unreachableServer() throws Exception { ManagedClientTransport.Listener listener = mock(ManagedClientTransport.Listener.class); clientTransport.start(listener); - ArgumentCaptor captor = ArgumentCaptor.forClass(Status.class); - verify(listener, timeout(TIME_OUT_MS)).transportShutdown(captor.capture()); - Status status = captor.getValue(); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + verify(listener, timeout(TIME_OUT_MS)).transportShutdown(statusCaptor.capture(), + eq(new GoAwayDisconnectError(GrpcUtil.Http2Error.INTERNAL_ERROR))); + Status status = statusCaptor.getValue(); assertEquals(Status.UNAVAILABLE.getCode(), status.getCode()); assertTrue(status.getCause().toString(), status.getCause() instanceof IOException); @@ -1852,9 +1873,10 @@ public void customSocketFactory() throws Exception { ManagedClientTransport.Listener listener = mock(ManagedClientTransport.Listener.class); clientTransport.start(listener); - ArgumentCaptor captor = ArgumentCaptor.forClass(Status.class); - verify(listener, timeout(TIME_OUT_MS)).transportShutdown(captor.capture()); - Status status = captor.getValue(); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + verify(listener, timeout(TIME_OUT_MS)).transportShutdown(statusCaptor.capture(), + eq(new GoAwayDisconnectError(GrpcUtil.Http2Error.INTERNAL_ERROR))); + Status status = statusCaptor.getValue(); assertEquals(Status.UNAVAILABLE.getCode(), status.getCode()); assertSame(exception, status.getCause()); } @@ -1903,7 +1925,8 @@ public void proxy_200() throws Exception { }); sock.getOutputStream().flush(); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class)); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(isA(Status.class), + any(DisconnectError.class)); while (sock.getInputStream().read() != -1) {} verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); sock.close(); @@ -1943,17 +1966,18 @@ public void proxy_500() throws Exception { assertEquals(-1, sock.getInputStream().read()); - ArgumentCaptor captor = ArgumentCaptor.forClass(Status.class); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(captor.capture()); - Status error = captor.getValue(); - assertTrue("Status didn't contain error code: " + captor.getValue(), - error.getDescription().contains("500")); - assertTrue("Status didn't contain error description: " + captor.getValue(), - error.getDescription().contains("OH NO")); - assertTrue("Status didn't contain error text: " + captor.getValue(), - error.getDescription().contains(errorText)); - assertEquals("Not UNAVAILABLE: " + captor.getValue(), - Status.UNAVAILABLE.getCode(), error.getCode()); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(statusCaptor.capture(), + eq(new GoAwayDisconnectError(GrpcUtil.Http2Error.INTERNAL_ERROR))); + Status status = statusCaptor.getValue(); + assertTrue("Status didn't contain error code: " + statusCaptor.getValue(), + status.getDescription().contains("500")); + assertTrue("Status didn't contain error description: " + statusCaptor.getValue(), + status.getDescription().contains("OH NO")); + assertTrue("Status didn't contain error text: " + statusCaptor.getValue(), + status.getDescription().contains(errorText)); + assertEquals("Not UNAVAILABLE: " + statusCaptor.getValue(), + Status.UNAVAILABLE.getCode(), status.getCode()); sock.close(); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); } @@ -1980,13 +2004,14 @@ public void proxy_immediateServerClose() throws Exception { serverSocket.close(); sock.close(); - ArgumentCaptor captor = ArgumentCaptor.forClass(Status.class); - verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(captor.capture()); - Status error = captor.getValue(); - assertTrue("Status didn't contain proxy: " + captor.getValue(), - error.getDescription().contains("proxy")); - assertEquals("Not UNAVAILABLE: " + captor.getValue(), - Status.UNAVAILABLE.getCode(), error.getCode()); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + verify(transportListener, timeout(TIME_OUT_MS)).transportShutdown(statusCaptor.capture(), + eq(new GoAwayDisconnectError(GrpcUtil.Http2Error.INTERNAL_ERROR))); + Status status = statusCaptor.getValue(); + assertTrue("Status didn't contain proxy: " + statusCaptor.getValue(), + status.getDescription().contains("proxy")); + assertEquals("Not UNAVAILABLE: " + statusCaptor.getValue(), + Status.UNAVAILABLE.getCode(), status.getCode()); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); } @@ -2017,7 +2042,8 @@ public void proxy_serverHangs() throws Exception { assertEquals("Host: theservice:80", reader.readLine()); while (!"".equals(reader.readLine())) {} - verify(transportListener, timeout(200)).transportShutdown(any(Status.class)); + verify(transportListener, timeout(200)).transportShutdown(any(Status.class), + any(DisconnectError.class)); verify(transportListener, timeout(TIME_OUT_MS)).transportTerminated(); sock.close(); } diff --git a/okhttp/src/test/java/io/grpc/okhttp/OkHttpReadableBufferTest.java b/okhttp/src/test/java/io/grpc/okhttp/OkHttpReadableBufferTest.java index 4aeeae2fa8b..be8dbf0e62b 100644 --- a/okhttp/src/test/java/io/grpc/okhttp/OkHttpReadableBufferTest.java +++ b/okhttp/src/test/java/io/grpc/okhttp/OkHttpReadableBufferTest.java @@ -44,18 +44,6 @@ public void setup() { } } - @Override - @Test - public void readToByteBufferShouldSucceed() { - // Not supported. - } - - @Override - @Test - public void partialReadToByteBufferShouldSucceed() { - // Not supported. - } - @Override @Test public void markAndResetWithReadShouldSucceed() { diff --git a/okhttp/src/test/java/io/grpc/okhttp/OkHttpServerTransportTest.java b/okhttp/src/test/java/io/grpc/okhttp/OkHttpServerTransportTest.java index 4d2744dc9c7..00db6e1d339 100644 --- a/okhttp/src/test/java/io/grpc/okhttp/OkHttpServerTransportTest.java +++ b/okhttp/src/test/java/io/grpc/okhttp/OkHttpServerTransportTest.java @@ -1268,6 +1268,31 @@ public void keepAliveEnforcer_noticesActive() throws Exception { eq(ByteString.encodeString("too_many_pings", GrpcUtil.US_ASCII))); } + @Test + public void keepAliveEnforcer_doesNotEnforcePingAcks() throws Exception { + serverBuilder.permitKeepAliveTime(1, TimeUnit.HOURS) + .permitKeepAliveWithoutCalls(true); + initTransport(); + handshake(); + + for (int i = 0; i < KeepAliveEnforcer.MAX_PING_STRIKES + 2; i++) { + int serverPingId = 0xDEAD + i; + clientFrameWriter.ping(true, serverPingId, 0); + clientFrameWriter.flush(); + } + + for (int i = 0; i < KeepAliveEnforcer.MAX_PING_STRIKES; i++) { + pingPong(); + } + + pingPongId++; + clientFrameWriter.ping(false, pingPongId, 0); + clientFrameWriter.flush(); + assertThat(clientFrameReader.nextFrame(clientFramesRead)).isTrue(); + verify(clientFramesRead).goAway(0, ErrorCode.ENHANCE_YOUR_CALM, + ByteString.encodeString("too_many_pings", GrpcUtil.US_ASCII)); + } + @Test public void maxConcurrentCallsPerConnection_failsWithRst() throws Exception { int maxConcurrentCallsPerConnection = 1; diff --git a/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java b/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java index 484cc5673dc..3155d6d533a 100644 --- a/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java +++ b/okhttp/third_party/okhttp/main/java/io/grpc/okhttp/internal/framed/Hpack.java @@ -354,6 +354,13 @@ int readInt(int firstByte, int prefixMask) throws IOException { if ((b & 0x80) != 0) { // Equivalent to (b >= 128) since b is in [0..255]. result += (b & 0x7f) << shift; shift += 7; + // We can safely store 31 bits, and then next byte will have 7 more bits. While the next + // byte may not have high bits set to cause an overflow, that's only useful for 256+ MiB + // values, which is excessive. This also gives us at least one bit of spare, which is + // necessary to store the carry from the addition. + if (shift >= 28) { + throw new IOException("Varint overflowed"); + } } else { result += b << shift; // Last byte. break; @@ -508,6 +515,9 @@ void writeInt(int value, int prefixMask, int bits) throws IOException { // Write the mask to start a multibyte value. out.writeByte(bits | prefixMask); value -= prefixMask; + if (value > 0xfffffff) { + throw new IOException("Varint would overflow reader"); + } // Write 7 bits at a time 'til we're done. while (value >= 0x80) { diff --git a/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/HpackTest.java b/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/HpackTest.java index 26580f85e54..dc5e030810f 100644 --- a/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/HpackTest.java +++ b/okhttp/third_party/okhttp/test/java/io/grpc/okhttp/internal/framed/HpackTest.java @@ -455,7 +455,7 @@ public void theSameHeaderAfterOneIncrementalIndexed() throws IOException { hpackReader.readHeaders(); fail(); } catch (IOException e) { - assertEquals("Header index too large -2147483521", e.getMessage()); + assertEquals("Varint overflowed", e.getMessage()); } } @@ -497,7 +497,7 @@ public void theSameHeaderAfterOneIncrementalIndexed() throws IOException { hpackReader.readHeaders(); fail(); } catch (IOException e) { - assertEquals("Invalid dynamic table size update -2147483648", e.getMessage()); + assertEquals("Varint overflowed", e.getMessage()); } } @@ -856,11 +856,53 @@ private void checkReadThirdRequestWithHuffman() { assertBytes(0xe0 | 31, 154, 10); } - @Test public void max31BitValue() throws IOException { - hpackWriter.writeInt(0x7fffffff, 31, 0); - assertBytes(31, 224, 255, 255, 255, 7); - assertEquals(0x7fffffff, - newReader(byteStream(224, 255, 255, 255, 7)).readInt(31, 31)); + @Test public void max29BitValue() throws IOException { + hpackWriter.writeInt(0x100000fe, 0xff, 0xff); + assertBytes(0xff, 0xff, 0xff, 0xff, 0x7f); + assertEquals(0x100000fe, + newReader(byteStream(0xff, 0xff, 0xff, 0x7f)).readInt(0xff, 0xff)); + } + + @Test public void beyondMax29BitValue() throws IOException { + try { + hpackWriter.writeInt(0x100000ff, 0xff, 0xff); + fail(); + } catch (IOException e) { + assertEquals("Varint would overflow reader", e.getMessage()); + } + try { + newReader(byteStream(0xff, 0xff, 0xff, 0xff, 0x80)).readInt(0xff, 0xff); + fail(); + } catch (IOException e) { + assertEquals("Varint overflowed", e.getMessage()); + } + } + + @Test public void beyondMax29BitValue_smallPrefix() throws IOException { + try { + hpackWriter.writeInt(0x10000001, 1, 1); + fail(); + } catch (IOException e) { + assertEquals("Varint would overflow reader", e.getMessage()); + } + try { + newReader(byteStream(0xff, 0xff, 0xff, 0xff, 0x80)).readInt(1, 1); + fail(); + } catch (IOException e) { + assertEquals("Varint overflowed", e.getMessage()); + } + } + + @Test public void readerAbortsLongVarintsWithZeros() throws IOException { + try { + // The reader should fail before getting to the end, because it will overflow as soon as there + // is a 1 bit, and the only reason to use this many continuations is to eventually have a 1 + // bit. + newReader(byteStream(0x80, 0x80, 0x80, 0x80, 0x80)).readInt(31, 31); + fail(); + } catch (IOException e) { + assertEquals("Varint overflowed", e.getMessage()); + } } @Test public void prefixMask() throws IOException { diff --git a/opentelemetry/build.gradle b/opentelemetry/build.gradle index b729f393e4b..594686294f0 100644 --- a/opentelemetry/build.gradle +++ b/opentelemetry/build.gradle @@ -12,9 +12,11 @@ dependencies { implementation libraries.guava, project(':grpc-core'), libraries.opentelemetry.api, - libraries.auto.value.annotations + libraries.auto.value.annotations, + libraries.animalsniffer.annotations testImplementation project(':grpc-testing'), + project(':grpc-testing-proto'), project(':grpc-inprocess'), testFixtures(project(':grpc-core')), testFixtures(project(':grpc-api')), @@ -28,6 +30,11 @@ dependencies { extension = "signature" } } + signature (libraries.signature.android) { + artifact { + extension = "signature" + } + } } tasks.named("jar").configure { diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java index e0af0f80ed3..87ad61c9f27 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java @@ -48,6 +48,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; +import javax.annotation.Nullable; +import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement; /** * The entrypoint for OpenTelemetry metrics functionality in gRPC. @@ -97,7 +100,8 @@ private GrpcOpenTelemetry(Builder builder) { this.resource = createMetricInstruments(meter, enableMetrics, disableDefault); this.optionalLabels = ImmutableList.copyOf(builder.optionalLabels); this.openTelemetryMetricsModule = new OpenTelemetryMetricsModule( - STOPWATCH_SUPPLIER, resource, optionalLabels, builder.plugins); + STOPWATCH_SUPPLIER, resource, optionalLabels, builder.plugins, + builder.targetFilter); this.openTelemetryTracingModule = new OpenTelemetryTracingModule(openTelemetrySdk); this.sink = new OpenTelemetryMetricSink(meter, enableMetrics, disableDefault, optionalLabels); } @@ -141,6 +145,11 @@ Tracer getTracer() { return this.openTelemetryTracingModule.getTracer(); } + @VisibleForTesting + TargetFilter getTargetAttributeFilter() { + return this.openTelemetryMetricsModule.getTargetAttributeFilter(); + } + /** * Registers GrpcOpenTelemetry globally, applying its configuration to all subsequently created * gRPC channels and servers. @@ -179,12 +188,15 @@ public void configureChannelBuilder(ManagedChannelBuilder builder) { * @param serverBuilder the server builder to configure */ public void configureServerBuilder(ServerBuilder serverBuilder) { - serverBuilder.addStreamTracerFactory(openTelemetryMetricsModule.getServerTracerFactory()); + /* To ensure baggage propagation to metrics, we need the tracing + tracers to be initialised before metrics */ if (ENABLE_OTEL_TRACING) { serverBuilder.addStreamTracerFactory( openTelemetryTracingModule.getServerTracerFactory()); serverBuilder.intercept(openTelemetryTracingModule.getServerSpanPropagationInterceptor()); } + serverBuilder.addStreamTracerFactory(openTelemetryMetricsModule.getServerTracerFactory()); + serverBuilder.addMetricSink(sink); } @VisibleForTesting @@ -347,6 +359,13 @@ static boolean isMetricEnabled(String metricName, Map enableMet && !disableDefault; } + /** + * Internal interface to avoid storing a {@link java.util.function.Predicate} directly, ensuring + * compatibility with Android devices (API level < 24) that do not use library desugaring. + */ + interface TargetFilter { + boolean test(String target); + } /** * Builder for configuring {@link GrpcOpenTelemetry}. @@ -357,6 +376,8 @@ public static class Builder { private final Collection optionalLabels = new ArrayList<>(); private final Map enableMetrics = new HashMap<>(); private boolean disableAll; + @Nullable + private TargetFilter targetFilter; private Builder() {} @@ -419,6 +440,26 @@ Builder enableTracing(boolean enable) { return this; } + /** + * Sets an optional filter to control recording of the {@code grpc.target} metric + * attribute. + * + *

If the predicate returns {@code true}, the original target is recorded. Otherwise, + * the target is recorded as {@code "other"} to limit metric cardinality. + * + *

If unset, all targets are recorded as-is. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12595") + @IgnoreJRERequirement + public Builder targetAttributeFilter(@Nullable Predicate filter) { + if (filter == null) { + this.targetFilter = null; + } else { + this.targetFilter = filter::test; + } + return this; + } + /** * Returns a new {@link GrpcOpenTelemetry} built with the configuration of this {@link * Builder}. diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java index 1d77f9ee3e4..f783b9495dd 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java @@ -18,6 +18,8 @@ import static com.google.common.base.Preconditions.checkNotNull; import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.BACKEND_SERVICE_KEY; +import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.BAGGAGE_KEY; +import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.CUSTOM_LABEL_KEY; import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.LOCALITY_KEY; import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.METHOD_KEY; import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.STATUS_KEY; @@ -38,13 +40,18 @@ import io.grpc.Deadline; import io.grpc.ForwardingClientCall.SimpleForwardingClientCall; import io.grpc.ForwardingClientCallListener.SimpleForwardingClientCallListener; +import io.grpc.Grpc; import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.grpc.ServerStreamTracer; import io.grpc.Status; import io.grpc.Status.Code; import io.grpc.StreamTracer; +import io.grpc.internal.StatsTraceContext.ServerCallMethodListener; +import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; +import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -65,6 +72,10 @@ * tracer. It's the tracer that reports per-attempt stats, and the factory that reports the stats * of the overall RPC, such as RETRIES_PER_CALL, to OpenTelemetry. * + *

This module optionally applies a target attribute filter to limit the cardinality of + * the {@code grpc.target} attribute in client-side metrics by mapping disallowed targets + * to a stable placeholder value. + * *

On the server-side, there is only one ServerStream per each ServerCall, and ServerStream * starts earlier than the ServerCall. Therefore, only one tracer is created per stream/call, and * it's the tracer that reports the summary to OpenTelemetry. @@ -91,16 +102,33 @@ final class OpenTelemetryMetricsModule { private final Supplier stopwatchSupplier; private final boolean localityEnabled; private final boolean backendServiceEnabled; + private final boolean customLabelEnabled; private final ImmutableList plugins; + @Nullable + private final TargetFilter targetAttributeFilter; + + OpenTelemetryMetricsModule(Supplier stopwatchSupplier, + OpenTelemetryMetricsResource resource, + Collection optionalLabels, List plugins) { + this(stopwatchSupplier, resource, optionalLabels, plugins, null); + } OpenTelemetryMetricsModule(Supplier stopwatchSupplier, - OpenTelemetryMetricsResource resource, Collection optionalLabels, - List plugins) { + OpenTelemetryMetricsResource resource, + Collection optionalLabels, List plugins, + @Nullable TargetFilter targetAttributeFilter) { this.resource = checkNotNull(resource, "resource"); this.stopwatchSupplier = checkNotNull(stopwatchSupplier, "stopwatchSupplier"); this.localityEnabled = optionalLabels.contains(LOCALITY_KEY.getKey()); this.backendServiceEnabled = optionalLabels.contains(BACKEND_SERVICE_KEY.getKey()); + this.customLabelEnabled = optionalLabels.contains(CUSTOM_LABEL_KEY.getKey()); this.plugins = ImmutableList.copyOf(plugins); + this.targetAttributeFilter = targetAttributeFilter; + } + + @VisibleForTesting + TargetFilter getTargetAttributeFilter() { + return targetAttributeFilter; } /** @@ -121,7 +149,15 @@ ClientInterceptor getClientInterceptor(String target) { pluginBuilder.add(plugin); } } - return new MetricsClientInterceptor(target, pluginBuilder.build()); + String filteredTarget = recordTarget(target); + return new MetricsClientInterceptor(filteredTarget, pluginBuilder.build()); + } + + String recordTarget(String target) { + if (targetAttributeFilter == null || target == null) { + return target; + } + return targetAttributeFilter.test(target) ? target : "other"; } static String recordMethodName(String fullMethodName, boolean isGeneratedMethod) { @@ -238,7 +274,7 @@ public void streamClosed(Status status) { statusCode = Code.DEADLINE_EXCEEDED; } } - attemptsState.attemptEnded(); + attemptsState.attemptEnded(info.getCallOptions()); recordFinishedAttempt(); } @@ -261,6 +297,10 @@ void recordFinishedAttempt() { } builder.put(BACKEND_SERVICE_KEY, savedBackendService); } + if (module.customLabelEnabled) { + builder.put( + CUSTOM_LABEL_KEY, info.getCallOptions().getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); + } for (OpenTelemetryPlugin.ClientStreamPlugin plugin : streamPlugins) { plugin.addLabels(builder); } @@ -268,15 +308,15 @@ void recordFinishedAttempt() { if (module.resource.clientAttemptDurationCounter() != null ) { module.resource.clientAttemptDurationCounter() - .record(attemptNanos * SECONDS_PER_NANO, attribute); + .record(attemptNanos * SECONDS_PER_NANO, attribute, attemptsState.otelContext); } if (module.resource.clientTotalSentCompressedMessageSizeCounter() != null) { module.resource.clientTotalSentCompressedMessageSizeCounter() - .record(outboundWireSize, attribute); + .record(outboundWireSize, attribute, attemptsState.otelContext); } if (module.resource.clientTotalReceivedCompressedMessageSizeCounter() != null) { module.resource.clientTotalReceivedCompressedMessageSizeCounter() - .record(inboundWireSize, attribute); + .record(inboundWireSize, attribute, attemptsState.otelContext); } } } @@ -291,6 +331,7 @@ static final class CallAttemptsTracerFactory extends ClientStreamTracer.Factory private boolean callEnded; private final String fullMethodName; private final List callPlugins; + private final Context otelContext; private Status status; private long retryDelayNanos; private long callLatencyNanos; @@ -306,22 +347,29 @@ static final class CallAttemptsTracerFactory extends ClientStreamTracer.Factory CallAttemptsTracerFactory( OpenTelemetryMetricsModule module, String target, + CallOptions callOptions, String fullMethodName, - List callPlugins) { + List callPlugins, Context otelContext) { this.module = checkNotNull(module, "module"); this.target = checkNotNull(target, "target"); this.fullMethodName = checkNotNull(fullMethodName, "fullMethodName"); this.callPlugins = checkNotNull(callPlugins, "callPlugins"); + this.otelContext = checkNotNull(otelContext, "otelContext"); this.attemptDelayStopwatch = module.stopwatchSupplier.get(); this.callStopWatch = module.stopwatchSupplier.get().start(); - io.opentelemetry.api.common.Attributes attribute = io.opentelemetry.api.common.Attributes.of( - METHOD_KEY, fullMethodName, - TARGET_KEY, target); + AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder() + .put(METHOD_KEY, fullMethodName) + .put(TARGET_KEY, target); + if (module.customLabelEnabled) { + builder.put( + CUSTOM_LABEL_KEY, callOptions.getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); + } + io.opentelemetry.api.common.Attributes attribute = builder.build(); // Record here in case mewClientStreamTracer() would never be called. if (module.resource.clientAttemptCountCounter() != null) { - module.resource.clientAttemptCountCounter().add(1, attribute); + module.resource.clientAttemptCountCounter().add(1, attribute, otelContext); } } @@ -341,11 +389,16 @@ public ClientStreamTracer newClientStreamTracer(StreamInfo info, Metadata metada // CallAttemptsTracerFactory constructor. attemptsPerCall will be non-zero after the first // attempt, as first attempt cannot be a transparent retry. if (attemptsPerCall.get() > 0) { - io.opentelemetry.api.common.Attributes attribute = - io.opentelemetry.api.common.Attributes.of(METHOD_KEY, fullMethodName, - TARGET_KEY, target); + AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder() + .put(METHOD_KEY, fullMethodName) + .put(TARGET_KEY, target); + if (module.customLabelEnabled) { + builder.put( + CUSTOM_LABEL_KEY, info.getCallOptions().getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); + } + io.opentelemetry.api.common.Attributes attribute = builder.build(); if (module.resource.clientAttemptCountCounter() != null) { - module.resource.clientAttemptCountCounter().add(1, attribute); + module.resource.clientAttemptCountCounter().add(1, attribute, otelContext); } } if (info.isTransparentRetry()) { @@ -371,7 +424,7 @@ private ClientTracer newClientTracer(StreamInfo info) { } // Called whenever each attempt is ended. - void attemptEnded() { + void attemptEnded(CallOptions callOptions) { boolean shouldRecordFinishedCall = false; synchronized (lock) { if (--activeStreams == 0) { @@ -383,11 +436,11 @@ void attemptEnded() { } } if (shouldRecordFinishedCall) { - recordFinishedCall(); + recordFinishedCall(callOptions); } } - void callEnded(Status status) { + void callEnded(Status status, CallOptions callOptions) { callStopWatch.stop(); this.status = status; boolean shouldRecordFinishedCall = false; @@ -403,11 +456,11 @@ void callEnded(Status status) { } } if (shouldRecordFinishedCall) { - recordFinishedCall(); + recordFinishedCall(callOptions); } } - void recordFinishedCall() { + void recordFinishedCall(CallOptions callOptions) { if (attemptsPerCall.get() == 0) { ClientTracer tracer = newClientTracer(null); tracer.attemptNanos = attemptDelayStopwatch.elapsed(TimeUnit.NANOSECONDS); @@ -417,11 +470,13 @@ void recordFinishedCall() { callLatencyNanos = callStopWatch.elapsed(TimeUnit.NANOSECONDS); // Base attributes - io.opentelemetry.api.common.Attributes baseAttributes = - io.opentelemetry.api.common.Attributes.of( - METHOD_KEY, fullMethodName, - TARGET_KEY, target - ); + AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder() + .put(METHOD_KEY, fullMethodName) + .put(TARGET_KEY, target); + if (module.customLabelEnabled) { + builder.put(CUSTOM_LABEL_KEY, callOptions.getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); + } + io.opentelemetry.api.common.Attributes baseAttributes = builder.build(); // Duration if (module.resource.clientCallDurationCounter() != null) { @@ -429,7 +484,8 @@ void recordFinishedCall() { callLatencyNanos * SECONDS_PER_NANO, baseAttributes.toBuilder() .put(STATUS_KEY, status.getCode().toString()) - .build() + .build(), + otelContext ); } @@ -437,7 +493,8 @@ void recordFinishedCall() { if (module.resource.clientCallRetriesCounter() != null) { long retriesPerCall = Math.max(attemptsPerCall.get() - 1, 0); if (retriesPerCall > 0) { - module.resource.clientCallRetriesCounter().record(retriesPerCall, baseAttributes); + module.resource.clientCallRetriesCounter() + .record(retriesPerCall, baseAttributes, otelContext); } } @@ -446,7 +503,7 @@ void recordFinishedCall() { long hedges = hedgedAttemptsPerCall.get(); if (hedges > 0) { module.resource.clientCallHedgesCounter() - .record(hedges, baseAttributes); + .record(hedges, baseAttributes, otelContext); } } @@ -454,8 +511,8 @@ void recordFinishedCall() { if (module.resource.clientCallTransparentRetriesCounter() != null) { long transparentRetries = transparentRetriesPerCall.get(); if (transparentRetries > 0) { - module.resource.clientCallTransparentRetriesCounter().record( - transparentRetries, baseAttributes); + module.resource.clientCallTransparentRetriesCounter() + .record(transparentRetries, baseAttributes, otelContext); } } @@ -463,13 +520,15 @@ void recordFinishedCall() { if (module.resource.clientCallRetryDelayCounter() != null) { module.resource.clientCallRetryDelayCounter().record( retryDelayNanos * SECONDS_PER_NANO, - baseAttributes + baseAttributes, + otelContext ); } } } - private static final class ServerTracer extends ServerStreamTracer { + private static final class ServerTracer extends ServerStreamTracer + implements ServerCallMethodListener { @Nullable private static final AtomicIntegerFieldUpdater streamClosedUpdater; @Nullable private static final AtomicLongFieldUpdater outboundWireSizeUpdater; @Nullable private static final AtomicLongFieldUpdater inboundWireSizeUpdater; @@ -504,6 +563,7 @@ private static final class ServerTracer extends ServerStreamTracer { private final OpenTelemetryMetricsModule module; private final String fullMethodName; private final List streamPlugins; + private Context otelContext = Context.root(); private volatile boolean isGeneratedMethod; private volatile int streamClosed; private final Stopwatch stopwatch; @@ -518,6 +578,22 @@ private static final class ServerTracer extends ServerStreamTracer { this.stopwatch = module.stopwatchSupplier.get().start(); } + @Override + public io.grpc.Context filterContext(io.grpc.Context context) { + Baggage baggage = BAGGAGE_KEY.get(context); + if (baggage != null) { + otelContext = Context.current().with(baggage); + } else { + otelContext = Context.current(); + } + return context; + } + + @Override + public void serverCallMethodResolved(MethodDescriptor method) { + isGeneratedMethod = method.isSampledToLocalTracing(); + } + @Override public void serverCallStarted(ServerCallInfo callInfo) { // Only record method name as an attribute if isSampledToLocalTracing is set to true, @@ -525,12 +601,13 @@ public void serverCallStarted(ServerCallInfo callInfo) { // created methods result in high cardinality metrics. boolean isSampledToLocalTracing = callInfo.getMethodDescriptor().isSampledToLocalTracing(); isGeneratedMethod = isSampledToLocalTracing; + io.opentelemetry.api.common.Attributes attribute = io.opentelemetry.api.common.Attributes.of( METHOD_KEY, recordMethodName(fullMethodName, isSampledToLocalTracing)); if (module.resource.serverCallCountCounter() != null) { - module.resource.serverCallCountCounter().add(1, attribute); + module.resource.serverCallCountCounter().add(1, attribute, otelContext); } } @@ -574,9 +651,24 @@ public void streamClosed(Status status) { } stopwatch.stop(); long elapsedTimeNanos = stopwatch.elapsed(TimeUnit.NANOSECONDS); - AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder() - .put(METHOD_KEY, recordMethodName(fullMethodName, isGeneratedMethod)) - .put(STATUS_KEY, status.getCode().toString()); + recordClosedStream( + status, + elapsedTimeNanos, + outboundWireSize, + inboundWireSize, + isGeneratedMethod); + } + + private void recordClosedStream( + Status status, + long elapsedTimeNanos, + long closedOutboundWireSize, + long closedInboundWireSize, + boolean generatedMethod) { + AttributesBuilder builder = + io.opentelemetry.api.common.Attributes.builder() + .put(METHOD_KEY, recordMethodName(fullMethodName, generatedMethod)) + .put(STATUS_KEY, status.getCode().toString()); for (OpenTelemetryPlugin.ServerStreamPlugin plugin : streamPlugins) { plugin.addLabels(builder); } @@ -584,15 +676,15 @@ public void streamClosed(Status status) { if (module.resource.serverCallDurationCounter() != null) { module.resource.serverCallDurationCounter() - .record(elapsedTimeNanos * SECONDS_PER_NANO, attributes); + .record(elapsedTimeNanos * SECONDS_PER_NANO, attributes, otelContext); } if (module.resource.serverTotalSentCompressedMessageSizeCounter() != null) { module.resource.serverTotalSentCompressedMessageSizeCounter() - .record(outboundWireSize, attributes); + .record(closedOutboundWireSize, attributes, otelContext); } if (module.resource.serverTotalReceivedCompressedMessageSizeCounter() != null) { module.resource.serverTotalReceivedCompressedMessageSizeCounter() - .record(inboundWireSize, attributes); + .record(closedInboundWireSize, attributes, otelContext); } } } @@ -612,7 +704,8 @@ public ServerStreamTracer newServerStreamTracer(String fullMethodName, Metadata } streamPlugins = Collections.unmodifiableList(streamPluginsMutable); } - return new ServerTracer(OpenTelemetryMetricsModule.this, fullMethodName, streamPlugins); + return new ServerTracer(OpenTelemetryMetricsModule.this, fullMethodName, + streamPlugins); } } @@ -643,13 +736,14 @@ public ClientCall interceptCall( callOptions = plugin.filterCallOptions(callOptions); } } + final CallOptions finalCallOptions = callOptions; // Only record method name as an attribute if isSampledToLocalTracing is set to true, // which is true for all generated methods. Otherwise, programatically // created methods result in high cardinality metrics. final CallAttemptsTracerFactory tracerFactory = new CallAttemptsTracerFactory( - OpenTelemetryMetricsModule.this, target, + OpenTelemetryMetricsModule.this, target, callOptions, recordMethodName(method.getFullMethodName(), method.isSampledToLocalTracing()), - callPlugins); + callPlugins, Context.current()); ClientCall call = next.newCall(method, callOptions.withStreamTracerFactory(tracerFactory)); return new SimpleForwardingClientCall(call) { @@ -662,7 +756,7 @@ public void start(Listener responseListener, Metadata headers) { new SimpleForwardingClientCallListener(responseListener) { @Override public void onClose(Status status, Metadata trailers) { - tracerFactory.callEnded(status); + tracerFactory.callEnded(status, finalCallOptions); super.onClose(status, trailers); } }, diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java index 8c42a189ac2..d214e99bd75 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import static io.grpc.ClientStreamTracer.NAME_RESOLUTION_DELAYED; import static io.grpc.internal.GrpcUtil.IMPLEMENTATION_VERSION; +import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.BAGGAGE_KEY; import com.google.common.annotations.VisibleForTesting; import io.grpc.Attributes; @@ -36,8 +37,10 @@ import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.ServerStreamTracer; +import io.grpc.internal.GrpcUtil; import io.grpc.opentelemetry.internal.OpenTelemetryConstants; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; @@ -58,6 +61,7 @@ final class OpenTelemetryTracingModule { @VisibleForTesting final io.grpc.Context.Key otelSpan = io.grpc.Context.key("opentelemetry-span-key"); + @Nullable private static final AtomicIntegerFieldUpdater callEndedUpdater; @Nullable @@ -242,13 +246,15 @@ private final class ServerTracer extends ServerStreamTracer { private final Span span; volatile int streamClosed; private int seqNo; + private Baggage baggage; - ServerTracer(String fullMethodName, @Nullable Span remoteSpan) { + ServerTracer(String fullMethodName, @Nullable Span remoteSpan, Baggage baggage) { checkNotNull(fullMethodName, "fullMethodName"); this.span = otelTracer.spanBuilder(generateTraceSpanName(true, fullMethodName)) .setParent(remoteSpan == null ? null : Context.current().with(remoteSpan)) .startSpan(); + this.baggage = baggage; } /** @@ -274,7 +280,9 @@ public void streamClosed(io.grpc.Status status) { @Override public io.grpc.Context filterContext(io.grpc.Context context) { - return context.withValue(otelSpan, span); + return context + .withValue(otelSpan, span) + .withValue(BAGGAGE_KEY, baggage); } @Override @@ -314,7 +322,8 @@ public ServerStreamTracer newServerStreamTracer(String fullMethodName, Metadata if (remoteSpan == Span.getInvalid()) { remoteSpan = null; } - return new ServerTracer(fullMethodName, remoteSpan); + Baggage baggage = Baggage.fromContext(context); + return new ServerTracer(fullMethodName, remoteSpan, baggage); } } @@ -329,7 +338,15 @@ public ServerCall.Listener interceptCall(ServerCall(next.startCall(call, headers), serverCallContext); } @@ -463,19 +480,11 @@ private void recordInboundMessageSize(Span span, int seqNo, long bytes) { span.addEvent("Inbound message", attributesBuilder.build()); } - private String generateErrorStatusDescription(io.grpc.Status status) { - if (status.getDescription() != null) { - return status.getCode() + ": " + status.getDescription(); - } else { - return status.getCode().toString(); - } - } - private void endSpanWithStatus(Span span, io.grpc.Status status) { if (status.isOk()) { span.setStatus(StatusCode.OK); } else { - span.setStatus(StatusCode.ERROR, generateErrorStatusDescription(status)); + span.setStatus(StatusCode.ERROR, GrpcUtil.statusToPrettyString(status)); } span.end(); } diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/internal/OpenTelemetryConstants.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/internal/OpenTelemetryConstants.java index ff2b88acbfd..c09a1a2beca 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/internal/OpenTelemetryConstants.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/internal/OpenTelemetryConstants.java @@ -16,7 +16,9 @@ package io.grpc.opentelemetry.internal; +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; +import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.common.AttributeKey; import java.util.List; @@ -36,12 +38,19 @@ public final class OpenTelemetryConstants { public static final AttributeKey BACKEND_SERVICE_KEY = AttributeKey.stringKey("grpc.lb.backend_service"); + public static final AttributeKey CUSTOM_LABEL_KEY = + AttributeKey.stringKey("grpc.client.call.custom"); + public static final AttributeKey DISCONNECT_ERROR_KEY = AttributeKey.stringKey("grpc.disconnect_error"); public static final AttributeKey SECURITY_LEVEL_KEY = AttributeKey.stringKey("grpc.security_level"); + @VisibleForTesting + public static final io.grpc.Context.Key BAGGAGE_KEY = + io.grpc.Context.key("opentelemetry-baggage-key"); + public static final List LATENCY_BUCKETS = ImmutableList.of( 0d, 0.00001d, 0.00005d, 0.0001d, 0.0003d, 0.0006d, 0.0008d, 0.001d, 0.002d, diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java index 1ae7b755a48..f0bd6f93098 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java @@ -26,9 +26,9 @@ import com.google.common.collect.ImmutableList; import io.grpc.ClientInterceptor; import io.grpc.ManagedChannelBuilder; -import io.grpc.MetricSink; import io.grpc.ServerBuilder; import io.grpc.internal.GrpcUtil; +import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.metrics.SdkMeterProvider; @@ -97,6 +97,7 @@ public void buildTracer() { grpcOpenTelemetry.configureServerBuilder(mockServerBuiler); verify(mockServerBuiler, times(2)).addStreamTracerFactory(any()); verify(mockServerBuiler).intercept(any()); + verify(mockServerBuiler).addMetricSink(any()); verifyNoMoreInteractions(mockServerBuiler); ManagedChannelBuilder mockChannelBuilder = mock(ManagedChannelBuilder.class); @@ -120,7 +121,6 @@ public void builderDefaults() { .build()); assertThat(module.getEnableMetrics()).isEmpty(); assertThat(module.getOptionalLabels()).isEmpty(); - assertThat(module.getSink()).isInstanceOf(MetricSink.class); assertThat(module.getTracer()).isSameInstanceAs(noopOpenTelemetry .getTracerProvider() @@ -130,6 +130,18 @@ public void builderDefaults() { ); } + @Test + public void builderTargetAttributeFilter() { + GrpcOpenTelemetry module = GrpcOpenTelemetry.newBuilder() + .targetAttributeFilter(t -> t.contains("allowed.com")) + .build(); + + TargetFilter internalFilter = module.getTargetAttributeFilter(); + + assertThat(internalFilter.test("allowed.com")).isTrue(); + assertThat(internalFilter.test("example.com")).isFalse(); + } + @Test public void enableDisableMetrics() { GrpcOpenTelemetry.Builder builder = GrpcOpenTelemetry.newBuilder(); diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java index 6d1234497d6..7c9db875196 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java @@ -25,8 +25,11 @@ import static java.util.Collections.emptyList; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import com.google.common.collect.ImmutableMap; @@ -37,22 +40,40 @@ import io.grpc.ClientInterceptor; import io.grpc.ClientInterceptors; import io.grpc.ClientStreamTracer; +import io.grpc.Grpc; import io.grpc.KnownLength; +import io.grpc.ManagedChannel; import io.grpc.Metadata; import io.grpc.MethodDescriptor; +import io.grpc.Server; import io.grpc.ServerCall; import io.grpc.ServerCallHandler; import io.grpc.ServerServiceDefinition; import io.grpc.ServerStreamTracer; import io.grpc.ServerStreamTracer.ServerCallInfo; +import io.grpc.ServiceDescriptor; import io.grpc.Status; import io.grpc.Status.Code; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.internal.FakeClock; +import io.grpc.internal.StatsTraceContext.ServerCallMethodListener; +import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; import io.grpc.opentelemetry.OpenTelemetryMetricsModule.CallAttemptsTracerFactory; import io.grpc.opentelemetry.internal.OpenTelemetryConstants; +import io.grpc.stub.ClientCalls; +import io.grpc.testing.GrpcCleanupRule; import io.grpc.testing.GrpcServerRule; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.api.baggage.propagation.W3CBaggagePropagator; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.metrics.DoubleHistogram; import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.metrics.data.MetricData; import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule; @@ -65,6 +86,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.annotation.Nullable; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -85,10 +107,9 @@ public class OpenTelemetryMetricsModuleTest { private static final CallOptions.Key CUSTOM_OPTION = CallOptions.Key.createWithDefault("option1", "default"); private static final CallOptions CALL_OPTIONS = - CallOptions.DEFAULT.withOption(CUSTOM_OPTION, "customvalue"); + CallOptions.DEFAULT.withOption(NAME_RESOLUTION_DELAYED, 10L); private static final ClientStreamTracer.StreamInfo STREAM_INFO = - ClientStreamTracer.StreamInfo.newBuilder() - .setCallOptions(CallOptions.DEFAULT.withOption(NAME_RESOLUTION_DELAYED, 10L)).build(); + ClientStreamTracer.StreamInfo.newBuilder().setCallOptions(CALL_OPTIONS).build(); private static final String CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME = "grpc.client.attempt.started"; private static final String CLIENT_ATTEMPT_DURATION_INSTRUMENT_NAME = "grpc.client.attempt.duration"; @@ -152,6 +173,8 @@ public String parse(InputStream stream) { @Rule public final MockitoRule mocks = MockitoJUnit.rule(); @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + @Rule public final GrpcServerRule grpcServerRule = new GrpcServerRule().directExecutor(); @Rule public final OpenTelemetryRule openTelemetryTesting = OpenTelemetryRule.create(); @@ -162,6 +185,9 @@ public String parse(InputStream stream) { @Captor private ArgumentCaptor statusCaptor; + private Server server; + private ManagedChannel channel; + private final FakeClock fakeClock = new FakeClock(); private final MethodDescriptor method = MethodDescriptor.newBuilder() @@ -180,6 +206,17 @@ public String parse(InputStream stream) { public void setUp() throws Exception { testMeter = openTelemetryTesting.getOpenTelemetry() .getMeter(OpenTelemetryConstants.INSTRUMENTATION_SCOPE); + + } + + @After + public void tearDown() { + if (channel != null) { + channel.shutdownNow(); + } + if (server != null) { + server.shutdownNow(); + } } @Test @@ -215,7 +252,8 @@ public ClientCall interceptCall( grpcServerRule.getChannel(), callOptionsCaptureInterceptor, module.getClientInterceptor("target:///")); ClientCall call; - call = interceptedChannel.newCall(method, CALL_OPTIONS); + call = interceptedChannel.newCall( + method, CallOptions.DEFAULT.withOption(CUSTOM_OPTION, "customvalue")); assertEquals("customvalue", capturedCallOptions.get().getOption(CUSTOM_OPTION)); assertEquals(1, capturedCallOptions.get().getStreamTracerFactories().size()); @@ -244,7 +282,8 @@ public void clientBasicMetrics() { enabledMetricsMap, disableDefaultMetrics); OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new CallAttemptsTracerFactory(module, target, method.getFullMethodName(), emptyList()); + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); Metadata headers = new Metadata(); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, headers); @@ -289,7 +328,7 @@ public void clientBasicMetrics() { tracer.inboundMessage(1); tracer.inboundWireSize(154); tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes clientAttributes = io.opentelemetry.api.common.Attributes.of( @@ -411,7 +450,8 @@ public void clientBasicMetrics_withRetryMetricsEnabled_shouldRecordZeroOrBeAbsen enabledMetrics, disableDefaultMetrics); OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new CallAttemptsTracerFactory(module, target, method.getFullMethodName(), emptyList()); + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); @@ -420,7 +460,7 @@ public void clientBasicMetrics_withRetryMetricsEnabled_shouldRecordZeroOrBeAbsen fakeClock.forwardTime(100, TimeUnit.MILLISECONDS); tracer.outboundMessage(0); tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes finalAttributes = io.opentelemetry.api.common.Attributes.of( @@ -478,8 +518,8 @@ public void recordAttemptMetrics() { enabledMetricsMap, disableDefaultMetrics); OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, - method.getFullMethodName(), emptyList()); + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, CALL_OPTIONS, + method.getFullMethodName(), emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); @@ -794,7 +834,7 @@ public void recordAttemptMetrics() { fakeClock.forwardTime(24, MILLISECONDS); // RPC succeeded tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes clientAttributes2 = io.opentelemetry.api.common.Attributes.of( @@ -935,8 +975,8 @@ public void recordAttemptMetrics_withRetryMetricsEnabled() { enabledMetrics, disableDefaultMetrics); OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, - method.getFullMethodName(), emptyList()); + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, CALL_OPTIONS, + method.getFullMethodName(), emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); @@ -962,7 +1002,7 @@ public void recordAttemptMetrics_withRetryMetricsEnabled() { tracer.streamClosed(Status.OK); // RPC succeeded // --- The overall call ends --- - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); // Define attributes for assertions io.opentelemetry.api.common.Attributes finalAttributes @@ -1023,8 +1063,8 @@ public void recordAttemptMetrics_withHedgedCalls() { enabledMetrics, disableDefaultMetrics); OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, - method.getFullMethodName(), emptyList()); + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, CALL_OPTIONS, + method.getFullMethodName(), emptyList(), Context.root()); // Create a StreamInfo specifically for hedged attempts final ClientStreamTracer.StreamInfo hedgedStreamInfo = @@ -1054,7 +1094,7 @@ public void recordAttemptMetrics_withHedgedCalls() { hedgeTracer2.streamClosed(Status.OK); // Second hedge succeeds // --- The overall call ends --- - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); // Define attributes for assertions io.opentelemetry.api.common.Attributes finalAttributes @@ -1104,11 +1144,11 @@ public void clientStreamNeverCreatedStillRecordMetrics() { enabledMetricsMap, disableDefaultMetrics); OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, - method.getFullMethodName(), emptyList()); + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory(module, target, CALL_OPTIONS, + method.getFullMethodName(), emptyList(), Context.root()); fakeClock.forwardTime(3000, MILLISECONDS); Status status = Status.DEADLINE_EXCEEDED.withDescription("5 seconds"); - callAttemptsTracerFactory.callEnded(status); + callAttemptsTracerFactory.callEnded(status, CALL_OPTIONS); io.opentelemetry.api.common.Attributes attemptStartedAttributes = io.opentelemetry.api.common.Attributes.of( @@ -1211,9 +1251,11 @@ public void clientLocalityMetrics_present() { OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, enabledMetricsMap, disableDefaultMetrics); OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( - fakeClock.getStopwatchSupplier(), resource, Arrays.asList("grpc.lb.locality"), emptyList()); + fakeClock.getStopwatchSupplier(), resource, Arrays.asList("grpc.lb.locality"), + emptyList()); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new CallAttemptsTracerFactory(module, target, method.getFullMethodName(), emptyList()); + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); @@ -1222,7 +1264,7 @@ public void clientLocalityMetrics_present() { tracer.addOptionalLabel("grpc.lb.locality", "the-moon"); tracer.addOptionalLabel("grpc.lb.foo", "thats-no-moon"); tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( TARGET_KEY, target, @@ -1279,14 +1321,16 @@ public void clientLocalityMetrics_missing() { OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, enabledMetricsMap, disableDefaultMetrics); OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( - fakeClock.getStopwatchSupplier(), resource, Arrays.asList("grpc.lb.locality"), emptyList()); + fakeClock.getStopwatchSupplier(), resource, Arrays.asList("grpc.lb.locality"), + emptyList()); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new CallAttemptsTracerFactory(module, target, method.getFullMethodName(), emptyList()); + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( TARGET_KEY, target, @@ -1346,7 +1390,8 @@ public void clientBackendServiceMetrics_present() { fakeClock.getStopwatchSupplier(), resource, Arrays.asList("grpc.lb.backend_service"), emptyList()); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new CallAttemptsTracerFactory(module, target, method.getFullMethodName(), emptyList()); + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); @@ -1355,7 +1400,7 @@ public void clientBackendServiceMetrics_present() { tracer.addOptionalLabel("grpc.lb.backend_service", "the-moon"); tracer.addOptionalLabel("grpc.lb.foo", "thats-no-moon"); tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( TARGET_KEY, target, @@ -1415,12 +1460,13 @@ public void clientBackendServiceMetrics_missing() { fakeClock.getStopwatchSupplier(), resource, Arrays.asList("grpc.lb.backend_service"), emptyList()); OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = - new CallAttemptsTracerFactory(module, target, method.getFullMethodName(), emptyList()); + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer(STREAM_INFO, new Metadata()); tracer.streamClosed(Status.OK); - callAttemptsTracerFactory.callEnded(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( TARGET_KEY, target, @@ -1471,6 +1517,100 @@ public void clientBackendServiceMetrics_missing() { point -> point.hasAttributes(clientAttributes)))); } + @Test + public void customLabel_present() { + Map enabledMetrics = ImmutableMap.of( + CLIENT_CALL_HEDGES, true, + CLIENT_CALL_RETRIES, true, + CLIENT_CALL_RETRY_DELAY, true, + CLIENT_CALL_TRANSPARENT_RETRIES, true + ); + String target = "target:///"; + String customValue = "some-random-value"; + CallOptions callOptions = + STREAM_INFO.getCallOptions().withOption(Grpc.CALL_OPTION_CUSTOM_LABEL, customValue); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetrics, disableDefaultMetrics); + String customLabel = "grpc.client.call.custom"; + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), resource, Arrays.asList(customLabel), + emptyList()); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory( + module, target, callOptions, method.getFullMethodName(), emptyList(), Context.root()); + + ClientStreamTracer.StreamInfo streamInfo = + STREAM_INFO.toBuilder().setCallOptions(callOptions).build(); + ClientStreamTracer tracer = + callAttemptsTracerFactory.newClientStreamTracer(streamInfo, new Metadata()); + tracer.streamClosed(Status.UNAVAILABLE); + + tracer = callAttemptsTracerFactory.newClientStreamTracer(streamInfo, new Metadata()); + tracer.streamClosed(Status.UNAVAILABLE); + + tracer = callAttemptsTracerFactory.newClientStreamTracer( + streamInfo.toBuilder().setIsTransparentRetry(true).build(), new Metadata()); + tracer.streamClosed(Status.UNAVAILABLE); + + tracer = callAttemptsTracerFactory.newClientStreamTracer( + streamInfo.toBuilder().setIsHedging(true).build(), new Metadata()); + tracer.streamClosed(Status.OK); + callAttemptsTracerFactory.callEnded(Status.OK, callOptions); + + AttributeKey attributeKey = AttributeKey.stringKey(customLabel); + + assertThat(sortByName(openTelemetryTesting.getMetrics())) + .satisfiesExactly( + metric -> assertThat(metric) + .hasName(CLIENT_ATTEMPT_DURATION_INSTRUMENT_NAME) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue), + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_ATTEMPT_RECV_TOTAL_COMPRESSED_MESSAGE_SIZE) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue), + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_ATTEMPT_SENT_TOTAL_COMPRESSED_MESSAGE_SIZE) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue), + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasLongSumSatisfying( + longSum -> longSum.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_CALL_DURATION) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_CALL_HEDGES) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_CALL_RETRIES) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_CALL_RETRY_DELAY) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue))), + metric -> assertThat(metric) + .hasName(CLIENT_CALL_TRANSPARENT_RETRIES) + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasAttribute(attributeKey, customValue)))); + } + @Test public void serverBasicMetrics() { OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, @@ -1595,12 +1735,274 @@ public void serverBasicMetrics() { } + @Test + public void serverMetrics_methodResolvedBeforeStreamClosed_generatedMethodRecordsName() { + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory(); + ServerStreamTracer tracer = + tracerFactory.newServerStreamTracer(method.getFullMethodName(), new Metadata()); + + ((ServerCallMethodListener) tracer).serverCallMethodResolved(method); + fakeClock.forwardTime(10, MILLISECONDS); + tracer.streamClosed(Status.CANCELLED); + + io.opentelemetry.api.common.Attributes serverAttributes = + io.opentelemetry.api.common.Attributes.of( + METHOD_KEY, method.getFullMethodName(), + STATUS_KEY, Code.CANCELLED.toString()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasName(SERVER_CALL_DURATION) + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasCount(1) + .hasSum(0.01) + .hasAttributes(serverAttributes)))); + } + + @Test + public void serverMetrics_methodResolvedBeforeStreamClosed_nonGeneratedMethodRecordsOther() { + MethodDescriptor nonGeneratedMethod = + method.toBuilder().setSampledToLocalTracing(false).build(); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory(); + ServerStreamTracer tracer = + tracerFactory.newServerStreamTracer(nonGeneratedMethod.getFullMethodName(), new Metadata()); + + ((ServerCallMethodListener) tracer).serverCallMethodResolved(nonGeneratedMethod); + fakeClock.forwardTime(10, MILLISECONDS); + tracer.streamClosed(Status.CANCELLED); + + io.opentelemetry.api.common.Attributes serverAttributes = + io.opentelemetry.api.common.Attributes.of( + METHOD_KEY, "other", + STATUS_KEY, Code.CANCELLED.toString()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasName(SERVER_CALL_DURATION) + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasCount(1) + .hasSum(0.01) + .hasAttributes(serverAttributes)))); + } + + @Test + public void serverMetrics_serverCallStarted_nonGeneratedMethodRecordsOther() { + MethodDescriptor nonGeneratedMethod = + method.toBuilder().setSampledToLocalTracing(false).build(); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory(); + ServerStreamTracer tracer = + tracerFactory.newServerStreamTracer(nonGeneratedMethod.getFullMethodName(), new Metadata()); + tracer.serverCallStarted( + new CallInfo<>(nonGeneratedMethod, Attributes.EMPTY, null)); + + io.opentelemetry.api.common.Attributes startedAttributes = + io.opentelemetry.api.common.Attributes.of(METHOD_KEY, "other"); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasName(SERVER_CALL_COUNT) + .hasUnit("{call}") + .hasLongSumSatisfying( + longSum -> + longSum.hasPointsSatisfying( + point -> + point + .hasAttributes(startedAttributes) + .hasValue(1)))); + + fakeClock.forwardTime(10, MILLISECONDS); + tracer.streamClosed(Status.CANCELLED); + + io.opentelemetry.api.common.Attributes closedAttributes = + io.opentelemetry.api.common.Attributes.of( + METHOD_KEY, "other", + STATUS_KEY, Code.CANCELLED.toString()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasName(SERVER_CALL_DURATION) + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasCount(1) + .hasSum(0.01) + .hasAttributes(closedAttributes)))); + } + + @Test + public void targetAttributeFilter_notSet_usesOriginalTarget() { + // Test that when no filter is set, the original target is used + String target = "dns:///example.com"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + + Channel interceptedChannel = + ClientInterceptors.intercept( + grpcServerRule.getChannel(), module.getClientInterceptor(target)); + + ClientCall call = interceptedChannel.newCall(method, CALL_OPTIONS); + + // Make the call + Metadata headers = new Metadata(); + call.start(mockClientCallListener, headers); + + // End the call + call.halfClose(); + call.request(1); + + io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, target, + METHOD_KEY, method.getFullMethodName()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasUnit("{attempt}") + .hasLongSumSatisfying( + longSum -> + longSum + .hasPointsSatisfying( + point -> + point + .hasAttributes(attributes)))); + } + + @Test + public void targetAttributeFilter_allowsTarget_usesOriginalTarget() { + // Test that when filter allows the target, the original target is used + String target = "dns:///example.com"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource, + t -> t.contains("example.com")); + + Channel interceptedChannel = + ClientInterceptors.intercept( + grpcServerRule.getChannel(), module.getClientInterceptor(target)); + + ClientCall call = interceptedChannel.newCall(method, CALL_OPTIONS); + + // Make the call + Metadata headers = new Metadata(); + call.start(mockClientCallListener, headers); + + // End the call + call.halfClose(); + call.request(1); + + io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, target, + METHOD_KEY, method.getFullMethodName()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasUnit("{attempt}") + .hasLongSumSatisfying( + longSum -> + longSum + .hasPointsSatisfying( + point -> + point + .hasAttributes(attributes)))); + } + + @Test + public void targetAttributeFilter_rejectsTarget_mapsToOther() { + // Test that when filter rejects the target, it is mapped to "other" + String target = "dns:///example.com"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource, + t -> t.contains("allowed.com")); + + Channel interceptedChannel = + ClientInterceptors.intercept( + grpcServerRule.getChannel(), module.getClientInterceptor(target)); + + ClientCall call = interceptedChannel.newCall(method, CALL_OPTIONS); + + // Make the call + Metadata headers = new Metadata(); + call.start(mockClientCallListener, headers); + + // End the call + call.halfClose(); + call.request(1); + + io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, "other", + METHOD_KEY, method.getFullMethodName()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasUnit("{attempt}") + .hasLongSumSatisfying( + longSum -> + longSum + .hasPointsSatisfying( + point -> + point + .hasAttributes(attributes)))); + } + private OpenTelemetryMetricsModule newOpenTelemetryMetricsModule( OpenTelemetryMetricsResource resource) { return new OpenTelemetryMetricsModule( fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList()); } + private OpenTelemetryMetricsModule newOpenTelemetryMetricsModule( + OpenTelemetryMetricsResource resource, TargetFilter filter) { + return new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList(), + filter); + } + static class CallInfo extends ServerCallInfo { private final MethodDescriptor methodDescriptor; private final Attributes attributes; @@ -1631,4 +2033,130 @@ public String getAuthority() { return authority; } } + + @Test + public void serverMetrics_recordsBaggage() { + DoubleHistogram mockDurationHistogram = mock(DoubleHistogram.class); + OpenTelemetryMetricsResource mockResource = OpenTelemetryMetricsResource.builder() + .serverCallDurationCounter(mockDurationHistogram) + .build(); + + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(mockResource); + ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory(); + + Baggage baggage = Baggage.builder() + .put("baggage-key-1", "baggage-val-1") + .build(); + + io.grpc.Context grpcContext = io.grpc.Context.ROOT + .withValue(OpenTelemetryConstants.BAGGAGE_KEY, baggage); + io.grpc.Context previous = grpcContext.attach(); + + ServerStreamTracer tracer; + try { + tracer = tracerFactory.newServerStreamTracer( + method.getFullMethodName(), new Metadata()); + tracer.filterContext(grpcContext); + tracer.serverCallStarted( + new CallInfo<>(method, Attributes.EMPTY, null)); + } finally { + grpcContext.detach(previous); + } + + try (io.opentelemetry.context.Scope scope = Context.root().makeCurrent()) { + tracer.streamClosed(Status.CANCELLED); + } + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + verify(mockDurationHistogram).record( + anyDouble(), + any(), + contextCaptor.capture()); + + Baggage capturedBaggage = Baggage.fromContext(contextCaptor.getValue()); + assertNotNull("Captured context should have baggage", capturedBaggage); + assertEquals( + "baggage-val-1", capturedBaggage.getEntryValue("baggage-key-1")); + } + + @Test + public void serverMetrics_recordsBaggage_endToEnd() throws Exception { + DoubleHistogram mockDurationHistogram = mock(DoubleHistogram.class); + OpenTelemetryMetricsResource mockResource = OpenTelemetryMetricsResource.builder() + .serverCallDurationCounter(mockDurationHistogram) + .build(); + + OpenTelemetry openTelemetry = OpenTelemetrySdk + .builder() + .setPropagators(ContextPropagators.create( + W3CBaggagePropagator.getInstance())) + .build(); + + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(mockResource); + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule(openTelemetry); + + String serverName = InProcessServerBuilder.generateName(); + InProcessServerBuilder serverBuilder = InProcessServerBuilder + .forName(serverName).directExecutor(); + + serverBuilder.addStreamTracerFactory(tracingModule.getServerTracerFactory()); + serverBuilder.intercept(tracingModule.getServerSpanPropagationInterceptor()); + serverBuilder.addStreamTracerFactory(module.getServerTracerFactory()); + + serverBuilder.addService(ServerServiceDefinition.builder( + ServiceDescriptor.newBuilder("package1.service2") + .addMethod(method) + .build()) + .addMethod(method, new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + call.sendHeaders(new Metadata()); + call.sendMessage("response"); + call.close(Status.OK, new Metadata()); + return new ServerCall.Listener() { + }; + } + }).build()); + grpcCleanup.register(serverBuilder.build().start()); + + InProcessChannelBuilder channelBuilder = InProcessChannelBuilder + .forName(serverName).directExecutor(); + channelBuilder.intercept(tracingModule.getClientInterceptor()); + channelBuilder.intercept(module.getClientInterceptor(serverName)); + Channel channel = grpcCleanup.register(channelBuilder.intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return next.newCall(method, callOptions); + } + }).build()); + + Baggage baggage = Baggage.builder() + .put("baggage-key-1", "baggage-val-1") + .build(); + + Context otelContext = Context.root().with(baggage); + + try (Scope scope = otelContext.makeCurrent()) { + ClientCalls.blockingUnaryCall(channel, + method, CallOptions.DEFAULT, "request"); + } + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + verify(mockDurationHistogram).record( + anyDouble(), + any(), + contextCaptor.capture()); + + Baggage capturedBaggage = Baggage.fromContext(contextCaptor.getValue()); + assertNotNull("Captured context should have baggage", capturedBaggage); + assertEquals( + "baggage-val-1", capturedBaggage.getEntryValue("baggage-key-1")); + } + + private static List sortByName(List metrics) { + metrics.sort((m1, m2) -> m1.getName().compareTo(m2.getName())); + return metrics; + } } diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java index bca6be94b9f..e6759aadb1e 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java @@ -17,12 +17,15 @@ package io.grpc.opentelemetry; import static io.grpc.ClientStreamTracer.NAME_RESOLUTION_DELAYED; +import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.BAGGAGE_KEY; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -59,11 +62,15 @@ import io.grpc.testing.GrpcCleanupRule; import io.grpc.testing.GrpcServerRule; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.SpanId; import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.TraceFlags; import io.opentelemetry.api.trace.TraceId; +import io.opentelemetry.api.trace.TraceState; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.api.trace.TracerBuilder; import io.opentelemetry.api.trace.TracerProvider; @@ -750,6 +757,115 @@ public void onComplete() { } } + /** + * Tests that baggage from the initial context is propagated + * to the context active during the next handler's execution. + */ + @Test + public void testBaggageIsPropagatedToHandlerContext() { + // 1. ARRANGE + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + ServerInterceptor interceptor = tracingModule.getServerSpanPropagationInterceptor(); + + // Create mocks for the gRPC call chain + @SuppressWarnings("unchecked") + ServerCallHandler mockHandler = mock(ServerCallHandler.class); + @SuppressWarnings("unchecked") + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + ServerCall mockCall = new NoopServerCall<>(); + Metadata mockHeaders = new Metadata(); + + // Create a non-null Span (required to pass the first 'if' check) + Span testSpan = Span.wrap( + SpanContext.create("time-period", "star-wars", + TraceFlags.getSampled(), TraceState.getDefault())); + + // Create the test Baggage + Baggage testBaggage = Baggage.builder().put("best-bot", "R2D2").build(); + + // Create the initial gRPC context that the interceptor will read from + io.grpc.Context initialGrpcContext = io.grpc.Context.current() + .withValue(tracingModule.otelSpan, testSpan) + .withValue(BAGGAGE_KEY, testBaggage); + + // This AtomicReference will capture the Baggage from *within* the handler + final AtomicReference capturedBaggage = new AtomicReference<>(); + + // Stub the handler to capture the *current* context when it's called + doAnswer(invocation -> { + // Baggage.current() gets baggage from io.opentelemetry.context.Context.current() + capturedBaggage.set(Baggage.current()); + return mockListener; + }).when(mockHandler).startCall(any(), any()); + + // 2. ACT + // Run the interceptCall method within the prepared context + io.grpc.Context previous = initialGrpcContext.attach(); + try { + interceptor.interceptCall(mockCall, mockHeaders, mockHandler); + } finally { + initialGrpcContext.detach(previous); + } + + // 3. ASSERT + // Verify the next handler was called + verify(mockHandler).startCall(same(mockCall), same(mockHeaders)); + + // Check the baggage that was captured + assertNotNull("Baggage should not be null in handler context", capturedBaggage.get()); + assertEquals("Baggage was not correctly propagated to the handler's context", + "R2D2", capturedBaggage.get().getEntryValue("best-bot")); + } + + /** + * Tests that the interceptor proceeds correctly if baggage is null or empty. + */ + @Test + public void testNullBaggageIsHandledGracefully() { + // 1. ARRANGE + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + ServerInterceptor interceptor = tracingModule.getServerSpanPropagationInterceptor(); + + @SuppressWarnings("unchecked") + ServerCallHandler mockHandler = mock(ServerCallHandler.class); + @SuppressWarnings("unchecked") + ServerCall.Listener mockListener = mock(ServerCall.Listener.class); + ServerCall mockCall = new NoopServerCall<>(); + Metadata mockHeaders = new Metadata(); + + Span testSpan = Span.getInvalid(); // A non-null span + + // No baggage is set in the context + io.grpc.Context initialGrpcContext = io.grpc.Context.current() + .withValue(tracingModule.otelSpan, testSpan); + + final AtomicReference capturedBaggage = new AtomicReference<>(); + + // Stub the handler to capture the *current* context when it's called + doAnswer(invocation -> { + // Baggage.current() gets baggage from io.opentelemetry.context.Context.current() + capturedBaggage.set(Baggage.current()); + return mockListener; + }).when(mockHandler).startCall(any(), any()); + + // 2. ACT + io.grpc.Context previous = initialGrpcContext.attach(); + try { + interceptor.interceptCall(mockCall, mockHeaders, mockHandler); + } finally { + initialGrpcContext.detach(previous); + } + + // 3. ASSERT + verify(mockHandler).startCall(same(mockCall), same(mockHeaders)); + + // Baggage should be null in the downstream context + assertEquals("Baggage should be empty when not provided", + Baggage.empty(), capturedBaggage.get()); + } + @Test public void generateTraceSpanName() { assertEquals( diff --git a/repositories.bzl b/repositories.bzl index 4b9d0327b66..88434371b2b 100644 --- a/repositories.bzl +++ b/repositories.bzl @@ -12,41 +12,45 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # GRPC_DEPS_START IO_GRPC_GRPC_JAVA_ARTIFACTS = [ "com.google.android:annotations:4.1.1.4", - "com.google.api.grpc:proto-google-common-protos:2.59.2", - "com.google.auth:google-auth-library-credentials:1.24.1", - "com.google.auth:google-auth-library-oauth2-http:1.24.1", + "com.google.api.grpc:proto-google-common-protos:2.64.1", + "com.google.auth:google-auth-library-credentials:1.42.1", + "com.google.auth:google-auth-library-oauth2-http:1.42.1", "com.google.auto.value:auto-value-annotations:1.11.0", "com.google.auto.value:auto-value:1.11.0", "com.google.code.findbugs:jsr305:3.0.2", - "com.google.code.gson:gson:2.11.0", - "com.google.errorprone:error_prone_annotations:2.36.0", + "com.google.code.gson:gson:2.14.0", + "com.google.errorprone:error_prone_annotations:2.50.0", "com.google.guava:failureaccess:1.0.1", - "com.google.guava:guava:33.4.8-android", + "com.google.guava:guava:33.6.0-android", "com.google.re2j:re2j:1.8", - "com.google.s2a.proto.v2:s2a-proto:0.1.2", - "com.google.truth:truth:1.4.2", + "com.google.s2a.proto.v2:s2a-proto:0.1.3", + "com.google.truth:truth:1.4.5", + "dev.cel:runtime:0.13.0", + "dev.cel:protobuf:0.13.0", + "dev.cel:common:0.13.0", "com.squareup.okhttp:okhttp:2.7.5", "com.squareup.okio:okio:2.10.0", # 3.0+ needs swapping to -jvm; need work to avoid flag-day - "io.netty:netty-buffer:4.1.124.Final", - "io.netty:netty-codec-http2:4.1.124.Final", - "io.netty:netty-codec-http:4.1.124.Final", - "io.netty:netty-codec-socks:4.1.124.Final", - "io.netty:netty-codec:4.1.124.Final", - "io.netty:netty-common:4.1.124.Final", - "io.netty:netty-handler-proxy:4.1.124.Final", - "io.netty:netty-handler:4.1.124.Final", - "io.netty:netty-resolver:4.1.124.Final", - "io.netty:netty-tcnative-boringssl-static:2.0.70.Final", - "io.netty:netty-tcnative-classes:2.0.70.Final", - "io.netty:netty-transport-native-epoll:jar:linux-x86_64:4.1.124.Final", - "io.netty:netty-transport-native-unix-common:4.1.124.Final", - "io.netty:netty-transport:4.1.124.Final", + "io.netty:netty-buffer:4.2.15.Final", + "io.netty:netty-codec-base:4.2.15.Final", + "io.netty:netty-codec-http2:4.2.15.Final", + "io.netty:netty-codec-http:4.2.15.Final", + "io.netty:netty-codec-socks:4.2.15.Final", + "io.netty:netty-common:4.2.15.Final", + "io.netty:netty-handler-proxy:4.2.15.Final", + "io.netty:netty-handler:4.2.15.Final", + "io.netty:netty-resolver:4.2.15.Final", + "io.netty:netty-tcnative-boringssl-static:2.0.75.Final", + "io.netty:netty-tcnative-classes:2.0.75.Final", + "io.netty:netty-transport-native-epoll:jar:linux-x86_64:4.2.15.Final", + "io.netty:netty-transport-native-unix-common:4.2.15.Final", + "io.netty:netty-transport:4.2.15.Final", "io.opencensus:opencensus-api:0.31.0", "io.opencensus:opencensus-contrib-grpc-metrics:0.31.0", "io.perfmark:perfmark-api:0.27.0", "junit:junit:4.13.2", + "org.mockito:mockito-core:4.4.0", "org.checkerframework:checker-qual:3.49.5", - "org.codehaus.mojo:animal-sniffer-annotations:1.24", + "org.codehaus.mojo:animal-sniffer-annotations:1.27", ] # GRPC_DEPS_END @@ -94,12 +98,19 @@ def grpc_java_repositories(): if not native.existing_rule("com_google_googleapis"): http_archive( name = "com_google_googleapis", - sha256 = "49930468563dd48283e8301e8d4e71436bf6d27ac27c235224cc1a098710835d", - strip_prefix = "googleapis-ca1372c6d7bcb199638ebfdb40d2b2660bab7b88", + sha256 = "397fd8eb8a1a62dcf144216d9775816fad7a3fcff0ced1614bee529003c30d9e", + strip_prefix = "googleapis-1dbb1a14e079f78d9214f8e48bf083f32e3ddb96", urls = [ - "https://github.com/googleapis/googleapis/archive/ca1372c6d7bcb199638ebfdb40d2b2660bab7b88.tar.gz", + "https://github.com/googleapis/googleapis/archive/1dbb1a14e079f78d9214f8e48bf083f32e3ddb96.tar.gz", ], ) + if not native.existing_rule("rules_proto"): + http_archive( + name = "rules_proto", + sha256 = "14a225870ab4e91869652cfd69ef2028277fc1dc4910d65d353b62d6e0ae21f4", + strip_prefix = "rules_proto-7.1.0", + urls = ["https://github.com/bazelbuild/rules_proto/releases/download/7.1.0/rules_proto-7.1.0.tar.gz"], + ) if not native.existing_rule("io_grpc_grpc_proto"): io_grpc_grpc_proto() if not native.existing_rule("bazel_jar_jar"): @@ -116,9 +127,9 @@ def com_google_protobuf(): # This statement defines the @com_google_protobuf repo. http_archive( name = "com_google_protobuf", - sha256 = "3cf7d5b17c4ff04fe9f038104e9d0cae6da09b8ce271c13e44f8ac69f51e4e0f", - strip_prefix = "protobuf-25.5", - urls = ["https://github.com/protocolbuffers/protobuf/releases/download/v25.5/protobuf-25.5.tar.gz"], + sha256 = "f0b6838e7522a8da96126d487068c959bc624926368f3024ac8fd03abd0a1ac4", + strip_prefix = "protobuf-35.1", + urls = ["https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protobuf-35.1.tar.gz"], ) def io_grpc_grpc_proto(): diff --git a/rls/src/main/java/io/grpc/rls/CachingRlsLbClient.java b/rls/src/main/java/io/grpc/rls/CachingRlsLbClient.java index cc3ac9f516e..a2846fd04c8 100644 --- a/rls/src/main/java/io/grpc/rls/CachingRlsLbClient.java +++ b/rls/src/main/java/io/grpc/rls/CachingRlsLbClient.java @@ -33,6 +33,7 @@ import io.grpc.ChannelLogger; import io.grpc.ChannelLogger.ChannelLogLevel; import io.grpc.ConnectivityState; +import io.grpc.Grpc; import io.grpc.LoadBalancer.Helper; import io.grpc.LoadBalancer.PickResult; import io.grpc.LoadBalancer.PickSubchannelArgs; @@ -53,7 +54,6 @@ import io.grpc.lookup.v1.RouteLookupServiceGrpc; import io.grpc.lookup.v1.RouteLookupServiceGrpc.RouteLookupServiceStub; import io.grpc.rls.ChildLoadBalancerHelper.ChildLoadBalancerHelperProvider; -import io.grpc.rls.LbPolicyConfiguration.ChildLbStatusListener; import io.grpc.rls.LbPolicyConfiguration.ChildPolicyWrapper; import io.grpc.rls.LbPolicyConfiguration.RefCountedChildPolicyWrapperFactory; import io.grpc.rls.LruCache.EvictionListener; @@ -61,6 +61,7 @@ import io.grpc.rls.RlsProtoConverters.RouteLookupResponseConverter; import io.grpc.rls.RlsProtoData.RouteLookupConfig; import io.grpc.rls.RlsProtoData.RouteLookupRequest; +import io.grpc.rls.RlsProtoData.RouteLookupRequestKey; import io.grpc.rls.RlsProtoData.RouteLookupResponse; import io.grpc.stub.StreamObserver; import io.grpc.util.ForwardingLoadBalancerHelper; @@ -112,7 +113,7 @@ final class CachingRlsLbClient { private final Future periodicCleaner; // any RPC on the fly will cached in this map @GuardedBy("lock") - private final Map pendingCallCache = new HashMap<>(); + private final Map pendingCallCache = new HashMap<>(); private final ScheduledExecutorService scheduledExecutorService; private final Ticker ticker; @@ -141,20 +142,22 @@ final class CachingRlsLbClient { "grpc.lb.rls.default_target_picks", "EXPERIMENTAL. Number of LB picks sent to the default target", "{pick}", Arrays.asList("grpc.target", "grpc.lb.rls.server_target", - "grpc.lb.rls.data_plane_target", "grpc.lb.pick_result"), Collections.emptyList(), + "grpc.lb.rls.data_plane_target", "grpc.lb.pick_result"), + Arrays.asList("grpc.client.call.custom"), false); TARGET_PICKS_COUNTER = metricInstrumentRegistry.registerLongCounter("grpc.lb.rls.target_picks", "EXPERIMENTAL. Number of LB picks sent to each RLS target. Note that if the default " + "target is also returned by the RLS server, RPCs sent to that target from the cache " + "will be counted in this metric, not in grpc.rls.default_target_picks.", "{pick}", Arrays.asList("grpc.target", "grpc.lb.rls.server_target", "grpc.lb.rls.data_plane_target", - "grpc.lb.pick_result"), Collections.emptyList(), + "grpc.lb.pick_result"), + Arrays.asList("grpc.client.call.custom"), false); FAILED_PICKS_COUNTER = metricInstrumentRegistry.registerLongCounter("grpc.lb.rls.failed_picks", "EXPERIMENTAL. Number of LB picks failed due to either a failed RLS request or the " + "RLS channel being throttled", "{pick}", Arrays.asList("grpc.target", "grpc.lb.rls.server_target"), - Collections.emptyList(), false); + Arrays.asList("grpc.client.call.custom"), false); CACHE_ENTRIES_GAUGE = metricInstrumentRegistry.registerLongGauge("grpc.lb.rls.cache_entries", "EXPERIMENTAL. Number of entries in the RLS cache", "{entry}", Arrays.asList("grpc.target", "grpc.lb.rls.server_target", "grpc.lb.rls.instance_uuid"), @@ -216,6 +219,35 @@ private CachingRlsLbClient(Builder builder) { rlsChannelBuilder.disableServiceConfigLookUp(); } rlsChannel = rlsChannelBuilder.build(); + Runnable rlsServerConnectivityStateChangeHandler = new Runnable() { + private boolean wasInTransientFailure; + @Override + public void run() { + ConnectivityState currentState = rlsChannel.getState(false); + if (currentState == ConnectivityState.TRANSIENT_FAILURE) { + wasInTransientFailure = true; + } else if (wasInTransientFailure && currentState == ConnectivityState.READY) { + wasInTransientFailure = false; + synchronized (lock) { + boolean anyBackoffsCanceled = false; + for (CacheEntry value : linkedHashLruCache.values()) { + if (value instanceof BackoffCacheEntry) { + if (((BackoffCacheEntry) value).scheduledFuture.cancel(false)) { + anyBackoffsCanceled = true; + } + } + } + if (anyBackoffsCanceled) { + // Cache updated. updateBalancingState() to reattempt picks + helper.triggerPendingRpcProcessing(); + } + } + } + rlsChannel.notifyWhenStateChanged(currentState, this); + } + }; + rlsChannel.notifyWhenStateChanged( + ConnectivityState.IDLE, rlsServerConnectivityStateChangeHandler); rlsStub = RouteLookupServiceGrpc.newStub(rlsChannel); childLbResolvedAddressFactory = checkNotNull(builder.resolvedAddressFactory, "resolvedAddressFactory"); @@ -225,8 +257,7 @@ private CachingRlsLbClient(Builder builder) { refCountedChildPolicyWrapperFactory = new RefCountedChildPolicyWrapperFactory( lbPolicyConfig.getLoadBalancingPolicy(), childLbResolvedAddressFactory, - childLbHelperProvider, - new BackoffRefreshListener()); + childLbHelperProvider); // TODO(creamsoup) wait until lb is ready String defaultTarget = lbPolicyConfig.getRouteLookupConfig().defaultTarget(); if (defaultTarget != null && !defaultTarget.isEmpty()) { @@ -292,18 +323,22 @@ private void periodicClean() { /** Populates async cache entry for new request. */ @GuardedBy("lock") private CachedRouteLookupResponse asyncRlsCall( - RouteLookupRequest request, @Nullable BackoffPolicy backoffPolicy) { + RouteLookupRequestKey routeLookupRequestKey, @Nullable BackoffPolicy backoffPolicy, + RouteLookupRequest.Reason routeLookupReason) { if (throttler.shouldThrottle()) { - logger.log(ChannelLogLevel.DEBUG, "[RLS Entry {0}] Throttled RouteLookup", request); + logger.log(ChannelLogLevel.DEBUG, "[RLS Entry {0}] Throttled RouteLookup", + routeLookupRequestKey); // Cache updated, but no need to call updateBalancingState because no RPCs were queued waiting // on this result return CachedRouteLookupResponse.backoffEntry(createBackOffEntry( - request, Status.RESOURCE_EXHAUSTED.withDescription("RLS throttled"), backoffPolicy)); + routeLookupRequestKey, Status.RESOURCE_EXHAUSTED.withDescription("RLS throttled"), + backoffPolicy)); } final SettableFuture response = SettableFuture.create(); - io.grpc.lookup.v1.RouteLookupRequest routeLookupRequest = REQUEST_CONVERTER.convert(request); + io.grpc.lookup.v1.RouteLookupRequest routeLookupRequest = REQUEST_CONVERTER.convert( + RouteLookupRequest.create(routeLookupRequestKey.keyMap(), routeLookupReason)); logger.log(ChannelLogLevel.DEBUG, - "[RLS Entry {0}] Starting RouteLookup: {1}", request, routeLookupRequest); + "[RLS Entry {0}] Starting RouteLookup: {1}", routeLookupRequestKey, routeLookupRequest); rlsStub.withDeadlineAfter(callTimeoutNanos, TimeUnit.NANOSECONDS) .routeLookup( routeLookupRequest, @@ -311,14 +346,14 @@ private CachedRouteLookupResponse asyncRlsCall( @Override public void onNext(io.grpc.lookup.v1.RouteLookupResponse value) { logger.log(ChannelLogLevel.DEBUG, - "[RLS Entry {0}] RouteLookup succeeded: {1}", request, value); + "[RLS Entry {0}] RouteLookup succeeded: {1}", routeLookupRequestKey, value); response.set(RESPONSE_CONVERTER.reverse().convert(value)); } @Override public void onError(Throwable t) { logger.log(ChannelLogLevel.DEBUG, - "[RLS Entry {0}] RouteLookup failed: {1}", request, t); + "[RLS Entry {0}] RouteLookup failed: {1}", routeLookupRequestKey, t); response.setException(t); throttler.registerBackendResponse(true); } @@ -329,7 +364,7 @@ public void onCompleted() { } }); return CachedRouteLookupResponse.pendingResponse( - createPendingEntry(request, response, backoffPolicy)); + createPendingEntry(routeLookupRequestKey, response, backoffPolicy)); } /** @@ -338,16 +373,20 @@ public void onCompleted() { * changed after the return. */ @CheckReturnValue - final CachedRouteLookupResponse get(final RouteLookupRequest request) { + final CachedRouteLookupResponse get(final RouteLookupRequestKey routeLookupRequestKey) { synchronized (lock) { final CacheEntry cacheEntry; - cacheEntry = linkedHashLruCache.read(request); - if (cacheEntry == null) { - PendingCacheEntry pendingEntry = pendingCallCache.get(request); + cacheEntry = linkedHashLruCache.read(routeLookupRequestKey); + if (cacheEntry == null + || (cacheEntry instanceof BackoffCacheEntry + && !((BackoffCacheEntry) cacheEntry).isInBackoffPeriod())) { + PendingCacheEntry pendingEntry = pendingCallCache.get(routeLookupRequestKey); if (pendingEntry != null) { return CachedRouteLookupResponse.pendingResponse(pendingEntry); } - return asyncRlsCall(request, /* backoffPolicy= */ null); + return asyncRlsCall(routeLookupRequestKey, cacheEntry instanceof BackoffCacheEntry + ? ((BackoffCacheEntry) cacheEntry).backoffPolicy : null, + RouteLookupRequest.Reason.REASON_MISS); } if (cacheEntry instanceof DataCacheEntry) { @@ -383,13 +422,14 @@ void requestConnection() { @GuardedBy("lock") private PendingCacheEntry createPendingEntry( - RouteLookupRequest request, + RouteLookupRequestKey routeLookupRequestKey, ListenableFuture pendingCall, @Nullable BackoffPolicy backoffPolicy) { - PendingCacheEntry entry = new PendingCacheEntry(request, pendingCall, backoffPolicy); + PendingCacheEntry entry = new PendingCacheEntry(routeLookupRequestKey, pendingCall, + backoffPolicy); // Add the entry to the map before adding the Listener, because the listener removes the // entry from the map - pendingCallCache.put(request, entry); + pendingCallCache.put(routeLookupRequestKey, entry); // Beware that the listener can run immediately on the current thread pendingCall.addListener(() -> pendingRpcComplete(entry), MoreExecutors.directExecutor()); return entry; @@ -397,17 +437,18 @@ private PendingCacheEntry createPendingEntry( private void pendingRpcComplete(PendingCacheEntry entry) { synchronized (lock) { - boolean clientClosed = pendingCallCache.remove(entry.request) == null; + boolean clientClosed = pendingCallCache.remove(entry.routeLookupRequestKey) == null; if (clientClosed) { return; } try { - createDataEntry(entry.request, Futures.getDone(entry.pendingCall)); + createDataEntry(entry.routeLookupRequestKey, Futures.getDone(entry.pendingCall)); // Cache updated. DataCacheEntry constructor indirectly calls updateBalancingState() to // reattempt picks when the child LB is done connecting } catch (Exception e) { - createBackOffEntry(entry.request, Status.fromThrowable(e), entry.backoffPolicy); + createBackOffEntry(entry.routeLookupRequestKey, Status.fromThrowable(e), + entry.backoffPolicy); // Cache updated. updateBalancingState() to reattempt picks helper.triggerPendingRpcProcessing(); } @@ -416,21 +457,21 @@ private void pendingRpcComplete(PendingCacheEntry entry) { @GuardedBy("lock") private DataCacheEntry createDataEntry( - RouteLookupRequest request, RouteLookupResponse routeLookupResponse) { + RouteLookupRequestKey routeLookupRequestKey, RouteLookupResponse routeLookupResponse) { logger.log( ChannelLogLevel.DEBUG, "[RLS Entry {0}] Transition to data cache: routeLookupResponse={1}", - request, routeLookupResponse); - DataCacheEntry entry = new DataCacheEntry(request, routeLookupResponse); + routeLookupRequestKey, routeLookupResponse); + DataCacheEntry entry = new DataCacheEntry(routeLookupRequestKey, routeLookupResponse); // Constructor for DataCacheEntry causes updateBalancingState, but the picks can't happen until // this cache update because the lock is held - linkedHashLruCache.cacheAndClean(request, entry); + linkedHashLruCache.cacheAndClean(routeLookupRequestKey, entry); return entry; } @GuardedBy("lock") - private BackoffCacheEntry createBackOffEntry( - RouteLookupRequest request, Status status, @Nullable BackoffPolicy backoffPolicy) { + private BackoffCacheEntry createBackOffEntry(RouteLookupRequestKey routeLookupRequestKey, + Status status, @Nullable BackoffPolicy backoffPolicy) { if (backoffPolicy == null) { backoffPolicy = backoffProvider.get(); } @@ -438,12 +479,13 @@ private BackoffCacheEntry createBackOffEntry( logger.log( ChannelLogLevel.DEBUG, "[RLS Entry {0}] Transition to back off: status={1}, delayNanos={2}", - request, status, delayNanos); - BackoffCacheEntry entry = new BackoffCacheEntry(request, status, backoffPolicy); + routeLookupRequestKey, status, delayNanos); + BackoffCacheEntry entry = new BackoffCacheEntry(routeLookupRequestKey, status, backoffPolicy, + ticker.read() + delayNanos * 2); // Lock is held, so the task can't execute before the assignment entry.scheduledFuture = scheduledExecutorService.schedule( () -> refreshBackoffEntry(entry), delayNanos, TimeUnit.NANOSECONDS); - linkedHashLruCache.cacheAndClean(request, entry); + linkedHashLruCache.cacheAndClean(routeLookupRequestKey, entry); return entry; } @@ -454,10 +496,8 @@ private void refreshBackoffEntry(BackoffCacheEntry entry) { // Future was previously cancelled return; } - logger.log(ChannelLogLevel.DEBUG, - "[RLS Entry {0}] Calling RLS for transition to pending", entry.request); - linkedHashLruCache.invalidate(entry.request); - asyncRlsCall(entry.request, entry.backoffPolicy); + // Cache updated. updateBalancingState() to reattempt picks + helper.triggerPendingRpcProcessing(); } } @@ -590,15 +630,15 @@ public String toString() { /** A pending cache entry when the async RouteLookup RPC is still on the fly. */ static final class PendingCacheEntry { private final ListenableFuture pendingCall; - private final RouteLookupRequest request; + private final RouteLookupRequestKey routeLookupRequestKey; @Nullable private final BackoffPolicy backoffPolicy; PendingCacheEntry( - RouteLookupRequest request, + RouteLookupRequestKey routeLookupRequestKey, ListenableFuture pendingCall, @Nullable BackoffPolicy backoffPolicy) { - this.request = checkNotNull(request, "request"); + this.routeLookupRequestKey = checkNotNull(routeLookupRequestKey, "request"); this.pendingCall = checkNotNull(pendingCall, "pendingCall"); this.backoffPolicy = backoffPolicy; } @@ -606,7 +646,7 @@ static final class PendingCacheEntry { @Override public String toString() { return MoreObjects.toStringHelper(this) - .add("request", request) + .add("routeLookupRequestKey", routeLookupRequestKey) .toString(); } } @@ -614,10 +654,10 @@ public String toString() { /** Common cache entry data for {@link RlsAsyncLruCache}. */ abstract static class CacheEntry { - protected final RouteLookupRequest request; + protected final RouteLookupRequestKey routeLookupRequestKey; - CacheEntry(RouteLookupRequest request) { - this.request = checkNotNull(request, "request"); + CacheEntry(RouteLookupRequestKey routeLookupRequestKey) { + this.routeLookupRequestKey = checkNotNull(routeLookupRequestKey, "request"); } abstract int getSizeBytes(); @@ -640,8 +680,9 @@ final class DataCacheEntry extends CacheEntry { private final List childPolicyWrappers; // GuardedBy CachingRlsLbClient.lock - DataCacheEntry(RouteLookupRequest request, final RouteLookupResponse response) { - super(request); + DataCacheEntry(RouteLookupRequestKey routeLookupRequestKey, + final RouteLookupResponse response) { + super(routeLookupRequestKey); this.response = checkNotNull(response, "response"); checkState(!response.targets().isEmpty(), "No targets returned by RLS"); childPolicyWrappers = @@ -669,13 +710,14 @@ final class DataCacheEntry extends CacheEntry { */ void maybeRefresh() { synchronized (lock) { // Lock is already held, but ErrorProne can't tell - if (pendingCallCache.containsKey(request)) { + if (pendingCallCache.containsKey(routeLookupRequestKey)) { // pending already requested return; } logger.log(ChannelLogLevel.DEBUG, - "[RLS Entry {0}] Cache entry is stale, refreshing", request); - asyncRlsCall(request, /* backoffPolicy= */ null); + "[RLS Entry {0}] Cache entry is stale, refreshing", routeLookupRequestKey); + asyncRlsCall(routeLookupRequestKey, /* backoffPolicy= */ null, + RouteLookupRequest.Reason.REASON_STALE); } } @@ -745,7 +787,7 @@ void cleanup() { @Override public String toString() { return MoreObjects.toStringHelper(this) - .add("request", request) + .add("request", routeLookupRequestKey) .add("response", response) .add("expireTime", expireTime) .add("staleTime", staleTime) @@ -762,12 +804,15 @@ private static final class BackoffCacheEntry extends CacheEntry { private final Status status; private final BackoffPolicy backoffPolicy; + private final long expiryTimeNanos; private Future scheduledFuture; - BackoffCacheEntry(RouteLookupRequest request, Status status, BackoffPolicy backoffPolicy) { - super(request); + BackoffCacheEntry(RouteLookupRequestKey routeLookupRequestKey, Status status, + BackoffPolicy backoffPolicy, long expiryTimeNanos) { + super(routeLookupRequestKey); this.status = checkNotNull(status, "status"); this.backoffPolicy = checkNotNull(backoffPolicy, "backoffPolicy"); + this.expiryTimeNanos = expiryTimeNanos; } Status getStatus() { @@ -779,9 +824,13 @@ int getSizeBytes() { return OBJ_OVERHEAD_B * 3 + Long.SIZE + 8; // 3 java objects, 1 long and a boolean } + boolean isInBackoffPeriod() { + return !scheduledFuture.isDone(); + } + @Override - boolean isExpired(long now) { - return scheduledFuture.isDone(); + boolean isExpired(long nowNanos) { + return nowNanos > expiryTimeNanos; } @Override @@ -792,7 +841,7 @@ void cleanup() { @Override public String toString() { return MoreObjects.toStringHelper(this) - .add("request", request) + .add("request", routeLookupRequestKey) .add("status", status) .toString(); } @@ -811,7 +860,7 @@ static final class Builder { private Throttler throttler = new HappyThrottler(); private ResolvedAddressFactory resolvedAddressFactory; private Ticker ticker = Ticker.systemTicker(); - private EvictionListener evictionListener; + private EvictionListener evictionListener; private BackoffPolicy.Provider backoffProvider = new ExponentialBackoffPolicy.Provider(); Builder setHelper(Helper helper) { @@ -845,7 +894,7 @@ Builder setTicker(Ticker ticker) { } Builder setEvictionListener( - @Nullable EvictionListener evictionListener) { + @Nullable EvictionListener evictionListener) { this.evictionListener = evictionListener; return this; } @@ -867,17 +916,17 @@ CachingRlsLbClient build() { * CacheEntry#cleanup()} after original {@link EvictionListener} is finished. */ private static final class AutoCleaningEvictionListener - implements EvictionListener { + implements EvictionListener { - private final EvictionListener delegate; + private final EvictionListener delegate; AutoCleaningEvictionListener( - @Nullable EvictionListener delegate) { + @Nullable EvictionListener delegate) { this.delegate = delegate; } @Override - public void onEviction(RouteLookupRequest key, CacheEntry value, EvictionType cause) { + public void onEviction(RouteLookupRequestKey key, CacheEntry value, EvictionType cause) { if (delegate != null) { delegate.onEviction(key, value, cause); } @@ -902,29 +951,29 @@ public void registerBackendResponse(boolean throttled) { /** Implementation of {@link LinkedHashLruCache} for RLS. */ private static final class RlsAsyncLruCache - extends LinkedHashLruCache { + extends LinkedHashLruCache { private final RlsLbHelper helper; RlsAsyncLruCache(long maxEstimatedSizeBytes, - @Nullable EvictionListener evictionListener, + @Nullable EvictionListener evictionListener, Ticker ticker, RlsLbHelper helper) { super(maxEstimatedSizeBytes, evictionListener, ticker); this.helper = checkNotNull(helper, "helper"); } @Override - protected boolean isExpired(RouteLookupRequest key, CacheEntry value, long nowNanos) { + protected boolean isExpired(RouteLookupRequestKey key, CacheEntry value, long nowNanos) { return value.isExpired(nowNanos); } @Override - protected int estimateSizeOf(RouteLookupRequest key, CacheEntry value) { + protected int estimateSizeOf(RouteLookupRequestKey key, CacheEntry value) { return value.getSizeBytes(); } @Override protected boolean shouldInvalidateEldestEntry( - RouteLookupRequest eldestKey, CacheEntry eldestValue, long now) { + RouteLookupRequestKey eldestKey, CacheEntry eldestValue, long now) { if (!eldestValue.isOldEnoughToBeEvicted(now)) { return false; } @@ -933,7 +982,7 @@ protected boolean shouldInvalidateEldestEntry( return this.estimatedSizeBytes() > this.estimatedMaxSizeBytes(); } - public CacheEntry cacheAndClean(RouteLookupRequest key, CacheEntry value) { + public CacheEntry cacheAndClean(RouteLookupRequestKey key, CacheEntry value) { CacheEntry newEntry = cache(key, value); // force cleanup if new entry pushed cache over max size (in bytes) @@ -944,32 +993,6 @@ public CacheEntry cacheAndClean(RouteLookupRequest key, CacheEntry value) { } } - /** - * LbStatusListener refreshes {@link BackoffCacheEntry} when lb state is changed to {@link - * ConnectivityState#READY} from {@link ConnectivityState#TRANSIENT_FAILURE}. - */ - private final class BackoffRefreshListener implements ChildLbStatusListener { - - @Nullable - private ConnectivityState prevState = null; - - @Override - public void onStatusChanged(ConnectivityState newState) { - if (prevState == ConnectivityState.TRANSIENT_FAILURE - && newState == ConnectivityState.READY) { - logger.log(ChannelLogLevel.DEBUG, "Transitioning from TRANSIENT_FAILURE to READY"); - synchronized (lock) { - for (CacheEntry value : linkedHashLruCache.values()) { - if (value instanceof BackoffCacheEntry) { - refreshBackoffEntry((BackoffCacheEntry) value); - } - } - } - } - prevState = newState; - } - } - /** A header will be added when RLS server respond with additional header data. */ @VisibleForTesting static final Metadata.Key RLS_DATA_KEY = @@ -989,9 +1012,9 @@ final class RlsPicker extends SubchannelPicker { public PickResult pickSubchannel(PickSubchannelArgs args) { String serviceName = args.getMethodDescriptor().getServiceName(); String methodName = args.getMethodDescriptor().getBareMethodName(); - RouteLookupRequest request = + RlsProtoData.RouteLookupRequestKey lookupRequestKey = requestFactory.create(serviceName, methodName, args.getHeaders()); - final CachedRouteLookupResponse response = CachingRlsLbClient.this.get(request); + final CachedRouteLookupResponse response = CachingRlsLbClient.this.get(lookupRequestKey); if (response.getHeaderData() != null && !response.getHeaderData().isEmpty()) { Metadata headers = args.getHeaders(); @@ -1013,7 +1036,7 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { helper.getMetricRecorder().addLongCounter(TARGET_PICKS_COUNTER, 1, Arrays.asList(helper.getChannelTarget(), lookupService, childPolicyWrapper.getTarget(), determineMetricsPickResult(pickResult)), - Collections.emptyList()); + Arrays.asList(determineCustomLabel(args))); } return pickResult; } else if (response.hasError()) { @@ -1021,7 +1044,8 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { return useFallback(args); } helper.getMetricRecorder().addLongCounter(FAILED_PICKS_COUNTER, 1, - Arrays.asList(helper.getChannelTarget(), lookupService), Collections.emptyList()); + Arrays.asList(helper.getChannelTarget(), lookupService), + Arrays.asList(determineCustomLabel(args))); return PickResult.withError( convertRlsServerStatus(response.getStatus(), lbPolicyConfig.getRouteLookupConfig().lookupService())); @@ -1041,7 +1065,7 @@ private PickResult useFallback(PickSubchannelArgs args) { helper.getMetricRecorder().addLongCounter(DEFAULT_TARGET_PICKS_COUNTER, 1, Arrays.asList(helper.getChannelTarget(), lookupService, fallbackChildPolicyWrapper.getTarget(), determineMetricsPickResult(pickResult)), - Collections.emptyList()); + Arrays.asList(determineCustomLabel(args))); } return pickResult; } @@ -1056,6 +1080,10 @@ private String determineMetricsPickResult(PickResult pickResult) { } } + private String determineCustomLabel(PickSubchannelArgs args) { + return args.getCallOptions().getOption(Grpc.CALL_OPTION_CUSTOM_LABEL); + } + // GuardedBy CachingRlsLbClient.lock void close() { synchronized (lock) { // Lock is already held, but ErrorProne can't tell diff --git a/rls/src/main/java/io/grpc/rls/LbPolicyConfiguration.java b/rls/src/main/java/io/grpc/rls/LbPolicyConfiguration.java index 226176d25ff..77ed080e654 100644 --- a/rls/src/main/java/io/grpc/rls/LbPolicyConfiguration.java +++ b/rls/src/main/java/io/grpc/rls/LbPolicyConfiguration.java @@ -210,20 +210,17 @@ static final class RefCountedChildPolicyWrapperFactory { new HashMap<>(); private final ChildLoadBalancerHelperProvider childLbHelperProvider; - private final ChildLbStatusListener childLbStatusListener; private final ChildLoadBalancingPolicy childPolicy; private ResolvedAddressFactory childLbResolvedAddressFactory; public RefCountedChildPolicyWrapperFactory( ChildLoadBalancingPolicy childPolicy, ResolvedAddressFactory childLbResolvedAddressFactory, - ChildLoadBalancerHelperProvider childLbHelperProvider, - ChildLbStatusListener childLbStatusListener) { + ChildLoadBalancerHelperProvider childLbHelperProvider) { this.childPolicy = checkNotNull(childPolicy, "childPolicy"); this.childLbResolvedAddressFactory = checkNotNull(childLbResolvedAddressFactory, "childLbResolvedAddressFactory"); this.childLbHelperProvider = checkNotNull(childLbHelperProvider, "childLbHelperProvider"); - this.childLbStatusListener = checkNotNull(childLbStatusListener, "childLbStatusListener"); } void init() { @@ -248,10 +245,10 @@ ChildPolicyWrapper createOrGet(String target) { RefCountedChildPolicyWrapper pooledChildPolicyWrapper = childPolicyMap.get(target); if (pooledChildPolicyWrapper == null) { ChildPolicyWrapper childPolicyWrapper = new ChildPolicyWrapper( - target, childPolicy, childLbResolvedAddressFactory, childLbHelperProvider, - childLbStatusListener); + target, childPolicy, childLbHelperProvider); pooledChildPolicyWrapper = RefCountedChildPolicyWrapper.of(childPolicyWrapper); childPolicyMap.put(target, pooledChildPolicyWrapper); + childPolicyWrapper.start(childLbResolvedAddressFactory); return pooledChildPolicyWrapper.getObject(); } else { ChildPolicyWrapper childPolicyWrapper = pooledChildPolicyWrapper.getObject(); @@ -298,12 +295,9 @@ static final class ChildPolicyWrapper { public ChildPolicyWrapper( String target, ChildLoadBalancingPolicy childPolicy, - final ResolvedAddressFactory childLbResolvedAddressFactory, - ChildLoadBalancerHelperProvider childLbHelperProvider, - ChildLbStatusListener childLbStatusListener) { + ChildLoadBalancerHelperProvider childLbHelperProvider) { this.target = target; - this.helper = - new ChildPolicyReportingHelper(childLbHelperProvider, childLbStatusListener); + this.helper = new ChildPolicyReportingHelper(childLbHelperProvider); LoadBalancerProvider lbProvider = childPolicy.getEffectiveLbProvider(); final ConfigOrError lbConfig = lbProvider @@ -313,6 +307,9 @@ public ChildPolicyWrapper( this.childLbConfig = lbConfig.getConfig(); helper.getChannelLogger().log( ChannelLogLevel.DEBUG, "RLS child lb created. config: {0}", childLbConfig); + } + + void start(ResolvedAddressFactory childLbResolvedAddressFactory) { helper.getSynchronizationContext().execute( new Runnable() { @Override @@ -386,14 +383,11 @@ public String toString() { final class ChildPolicyReportingHelper extends ForwardingLoadBalancerHelper { private final ChildLoadBalancerHelper delegate; - private final ChildLbStatusListener listener; ChildPolicyReportingHelper( - ChildLoadBalancerHelperProvider childHelperProvider, - ChildLbStatusListener listener) { + ChildLoadBalancerHelperProvider childHelperProvider) { checkNotNull(childHelperProvider, "childHelperProvider"); this.delegate = childHelperProvider.forTarget(getTarget()); - this.listener = checkNotNull(listener, "listener"); } @Override @@ -406,18 +400,10 @@ public void updateBalancingState(ConnectivityState newState, SubchannelPicker ne picker = newPicker; state = newState; super.updateBalancingState(newState, newPicker); - listener.onStatusChanged(newState); } } } - /** Listener for child lb status change events. */ - interface ChildLbStatusListener { - - /** Notifies when child lb status changes. */ - void onStatusChanged(ConnectivityState newState); - } - private static final class RefCountedChildPolicyWrapper implements ObjectPool { diff --git a/rls/src/main/java/io/grpc/rls/RlsLoadBalancer.java b/rls/src/main/java/io/grpc/rls/RlsLoadBalancer.java index 6e59e867e32..848199f50a8 100644 --- a/rls/src/main/java/io/grpc/rls/RlsLoadBalancer.java +++ b/rls/src/main/java/io/grpc/rls/RlsLoadBalancer.java @@ -19,7 +19,6 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.MoreObjects; import io.grpc.ChannelLogger; import io.grpc.ChannelLogger.ChannelLogLevel; import io.grpc.ConnectivityState; @@ -93,27 +92,14 @@ public void requestConnection() { @Override public void handleNameResolutionError(final Status error) { - class ErrorPicker extends SubchannelPicker { - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return PickResult.withError(error); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("error", error) - .toString(); - } - } - if (routeLookupClient != null) { logger.log(ChannelLogLevel.DEBUG, "closing the routeLookupClient on a name resolution error"); routeLookupClient.close(); routeLookupClient = null; lbPolicyConfiguration = null; } - helper.updateBalancingState(ConnectivityState.TRANSIENT_FAILURE, new ErrorPicker()); + helper.updateBalancingState( + ConnectivityState.TRANSIENT_FAILURE, new FixedResultPicker(PickResult.withError(error))); } @Override diff --git a/rls/src/main/java/io/grpc/rls/RlsProtoConverters.java b/rls/src/main/java/io/grpc/rls/RlsProtoConverters.java index aa5147449c4..70f9fb4d891 100644 --- a/rls/src/main/java/io/grpc/rls/RlsProtoConverters.java +++ b/rls/src/main/java/io/grpc/rls/RlsProtoConverters.java @@ -64,7 +64,9 @@ static final class RouteLookupRequestConverter @Override protected RlsProtoData.RouteLookupRequest doForward(RouteLookupRequest routeLookupRequest) { return RlsProtoData.RouteLookupRequest.create( - ImmutableMap.copyOf(routeLookupRequest.getKeyMapMap())); + ImmutableMap.copyOf(routeLookupRequest.getKeyMapMap()), + RlsProtoData.RouteLookupRequest.Reason.valueOf(routeLookupRequest.getReason().name()) + ); } @Override @@ -72,6 +74,7 @@ protected RouteLookupRequest doBackward(RlsProtoData.RouteLookupRequest routeLoo return RouteLookupRequest.newBuilder() .setTargetType("grpc") + .setReason(RouteLookupRequest.Reason.valueOf(routeLookupRequest.reason().name())) .putAllKeyMap(routeLookupRequest.keyMap()) .build(); } diff --git a/rls/src/main/java/io/grpc/rls/RlsProtoData.java b/rls/src/main/java/io/grpc/rls/RlsProtoData.java index 49f32c6b6e3..39c404870f9 100644 --- a/rls/src/main/java/io/grpc/rls/RlsProtoData.java +++ b/rls/src/main/java/io/grpc/rls/RlsProtoData.java @@ -27,16 +27,42 @@ final class RlsProtoData { private RlsProtoData() {} + /** A key object for the Rls route lookup data cache. */ + @AutoValue + @Immutable + abstract static class RouteLookupRequestKey { + + /** Returns a map of key values extracted via key builders for the gRPC or HTTP request. */ + abstract ImmutableMap keyMap(); + + static RouteLookupRequestKey create(ImmutableMap keyMap) { + return new AutoValue_RlsProtoData_RouteLookupRequestKey(keyMap); + } + } + /** A request object sent to route lookup service. */ @AutoValue @Immutable abstract static class RouteLookupRequest { + /** Names should match those in {@link io.grpc.lookup.v1.RouteLookupRequest.Reason}. */ + enum Reason { + /** Unused. */ + REASON_UNKNOWN, + /** No data available in local cache. */ + REASON_MISS, + /** Data in local cache is stale. */ + REASON_STALE; + } + + /** Reason for making this request. */ + abstract Reason reason(); + /** Returns a map of key values extracted via key builders for the gRPC or HTTP request. */ abstract ImmutableMap keyMap(); - static RouteLookupRequest create(ImmutableMap keyMap) { - return new AutoValue_RlsProtoData_RouteLookupRequest(keyMap); + static RouteLookupRequest create(ImmutableMap keyMap, Reason reason) { + return new AutoValue_RlsProtoData_RouteLookupRequest(reason, keyMap); } } diff --git a/rls/src/main/java/io/grpc/rls/RlsRequestFactory.java b/rls/src/main/java/io/grpc/rls/RlsRequestFactory.java index e26e49979e1..1fed78f4df3 100644 --- a/rls/src/main/java/io/grpc/rls/RlsRequestFactory.java +++ b/rls/src/main/java/io/grpc/rls/RlsRequestFactory.java @@ -27,13 +27,13 @@ import io.grpc.rls.RlsProtoData.GrpcKeyBuilder.Name; import io.grpc.rls.RlsProtoData.NameMatcher; import io.grpc.rls.RlsProtoData.RouteLookupConfig; -import io.grpc.rls.RlsProtoData.RouteLookupRequest; +import io.grpc.rls.RlsProtoData.RouteLookupRequestKey; import java.util.HashMap; import java.util.List; import java.util.Map; /** - * A RlsRequestFactory creates {@link RouteLookupRequest} using key builder map from {@link + * A RlsRequestFactory creates {@link RouteLookupRequestKey} using key builder map from {@link * RouteLookupConfig}. */ final class RlsRequestFactory { @@ -61,9 +61,9 @@ private static Map createKeyBuilderTable( return table; } - /** Creates a {@link RouteLookupRequest} for given request's metadata. */ + /** Creates a {@link RouteLookupRequestKey} for the given request lookup metadata. */ @CheckReturnValue - RouteLookupRequest create(String service, String method, Metadata metadata) { + RouteLookupRequestKey create(String service, String method, Metadata metadata) { checkNotNull(service, "service"); checkNotNull(method, "method"); String path = "/" + service + "/" + method; @@ -73,7 +73,7 @@ RouteLookupRequest create(String service, String method, Metadata metadata) { grpcKeyBuilder = keyBuilderTable.get("/" + service + "/*"); } if (grpcKeyBuilder == null) { - return RouteLookupRequest.create(ImmutableMap.of()); + return RouteLookupRequestKey.create(ImmutableMap.of()); } ImmutableMap.Builder rlsRequestHeaders = createRequestHeaders(metadata, grpcKeyBuilder.headers()); @@ -89,7 +89,7 @@ RouteLookupRequest create(String service, String method, Metadata metadata) { rlsRequestHeaders.put(extraKeys.method(), method); } rlsRequestHeaders.putAll(constantKeys); - return RouteLookupRequest.create(rlsRequestHeaders.buildOrThrow()); + return RouteLookupRequestKey.create(rlsRequestHeaders.buildOrThrow()); } private ImmutableMap.Builder createRequestHeaders( diff --git a/rls/src/test/java/io/grpc/rls/CachingRlsLbClientTest.java b/rls/src/test/java/io/grpc/rls/CachingRlsLbClientTest.java index 4f086abc4a2..b349aecdbf3 100644 --- a/rls/src/test/java/io/grpc/rls/CachingRlsLbClientTest.java +++ b/rls/src/test/java/io/grpc/rls/CachingRlsLbClientTest.java @@ -59,6 +59,7 @@ import io.grpc.MetricRecorder.BatchRecorder; import io.grpc.MetricRecorder.Registration; import io.grpc.NameResolver.ConfigOrError; +import io.grpc.Server; import io.grpc.Status; import io.grpc.Status.Code; import io.grpc.SynchronizationContext; @@ -66,7 +67,10 @@ import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.internal.BackoffPolicy; import io.grpc.internal.FakeClock; +import io.grpc.internal.GrpcUtil; +import io.grpc.internal.ObjectPool; import io.grpc.internal.PickSubchannelArgsImpl; +import io.grpc.internal.SharedResourcePool; import io.grpc.lookup.v1.RouteLookupServiceGrpc; import io.grpc.rls.CachingRlsLbClient.CacheEntry; import io.grpc.rls.CachingRlsLbClient.CachedRouteLookupResponse; @@ -96,10 +100,13 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nonnull; import org.junit.After; import org.junit.Before; @@ -128,7 +135,7 @@ public class CachingRlsLbClientTest { public final GrpcCleanupRule grpcCleanupRule = new GrpcCleanupRule(); @Mock - private EvictionListener evictionListener; + private EvictionListener evictionListener; @Mock private SocketAddress socketAddress; @Mock @@ -160,8 +167,9 @@ public void uncaughtException(Thread t, Throwable e) { fakeClock.getScheduledExecutorService()); private final ChildLoadBalancingPolicy childLbPolicy = new ChildLoadBalancingPolicy("target", Collections.emptyMap(), lbProvider); + private final FakeHelper fakeHelper = new FakeHelper(); private final Helper helper = - mock(Helper.class, delegatesTo(new FakeHelper())); + mock(Helper.class, delegatesTo(fakeHelper)); private final FakeThrottler fakeThrottler = new FakeThrottler(); private final LbPolicyConfiguration lbPolicyConfiguration = new LbPolicyConfiguration(ROUTE_LOOKUP_CONFIG, null, childLbPolicy); @@ -200,14 +208,14 @@ public void tearDown() throws Exception { } private CachedRouteLookupResponse getInSyncContext( - final RouteLookupRequest request) + final RlsProtoData.RouteLookupRequestKey routeLookupRequestKey) throws ExecutionException, InterruptedException, TimeoutException { final SettableFuture responseSettableFuture = SettableFuture.create(); syncContext.execute(new Runnable() { @Override public void run() { - responseSettableFuture.set(rlsLbClient.get(request)); + responseSettableFuture.set(rlsLbClient.get(routeLookupRequestKey)); } }); return responseSettableFuture.get(5, TimeUnit.SECONDS); @@ -217,48 +225,53 @@ public void run() { public void get_noError_lifeCycle() throws Exception { setUpRlsLbClient(); InOrder inOrder = inOrder(evictionListener); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create(ImmutableList.of("target"), "header"))); // initial request - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); // server response fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); // cache hit for staled entry fakeClock.forwardTime(ROUTE_LOOKUP_CONFIG.staleAgeInNanos(), TimeUnit.NANOSECONDS); - resp = getInSyncContext(routeLookupRequest); + rlsServerImpl.routeLookupReason = null; + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); // async refresh finishes fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); inOrder .verify(evictionListener) - .onEviction(eq(routeLookupRequest), any(CacheEntry.class), eq(EvictionType.REPLACED)); + .onEviction(eq(routeLookupRequestKey), any(CacheEntry.class), eq(EvictionType.REPLACED)); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); + assertThat(rlsServerImpl.routeLookupReason).isEqualTo( + io.grpc.lookup.v1.RouteLookupRequest.Reason.REASON_STALE); assertThat(resp.hasData()).isTrue(); // existing cache expired fakeClock.forwardTime(ROUTE_LOOKUP_CONFIG.maxAgeInNanos(), TimeUnit.NANOSECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); inOrder .verify(evictionListener) - .onEviction(eq(routeLookupRequest), any(CacheEntry.class), eq(EvictionType.EXPIRED)); + .onEviction(eq(routeLookupRequestKey), any(CacheEntry.class), eq(EvictionType.EXPIRED)); inOrder.verifyNoMoreInteractions(); } @@ -287,88 +300,262 @@ public void rls_withCustomRlsChannelServiceConfig() throws Exception { .setThrottler(fakeThrottler) .setTicker(fakeClock.getTicker()) .build(); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create(ImmutableList.of("target"), "header"))); + rlsServerImpl.routeLookupReason = null; // initial request - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); // server response fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); + assertThat(rlsServerImpl.routeLookupReason).isEqualTo( + io.grpc.lookup.v1.RouteLookupRequest.Reason.REASON_MISS); assertThat(rlsChannelOverriddenAuthority).isEqualTo("bigtable.googleapis.com:443"); assertThat(rlsChannelServiceConfig).isEqualTo(routeLookupChannelServiceConfig); } @Test - public void get_throttledAndRecover() throws Exception { + public void backoffTimerEnd_updatesPicker() throws Exception { setUpRlsLbClient(); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + InOrder inOrder = inOrder(helper); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create(ImmutableList.of("target"), "header"))); fakeThrottler.nextResult = true; fakeBackoffProvider.nextPolicy = createBackoffPolicy(10, TimeUnit.MILLISECONDS); - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); - + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasError()).isTrue(); fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); - // initially backed off entry is backed off again - verify(evictionListener) - .onEviction(eq(routeLookupRequest), any(CacheEntry.class), eq(EvictionType.EXPLICIT)); + // Assert that Rls LB policy picker was updated which picks the fallback target + ArgumentCaptor pickerCaptor = ArgumentCaptor.forClass(SubchannelPicker.class); + ArgumentCaptor stateCaptor = + ArgumentCaptor.forClass(ConnectivityState.class); - resp = getInSyncContext(routeLookupRequest); + inOrder.verify(helper, times(3)) + .updateBalancingState(stateCaptor.capture(), pickerCaptor.capture()); + assertThat(new HashSet<>(pickerCaptor.getAllValues())).hasSize(1); + assertThat(stateCaptor.getAllValues()) + .containsExactly(ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.CONNECTING, + ConnectivityState.CONNECTING); + Metadata headers = new Metadata(); + PickResult pickResult = getPickResultForCreate(pickerCaptor, headers); + assertThat(pickResult.getStatus().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(pickResult.getStatus().getDescription()).isEqualTo("fallback not available"); + } + @Test + public void get_throttledTwice_usesSameBackoffpolicy() throws Exception { + setUpRlsLbClient(); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + rlsServerImpl.setLookupTable( + ImmutableMap.of( + routeLookupRequestKey, + RouteLookupResponse.create(ImmutableList.of("target"), "header"))); + + fakeThrottler.nextResult = true; + fakeBackoffProvider.nextPolicy = createBackoffPolicy(10, TimeUnit.MILLISECONDS); + + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); + + assertThat(resp.hasError()).isTrue(); + + fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); + + // Assert that the same backoff policy is still in effect for the cache entry. + // The below provider should not get used, so the back off time will still be set to 10ms. + fakeBackoffProvider.nextPolicy = createBackoffPolicy(20, TimeUnit.MILLISECONDS); + // let it be throttled again + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasError()).isTrue(); - // let it pass throttler + fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); + + // Backoff entry's backoff timer has gone off, so next rpc should not be backed off. fakeThrottler.nextResult = false; + resp = getInSyncContext(routeLookupRequestKey); + assertThat(resp.isPending()).isTrue(); + + rlsServerImpl.routeLookupReason = null; + // server responses + fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); + assertThat(rlsServerImpl.routeLookupReason).isEqualTo( + io.grpc.lookup.v1.RouteLookupRequest.Reason.REASON_MISS); + } + + @Test + public void get_errorResponseTwice_usesSameBackoffPolicy() throws Exception { + setUpRlsLbClient(); + RlsProtoData.RouteLookupRequestKey invalidRouteLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create(ImmutableMap.of()); + CachedRouteLookupResponse resp = getInSyncContext(invalidRouteLookupRequestKey); + assertThat(resp.isPending()).isTrue(); + fakeBackoffProvider.nextPolicy = createBackoffPolicy(10, TimeUnit.MILLISECONDS); + fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); + assertThat(rlsServerImpl.routeLookupReason).isEqualTo( + io.grpc.lookup.v1.RouteLookupRequest.Reason.REASON_MISS); + + resp = getInSyncContext(invalidRouteLookupRequestKey); + assertThat(resp.hasError()).isTrue(); + + // Backoff time expiry fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); + resp = getInSyncContext(invalidRouteLookupRequestKey); + assertThat(resp.isPending()).isTrue(); + // Assert that the same backoff policy is still in effect for the cache entry. + // The below provider should not get used, so the back off time will still be set to 10ms. + fakeBackoffProvider.nextPolicy = createBackoffPolicy(20, TimeUnit.MILLISECONDS); + // Gets error again and backed off again + fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(invalidRouteLookupRequestKey); + assertThat(resp.hasError()).isTrue(); + // Backoff time expiry + fakeClock.forwardTime(10, TimeUnit.MILLISECONDS); + resp = getInSyncContext(invalidRouteLookupRequestKey); assertThat(resp.isPending()).isTrue(); + rlsServerImpl.routeLookupReason = null; // server responses fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); + assertThat(rlsServerImpl.routeLookupReason).isEqualTo( + io.grpc.lookup.v1.RouteLookupRequest.Reason.REASON_MISS); + } - resp = getInSyncContext(routeLookupRequest); + @Test + public void controlPlaneTransientToReady_backOffEntriesRemovedAndPickerUpdated() + throws Exception { + setUpRlsLbClient(); + InOrder inOrder = inOrder(helper); + final ConnectivityState[] rlsChannelState = new ConnectivityState[1]; + Runnable channelStateListener = new Runnable() { + @Override + public void run() { + rlsChannelState[0] = fakeHelper.oobChannel.getState(false); + fakeHelper.oobChannel.notifyWhenStateChanged(rlsChannelState[0], this); + synchronized (this) { + notify(); + } + } + }; + fakeHelper.oobChannel.notifyWhenStateChanged(fakeHelper.oobChannel.getState(false), + channelStateListener); + + fakeHelper.server.shutdown(); + // Channel goes to IDLE state from the shutdown listener handling. + try { + if (!fakeHelper.server.awaitTermination(10, TimeUnit.SECONDS)) { + fakeHelper.server.shutdownNow(); // Forceful shutdown if graceful timeout expires + } + } catch (InterruptedException e) { + fakeHelper.server.shutdownNow(); + } + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create(ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + // Rls channel will go to TRANSIENT_FAILURE (connection back-off). + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); + assertThat(resp.isPending()).isTrue(); + assertThat(rlsChannelState[0]).isEqualTo(ConnectivityState.TRANSIENT_FAILURE); + // Throttle the next rpc call. + fakeThrottler.nextResult = true; + fakeBackoffProvider.nextPolicy = createBackoffPolicy(10, TimeUnit.MILLISECONDS); - assertThat(resp.hasData()).isTrue(); + // Cause two cache misses by using new request keys. This will create back-off Rls cache + // entries. RLS control plane state transitioning to READY should reset both back-offs but + // update picker only once. + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey2 = + RlsProtoData.RouteLookupRequestKey.create(ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo2", "method-key", "bar")); + resp = getInSyncContext(routeLookupRequestKey2); + assertThat(resp.hasError()).isTrue(); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey3 = + RlsProtoData.RouteLookupRequestKey.create(ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo3", "method-key", "bar")); + resp = getInSyncContext(routeLookupRequestKey3); + assertThat(resp.hasError()).isTrue(); + + fakeHelper.createServerAndRegister("service1"); + // Wait for Rls control plane channel back-off expiry and its moving to READY + synchronized (channelStateListener) { + channelStateListener.wait(2000); + } + assertThat(rlsChannelState[0]).isEqualTo(ConnectivityState.READY); + final ObjectPool defaultExecutorPool = + SharedResourcePool.forResource(GrpcUtil.SHARED_CHANNEL_EXECUTOR); + AtomicBoolean isSuccess = new AtomicBoolean(false); + ((ExecutorService) defaultExecutorPool.getObject()).submit(() -> { + // Assert that Rls LB policy picker was updated which picks the fallback target + ArgumentCaptor pickerCaptor = + ArgumentCaptor.forClass(SubchannelPicker.class); + ArgumentCaptor stateCaptor = + ArgumentCaptor.forClass(ConnectivityState.class); + + inOrder.verify(helper, times(4)) + .updateBalancingState(stateCaptor.capture(), pickerCaptor.capture()); + assertThat(new HashSet<>(pickerCaptor.getAllValues())).hasSize(1); + assertThat(stateCaptor.getAllValues()) + .containsExactly(ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.CONNECTING, + ConnectivityState.CONNECTING, ConnectivityState.CONNECTING); + Metadata headers = new Metadata(); + PickResult pickResult = getPickResultForCreate(pickerCaptor, headers); + assertThat(pickResult.getStatus().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(pickResult.getStatus().getDescription()).isEqualTo("fallback not available"); + isSuccess.set(true); + }).get(); + assertThat(isSuccess.get()).isTrue(); + + fakeThrottler.nextResult = false; + // Rpcs are not backed off now. + assertThat(getInSyncContext(routeLookupRequestKey2).isPending()).isTrue(); + assertThat(getInSyncContext(routeLookupRequestKey3).isPending()).isTrue(); } @Test public void get_updatesLbState() throws Exception { setUpRlsLbClient(); InOrder inOrder = inOrder(helper); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "service1", "method-key", "create")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "service1", + "method-key", "create")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create( ImmutableList.of("primary.cloudbigtable.googleapis.com"), "header-rls-data-value"))); // valid channel - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); ArgumentCaptor pickerCaptor = ArgumentCaptor.forClass(SubchannelPicker.class); @@ -393,13 +580,13 @@ public void get_updatesLbState() throws Exception { // move backoff further back to only test error behavior fakeBackoffProvider.nextPolicy = createBackoffPolicy(100, TimeUnit.MILLISECONDS); // try to get invalid - RouteLookupRequest invalidRouteLookupRequest = - RouteLookupRequest.create(ImmutableMap.of()); - CachedRouteLookupResponse errorResp = getInSyncContext(invalidRouteLookupRequest); + RlsProtoData.RouteLookupRequestKey invalidRouteLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create(ImmutableMap.of()); + CachedRouteLookupResponse errorResp = getInSyncContext(invalidRouteLookupRequestKey); assertThat(errorResp.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - errorResp = getInSyncContext(invalidRouteLookupRequest); + errorResp = getInSyncContext(invalidRouteLookupRequestKey); assertThat(errorResp.hasError()).isTrue(); // Channel is still READY because the subchannel for method /service1/create is still READY. @@ -423,21 +610,24 @@ public void get_updatesLbState() throws Exception { @Test public void timeout_not_changing_picked_subchannel() throws Exception { setUpRlsLbClient(); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "service1", "method-key", "create")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "service1", + "method-key", "create")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create( ImmutableList.of("primary.cloudbigtable.googleapis.com", "target2", "target3"), "header-rls-data-value"))); // valid channel - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isFalse(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); ArgumentCaptor pickerCaptor = ArgumentCaptor.forClass(SubchannelPicker.class); @@ -493,21 +683,24 @@ public void get_withAdaptiveThrottler() throws Exception { .setTicker(fakeClock.getTicker()) .build(); InOrder inOrder = inOrder(helper); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "service1", "method-key", "create")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "service1", + "method-key", "create")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create( ImmutableList.of("primary.cloudbigtable.googleapis.com"), "header-rls-data-value"))); // valid channel - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); ArgumentCaptor pickerCaptor = ArgumentCaptor.forClass(SubchannelPicker.class); @@ -524,13 +717,13 @@ public void get_withAdaptiveThrottler() throws Exception { // move backoff further back to only test error behavior fakeBackoffProvider.nextPolicy = createBackoffPolicy(100, TimeUnit.MILLISECONDS); // try to get invalid - RouteLookupRequest invalidRouteLookupRequest = - RouteLookupRequest.create(ImmutableMap.of()); - CachedRouteLookupResponse errorResp = getInSyncContext(invalidRouteLookupRequest); + RlsProtoData.RouteLookupRequestKey invalidRouteLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create(ImmutableMap.of()); + CachedRouteLookupResponse errorResp = getInSyncContext(invalidRouteLookupRequestKey); assertThat(errorResp.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - errorResp = getInSyncContext(invalidRouteLookupRequest); + errorResp = getInSyncContext(invalidRouteLookupRequestKey); assertThat(errorResp.hasError()).isTrue(); // Channel is still READY because the subchannel for method /service1/create is still READY. @@ -560,22 +753,26 @@ private PickSubchannelArgsImpl getInvalidArgs(Metadata headers) { @Test public void get_childPolicyWrapper_reusedForSameTarget() throws Exception { setUpRlsLbClient(); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); - RouteLookupRequest routeLookupRequest2 = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "baz")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey2 = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "baz")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create(ImmutableList.of("target"), "header"), - routeLookupRequest2, + routeLookupRequestKey2, RouteLookupResponse.create(ImmutableList.of("target"), "header2"))); - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); assertThat(resp.getHeaderData()).isEqualTo("header"); @@ -585,11 +782,11 @@ public void get_childPolicyWrapper_reusedForSameTarget() throws Exception { assertThat(childPolicyWrapper.getPicker()).isNotInstanceOf(RlsPicker.class); // request2 has same target, it should reuse childPolicyWrapper - CachedRouteLookupResponse resp2 = getInSyncContext(routeLookupRequest2); + CachedRouteLookupResponse resp2 = getInSyncContext(routeLookupRequestKey2); assertThat(resp2.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp2 = getInSyncContext(routeLookupRequest2); + resp2 = getInSyncContext(routeLookupRequestKey2); assertThat(resp2.hasData()).isTrue(); assertThat(resp2.getHeaderData()).isEqualTo("header2"); assertThat(resp2.getChildPolicyWrapper()).isEqualTo(resp.getChildPolicyWrapper()); @@ -598,20 +795,22 @@ public void get_childPolicyWrapper_reusedForSameTarget() throws Exception { @Test public void get_childPolicyWrapper_multiTarget() throws Exception { setUpRlsLbClient(); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create(ImmutableMap.of( - "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of( + "server", "bigtable.googleapis.com", "service-key", "foo", "method-key", "bar")); rlsServerImpl.setLookupTable( ImmutableMap.of( - routeLookupRequest, + routeLookupRequestKey, RouteLookupResponse.create( ImmutableList.of("target1", "target2", "target3"), "header"))); - CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequest); + CachedRouteLookupResponse resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.isPending()).isTrue(); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); - resp = getInSyncContext(routeLookupRequest); + resp = getInSyncContext(routeLookupRequestKey); assertThat(resp.hasData()).isTrue(); List policyWrappers = new ArrayList<>(); @@ -680,14 +879,15 @@ public void metricGauges() throws ExecutionException, InterruptedException, Time .recordLongGauge(argThat(new LongGaugeInstrumentArgumentMatcher("grpc.lb.rls.cache_size")), eq(0L), any(), any()); - RouteLookupRequest routeLookupRequest = RouteLookupRequest.create( - ImmutableMap.of("server", "bigtable.googleapis.com", "service-key", "foo", "method-key", - "bar")); - rlsServerImpl.setLookupTable(ImmutableMap.of(routeLookupRequest, + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + RlsProtoData.RouteLookupRequestKey.create( + ImmutableMap.of("server", "bigtable.googleapis.com", "service-key", "foo", "method-key", + "bar")); + rlsServerImpl.setLookupTable(ImmutableMap.of(routeLookupRequestKey, RouteLookupResponse.create(ImmutableList.of("target"), "header"))); // Make a request that will populate the cache with an entry - getInSyncContext(routeLookupRequest); + getInSyncContext(routeLookupRequestKey); fakeClock.forwardTime(SERVER_LATENCY_MILLIS, TimeUnit.MILLISECONDS); // Gauge values should reflect the new cache entry. @@ -825,14 +1025,9 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { @Override public void handleNameResolutionError(final Status error) { - class ErrorPicker extends SubchannelPicker { - @Override - public PickResult pickSubchannel(PickSubchannelArgs args) { - return PickResult.withError(error); - } - } - - helper.updateBalancingState(ConnectivityState.TRANSIENT_FAILURE, new ErrorPicker()); + helper.updateBalancingState( + ConnectivityState.TRANSIENT_FAILURE, + new FixedResultPicker(PickResult.withError(error))); } @Override @@ -857,7 +1052,9 @@ private static final class StaticFixedDelayRlsServerImpl private final long responseDelayNano; private final ScheduledExecutorService scheduledExecutorService; - private Map lookupTable = ImmutableMap.of(); + private Map lookupTable = + ImmutableMap.of(); + io.grpc.lookup.v1.RouteLookupRequest.Reason routeLookupReason; public StaticFixedDelayRlsServerImpl( long responseDelayNano, ScheduledExecutorService scheduledExecutorService) { @@ -867,7 +1064,8 @@ public StaticFixedDelayRlsServerImpl( checkNotNull(scheduledExecutorService, "scheduledExecutorService"); } - private void setLookupTable(Map lookupTable) { + private void setLookupTable(Map lookupTable) { this.lookupTable = checkNotNull(lookupTable, "lookupTable"); } @@ -879,8 +1077,11 @@ public void routeLookup(final io.grpc.lookup.v1.RouteLookupRequest request, new Runnable() { @Override public void run() { + routeLookupReason = request.getReason(); RouteLookupResponse response = - lookupTable.get(REQUEST_CONVERTER.convert(request)); + lookupTable.get( + RlsProtoData.RouteLookupRequestKey.create( + REQUEST_CONVERTER.convert(request).keyMap())); if (response == null) { responseObserver.onError(new RuntimeException("not found")); } else { @@ -894,16 +1095,23 @@ public void run() { private final class FakeHelper extends Helper { + Server server; + ManagedChannel oobChannel; + + void createServerAndRegister(String target) throws IOException { + server = InProcessServerBuilder.forName(target) + .addService(rlsServerImpl) + .directExecutor() + .build() + .start(); + grpcCleanupRule.register(server); + } + @Override public ManagedChannelBuilder createResolvingOobChannelBuilder( String target, ChannelCredentials creds) { try { - grpcCleanupRule.register( - InProcessServerBuilder.forName(target) - .addService(rlsServerImpl) - .directExecutor() - .build() - .start()); + createServerAndRegister(target); } catch (IOException e) { throw new RuntimeException("cannot create server: " + target, e); } @@ -919,7 +1127,8 @@ protected ManagedChannelBuilder delegate() { @Override public ManagedChannel build() { - return grpcCleanupRule.register(super.build()); + oobChannel = super.build(); + return grpcCleanupRule.register(oobChannel); } @Override @@ -948,7 +1157,6 @@ public ManagedChannel createOobChannel(EquivalentAddressGroup eag, String author @Override public void updateBalancingState( @Nonnull ConnectivityState newState, @Nonnull SubchannelPicker newPicker) { - // no-op } @Override diff --git a/rls/src/test/java/io/grpc/rls/LbPolicyConfigurationTest.java b/rls/src/test/java/io/grpc/rls/LbPolicyConfigurationTest.java index d6025d5bad4..de41d0488fc 100644 --- a/rls/src/test/java/io/grpc/rls/LbPolicyConfigurationTest.java +++ b/rls/src/test/java/io/grpc/rls/LbPolicyConfigurationTest.java @@ -21,6 +21,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -39,14 +40,13 @@ import io.grpc.Status; import io.grpc.SynchronizationContext; import io.grpc.rls.ChildLoadBalancerHelper.ChildLoadBalancerHelperProvider; -import io.grpc.rls.LbPolicyConfiguration.ChildLbStatusListener; import io.grpc.rls.LbPolicyConfiguration.ChildLoadBalancingPolicy; import io.grpc.rls.LbPolicyConfiguration.ChildPolicyWrapper; import io.grpc.rls.LbPolicyConfiguration.ChildPolicyWrapper.ChildPolicyReportingHelper; import io.grpc.rls.LbPolicyConfiguration.InvalidChildPolicyConfigException; import io.grpc.rls.LbPolicyConfiguration.RefCountedChildPolicyWrapperFactory; -import java.lang.Thread.UncaughtExceptionHandler; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -61,7 +61,9 @@ public class LbPolicyConfigurationTest { private final LoadBalancer lb = mock(LoadBalancer.class); private final SubchannelStateManager subchannelStateManager = new SubchannelStateManagerImpl(); private final SubchannelPicker picker = mock(SubchannelPicker.class); - private final ChildLbStatusListener childLbStatusListener = mock(ChildLbStatusListener.class); + private final SynchronizationContext syncContext = new SynchronizationContext((t, e) -> { + throw new AssertionError(e); + }); private final ResolvedAddressFactory resolvedAddressFactory = new ResolvedAddressFactory() { @Override @@ -78,21 +80,12 @@ public ResolvedAddresses create(Object childLbConfig) { ImmutableMap.of("foo", "bar"), lbProvider), resolvedAddressFactory, - new ChildLoadBalancerHelperProvider(helper, subchannelStateManager, picker), - childLbStatusListener); + new ChildLoadBalancerHelperProvider(helper, subchannelStateManager, picker)); @Before public void setUp() { doReturn(mock(ChannelLogger.class)).when(helper).getChannelLogger(); - doReturn( - new SynchronizationContext( - new UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - throw new AssertionError(e); - } - })) - .when(helper).getSynchronizationContext(); + doReturn(syncContext).when(helper).getSynchronizationContext(); doReturn(lb).when(lbProvider).newLoadBalancer(any(Helper.class)); doReturn(ConfigOrError.fromConfig(new Object())) .when(lbProvider).parseLoadBalancingPolicyConfig(ArgumentMatchers.>any()); @@ -185,9 +178,26 @@ public void updateBalancingState_triggersListener() { childPolicyReportingHelper.updateBalancingState(ConnectivityState.READY, childPicker); - verify(childLbStatusListener).onStatusChanged(ConnectivityState.READY); assertThat(childPolicyWrapper.getPicker()).isEqualTo(childPicker); // picker governs childPickers will be reported to parent LB verify(helper).updateBalancingState(ConnectivityState.READY, picker); } + + @Test + public void refCountedGetOrCreate_addsChildBeforeConfiguringChild() { + AtomicBoolean calledAlready = new AtomicBoolean(); + when(lb.acceptResolvedAddresses(any(ResolvedAddresses.class))).thenAnswer(i -> { + if (!calledAlready.get()) { + calledAlready.set(true); + // Should end up calling this function again, as this child should already be added to the + // list of children. In practice, this can be caused by CDS is_dynamic=true starting a watch + // when XdsClient already has the cluster cached (e.g., from another channel). + syncContext.execute(() -> + factory.acceptResolvedAddressFactory(resolvedAddressFactory)); + } + return Status.OK; + }); + ChildPolicyWrapper unused = factory.createOrGet("foo.google.com"); + verify(lb, times(2)).acceptResolvedAddresses(any(ResolvedAddresses.class)); + } } diff --git a/rls/src/test/java/io/grpc/rls/RlsLoadBalancerTest.java b/rls/src/test/java/io/grpc/rls/RlsLoadBalancerTest.java index 354466f3caf..a52390743a6 100644 --- a/rls/src/test/java/io/grpc/rls/RlsLoadBalancerTest.java +++ b/rls/src/test/java/io/grpc/rls/RlsLoadBalancerTest.java @@ -42,6 +42,7 @@ import io.grpc.ConnectivityStateInfo; import io.grpc.EquivalentAddressGroup; import io.grpc.ForwardingChannelBuilder2; +import io.grpc.Grpc; import io.grpc.InternalManagedChannelBuilder; import io.grpc.LoadBalancer.CreateSubchannelArgs; import io.grpc.LoadBalancer.Helper; @@ -72,7 +73,6 @@ import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.internal.FakeClock; import io.grpc.internal.JsonParser; -import io.grpc.internal.PickFirstLoadBalancerProvider; import io.grpc.internal.PickSubchannelArgsImpl; import io.grpc.internal.testing.StreamRecorder; import io.grpc.lookup.v1.RouteLookupServiceGrpc; @@ -166,15 +166,19 @@ public void setUp() { .build(); fakeRlsServerImpl.setLookupTable( ImmutableMap.of( - RouteLookupRequest.create(ImmutableMap.of( + RouteLookupRequest.create( + ImmutableMap.of( "server", "fake-bigtable.googleapis.com", "service-key", "com.google", - "method-key", "Search")), + "method-key", "Search"), + RouteLookupRequest.Reason.REASON_MISS), RouteLookupResponse.create(ImmutableList.of("wilderness"), "where are you?"), - RouteLookupRequest.create(ImmutableMap.of( + RouteLookupRequest.create( + ImmutableMap.of( "server", "fake-bigtable.googleapis.com", "service-key", "com.google", - "method-key", "Rescue")), + "method-key", "Rescue"), + RouteLookupRequest.Reason.REASON_MISS), RouteLookupResponse.create(ImmutableList.of("civilization"), "you are safe"))); rlsLb = (RlsLoadBalancer) provider.newLoadBalancer(helper); @@ -208,12 +212,14 @@ public void lb_serverStatusCodeConversion() throws Exception { throw new RuntimeException(e); } }); + assertThat(subchannels.poll()).isNotNull(); // default target + assertThat(subchannels.poll()).isNull(); + // Warm-up pick; will be queued InOrder inOrder = inOrder(helper); inOrder.verify(helper) .updateBalancingState(eq(ConnectivityState.CONNECTING), pickerCaptor.capture()); SubchannelPicker picker = pickerCaptor.getValue(); PickSubchannelArgs fakeSearchMethodArgs = newPickSubchannelArgs(fakeSearchMethod); - // Warm-up pick; will be queued PickResult res = picker.pickSubchannel(fakeSearchMethodArgs); assertThat(res.getStatus().isOk()).isTrue(); assertThat(res.getSubchannel()).isNull(); @@ -226,8 +232,7 @@ public void lb_serverStatusCodeConversion() throws Exception { subchannel.updateState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); res = picker.pickSubchannel(fakeSearchMethodArgs); assertThat(res.getStatus().getCode()).isEqualTo(Status.Code.OK); - int expectedTimes = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 1 : 2; - verifyLongCounterAdd("grpc.lb.rls.target_picks", expectedTimes, 1, "wilderness", "complete"); + verifyLongCounterAdd("grpc.lb.rls.target_picks", 1, 1, "wilderness", "complete"); // Check on conversion Throwable cause = new Throwable("cause"); @@ -280,8 +285,7 @@ public void lb_working_withDefaultTarget_rlsResponding() throws Exception { res = picker.pickSubchannel(searchSubchannelArgs); assertThat(subchannelIsReady(res.getSubchannel())).isTrue(); assertThat(res.getSubchannel()).isSameInstanceAs(searchSubchannel); - int expectedTimes = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 1 : 2; - verifyLongCounterAdd("grpc.lb.rls.target_picks", expectedTimes, 1, "wilderness", "complete"); + verifyLongCounterAdd("grpc.lb.rls.target_picks", 1, 1, "wilderness", "complete"); // rescue should be pending status although the overall channel state is READY res = picker.pickSubchannel(rescueSubchannelArgs); @@ -367,8 +371,10 @@ public void metricsWithRealChannel() throws Exception { .build()); StreamRecorder recorder = StreamRecorder.create(); + CallOptions callOptions = CallOptions.DEFAULT + .withOption(Grpc.CALL_OPTION_CUSTOM_LABEL, "customvalue"); StreamObserver requestObserver = ClientCalls.asyncClientStreamingCall( - channel.newCall(fakeSearchMethod, CallOptions.DEFAULT), recorder); + channel.newCall(fakeSearchMethod, callOptions), recorder); requestObserver.onCompleted(); assertThat(recorder.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); assertThat(recorder.getError()).isNull(); @@ -378,7 +384,7 @@ public void metricsWithRealChannel() throws Exception { eq(1L), eq(Arrays.asList("directaddress:///fake-bigtable.googleapis.com", "localhost:8972", "defaultTarget", "complete")), - eq(Arrays.asList())); + eq(Arrays.asList("customvalue"))); } @Test @@ -427,7 +433,7 @@ public void lb_working_withDefaultTarget_noRlsResponse() throws Exception { inOrder.verify(helper).getMetricRecorder(); inOrder.verify(helper).getChannelTarget(); inOrder.verifyNoMoreInteractions(); - int times = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 1 : 2; + int times = 1; verifyLongCounterAdd("grpc.lb.rls.default_target_picks", times, 1, "defaultTarget", "complete"); @@ -452,8 +458,7 @@ public void lb_working_withDefaultTarget_noRlsResponse() throws Exception { (FakeSubchannel) markReadyAndGetPickResult(inOrder, searchSubchannelArgs).getSubchannel(); assertThat(searchSubchannel).isNotNull(); assertThat(searchSubchannel).isNotSameInstanceAs(fallbackSubchannel); - times = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 1 : 2; - verifyLongCounterAdd("grpc.lb.rls.target_picks", times, 1, "wilderness", "complete"); + verifyLongCounterAdd("grpc.lb.rls.target_picks", 1, 1, "wilderness", "complete"); // create rescue subchannel picker.pickSubchannel(rescueSubchannelArgs); @@ -533,8 +538,7 @@ public void lb_working_withoutDefaultTarget() throws Exception { res = picker.pickSubchannel(newPickSubchannelArgs(fakeSearchMethod)); assertThat(res.getStatus().isOk()).isFalse(); assertThat(subchannelIsReady(res.getSubchannel())).isFalse(); - int expectedTimes = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 1 : 2; - verifyLongCounterAdd("grpc.lb.rls.target_picks", expectedTimes, 1, "wilderness", "complete"); + verifyLongCounterAdd("grpc.lb.rls.target_picks", 1, 1, "wilderness", "complete"); res = picker.pickSubchannel(newPickSubchannelArgs(fakeRescueMethod)); assertThat(subchannelIsReady(res.getSubchannel())).isTrue(); @@ -684,7 +688,7 @@ private void verifyLongCounterAdd(String name, int times, long value, verify(mockMetricRecorder, times(times)).addLongCounter( eqMetricInstrumentName(name), eq(value), eq(Lists.newArrayList(channelTarget, "localhost:8972", dataPlaneTargetLabel, pickResult)), - eq(Lists.newArrayList())); + eq(Lists.newArrayList(""))); } // This one is for verifying the failed_pick metric specifically. @@ -693,7 +697,7 @@ private void verifyFailedPicksCounterAdd(int times, long value) { verify(mockMetricRecorder, times(times)).addLongCounter( eqMetricInstrumentName("grpc.lb.rls.failed_picks"), eq(value), eq(Lists.newArrayList(channelTarget, "localhost:8972")), - eq(Lists.newArrayList())); + eq(Lists.newArrayList(""))); } @SuppressWarnings("TypeParameterUnusedInFormals") diff --git a/rls/src/test/java/io/grpc/rls/RlsProtoConvertersTest.java b/rls/src/test/java/io/grpc/rls/RlsProtoConvertersTest.java index fc5fdb59f21..82ad606c50d 100644 --- a/rls/src/test/java/io/grpc/rls/RlsProtoConvertersTest.java +++ b/rls/src/test/java/io/grpc/rls/RlsProtoConvertersTest.java @@ -61,12 +61,14 @@ public void convert_toRequestObject() { Converter converter = new RouteLookupRequestConverter().reverse(); RlsProtoData.RouteLookupRequest requestObject = - RlsProtoData.RouteLookupRequest.create(ImmutableMap.of("key1", "val1")); + RlsProtoData.RouteLookupRequest.create(ImmutableMap.of("key1", "val1"), + RlsProtoData.RouteLookupRequest.Reason.REASON_MISS); RouteLookupRequest proto = converter.convert(requestObject); assertThat(proto.getTargetType()).isEqualTo("grpc"); assertThat(proto.getKeyMapMap()).containsExactly("key1", "val1"); + assertThat(proto.getReason()).isEqualTo(RouteLookupRequest.Reason.REASON_MISS); } @Test @@ -528,6 +530,65 @@ public void convert_jsonRlsConfig_doNotClampMaxAgeIfStaleAgeIsSet() throws IOExc assertThat(converted).isEqualTo(expectedConfig); } + @Test + public void convert_jsonRlsConfig_clampMaxAgeIfStaleAgeMissing() throws IOException { + String jsonStr = "{\n" + + " \"grpcKeybuilders\": [\n" + + " {\n" + + " \"names\": [\n" + + " {\n" + + " \"service\": \"service1\",\n" + + " \"method\": \"create\"\n" + + " }\n" + + " ],\n" + + " \"headers\": [\n" + + " {\n" + + " \"key\": \"user\"," + + " \"names\": [\"User\", \"Parent\"],\n" + + " \"optional\": true\n" + + " },\n" + + " {\n" + + " \"key\": \"id\"," + + " \"names\": [\"X-Google-Id\"],\n" + + " \"optional\": true\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"lookupService\": \"service1\",\n" + + " \"lookupServiceTimeout\": \"2s\",\n" + + " \"maxAge\": \"350s\",\n" // Exceeds 5m limit + + " \"validTargets\": [\"a valid target\"]," + + " \"cacheSizeBytes\": \"1000\",\n" + + " \"defaultTarget\": \"us_east_1.cloudbigtable.googleapis.com\"\n" + + "}"; + + RouteLookupConfig expectedConfig = + RouteLookupConfig.builder() + .grpcKeybuilders(ImmutableList.of( + GrpcKeyBuilder.create( + ImmutableList.of(Name.create("service1", "create")), + ImmutableList.of( + NameMatcher.create("user", ImmutableList.of("User", "Parent")), + NameMatcher.create("id", ImmutableList.of("X-Google-Id"))), + ExtraKeys.DEFAULT, + ImmutableMap.of()))) + .lookupService("service1") + .lookupServiceTimeoutInNanos(TimeUnit.SECONDS.toNanos(2)) + // Should be clamped to 300s (5m) because staleAge is missing + .maxAgeInNanos(TimeUnit.MINUTES.toNanos(5)) + .staleAgeInNanos(TimeUnit.MINUTES.toNanos(5)) + .cacheSizeBytes(1000) + .defaultTarget("us_east_1.cloudbigtable.googleapis.com") + .build(); + + RouteLookupConfigConverter converter = new RouteLookupConfigConverter(); + @SuppressWarnings("unchecked") + Map parsedJson = (Map) JsonParser.parse(jsonStr); + RouteLookupConfig converted = converter.convert(parsedJson); + assertThat(converted).isEqualTo(expectedConfig); + } + @Test public void convert_jsonRlsConfig_keyBuilderWithoutName() throws IOException { String jsonStr = "{\n" diff --git a/rls/src/test/java/io/grpc/rls/RlsRequestFactoryTest.java b/rls/src/test/java/io/grpc/rls/RlsRequestFactoryTest.java index 6ee2c01af8a..2b900994ed9 100644 --- a/rls/src/test/java/io/grpc/rls/RlsRequestFactoryTest.java +++ b/rls/src/test/java/io/grpc/rls/RlsRequestFactoryTest.java @@ -26,7 +26,6 @@ import io.grpc.rls.RlsProtoData.GrpcKeyBuilder.Name; import io.grpc.rls.RlsProtoData.NameMatcher; import io.grpc.rls.RlsProtoData.RouteLookupConfig; -import io.grpc.rls.RlsProtoData.RouteLookupRequest; import java.util.concurrent.TimeUnit; import org.junit.Test; import org.junit.runner.RunWith; @@ -82,8 +81,9 @@ public void create_pathMatches() { metadata.put(Metadata.Key.of("X-Google-Id", Metadata.ASCII_STRING_MARSHALLER), "123"); metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); - RouteLookupRequest request = factory.create("com.google.service1", "Create", metadata); - assertThat(request.keyMap()).containsExactly( + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + factory.create("com.google.service1", "Create", metadata); + assertThat(routeLookupRequestKey.keyMap()).containsExactly( "user", "test", "id", "123", "server-1", "bigtable.googleapis.com", @@ -97,9 +97,10 @@ public void create_pathFallbackMatches() { metadata.put(Metadata.Key.of("Password", Metadata.ASCII_STRING_MARSHALLER), "hunter2"); metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); - RouteLookupRequest request = factory.create("com.google.service1" , "Update", metadata); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + factory.create("com.google.service1" , "Update", metadata); - assertThat(request.keyMap()).containsExactly( + assertThat(routeLookupRequestKey.keyMap()).containsExactly( "user", "test", "password", "hunter2", "service-2", "com.google.service1", @@ -113,9 +114,10 @@ public void create_pathFallbackMatches_optionalHeaderMissing() { metadata.put(Metadata.Key.of("X-Google-Id", Metadata.ASCII_STRING_MARSHALLER), "123"); metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); - RouteLookupRequest request = factory.create("com.google.service1", "Update", metadata); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + factory.create("com.google.service1", "Update", metadata); - assertThat(request.keyMap()).containsExactly( + assertThat(routeLookupRequestKey.keyMap()).containsExactly( "user", "test", "service-2", "com.google.service1", "const-key-2", "const-value-2"); @@ -128,8 +130,9 @@ public void create_unknownPath() { metadata.put(Metadata.Key.of("X-Google-Id", Metadata.ASCII_STRING_MARSHALLER), "123"); metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); - RouteLookupRequest request = factory.create("abc.def.service999", "Update", metadata); - assertThat(request.keyMap()).isEmpty(); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + factory.create("abc.def.service999", "Update", metadata); + assertThat(routeLookupRequestKey.keyMap()).isEmpty(); } @Test @@ -139,9 +142,10 @@ public void create_noMethodInRlsConfig() { metadata.put(Metadata.Key.of("X-Google-Id", Metadata.ASCII_STRING_MARSHALLER), "123"); metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); - RouteLookupRequest request = factory.create("com.google.service3", "Update", metadata); + RlsProtoData.RouteLookupRequestKey routeLookupRequestKey = + factory.create("com.google.service3", "Update", metadata); - assertThat(request.keyMap()).containsExactly( + assertThat(routeLookupRequestKey.keyMap()).containsExactly( "user", "test", "const-key-4", "const-value-4"); } } diff --git a/s2a/BUILD.bazel b/s2a/BUILD.bazel index 6f58670534b..34387206ba5 100644 --- a/s2a/BUILD.bazel +++ b/s2a/BUILD.bazel @@ -56,8 +56,8 @@ java_library( "src/main/java/io/grpc/s2a/internal/handshaker/SslContextFactory.java", ], deps = [ - ":token_manager", ":s2a_identity", + ":token_manager", "//api", "//core:internal", "//netty", @@ -87,7 +87,7 @@ java_library( "//netty", artifact("com.google.code.findbugs:jsr305"), artifact("com.google.errorprone:error_prone_annotations"), - artifact("com.google.guava:guava"), - artifact("org.checkerframework:checker-qual"), + artifact("com.google.guava:guava"), + artifact("org.checkerframework:checker-qual"), ], -) \ No newline at end of file +) diff --git a/s2a/src/main/java/io/grpc/s2a/internal/channel/S2AHandshakerServiceChannel.java b/s2a/src/main/java/io/grpc/s2a/internal/channel/S2AHandshakerServiceChannel.java index b1ba88d1886..8453268efc0 100644 --- a/s2a/src/main/java/io/grpc/s2a/internal/channel/S2AHandshakerServiceChannel.java +++ b/s2a/src/main/java/io/grpc/s2a/internal/channel/S2AHandshakerServiceChannel.java @@ -24,9 +24,6 @@ import io.grpc.ManagedChannel; import io.grpc.internal.SharedResourceHolder.Resource; import io.grpc.netty.NettyChannelBuilder; -import java.time.Duration; -import java.util.logging.Level; -import java.util.logging.Logger; import javax.annotation.concurrent.ThreadSafe; /** @@ -50,9 +47,6 @@ */ @ThreadSafe public final class S2AHandshakerServiceChannel { - private static final Duration CHANNEL_SHUTDOWN_TIMEOUT = Duration.ofSeconds(10); - private static final Logger logger = - Logger.getLogger(S2AHandshakerServiceChannel.class.getName()); /** * Returns a {@link SharedResourceHolder.Resource} instance for managing channels to an S2A server @@ -101,13 +95,6 @@ public void close(Channel instanceChannel) { checkNotNull(instanceChannel); ManagedChannel channel = (ManagedChannel) instanceChannel; channel.shutdownNow(); - try { - channel.awaitTermination(CHANNEL_SHUTDOWN_TIMEOUT.getSeconds(), SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.log(Level.WARNING, "Channel to S2A was not shutdown."); - } - } @Override diff --git a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AProtocolNegotiatorFactory.java b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AProtocolNegotiatorFactory.java index 03976cc7d7b..9dcbdcf0509 100644 --- a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AProtocolNegotiatorFactory.java +++ b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AProtocolNegotiatorFactory.java @@ -38,7 +38,6 @@ import io.grpc.netty.InternalProtocolNegotiator.ProtocolNegotiator; import io.grpc.netty.InternalProtocolNegotiators; import io.grpc.netty.InternalProtocolNegotiators.ProtocolNegotiationHandler; -import io.grpc.s2a.internal.handshaker.S2AIdentity; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerAdapter; import io.netty.channel.ChannelHandlerContext; @@ -152,7 +151,7 @@ public ChannelHandler newHandler(GrpcHttp2ConnectionHandler grpcHandler) { String hostname = getHostNameFromAuthority(grpcHandler.getAuthority()); checkArgument(!isNullOrEmpty(hostname), "hostname should not be null or empty."); return new S2AProtocolNegotiationHandler( - grpcHandler, channel, localIdentity, hostname, service, stub); + grpcHandler, channel, localIdentity, hostname, service, stub); } @Override @@ -259,7 +258,8 @@ public void onSuccess(SslContext sslContext) { public void run() { s2aStub.close(); } - })) + }), + null, null) .newHandler(grpcHandler); // Delegate the rest of the handshake to the TLS handler. and remove the diff --git a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AStub.java b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AStub.java index fe2ec388fe4..37236f26f4b 100644 --- a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AStub.java +++ b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2AStub.java @@ -43,6 +43,7 @@ public class S2AStub implements AutoCloseable { private final BlockingQueue responses = new ArrayBlockingQueue<>(10); private S2AServiceGrpc.S2AServiceStub serviceStub; private StreamObserver writer; + private long deadlineSeconds = HANDSHAKE_RPC_DEADLINE_SECS; private boolean doneReading = false; private boolean doneWriting = false; private boolean isClosed = false; @@ -53,6 +54,14 @@ public static S2AStub newInstance(S2AServiceGrpc.S2AServiceStub serviceStub) { return new S2AStub(serviceStub); } + @VisibleForTesting + static S2AStub newInstanceWithDeadline( + S2AServiceGrpc.S2AServiceStub serviceStub, long deadlineSeconds) { + checkNotNull(serviceStub); + checkArgument(deadlineSeconds > 0); + return new S2AStub(serviceStub, deadlineSeconds); + } + @VisibleForTesting static S2AStub newInstanceForTesting(StreamObserver writer) { checkNotNull(writer); @@ -63,6 +72,11 @@ private S2AStub(S2AServiceGrpc.S2AServiceStub serviceStub) { this.serviceStub = serviceStub; } + private S2AStub(S2AServiceGrpc.S2AServiceStub serviceStub, long deadlineSeconds) { + this.serviceStub = serviceStub; + this.deadlineSeconds = deadlineSeconds; + } + private S2AStub(StreamObserver writer) { this.writer = writer; } @@ -154,7 +168,7 @@ private void createWriterIfNull() { writer = serviceStub .withWaitForReady() - .withDeadlineAfter(HANDSHAKE_RPC_DEADLINE_SECS, SECONDS) + .withDeadlineAfter(deadlineSeconds, SECONDS) .setUpSession(reader); } } diff --git a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2ATrustManager.java b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2ATrustManager.java index a7ffafd01f2..6f329945c20 100644 --- a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2ATrustManager.java +++ b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/S2ATrustManager.java @@ -28,17 +28,19 @@ import com.google.s2a.proto.v2.ValidatePeerCertificateChainResp; import io.grpc.s2a.internal.handshaker.S2AIdentity; import java.io.IOException; +import java.net.Socket; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Optional; import javax.annotation.concurrent.NotThreadSafe; -import javax.net.ssl.X509TrustManager; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedTrustManager; import org.checkerframework.checker.nullness.qual.Nullable; /** Offloads verification of the peer certificate chain to S2A. */ @NotThreadSafe -final class S2ATrustManager implements X509TrustManager { +final class S2ATrustManager extends X509ExtendedTrustManager { private final Optional localIdentity; private final S2AStub stub; private final String hostname; @@ -71,6 +73,18 @@ public void checkClientTrusted(X509Certificate[] chain, String authType) checkPeerTrusted(chain, /* isCheckingClientCertificateChain= */ true); } + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + checkClientTrusted(chain, authType); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + checkClientTrusted(chain, authType); + } + /** * Validates the given certificate chain provided by the peer. * @@ -86,6 +100,18 @@ public void checkServerTrusted(X509Certificate[] chain, String authType) checkPeerTrusted(chain, /* isCheckingClientCertificateChain= */ false); } + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) + throws CertificateException { + checkServerTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) + throws CertificateException { + checkServerTrusted(chain, authType); + } + /** * Returns null because the accepted issuers are held in S2A and this class receives decision made * from S2A on the fly about which to use to verify a given chain. @@ -156,4 +182,4 @@ private static ImmutableList certificateChainToDerChain(X509Certific } return derChain.build(); } -} \ No newline at end of file +} diff --git a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/SslContextFactory.java b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/SslContextFactory.java index 2dfde16cf2f..5d4ef9eb667 100644 --- a/s2a/src/main/java/io/grpc/s2a/internal/handshaker/SslContextFactory.java +++ b/s2a/src/main/java/io/grpc/s2a/internal/handshaker/SslContextFactory.java @@ -150,8 +150,8 @@ private static void configureSslContextWithClientTlsConfiguration( ProtoUtil.buildTlsProtocolVersionSet( clientTlsConfiguration.getMinTlsVersion(), clientTlsConfiguration.getMaxTlsVersion()); if (tlsVersions.isEmpty()) { - throw new S2AConnectionException("Set of TLS versions received from S2A server is" - + " empty or not supported."); + throw new S2AConnectionException( + "Set of TLS versions received from S2A server is empty or not supported."); } sslContextBuilder.protocols(tlsVersions); } @@ -184,4 +184,4 @@ private static X509Certificate convertStringToX509Cert(String certificate) } private SslContextFactory() {} -} \ No newline at end of file +} diff --git a/s2a/src/test/java/io/grpc/s2a/internal/handshaker/FakeS2AServerTest.java b/s2a/src/test/java/io/grpc/s2a/internal/handshaker/FakeS2AServerTest.java index d8374a8a382..c3155b864b3 100644 --- a/s2a/src/test/java/io/grpc/s2a/internal/handshaker/FakeS2AServerTest.java +++ b/s2a/src/test/java/io/grpc/s2a/internal/handshaker/FakeS2AServerTest.java @@ -45,6 +45,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; import java.util.logging.Logger; import org.junit.After; import org.junit.Before; @@ -77,7 +78,10 @@ public void tearDown() throws Exception { @Test public void callS2AServerOnce_getTlsConfiguration_returnsValidResult() - throws InterruptedException, IOException, java.util.concurrent.ExecutionException { + throws InterruptedException, + IOException, + java.util.concurrent.ExecutionException, + TimeoutException { ExecutorService executor = Executors.newSingleThreadExecutor(); logger.info("Client connecting to: " + serverAddress); ManagedChannel channel = @@ -121,6 +125,7 @@ public void onCompleted() { // Mark the end of requests. requestObserver.onCompleted(); // Wait for receiving to happen. + respFuture.get(5, SECONDS); } finally { channel.shutdown(); channel.awaitTermination(1, SECONDS); @@ -165,7 +170,7 @@ public void onCompleted() { @Test public void callS2AServerOnce_validatePeerCertifiate_returnsValidResult() - throws InterruptedException, java.util.concurrent.ExecutionException { + throws InterruptedException, java.util.concurrent.ExecutionException, TimeoutException { ExecutorService executor = Executors.newSingleThreadExecutor(); logger.info("Client connecting to: " + serverAddress); ManagedChannel channel = @@ -212,6 +217,7 @@ public void onCompleted() { // Mark the end of requests. requestObserver.onCompleted(); // Wait for receiving to happen. + respFuture.get(5, SECONDS); } finally { channel.shutdown(); channel.awaitTermination(1, SECONDS); diff --git a/s2a/src/test/java/io/grpc/s2a/internal/handshaker/S2AStubTest.java b/s2a/src/test/java/io/grpc/s2a/internal/handshaker/S2AStubTest.java index 713984c361d..2c7a7dd8405 100644 --- a/s2a/src/test/java/io/grpc/s2a/internal/handshaker/S2AStubTest.java +++ b/s2a/src/test/java/io/grpc/s2a/internal/handshaker/S2AStubTest.java @@ -61,17 +61,17 @@ public void setUp() { @Test public void send_receiveOkStatus() throws Exception { - ObjectPool channelPool = - SharedResourcePool.forResource( - S2AHandshakerServiceChannel.getChannelResource( - S2A_ADDRESS, InsecureChannelCredentials.create())); - S2AServiceGrpc.S2AServiceStub serviceStub = S2AServiceGrpc.newStub(channelPool.getObject()); - S2AStub newStub = S2AStub.newInstance(serviceStub); + SessionReq req = + SessionReq.newBuilder() + .setGetTlsConfigurationReq( + GetTlsConfigurationReq.newBuilder() + .setConnectionSide(ConnectionSide.CONNECTION_SIDE_CLIENT)) + .build(); - IOException expected = - assertThrows(IOException.class, () -> newStub.send(SessionReq.getDefaultInstance())); + SessionResp resp = stub.send(req); - assertThat(expected).hasMessageThat().contains("DEADLINE_EXCEEDED"); + assertThat(resp.hasGetTlsConfigurationResp()).isTrue(); + assertThat(resp.getGetTlsConfigurationResp().hasClientTlsConfiguration()).isTrue(); } @Test @@ -233,6 +233,21 @@ public void send_afterEarlyClose_receivesClosedException() throws InterruptedExc assertThat(expected).hasMessageThat().contains("Stream to the S2A is closed."); } + @Test + public void send_withUnavailableService_throwsDeadlineExceeded() throws Exception { + ObjectPool channelPool = + SharedResourcePool.forResource( + S2AHandshakerServiceChannel.getChannelResource( + S2A_ADDRESS, InsecureChannelCredentials.create())); + S2AServiceGrpc.S2AServiceStub serviceStub = S2AServiceGrpc.newStub(channelPool.getObject()); + S2AStub newStub = S2AStub.newInstanceWithDeadline(serviceStub, 1); + + IOException expected = + assertThrows(IOException.class, () -> newStub.send(SessionReq.getDefaultInstance())); + + assertThat(expected).hasMessageThat().contains("DEADLINE_EXCEEDED"); + } + @Test public void send_failToWrite() throws Exception { FailWriter failWriter = new FailWriter(); diff --git a/services/src/main/java/io/grpc/protobuf/services/HealthCheckingLoadBalancerFactory.java b/services/src/main/java/io/grpc/protobuf/services/HealthCheckingLoadBalancerFactory.java index 8cf1458f5dc..b9f235d0aff 100644 --- a/services/src/main/java/io/grpc/protobuf/services/HealthCheckingLoadBalancerFactory.java +++ b/services/src/main/java/io/grpc/protobuf/services/HealthCheckingLoadBalancerFactory.java @@ -144,6 +144,30 @@ void setHealthCheckedService(@Nullable String service) { public String toString() { return MoreObjects.toStringHelper(this).add("delegate", delegate()).toString(); } + + @Override + public void updateBalancingState( + io.grpc.ConnectivityState newState, LoadBalancer.SubchannelPicker newPicker) { + delegate().updateBalancingState(newState, new HealthCheckPicker(newPicker)); + } + + private final class HealthCheckPicker extends LoadBalancer.SubchannelPicker { + private final LoadBalancer.SubchannelPicker delegate; + + HealthCheckPicker(LoadBalancer.SubchannelPicker delegate) { + this.delegate = delegate; + } + + @Override + public LoadBalancer.PickResult pickSubchannel(LoadBalancer.PickSubchannelArgs args) { + LoadBalancer.PickResult result = delegate.pickSubchannel(args); + LoadBalancer.Subchannel subchannel = result.getSubchannel(); + if (subchannel instanceof SubchannelImpl) { + return result.copyWithSubchannel(((SubchannelImpl) subchannel).delegate()); + } + return result; + } + } } @VisibleForTesting diff --git a/services/src/main/proto/grpc/binlog/v1/binarylog.proto b/services/src/main/proto/grpc/binlog/v1/binarylog.proto index 9ed1733e2d8..b18bd88ddc9 100644 --- a/services/src/main/proto/grpc/binlog/v1/binarylog.proto +++ b/services/src/main/proto/grpc/binlog/v1/binarylog.proto @@ -120,7 +120,7 @@ message ClientHeader { // A single process may be used to run multiple virtual // servers with different identities. - // The authority is the name of such a server identitiy. + // The authority is the name of such a server identity. // It is typically a portion of the URI in the form of // or : . string authority = 3; diff --git a/services/src/main/proto/grpc/reflection/v1alpha/reflection.proto b/services/src/main/proto/grpc/reflection/v1alpha/reflection.proto index 8c5e06fe148..a3984b55c2d 100644 --- a/services/src/main/proto/grpc/reflection/v1alpha/reflection.proto +++ b/services/src/main/proto/grpc/reflection/v1alpha/reflection.proto @@ -80,7 +80,7 @@ message ExtensionRequest { message ServerReflectionResponse { string valid_host = 1; ServerReflectionRequest original_request = 2; - // The server set one of the following fields accroding to the message_request + // The server set one of the following fields according to the message_request // in the request. oneof message_response { // This message is used to answer file_by_filename, file_containing_symbol, @@ -91,7 +91,7 @@ message ServerReflectionResponse { // that were previously sent in response to earlier requests in the stream. FileDescriptorResponse file_descriptor_response = 4; - // This message is used to answer all_extension_numbers_of_type requst. + // This message is used to answer all_extension_numbers_of_type request. ExtensionNumberResponse all_extension_numbers_response = 5; // This message is used to answer list_services request. diff --git a/servlet/jakarta/build.gradle b/servlet/jakarta/build.gradle index bcd904ccaee..5cd213949f4 100644 --- a/servlet/jakarta/build.gradle +++ b/servlet/jakarta/build.gradle @@ -122,6 +122,11 @@ if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) { tasks.named("check").configure { dependsOn jetty11Test } + tasks.named("jacocoTestReport").configure { + // Must use executionData(Task...) override. The executionData(Object...) override doesn't + // find execution data correctly for tasks. + executionData jetty11Test.get() + } } if (JavaVersion.current().isJava11Compatible()) { def tomcat10Test = tasks.register('tomcat10Test', Test) { @@ -150,4 +155,9 @@ if (JavaVersion.current().isJava11Compatible()) { tasks.named("check").configure { dependsOn tomcat10Test, undertowTest } + tasks.named("jacocoTestReport").configure { + // Must use executionData(Task...) override. The executionData(Object...) override doesn't + // find execution data correctly for tasks. + executionData tomcat10Test.get(), undertowTest.get() + } } diff --git a/servlet/src/jettyTest/java/io/grpc/servlet/JettyTransportTest.java b/servlet/src/jettyTest/java/io/grpc/servlet/JettyTransportTest.java index c896c7a23ea..58143a8516c 100644 --- a/servlet/src/jettyTest/java/io/grpc/servlet/JettyTransportTest.java +++ b/servlet/src/jettyTest/java/io/grpc/servlet/JettyTransportTest.java @@ -77,9 +77,7 @@ public void start(ServerListener listener) throws IOException { ServerConnector sc = (ServerConnector) jettyServer.getConnectors()[0]; HttpConfiguration httpConfiguration = new HttpConfiguration(); - // Must be set for several tests to pass, so that the request handling can begin before - // content arrives. - httpConfiguration.setDelayDispatchUntilContent(false); + setDelayDispatchUntilContent(httpConfiguration); HTTP2CServerConnectionFactory factory = new HTTP2CServerConnectionFactory(httpConfiguration); @@ -135,6 +133,16 @@ protected InternalServer newServer(int port, return newServer(streamTracerFactories); } + // The future default appears to be false as people are supposed to be migrate to + // EagerContentHandler, but the default is still true. Seems they messed up the migration + // process here by not flipping the default. + @SuppressWarnings("removal") + private static void setDelayDispatchUntilContent(HttpConfiguration httpConfiguration) { + // Must be set for several tests to pass, so that the request handling can begin before + // content arrives. + httpConfiguration.setDelayDispatchUntilContent(false); + } + @Override protected ManagedClientTransport newClientTransport(InternalServer server) { NettyChannelBuilder nettyChannelBuilder = NettyChannelBuilder diff --git a/servlet/src/main/java/io/grpc/servlet/ServletServerBuilder.java b/servlet/src/main/java/io/grpc/servlet/ServletServerBuilder.java index aee25de01ad..5bea4c6e03b 100644 --- a/servlet/src/main/java/io/grpc/servlet/ServletServerBuilder.java +++ b/servlet/src/main/java/io/grpc/servlet/ServletServerBuilder.java @@ -78,7 +78,9 @@ public final class ServletServerBuilder extends ForwardingServerBuilder + buildTransportServers(streamTracerFactories)); } /** diff --git a/servlet/src/threadingTest/java/io/grpc/servlet/AsyncServletOutputStreamWriterConcurrencyTest.java b/servlet/src/threadingTest/java/io/grpc/servlet/AsyncServletOutputStreamWriterConcurrencyTest.java index ebe1df8c3f3..b2891b6e47e 100644 --- a/servlet/src/threadingTest/java/io/grpc/servlet/AsyncServletOutputStreamWriterConcurrencyTest.java +++ b/servlet/src/threadingTest/java/io/grpc/servlet/AsyncServletOutputStreamWriterConcurrencyTest.java @@ -28,7 +28,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; -import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.ModelCheckingCTest; import org.jetbrains.lincheck.datastructures.BooleanGen; import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; import org.jetbrains.lincheck.datastructures.Operation; @@ -49,7 +48,6 @@ * test all possibly interleaves (on context switch) between the two threads, and then verify the * operations are linearizable in each interleave scenario. */ -@ModelCheckingCTest @Param(name = "keepReady", gen = BooleanGen.class) @RunWith(JUnit4.class) public class AsyncServletOutputStreamWriterConcurrencyTest { diff --git a/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatInteropTest.java b/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatInteropTest.java index 1422b5388fd..d072fea93a1 100644 --- a/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatInteropTest.java +++ b/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatInteropTest.java @@ -113,27 +113,28 @@ protected boolean metricsExpected() { @Test public void gracefulShutdown() {} - // FIXME @Override @Ignore("Tomcat is not able to send trailer only") @Test public void specialStatusMessage() {} - // FIXME @Override @Ignore("Tomcat is not able to send trailer only") @Test public void unimplementedMethod() {} - // FIXME @Override @Ignore("Tomcat is not able to send trailer only") @Test public void statusCodeAndMessage() {} - // FIXME @Override @Ignore("Tomcat is not able to send trailer only") @Test public void emptyStream() {} + + @Override + @Ignore("Tomcat is not able to send trailer only") + @Test + public void timeoutOnSleepingServer() {} } diff --git a/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatTransportTest.java b/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatTransportTest.java index 2171c6eb2df..cd73b096ccb 100644 --- a/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatTransportTest.java +++ b/servlet/src/tomcatTest/java/io/grpc/servlet/TomcatTransportTest.java @@ -93,6 +93,10 @@ public void start(ServerListener listener) throws IOException { .setAsyncSupported(true); ctx.addServletMappingDecoded("/*", "TomcatTransportTest"); tomcatServer.getConnector().addUpgradeProtocol(new Http2Protocol()); + // Workaround for https://github.com/grpc/grpc-java/issues/12540 + // Prevent premature OutputBuffer recycling by disabling facade recycling. + // This should be revisited once the root cause is fixed. + tomcatServer.getConnector().setDiscardFacades(false); try { tomcatServer.start(); } catch (LifecycleException e) { diff --git a/servlet/src/undertowTest/java/io/grpc/servlet/UndertowInteropTest.java b/servlet/src/undertowTest/java/io/grpc/servlet/UndertowInteropTest.java index b8be018664d..21b9c29225d 100644 --- a/servlet/src/undertowTest/java/io/grpc/servlet/UndertowInteropTest.java +++ b/servlet/src/undertowTest/java/io/grpc/servlet/UndertowInteropTest.java @@ -47,6 +47,7 @@ public class UndertowInteropTest extends AbstractInteropTest { private static final String HOST = "localhost"; private static final String MYAPP = "/grpc.testing.TestService"; + private static final long MAX_ENTITY_SIZE = AbstractInteropTest.MAX_MESSAGE_SIZE; private int port; private Undertow server; private DeploymentManager manager; @@ -101,6 +102,7 @@ protected void startServer(ServerBuilder builder) { .addPrefixPath("/", servletHandler); // for unimplementedService test server = Undertow.builder() .setServerOption(UndertowOptions.ENABLE_HTTP2, true) + .setServerOption(UndertowOptions.MAX_ENTITY_SIZE, MAX_ENTITY_SIZE) .setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 5000 /* 5 sec */) .addHttpListener(0, HOST) .setHandler(path) diff --git a/servlet/src/undertowTest/java/io/grpc/servlet/UndertowTransportTest.java b/servlet/src/undertowTest/java/io/grpc/servlet/UndertowTransportTest.java index ef897c87d70..825fd9563d3 100644 --- a/servlet/src/undertowTest/java/io/grpc/servlet/UndertowTransportTest.java +++ b/servlet/src/undertowTest/java/io/grpc/servlet/UndertowTransportTest.java @@ -63,6 +63,7 @@ public class UndertowTransportTest extends AbstractTransportTest { private static final String HOST = "localhost"; private static final String MYAPP = "/service"; + private static final long MAX_ENTITY_SIZE = Integer.MAX_VALUE; private final FakeClock fakeClock = new FakeClock(); @@ -131,6 +132,7 @@ public void start(ServerListener listener) throws IOException { undertowServer = Undertow.builder() .setServerOption(UndertowOptions.ENABLE_HTTP2, true) + .setServerOption(UndertowOptions.MAX_ENTITY_SIZE, MAX_ENTITY_SIZE) .setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 5000 /* 5 sec */) .addHttpListener(0, HOST) .setHandler(path) diff --git a/settings.gradle b/settings.gradle index f4df1105090..768835c8c55 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,12 +20,13 @@ pluginManagement { // https://github.com/kt3k/coveralls-gradle-plugin/tags id "com.github.kt3k.coveralls" version "2.12.2" // https://github.com/GoogleCloudPlatform/appengine-plugins/releases - id "com.google.cloud.tools.appengine" version "2.8.0" + id "com.google.cloud.tools.appengine" version "2.8.7" // https://github.com/GoogleContainerTools/jib/blob/master/jib-gradle-plugin/CHANGELOG.md - id "com.google.cloud.tools.jib" version "3.4.5" + id "com.google.cloud.tools.jib" version "3.5.3" // https://github.com/google/osdetector-gradle-plugin/tags id "com.google.osdetector" version "1.7.3" // https://github.com/google/protobuf-gradle-plugin/releases + // 0.10+ requires Java 11+ id "com.google.protobuf" version "0.9.5" // https://github.com/GradleUp/shadow/releases // 8.3.2+ requires Java 11+ @@ -36,7 +37,8 @@ pluginManagement { // https://github.com/melix/jmh-gradle-plugin/releases id "me.champeau.jmh" version "0.7.3" // https://github.com/tbroyer/gradle-errorprone-plugin/releases - id "net.ltgt.errorprone" version "4.3.0" + // 5+ requires Java 11+ + id "net.ltgt.errorprone" version "4.4.0" // https://github.com/xvik/gradle-animalsniffer-plugin/releases id "ru.vyarus.animalsniffer" version "2.0.1" } diff --git a/stub/src/main/java/io/grpc/stub/AbstractAsyncStub.java b/stub/src/main/java/io/grpc/stub/AbstractAsyncStub.java index f369eeaf87f..041f9ed08ed 100644 --- a/stub/src/main/java/io/grpc/stub/AbstractAsyncStub.java +++ b/stub/src/main/java/io/grpc/stub/AbstractAsyncStub.java @@ -20,7 +20,6 @@ import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.stub.ClientCalls.StubType; -import javax.annotation.concurrent.ThreadSafe; /** * Stub implementations for async stubs. @@ -28,9 +27,10 @@ *

DO NOT MOCK: Customizing options doesn't work properly in mocks. Use InProcessChannelBuilder * to create a real channel suitable for testing. It is also possible to mock Channel instead. * + *

This class is thread-safe. + * * @since 1.26.0 */ -@ThreadSafe @CheckReturnValue public abstract class AbstractAsyncStub> extends AbstractStub { diff --git a/stub/src/main/java/io/grpc/stub/AbstractBlockingStub.java b/stub/src/main/java/io/grpc/stub/AbstractBlockingStub.java index 4bdb3c0bb94..49ecd1fca40 100644 --- a/stub/src/main/java/io/grpc/stub/AbstractBlockingStub.java +++ b/stub/src/main/java/io/grpc/stub/AbstractBlockingStub.java @@ -20,7 +20,6 @@ import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.stub.ClientCalls.StubType; -import javax.annotation.concurrent.ThreadSafe; /** * Stub implementations for blocking stubs. @@ -28,9 +27,10 @@ *

DO NOT MOCK: Customizing options doesn't work properly in mocks. Use InProcessChannelBuilder * to create a real channel suitable for testing. It is also possible to mock Channel instead. * + *

This class is thread-safe. + * * @since 1.26.0 */ -@ThreadSafe @CheckReturnValue public abstract class AbstractBlockingStub> extends AbstractStub { diff --git a/stub/src/main/java/io/grpc/stub/AbstractFutureStub.java b/stub/src/main/java/io/grpc/stub/AbstractFutureStub.java index 5e37b1e4915..4aede0dcbbe 100644 --- a/stub/src/main/java/io/grpc/stub/AbstractFutureStub.java +++ b/stub/src/main/java/io/grpc/stub/AbstractFutureStub.java @@ -20,7 +20,6 @@ import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.stub.ClientCalls.StubType; -import javax.annotation.concurrent.ThreadSafe; /** * Stub implementations for future stubs. @@ -28,9 +27,10 @@ *

DO NOT MOCK: Customizing options doesn't work properly in mocks. Use InProcessChannelBuilder * to create a real channel suitable for testing. It is also possible to mock Channel instead. * + *

This class is thread-safe. + * * @since 1.26.0 */ -@ThreadSafe @CheckReturnValue public abstract class AbstractFutureStub> extends AbstractStub { diff --git a/stub/src/main/java/io/grpc/stub/AbstractStub.java b/stub/src/main/java/io/grpc/stub/AbstractStub.java index 697107760db..409f1e7ed53 100644 --- a/stub/src/main/java/io/grpc/stub/AbstractStub.java +++ b/stub/src/main/java/io/grpc/stub/AbstractStub.java @@ -32,7 +32,6 @@ import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; -import javax.annotation.concurrent.ThreadSafe; import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement; /** @@ -46,10 +45,11 @@ *

DO NOT MOCK: Customizing options doesn't work properly in mocks. Use InProcessChannelBuilder * to create a real channel suitable for testing. It is also possible to mock Channel instead. * + *

This class is thread-safe. + * * @since 1.0.0 * @param the concrete type of this stub. */ -@ThreadSafe @CheckReturnValue public abstract class AbstractStub> { private final Channel channel; diff --git a/stub/src/main/java/io/grpc/stub/BlockingClientCall.java b/stub/src/main/java/io/grpc/stub/BlockingClientCall.java index 27bd42e53bb..6a52ce50776 100644 --- a/stub/src/main/java/io/grpc/stub/BlockingClientCall.java +++ b/stub/src/main/java/io/grpc/stub/BlockingClientCall.java @@ -225,7 +225,7 @@ private boolean write(boolean waitForever, ReqT request, long endNanoTime) (x) -> x.call.isReady() || x.closeState.get() != null; executor.waitAndDrainWithTimeout(waitForever, endNanoTime, predicate, this); CloseState savedCloseState = closeState.get(); - if (savedCloseState == null || savedCloseState.status == null) { + if (savedCloseState == null) { call.sendMessage(request); return true; } else if (savedCloseState.status.isOk()) { diff --git a/testing-proto/BUILD.bazel b/testing-proto/BUILD.bazel index f02957c6fc3..aa0fc9ee20b 100644 --- a/testing-proto/BUILD.bazel +++ b/testing-proto/BUILD.bazel @@ -1,5 +1,5 @@ -load("@rules_java//java:defs.bzl", "java_proto_library") -load("@rules_proto//proto:defs.bzl", "proto_library") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("//:java_grpc_library.bzl", "java_grpc_library") proto_library( diff --git a/util/build.gradle b/util/build.gradle index 6fbd6925c00..846b110b106 100644 --- a/util/build.gradle +++ b/util/build.gradle @@ -58,6 +58,7 @@ animalsniffer { tasks.named("javadoc").configure { exclude 'io/grpc/util/MultiChildLoadBalancer.java' exclude 'io/grpc/util/OutlierDetectionLoadBalancer*' + exclude 'io/grpc/util/RandomSubsettingLoadBalancer*' exclude 'io/grpc/util/RoundRobinLoadBalancer*' } diff --git a/util/src/main/java/io/grpc/util/AdvancedTlsX509KeyManager.java b/util/src/main/java/io/grpc/util/AdvancedTlsX509KeyManager.java index 1f807cd405d..eea664f2ad4 100644 --- a/util/src/main/java/io/grpc/util/AdvancedTlsX509KeyManager.java +++ b/util/src/main/java/io/grpc/util/AdvancedTlsX509KeyManager.java @@ -32,6 +32,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import java.util.logging.Logger; import javax.net.ssl.SSLEngine; @@ -40,59 +41,86 @@ /** * AdvancedTlsX509KeyManager is an {@code X509ExtendedKeyManager} that allows users to configure * advanced TLS features, such as private key and certificate chain reloading. + * + *

The alias increments on every credential load (e.g. {@code "key-1"}, {@code "key-2"}, ...), + * so the same alias always maps to the same key material. The previous alias is retained for one + * rotation to allow in-progress handshakes to complete, ensuring alias-to-key-material consistency + * across credential reloads. */ public final class AdvancedTlsX509KeyManager extends X509ExtendedKeyManager { private static final Logger log = Logger.getLogger(AdvancedTlsX509KeyManager.class.getName()); // Minimum allowed period for refreshing files with credential information. - private static final int MINIMUM_REFRESH_PERIOD_IN_MINUTES = 1 ; - // The credential information to be sent to peers to prove our identity. - private volatile KeyInfo keyInfo; + private static final int MINIMUM_REFRESH_PERIOD_IN_MINUTES = 1; + // Prefix for the key material alias; revision counter appended on each credential load. + static final String ALIAS_PREFIX = "key-"; + + private final AtomicInteger revision = new AtomicInteger(0); + // Snapshot of current and previous KeyInfo; previous is retained for in-progress handshakes + // after one rotation. + private volatile KeyInfoSnapshot snapshot = new KeyInfoSnapshot(null, null); + + public AdvancedTlsX509KeyManager() {} + + private String alias() { + KeyInfo curr = this.snapshot.current; + return curr != null ? curr.alias : null; + } @Override public PrivateKey getPrivateKey(String alias) { - if (alias.equals("default")) { - return this.keyInfo.key; + KeyInfoSnapshot snap = this.snapshot; + if (snap.current != null && snap.current.alias.equals(alias)) { + return snap.current.key; + } + if (snap.previous != null && snap.previous.alias.equals(alias)) { + return snap.previous.key; } return null; } @Override public X509Certificate[] getCertificateChain(String alias) { - if (alias.equals("default")) { - return Arrays.copyOf(this.keyInfo.certs, this.keyInfo.certs.length); + KeyInfoSnapshot snap = this.snapshot; + if (snap.current != null && snap.current.alias.equals(alias)) { + return Arrays.copyOf(snap.current.certs, snap.current.certs.length); + } + if (snap.previous != null && snap.previous.alias.equals(alias)) { + return Arrays.copyOf(snap.previous.certs, snap.previous.certs.length); } return null; } @Override public String[] getClientAliases(String keyType, Principal[] issuers) { - return new String[] {"default"}; + String alias = alias(); + return alias != null ? new String[] {alias} : null; } @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { - return "default"; + return alias(); } @Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { - return "default"; + return alias(); } @Override public String[] getServerAliases(String keyType, Principal[] issuers) { - return new String[] {"default"}; + String alias = alias(); + return alias != null ? new String[] {alias} : null; } @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { - return "default"; + return alias(); } @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { - return "default"; + return alias(); } /** @@ -116,7 +144,9 @@ public void updateIdentityCredentials(PrivateKey key, X509Certificate[] certs) { * @param key the private key that is going to be used */ public void updateIdentityCredentials(X509Certificate[] certs, PrivateKey key) { - this.keyInfo = new KeyInfo(checkNotNull(certs, "certs"), checkNotNull(key, "key")); + KeyInfo newInfo = new KeyInfo(checkNotNull(certs, "certs"), checkNotNull(key, "key"), + ALIAS_PREFIX + revision.incrementAndGet()); + this.snapshot = new KeyInfoSnapshot(newInfo, this.snapshot.current); } /** @@ -218,10 +248,22 @@ private static class KeyInfo { // The private key and the cert chain we will use to send to peers to prove our identity. final X509Certificate[] certs; final PrivateKey key; + final String alias; - public KeyInfo(X509Certificate[] certs, PrivateKey key) { + public KeyInfo(X509Certificate[] certs, PrivateKey key, String alias) { this.certs = certs; this.key = key; + this.alias = alias; + } + } + + private static class KeyInfoSnapshot { + final KeyInfo current; + final KeyInfo previous; + + KeyInfoSnapshot(KeyInfo current, KeyInfo previous) { + this.current = current; + this.previous = previous; } } @@ -309,4 +351,3 @@ public interface Closeable extends java.io.Closeable { void close(); } } - diff --git a/util/src/main/java/io/grpc/util/ForwardingLoadBalancer.java b/util/src/main/java/io/grpc/util/ForwardingLoadBalancer.java index cefcbf344ea..d52ff42e652 100644 --- a/util/src/main/java/io/grpc/util/ForwardingLoadBalancer.java +++ b/util/src/main/java/io/grpc/util/ForwardingLoadBalancer.java @@ -29,6 +29,7 @@ public abstract class ForwardingLoadBalancer extends LoadBalancer { */ protected abstract LoadBalancer delegate(); + @Deprecated @Override public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { delegate().handleResolvedAddresses(resolvedAddresses); @@ -52,6 +53,8 @@ public void shutdown() { } @Override + @Deprecated + @SuppressWarnings("InlineMeSuggester") public boolean canHandleEmptyAddressListFromNameResolution() { return delegate().canHandleEmptyAddressListFromNameResolution(); } diff --git a/util/src/main/java/io/grpc/util/GracefulSwitchLoadBalancer.java b/util/src/main/java/io/grpc/util/GracefulSwitchLoadBalancer.java index 1dc4fb6750a..27dc080c71b 100644 --- a/util/src/main/java/io/grpc/util/GracefulSwitchLoadBalancer.java +++ b/util/src/main/java/io/grpc/util/GracefulSwitchLoadBalancer.java @@ -84,6 +84,7 @@ public GracefulSwitchLoadBalancer(Helper helper) { this.helper = checkNotNull(helper, "helper"); } + @Deprecated @Override public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { Config config = (Config) resolvedAddresses.getLoadBalancingPolicyConfig(); @@ -207,14 +208,14 @@ public static ConfigOrError parseLoadBalancingPolicyConfig( ServiceConfigUtil.unwrapLoadBalancingConfigList(loadBalancingConfigs); if (childConfigCandidates == null || childConfigCandidates.isEmpty()) { return ConfigOrError.fromError( - Status.INTERNAL.withDescription("No child LB config specified")); + Status.UNAVAILABLE.withDescription("No child LB config specified")); } ConfigOrError selectedConfig = ServiceConfigUtil.selectLbPolicyFromList(childConfigCandidates, lbRegistry); if (selectedConfig.getError() != null) { Status error = selectedConfig.getError(); return ConfigOrError.fromError( - Status.INTERNAL + Status.UNAVAILABLE .withCause(error.getCause()) .withDescription(error.getDescription()) .augmentDescription("Failed to select child config")); diff --git a/util/src/main/java/io/grpc/util/HealthProducerHelper.java b/util/src/main/java/io/grpc/util/HealthProducerHelper.java index b11864765ea..d871911d203 100644 --- a/util/src/main/java/io/grpc/util/HealthProducerHelper.java +++ b/util/src/main/java/io/grpc/util/HealthProducerHelper.java @@ -22,6 +22,7 @@ import com.google.common.annotations.VisibleForTesting; import io.grpc.Attributes; +import io.grpc.ConnectivityState; import io.grpc.ConnectivityStateInfo; import io.grpc.Internal; import io.grpc.LoadBalancer; @@ -84,6 +85,31 @@ protected LoadBalancer.Helper delegate() { return delegate; } + @Override + public void updateBalancingState( + ConnectivityState newState, LoadBalancer.SubchannelPicker newPicker) { + delegate.updateBalancingState(newState, new HealthProducerPicker(newPicker)); + } + + private static final class HealthProducerPicker extends LoadBalancer.SubchannelPicker { + private final LoadBalancer.SubchannelPicker delegate; + + HealthProducerPicker(LoadBalancer.SubchannelPicker delegate) { + this.delegate = delegate; + } + + @Override + public LoadBalancer.PickResult pickSubchannel(LoadBalancer.PickSubchannelArgs args) { + LoadBalancer.PickResult result = delegate.pickSubchannel(args); + LoadBalancer.Subchannel subchannel = result.getSubchannel(); + if (subchannel instanceof HealthProducerSubchannel) { + return result.copyWithSubchannel( + ((HealthProducerSubchannel) subchannel).delegate()); + } + return result; + } + } + // The parent subchannel in the health check producer LB chain. It duplicates subchannel state to // both the state listener and health listener. @VisibleForTesting diff --git a/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancer.java b/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancer.java index d72a85012f2..dc61441bccd 100644 --- a/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancer.java +++ b/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancer.java @@ -442,9 +442,14 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { Subchannel subchannel = pickResult.getSubchannel(); if (subchannel != null) { - return PickResult.withSubchannel(subchannel, new ResultCountingClientStreamTracerFactory( - subchannel.getAttributes().get(ENDPOINT_TRACKER_KEY), - pickResult.getStreamTracerFactory())); + EndpointTracker tracker = subchannel.getAttributes().get(ENDPOINT_TRACKER_KEY); + if (subchannel instanceof OutlierDetectionSubchannel) { + subchannel = ((OutlierDetectionSubchannel) subchannel).delegate(); + } + return pickResult.copyWithSubchannel(subchannel) + .copyWithStreamTracerFactory(new ResultCountingClientStreamTracerFactory( + tracker, + pickResult.getStreamTracerFactory())); } return pickResult; diff --git a/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancerProvider.java b/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancerProvider.java index c76d68a03de..084898bc38f 100644 --- a/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancerProvider.java +++ b/util/src/main/java/io/grpc/util/OutlierDetectionLoadBalancerProvider.java @@ -23,6 +23,7 @@ import io.grpc.LoadBalancerProvider; import io.grpc.NameResolver.ConfigOrError; import io.grpc.Status; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.JsonUtil; import io.grpc.util.OutlierDetectionLoadBalancer.OutlierDetectionLoadBalancerConfig; import io.grpc.util.OutlierDetectionLoadBalancer.OutlierDetectionLoadBalancerConfig.FailurePercentageEjection; @@ -148,9 +149,10 @@ private ConfigOrError parseLoadBalancingPolicyConfigInternal(Map rawC ConfigOrError childConfig = GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( JsonUtil.getListOfObjects(rawConfig, "childPolicy")); if (childConfig.getError() != null) { - return ConfigOrError.fromError(Status.INTERNAL - .withDescription("Failed to parse child in outlier_detection_experimental: " + rawConfig) - .withCause(childConfig.getError().asRuntimeException())); + return ConfigOrError.fromError(GrpcUtil.statusWithDetails( + Status.Code.UNAVAILABLE, + "Failed to parse child in outlier_detection_experimental", + childConfig.getError())); } configBuilder.setChildConfig(childConfig.getConfig()); diff --git a/util/src/main/java/io/grpc/util/RandomSubsettingLoadBalancer.java b/util/src/main/java/io/grpc/util/RandomSubsettingLoadBalancer.java new file mode 100644 index 00000000000..ad4de9e8921 --- /dev/null +++ b/util/src/main/java/io/grpc/util/RandomSubsettingLoadBalancer.java @@ -0,0 +1,161 @@ +/* + * 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.util; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.hash.HashCode; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; +import com.google.common.primitives.Ints; +import io.grpc.EquivalentAddressGroup; +import io.grpc.LoadBalancer; +import io.grpc.Status; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Random; + + +/** + * Wraps a child {@code LoadBalancer}, separating the total set of backends into smaller subsets for + * the child balancer to balance across. + * + *

This implements random subsetting gRFC: + * https://https://github.com/grpc/proposal/blob/master/A68-random-subsetting.md + */ +final class RandomSubsettingLoadBalancer extends LoadBalancer { + private final GracefulSwitchLoadBalancer switchLb; + private final HashFunction hashFunc; + + public RandomSubsettingLoadBalancer(Helper helper) { + this(helper, new Random().nextInt()); + } + + @VisibleForTesting + RandomSubsettingLoadBalancer(Helper helper, int seed) { + switchLb = new GracefulSwitchLoadBalancer(checkNotNull(helper, "helper")); + hashFunc = Hashing.murmur3_128(seed); + } + + @Override + public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { + RandomSubsettingLoadBalancerConfig config = + (RandomSubsettingLoadBalancerConfig) + resolvedAddresses.getLoadBalancingPolicyConfig(); + + ResolvedAddresses subsetAddresses = filterEndpoints(resolvedAddresses, config.subsetSize); + + return switchLb.acceptResolvedAddresses( + subsetAddresses.toBuilder() + .setLoadBalancingPolicyConfig(config.childConfig) + .build()); + } + + // implements the subsetting algorithm, as described in A68: + // https://github.com/grpc/proposal/pull/423 + private ResolvedAddresses filterEndpoints(ResolvedAddresses resolvedAddresses, int subsetSize) { + if (subsetSize >= resolvedAddresses.getAddresses().size()) { + return resolvedAddresses; + } + + ArrayList endpointWithHashList = + new ArrayList<>(resolvedAddresses.getAddresses().size()); + + for (EquivalentAddressGroup addressGroup : resolvedAddresses.getAddresses()) { + HashCode hashCode = hashFunc.hashString( + addressGroup.getAddresses().get(0).toString(), + StandardCharsets.UTF_8); + endpointWithHashList.add(new EndpointWithHash(addressGroup, hashCode.asLong())); + } + + Collections.sort(endpointWithHashList, new HashAddressComparator()); + + ArrayList addressGroups = new ArrayList<>(subsetSize); + + for (int idx = 0; idx < subsetSize; ++idx) { + addressGroups.add(endpointWithHashList.get(idx).addressGroup); + } + + return resolvedAddresses.toBuilder().setAddresses(addressGroups).build(); + } + + @Override + public void handleNameResolutionError(Status error) { + switchLb.handleNameResolutionError(error); + } + + @Override + public void shutdown() { + switchLb.shutdown(); + } + + private static final class EndpointWithHash { + public final EquivalentAddressGroup addressGroup; + public final long hashCode; + + public EndpointWithHash(EquivalentAddressGroup addressGroup, long hashCode) { + this.addressGroup = addressGroup; + this.hashCode = hashCode; + } + } + + private static final class HashAddressComparator implements Comparator { + @Override + public int compare(EndpointWithHash lhs, EndpointWithHash rhs) { + return Long.compare(lhs.hashCode, rhs.hashCode); + } + } + + public static final class RandomSubsettingLoadBalancerConfig { + public final int subsetSize; + public final Object childConfig; + + private RandomSubsettingLoadBalancerConfig(int subsetSize, Object childConfig) { + this.subsetSize = subsetSize; + this.childConfig = childConfig; + } + + public static class Builder { + int subsetSize; + Object childConfig; + + public Builder setSubsetSize(long subsetSize) { + checkArgument(subsetSize > 0L, "Subset size must be greater than 0"); + // clamping subset size to Integer.MAX_VALUE due to collection indexing limitations in JVM + this.subsetSize = Ints.saturatedCast(subsetSize); + return this; + } + + public Builder setChildConfig(Object childConfig) { + this.childConfig = checkNotNull(childConfig, "childConfig"); + return this; + } + + public RandomSubsettingLoadBalancerConfig build() { + checkState(subsetSize != 0L, "Subset size must be set before building the config"); + return new RandomSubsettingLoadBalancerConfig( + subsetSize, + checkNotNull(childConfig, "childConfig")); + } + } + } +} diff --git a/util/src/main/java/io/grpc/util/RandomSubsettingLoadBalancerProvider.java b/util/src/main/java/io/grpc/util/RandomSubsettingLoadBalancerProvider.java new file mode 100644 index 00000000000..edcbf48a201 --- /dev/null +++ b/util/src/main/java/io/grpc/util/RandomSubsettingLoadBalancerProvider.java @@ -0,0 +1,86 @@ +/* + * 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.util; + +import io.grpc.Internal; +import io.grpc.LoadBalancer; +import io.grpc.LoadBalancerProvider; +import io.grpc.NameResolver.ConfigOrError; +import io.grpc.Status; +import io.grpc.internal.JsonUtil; +import java.util.Map; + +@Internal +public final class RandomSubsettingLoadBalancerProvider extends LoadBalancerProvider { + private static final String POLICY_NAME = "random_subsetting_experimental"; + + @Override + public LoadBalancer newLoadBalancer(LoadBalancer.Helper helper) { + return new RandomSubsettingLoadBalancer(helper); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public int getPriority() { + return 5; + } + + @Override + public String getPolicyName() { + return POLICY_NAME; + } + + @Override + public ConfigOrError parseLoadBalancingPolicyConfig(Map rawConfig) { + try { + return parseLoadBalancingPolicyConfigInternal(rawConfig); + } catch (RuntimeException e) { + return ConfigOrError.fromError( + Status.UNAVAILABLE + .withCause(e) + .withDescription("Failed parsing configuration for " + getPolicyName())); + } + } + + private ConfigOrError parseLoadBalancingPolicyConfigInternal(Map rawConfig) { + Long subsetSize = JsonUtil.getNumberAsLong(rawConfig, "subsetSize"); + if (subsetSize == null) { + return ConfigOrError.fromError( + Status.UNAVAILABLE.withDescription( + "Subset size missing in " + getPolicyName() + ", LB policy config=" + rawConfig)); + } + + ConfigOrError childConfig = GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( + JsonUtil.getListOfObjects(rawConfig, "childPolicy")); + if (childConfig.getError() != null) { + return ConfigOrError.fromError(Status.UNAVAILABLE + .withDescription( + "Failed to parse child in " + getPolicyName() + ", LB policy config=" + rawConfig) + .withCause(childConfig.getError().asRuntimeException())); + } + + return ConfigOrError.fromConfig( + new RandomSubsettingLoadBalancer.RandomSubsettingLoadBalancerConfig.Builder() + .setSubsetSize(subsetSize) + .setChildConfig(childConfig.getConfig()) + .build()); + } +} diff --git a/util/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider b/util/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider index 1fdd69cb00b..d973a6f6728 100644 --- a/util/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider +++ b/util/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider @@ -1,2 +1,3 @@ io.grpc.util.SecretRoundRobinLoadBalancerProvider$Provider io.grpc.util.OutlierDetectionLoadBalancerProvider +io.grpc.util.RandomSubsettingLoadBalancerProvider diff --git a/util/src/test/java/io/grpc/util/AdvancedTlsX509KeyManagerTest.java b/util/src/test/java/io/grpc/util/AdvancedTlsX509KeyManagerTest.java index f96c85e4f4f..b8431d4f991 100644 --- a/util/src/test/java/io/grpc/util/AdvancedTlsX509KeyManagerTest.java +++ b/util/src/test/java/io/grpc/util/AdvancedTlsX509KeyManagerTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -48,7 +49,6 @@ public class AdvancedTlsX509KeyManagerTest { private static final String SERVER_0_PEM_FILE = "server0.pem"; private static final String CLIENT_0_KEY_FILE = "client.key"; private static final String CLIENT_0_PEM_FILE = "client.pem"; - private static final String ALIAS = "default"; private ScheduledExecutorService executor; @@ -79,22 +79,62 @@ public void setUp() throws Exception { public void updateTrustCredentials_replacesIssuers() throws Exception { // Overall happy path checking of public API. AdvancedTlsX509KeyManager serverKeyManager = new AdvancedTlsX509KeyManager(); + serverKeyManager.updateIdentityCredentials(serverCert0, serverKey0); - assertEquals(serverKey0, serverKeyManager.getPrivateKey(ALIAS)); - assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(ALIAS)); + String alias1 = serverKeyManager.chooseEngineServerAlias(null, null, null); + assertEquals(AdvancedTlsX509KeyManager.ALIAS_PREFIX + "1", alias1); + assertEquals(serverKey0, serverKeyManager.getPrivateKey(alias1)); + assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(alias1)); serverKeyManager.updateIdentityCredentials(clientCert0File, clientKey0File); - assertEquals(clientKey0, serverKeyManager.getPrivateKey(ALIAS)); - assertArrayEquals(clientCert0, serverKeyManager.getCertificateChain(ALIAS)); - - serverKeyManager.updateIdentityCredentials(serverCert0File, serverKey0File,1, + String alias2 = serverKeyManager.chooseEngineServerAlias(null, null, null); + assertEquals(AdvancedTlsX509KeyManager.ALIAS_PREFIX + "2", alias2); + assertEquals(clientKey0, serverKeyManager.getPrivateKey(alias2)); + assertArrayEquals(clientCert0, serverKeyManager.getCertificateChain(alias2)); + // Previous alias still resolves — retained to allow in-progress handshakes to complete. + assertEquals(serverKey0, serverKeyManager.getPrivateKey(alias1)); + assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(alias1)); + + serverKeyManager.updateIdentityCredentials(serverCert0File, serverKey0File, 1, TimeUnit.MINUTES, executor); - assertEquals(serverKey0, serverKeyManager.getPrivateKey(ALIAS)); - assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(ALIAS)); + String alias3 = serverKeyManager.chooseEngineServerAlias(null, null, null); + assertEquals(serverKey0, serverKeyManager.getPrivateKey(alias3)); + assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(alias3)); + // alias1 is now two rotations back — no longer retained. + assertNull(serverKeyManager.getPrivateKey(alias1)); serverKeyManager.updateIdentityCredentials(serverCert0, serverKey0); - assertEquals(serverKey0, serverKeyManager.getPrivateKey(ALIAS)); - assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(ALIAS)); + String alias4 = serverKeyManager.chooseEngineServerAlias(null, null, null); + assertEquals(serverKey0, serverKeyManager.getPrivateKey(alias4)); + assertArrayEquals(serverCert0, serverKeyManager.getCertificateChain(alias4)); + } + + @Test + public void allAliasMethods_returnNullBeforeCredentialsLoaded() { + AdvancedTlsX509KeyManager keyManager = new AdvancedTlsX509KeyManager(); + + assertNull(keyManager.chooseClientAlias(null, null, null)); + assertNull(keyManager.chooseServerAlias(null, null, null)); + assertNull(keyManager.chooseEngineClientAlias(null, null, null)); + assertNull(keyManager.chooseEngineServerAlias(null, null, null)); + assertNull(keyManager.getClientAliases(null, null)); + assertNull(keyManager.getServerAliases(null, null)); + assertNull(keyManager.getPrivateKey("key-1")); + assertNull(keyManager.getCertificateChain("key-1")); + } + + @Test + public void allAliasMethods_agreeAfterCredentialLoad() throws Exception { + AdvancedTlsX509KeyManager keyManager = new AdvancedTlsX509KeyManager(); + keyManager.updateIdentityCredentials(serverCert0, serverKey0); + + String expectedAlias = AdvancedTlsX509KeyManager.ALIAS_PREFIX + "1"; + assertEquals(expectedAlias, keyManager.chooseClientAlias(null, null, null)); + assertEquals(expectedAlias, keyManager.chooseServerAlias(null, null, null)); + assertEquals(expectedAlias, keyManager.chooseEngineClientAlias(null, null, null)); + assertEquals(expectedAlias, keyManager.chooseEngineServerAlias(null, null, null)); + assertArrayEquals(new String[]{expectedAlias}, keyManager.getClientAliases(null, null)); + assertArrayEquals(new String[]{expectedAlias}, keyManager.getServerAliases(null, null)); } @Test diff --git a/util/src/test/java/io/grpc/util/AdvancedTlsX509TrustManagerTest.java b/util/src/test/java/io/grpc/util/AdvancedTlsX509TrustManagerTest.java index b9803b03570..7a6fd7b6124 100644 --- a/util/src/test/java/io/grpc/util/AdvancedTlsX509TrustManagerTest.java +++ b/util/src/test/java/io/grpc/util/AdvancedTlsX509TrustManagerTest.java @@ -44,6 +44,7 @@ import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement; import org.junit.Before; @@ -161,6 +162,7 @@ public void clientTrustedWithSocketTest() throws Exception { SSLSocket sslSocket = mock(SSLSocket.class); when(sslSocket.isConnected()).thenReturn(true); when(sslSocket.getHandshakeSession()).thenReturn(null); + when(sslSocket.getSSLParameters()).thenReturn(new SSLParameters()); CertificateException ce = assertThrows(CertificateException.class, () -> trustManager .checkClientTrusted(serverCert0, "RSA", sslSocket)); assertEquals("No handshake session", ce.getMessage()); diff --git a/util/src/test/java/io/grpc/util/GracefulSwitchLoadBalancerTest.java b/util/src/test/java/io/grpc/util/GracefulSwitchLoadBalancerTest.java index 8f87dab5da6..0467f9526f6 100644 --- a/util/src/test/java/io/grpc/util/GracefulSwitchLoadBalancerTest.java +++ b/util/src/test/java/io/grpc/util/GracefulSwitchLoadBalancerTest.java @@ -102,6 +102,7 @@ public void handleSubchannelState_shouldThrow() { } @Test + @Deprecated public void canHandleEmptyAddressListFromNameResolutionForwardedToLatestPolicy() { assertIsOk(gracefulSwitchLb.acceptResolvedAddresses(addressesBuilder() .setLoadBalancingPolicyConfig(createConfig(lbPolicies[0], new Object())) @@ -136,6 +137,7 @@ public void canHandleEmptyAddressListFromNameResolutionForwardedToLatestPolicy() assertThat(gracefulSwitchLb.canHandleEmptyAddressListFromNameResolution()).isTrue(); } + @Deprecated @Test public void handleResolvedAddressesAndNameResolutionErrorForwardedToLatestPolicy() { ResolvedAddresses addresses = newFakeAddresses(); diff --git a/util/src/test/java/io/grpc/util/OutlierDetectionLoadBalancerTest.java b/util/src/test/java/io/grpc/util/OutlierDetectionLoadBalancerTest.java index c4eb4c7bae5..39f5b5fb7d6 100644 --- a/util/src/test/java/io/grpc/util/OutlierDetectionLoadBalancerTest.java +++ b/util/src/test/java/io/grpc/util/OutlierDetectionLoadBalancerTest.java @@ -54,7 +54,6 @@ import io.grpc.SynchronizationContext; import io.grpc.internal.FakeClock; import io.grpc.internal.FakeClock.ScheduledTask; -import io.grpc.internal.PickFirstLoadBalancerProvider; import io.grpc.internal.TestUtils.StandardLoadBalancerProvider; import io.grpc.util.OutlierDetectionLoadBalancer.EndpointTracker; import io.grpc.util.OutlierDetectionLoadBalancer.OutlierDetectionLoadBalancerConfig; @@ -409,7 +408,7 @@ public void delegatePick() throws Exception { // Make sure that we can pick the single READY subchannel. SubchannelPicker picker = pickerCaptor.getAllValues().get(2); PickResult pickResult = picker.pickSubchannel(mock(PickSubchannelArgs.class)); - Subchannel s = ((OutlierDetectionSubchannel) pickResult.getSubchannel()).delegate(); + Subchannel s = pickResult.getSubchannel(); if (s instanceof HealthProducerHelper.HealthProducerSubchannel) { s = ((HealthProducerHelper.HealthProducerSubchannel) s).delegate(); } @@ -568,9 +567,7 @@ public void successRateOneOutlier_configChange() { loadBalancer.acceptResolvedAddresses(buildResolvedAddress(config, servers)); - // The PickFirstLeafLB has an extra level of indirection because of health - int expectedStateChanges = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 8 : 12; - generateLoad(ImmutableMap.of(subchannel2, Status.DEADLINE_EXCEEDED), expectedStateChanges); + generateLoad(ImmutableMap.of(subchannel2, Status.DEADLINE_EXCEEDED), 8); // Move forward in time to a point where the detection timer has fired. forwardTime(config); @@ -604,8 +601,7 @@ public void successRateOneOutlier_unejected() { assertEjectedSubchannels(ImmutableSet.of(ImmutableSet.copyOf(servers.get(0).getAddresses()))); // Now we produce more load, but the subchannel has started working and is no longer an outlier. - int expectedStateChanges = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 8 : 12; - generateLoad(ImmutableMap.of(), expectedStateChanges); + generateLoad(ImmutableMap.of(), 8); // Move forward in time to a point where the detection timer has fired. fakeClock.forwardTime(config.maxEjectionTimeNanos + 1, TimeUnit.NANOSECONDS); diff --git a/util/src/test/java/io/grpc/util/RandomSubsettingLoadBalancerProviderTest.java b/util/src/test/java/io/grpc/util/RandomSubsettingLoadBalancerProviderTest.java new file mode 100644 index 00000000000..18a0766d4b2 --- /dev/null +++ b/util/src/test/java/io/grpc/util/RandomSubsettingLoadBalancerProviderTest.java @@ -0,0 +1,135 @@ +/* + * 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.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; + +import io.grpc.InternalServiceProviders; +import io.grpc.LoadBalancer.Helper; +import io.grpc.LoadBalancerProvider; +import io.grpc.NameResolver.ConfigOrError; +import io.grpc.Status; +import io.grpc.internal.JsonParser; +import io.grpc.util.RandomSubsettingLoadBalancer.RandomSubsettingLoadBalancerConfig; +import java.io.IOException; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class RandomSubsettingLoadBalancerProviderTest { + private final RandomSubsettingLoadBalancerProvider provider = + new RandomSubsettingLoadBalancerProvider(); + + @Test + public void registered() { + for (LoadBalancerProvider current : + InternalServiceProviders.getCandidatesViaServiceLoader( + LoadBalancerProvider.class, getClass().getClassLoader())) { + if (current instanceof RandomSubsettingLoadBalancerProvider) { + return; + } + } + fail("RandomSubsettingLoadBalancerProvider not registered"); + } + + @Test + public void providesLoadBalancer() { + Helper helper = mock(Helper.class); + assertThat(provider.newLoadBalancer(helper)) + .isInstanceOf(RandomSubsettingLoadBalancer.class); + } + + @Test + public void parseConfigRequiresSubsetSize() throws IOException { + String emptyConfig = "{}"; + + ConfigOrError configOrError = + provider.parseLoadBalancingPolicyConfig(parseJsonObject(emptyConfig)); + assertThat(configOrError.getError()).isNotNull(); + assertThat(configOrError.getError().toString()) + .isEqualTo( + Status.UNAVAILABLE + .withDescription( + "Subset size missing in random_subsetting_experimental, LB policy config={}") + .toString()); + } + + @Test + public void parseConfigReturnsErrorWhenChildPolicyMissing() throws IOException { + String missingChildPolicyConfig = "{\"subsetSize\": 3}"; + + ConfigOrError configOrError = + provider.parseLoadBalancingPolicyConfig(parseJsonObject(missingChildPolicyConfig)); + assertThat(configOrError.getError()).isNotNull(); + + Status error = configOrError.getError(); + assertThat(error.getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(error.getDescription()).isEqualTo( + "Failed to parse child in random_subsetting_experimental" + + ", LB policy config={subsetSize=3.0}"); + assertThat(error.getCause().getMessage()).isEqualTo( + "UNAVAILABLE: No child LB config specified"); + } + + @Test + public void parseConfigReturnsErrorWhenChildPolicyInvalid() throws IOException { + String invalidChildPolicyConfig = + "{" + + "\"subsetSize\": 3, " + + "\"childPolicy\" : [{\"random_policy\" : {}}]" + + "}"; + + ConfigOrError configOrError = + provider.parseLoadBalancingPolicyConfig(parseJsonObject(invalidChildPolicyConfig)); + assertThat(configOrError.getError()).isNotNull(); + + Status error = configOrError.getError(); + assertThat(error.getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(error.getDescription()).isEqualTo( + "Failed to parse child in random_subsetting_experimental, LB policy config=" + + "{subsetSize=3.0, childPolicy=[{random_policy={}}]}"); + assertThat(error.getCause().getMessage()).contains( + "UNAVAILABLE: None of [random_policy] specified by Service Config are available."); + } + + @Test + public void parseValidConfig() throws IOException { + String validConfig = + "{" + + "\"subsetSize\": 3, " + + "\"childPolicy\" : [{\"round_robin\" : {}}]" + + "}"; + ConfigOrError configOrError = + provider.parseLoadBalancingPolicyConfig(parseJsonObject(validConfig)); + assertThat(configOrError.getConfig()).isNotNull(); + + RandomSubsettingLoadBalancerConfig actualConfig = + (RandomSubsettingLoadBalancerConfig) configOrError.getConfig(); + assertThat(GracefulSwitchLoadBalancerAccessor.getChildProvider( + actualConfig.childConfig).getPolicyName()).isEqualTo("round_robin"); + assertThat(actualConfig.subsetSize).isEqualTo(3); + } + + @SuppressWarnings("unchecked") + private static Map parseJsonObject(String json) throws IOException { + return (Map) JsonParser.parse(json); + } +} diff --git a/util/src/test/java/io/grpc/util/RandomSubsettingLoadBalancerTest.java b/util/src/test/java/io/grpc/util/RandomSubsettingLoadBalancerTest.java new file mode 100644 index 00000000000..2c43e8f4c3a --- /dev/null +++ b/util/src/test/java/io/grpc/util/RandomSubsettingLoadBalancerTest.java @@ -0,0 +1,333 @@ +/* + * 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.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import io.grpc.ConnectivityState; +import io.grpc.ConnectivityStateInfo; +import io.grpc.EquivalentAddressGroup; +import io.grpc.LoadBalancer; +import io.grpc.LoadBalancer.CreateSubchannelArgs; +import io.grpc.LoadBalancer.ResolvedAddresses; +import io.grpc.LoadBalancer.Subchannel; +import io.grpc.LoadBalancer.SubchannelStateListener; +import io.grpc.LoadBalancerProvider; +import io.grpc.Status; +import io.grpc.internal.TestUtils; +import io.grpc.util.RandomSubsettingLoadBalancer.RandomSubsettingLoadBalancerConfig; +import java.net.SocketAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +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.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.stubbing.Answer; + +@RunWith(JUnit4.class) +public class RandomSubsettingLoadBalancerTest { + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private LoadBalancer.Helper mockHelper; + @Mock + private LoadBalancer mockChildLb; + @Mock + private SocketAddress mockSocketAddress; + + @Captor + private ArgumentCaptor resolvedAddrCaptor; + + private BackendDetails backendDetails; + + private RandomSubsettingLoadBalancer loadBalancer; + + private final LoadBalancerProvider mockChildLbProvider = + new TestUtils.StandardLoadBalancerProvider("foo_policy") { + @Override + public LoadBalancer newLoadBalancer(LoadBalancer.Helper helper) { + return mockChildLb; + } + }; + + private final LoadBalancerProvider roundRobinLbProvider = + new TestUtils.StandardLoadBalancerProvider("round_robin") { + @Override + public LoadBalancer newLoadBalancer(LoadBalancer.Helper helper) { + return new RoundRobinLoadBalancer(helper); + } + }; + + private Object newChildConfig(LoadBalancerProvider provider, Object config) { + return GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig(provider, config); + } + + private RandomSubsettingLoadBalancerConfig createRandomSubsettingLbConfig( + int subsetSize, LoadBalancerProvider childLbProvider, Object childConfig) { + return new RandomSubsettingLoadBalancer.RandomSubsettingLoadBalancerConfig.Builder() + .setSubsetSize(subsetSize) + .setChildConfig(newChildConfig(childLbProvider, childConfig)) + .build(); + } + + private BackendDetails setupBackends(int backendCount) { + List servers = Lists.newArrayList(); + Map, Subchannel> subchannels = Maps.newLinkedHashMap(); + + for (int i = 0; i < backendCount; i++) { + SocketAddress addr = new FakeSocketAddress("server" + i); + EquivalentAddressGroup addressGroup = new EquivalentAddressGroup(addr); + servers.add(addressGroup); + Subchannel subchannel = mock(Subchannel.class); + subchannels.put(Arrays.asList(addressGroup), subchannel); + } + + return new BackendDetails(servers, subchannels); + } + + @Before + public void setUp() { + int seed = 0; + loadBalancer = new RandomSubsettingLoadBalancer(mockHelper, seed); + + int backendSize = 5; + backendDetails = setupBackends(backendSize); + } + + @Test + public void handleNameResolutionError() { + int subsetSize = 2; + Object childConfig = "someConfig"; + + RandomSubsettingLoadBalancerConfig config = createRandomSubsettingLbConfig( + subsetSize, mockChildLbProvider, childConfig); + + loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.of(new EquivalentAddressGroup(mockSocketAddress))) + .setLoadBalancingPolicyConfig(config) + .build()); + + loadBalancer.handleNameResolutionError(Status.DEADLINE_EXCEEDED); + verify(mockChildLb).handleNameResolutionError(Status.DEADLINE_EXCEEDED); + } + + @Test + public void shutdown() { + int subsetSize = 2; + Object childConfig = "someConfig"; + + RandomSubsettingLoadBalancerConfig config = createRandomSubsettingLbConfig( + subsetSize, mockChildLbProvider, childConfig); + + loadBalancer.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.of(new EquivalentAddressGroup(mockSocketAddress))) + .setLoadBalancingPolicyConfig(config) + .build()); + + loadBalancer.shutdown(); + verify(mockChildLb).shutdown(); + } + + @Test + public void acceptResolvedAddresses_mockedChildLbPolicy() { + int subsetSize = 3; + Object childConfig = "someConfig"; + + RandomSubsettingLoadBalancerConfig config = createRandomSubsettingLbConfig( + subsetSize, mockChildLbProvider, childConfig); + + ResolvedAddresses resolvedAddresses = + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.copyOf(backendDetails.servers)) + .setLoadBalancingPolicyConfig(config) + .build(); + + loadBalancer.acceptResolvedAddresses(resolvedAddresses); + + verify(mockChildLb).acceptResolvedAddresses(resolvedAddrCaptor.capture()); + assertThat(resolvedAddrCaptor.getValue().getAddresses().size()).isEqualTo(subsetSize); + assertThat(resolvedAddrCaptor.getValue().getLoadBalancingPolicyConfig()).isEqualTo(childConfig); + } + + @Test + public void acceptResolvedAddresses_roundRobinChildLbPolicy() { + int subsetSize = 3; + Object childConfig = null; + + RandomSubsettingLoadBalancerConfig config = createRandomSubsettingLbConfig( + subsetSize, roundRobinLbProvider, childConfig); + + ResolvedAddresses resolvedAddresses = + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.copyOf(backendDetails.servers)) + .setLoadBalancingPolicyConfig(config) + .build(); + + loadBalancer.acceptResolvedAddresses(resolvedAddresses); + + int insubset = 0; + for (Subchannel subchannel : backendDetails.subchannels.values()) { + LoadBalancer.SubchannelStateListener ssl = + backendDetails.subchannelStateListeners.get(subchannel); + if (ssl != null) { // it might be null if it's not in the subset. + insubset += 1; + ssl.onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + } + } + + assertThat(insubset).isEqualTo(subsetSize); + } + + // verifies: https://github.com/grpc/proposal/blob/master/A68_graphics/subsetting100-100-5.png + @Test + public void backendsCanBeDistributedEvenly_subsetting100_100_5() { + verifyConnectionsByServer(100, 100, 5, 15); + } + + // verifies https://github.com/grpc/proposal/blob/master/A68_graphics/subsetting100-100-25.png + @Test + public void backendsCanBeDistributedEvenly_subsetting100_100_25() { + verifyConnectionsByServer(100, 100, 25, 40); + } + + // verifies: https://github.com/grpc/proposal/blob/master/A68_graphics/subsetting100-10-5.png + @Test + public void backendsCanBeDistributedEvenly_subsetting100_10_5() { + verifyConnectionsByServer(100, 10, 5, 65); + } + + // verifies: https://github.com/grpc/proposal/blob/master/A68_graphics/subsetting500-10-5.png + @Test + public void backendsCanBeDistributedEvenly_subsetting500_10_5() { + verifyConnectionsByServer(500, 10, 5, 600); + } + + // verifies: https://github.com/grpc/proposal/blob/master/A68_graphics/subsetting2000-10-5.png + @Test + public void backendsCanBeDistributedEvenly_subsetting2000_100_5() { + verifyConnectionsByServer(2000, 10, 5, 1200); + } + + public void verifyConnectionsByServer( + int clientsCount, int serversCount, int subsetSize, int expectedMaxConnections) { + backendDetails = setupBackends(serversCount); + Object childConfig = "someConfig"; + + List configs = Lists.newArrayList(); + for (int i = 0; i < clientsCount; i++) { + configs.add(createRandomSubsettingLbConfig(subsetSize, mockChildLbProvider, childConfig)); + } + + Map connectionsByServer = Maps.newLinkedHashMap(); + + for (int i = 0; i < clientsCount; i++) { + RandomSubsettingLoadBalancerConfig config = configs.get(i); + + ResolvedAddresses resolvedAddresses = + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.copyOf(backendDetails.servers)) + .setLoadBalancingPolicyConfig(config) + .build(); + + loadBalancer = new RandomSubsettingLoadBalancer(mockHelper, i); + loadBalancer.acceptResolvedAddresses(resolvedAddresses); + + verify(mockChildLb, atLeastOnce()).acceptResolvedAddresses(resolvedAddrCaptor.capture()); + // Verify ChildLB is only getting subsetSize ResolvedAddresses each time + assertThat(resolvedAddrCaptor.getValue().getAddresses().size()).isEqualTo(config.subsetSize); + + for (EquivalentAddressGroup eag : resolvedAddrCaptor.getValue().getAddresses()) { + for (SocketAddress addr : eag.getAddresses()) { + Integer prev = connectionsByServer.getOrDefault(addr, 0); + connectionsByServer.put(addr, prev + 1); + } + } + } + + int maxConnections = Collections.max(connectionsByServer.values()); + + assertThat(maxConnections).isAtMost(expectedMaxConnections); + } + + private class BackendDetails { + private final List servers; + private final Map, Subchannel> subchannels; + private final Map subchannelStateListeners; + + BackendDetails(List servers, + Map, Subchannel> subchannels) { + this.servers = servers; + this.subchannels = subchannels; + this.subchannelStateListeners = Maps.newLinkedHashMap(); + + when(mockHelper.createSubchannel(any(LoadBalancer.CreateSubchannelArgs.class))).then( + new Answer() { + @Override + public Subchannel answer(InvocationOnMock invocation) throws Throwable { + CreateSubchannelArgs args = (CreateSubchannelArgs) invocation.getArguments()[0]; + final Subchannel subchannel = backendDetails.subchannels.get(args.getAddresses()); + when(subchannel.getAllAddresses()).thenReturn(args.getAddresses()); + when(subchannel.getAttributes()).thenReturn(args.getAttributes()); + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + subchannelStateListeners.put(subchannel, + (SubchannelStateListener) invocation.getArguments()[0]); + return null; + } + }).when(subchannel).start(any(SubchannelStateListener.class)); + return subchannel; + } + }); + } + } + + private static class FakeSocketAddress extends SocketAddress { + final String name; + + FakeSocketAddress(String name) { + this.name = name; + } + + @Override + public String toString() { + return "FakeSocketAddress-" + name; + } + } +} diff --git a/xds/BUILD.bazel b/xds/BUILD.bazel index 66c790a654d..47831c5139c 100644 --- a/xds/BUILD.bazel +++ b/xds/BUILD.bazel @@ -1,6 +1,7 @@ -load("@rules_java//java:defs.bzl", "java_binary", "java_library", "java_proto_library", "java_test") -load("@rules_proto//proto:defs.bzl", "proto_library") load("@bazel_jar_jar//:jar_jar.bzl", "jar_jar") +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_java//java:defs.bzl", "java_binary", "java_library", "java_test") load("@rules_jvm_external//:defs.bzl", "artifact") load("//:java_grpc_library.bzl", "INTERNAL_java_grpc_library_for_xds", "java_grpc_library", "java_rpc_toolchain") @@ -40,8 +41,11 @@ java_library( artifact("com.google.errorprone:error_prone_annotations"), artifact("com.google.guava:guava"), artifact("com.google.re2j:re2j"), + artifact("dev.cel:runtime"), + artifact("dev.cel:protobuf"), + artifact("dev.cel:common"), artifact("io.netty:netty-buffer"), - artifact("io.netty:netty-codec"), + artifact("io.netty:netty-codec-base"), artifact("io.netty:netty-common"), artifact("io.netty:netty-handler"), artifact("io.netty:netty-transport"), @@ -96,6 +100,8 @@ JAR_JAR_RULES = [ "rule com.google.api.expr.** io.grpc.xds.shaded.com.google.api.expr.@1", "rule com.google.security.** io.grpc.xds.shaded.com.google.security.@1", "rule dev.cel.expr.** io.grpc.xds.shaded.dev.cel.expr.@1", + "rule dev.cel.** io.grpc.xds.shaded.dev.cel.@1", + "rule cel.** io.grpc.xds.shaded.cel.@1", "rule envoy.annotations.** io.grpc.xds.shaded.envoy.annotations.@1", "rule io.envoyproxy.** io.grpc.xds.shaded.io.envoyproxy.@1", "rule udpa.annotations.** io.grpc.xds.shaded.udpa.annotations.@1", @@ -342,6 +348,7 @@ java_library( ":xds", ":xds_java_proto", "//api", + "//api:test_fixtures", "//core:internal", "//stub", "//testing-proto:simpleservice_java_grpc", diff --git a/xds/build.gradle b/xds/build.gradle index 8394fe12f6b..8036f8691ec 100644 --- a/xds/build.gradle +++ b/xds/build.gradle @@ -56,11 +56,18 @@ dependencies { libraries.re2j, libraries.auto.value.annotations, libraries.protobuf.java.util + implementation(libraries.cel.runtime) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } + implementation(libraries.cel.protobuf) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } def nettyDependency = implementation project(':grpc-netty') testImplementation project(':grpc-api') testImplementation project(':grpc-rls') testImplementation project(':grpc-inprocess') + testImplementation libraries.cel.compiler testImplementation testFixtures(project(':grpc-core')), testFixtures(project(':grpc-api')), testFixtures(project(':grpc-util')) @@ -175,6 +182,7 @@ tasks.named("javadoc").configure { exclude 'io/grpc/xds/XdsNameResolverProvider.java' exclude 'io/grpc/xds/internal/**' exclude 'io/grpc/xds/Internal*' + exclude 'dev/cel/**' } def prefixName = 'io.grpc.xds' @@ -182,6 +190,7 @@ tasks.named("shadowJar").configure { archiveClassifier = null dependencies { include(project(':grpc-xds')) + include(dependency('dev.cel:.*')) } // Relocated packages commonly need exclusions in jacocoTestReport and javadoc // Keep in sync with BUILD.bazel's JAR_JAR_RULES @@ -198,6 +207,8 @@ tasks.named("shadowJar").configure { // TODO: missing java_package option in .proto relocate 'udpa.annotations', "${prefixName}.shaded.udpa.annotations" relocate 'xds.annotations', "${prefixName}.shaded.xds.annotations" + relocate 'dev.cel', "${prefixName}.shaded.dev.cel" + relocate 'cel', "${prefixName}.shaded.cel" exclude "**/*.proto" } diff --git a/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java new file mode 100644 index 00000000000..df9b7a3514b --- /dev/null +++ b/xds/src/generated/thirdparty/grpc/io/envoyproxy/envoy/service/auth/v3/AuthorizationGrpc.java @@ -0,0 +1,377 @@ +package io.envoyproxy.envoy.service.auth.v3; + +import static io.grpc.MethodDescriptor.generateFullMethodName; + +/** + *

+ * A generic interface for performing authorization check on incoming
+ * requests to a networked service.
+ * 
+ */ +@io.grpc.stub.annotations.GrpcGenerated +public final class AuthorizationGrpc { + + private AuthorizationGrpc() {} + + public static final java.lang.String SERVICE_NAME = "envoy.service.auth.v3.Authorization"; + + // Static method descriptors that strictly reflect the proto. + private static volatile io.grpc.MethodDescriptor getCheckMethod; + + @io.grpc.stub.annotations.RpcMethod( + fullMethodName = SERVICE_NAME + '/' + "Check", + requestType = io.envoyproxy.envoy.service.auth.v3.CheckRequest.class, + responseType = io.envoyproxy.envoy.service.auth.v3.CheckResponse.class, + methodType = io.grpc.MethodDescriptor.MethodType.UNARY) + public static io.grpc.MethodDescriptor getCheckMethod() { + io.grpc.MethodDescriptor getCheckMethod; + if ((getCheckMethod = AuthorizationGrpc.getCheckMethod) == null) { + synchronized (AuthorizationGrpc.class) { + if ((getCheckMethod = AuthorizationGrpc.getCheckMethod) == null) { + AuthorizationGrpc.getCheckMethod = getCheckMethod = + io.grpc.MethodDescriptor.newBuilder() + .setType(io.grpc.MethodDescriptor.MethodType.UNARY) + .setFullMethodName(generateFullMethodName(SERVICE_NAME, "Check")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller( + io.envoyproxy.envoy.service.auth.v3.CheckRequest.getDefaultInstance())) + .setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller( + io.envoyproxy.envoy.service.auth.v3.CheckResponse.getDefaultInstance())) + .setSchemaDescriptor(new AuthorizationMethodDescriptorSupplier("Check")) + .build(); + } + } + } + return getCheckMethod; + } + + /** + * Creates a new async stub that supports all call types for the service + */ + public static AuthorizationStub newStub(io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationStub(channel, callOptions); + } + }; + return AuthorizationStub.newStub(factory, channel); + } + + /** + * Creates a new blocking-style stub that supports all types of calls on the service + */ + public static AuthorizationBlockingV2Stub newBlockingV2Stub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingV2Stub(channel, callOptions); + } + }; + return AuthorizationBlockingV2Stub.newStub(factory, channel); + } + + /** + * Creates a new blocking-style stub that supports unary and streaming output calls on the service + */ + public static AuthorizationBlockingStub newBlockingStub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingStub(channel, callOptions); + } + }; + return AuthorizationBlockingStub.newStub(factory, channel); + } + + /** + * Creates a new ListenableFuture-style stub that supports unary calls on the service + */ + public static AuthorizationFutureStub newFutureStub( + io.grpc.Channel channel) { + io.grpc.stub.AbstractStub.StubFactory factory = + new io.grpc.stub.AbstractStub.StubFactory() { + @java.lang.Override + public AuthorizationFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationFutureStub(channel, callOptions); + } + }; + return AuthorizationFutureStub.newStub(factory, channel); + } + + /** + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public interface AsyncService { + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + default void check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request, + io.grpc.stub.StreamObserver responseObserver) { + io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getCheckMethod(), responseObserver); + } + } + + /** + * Base class for the server implementation of the service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static abstract class AuthorizationImplBase + implements io.grpc.BindableService, AsyncService { + + @java.lang.Override public final io.grpc.ServerServiceDefinition bindService() { + return AuthorizationGrpc.bindService(this); + } + } + + /** + * A stub to allow clients to do asynchronous rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationStub + extends io.grpc.stub.AbstractAsyncStub { + private AuthorizationStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationStub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public void check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request, + io.grpc.stub.StreamObserver responseObserver) { + io.grpc.stub.ClientCalls.asyncUnaryCall( + getChannel().newCall(getCheckMethod(), getCallOptions()), request, responseObserver); + } + } + + /** + * A stub to allow clients to do synchronous rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationBlockingV2Stub + extends io.grpc.stub.AbstractBlockingStub { + private AuthorizationBlockingV2Stub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationBlockingV2Stub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingV2Stub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public io.envoyproxy.envoy.service.auth.v3.CheckResponse check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request) throws io.grpc.StatusException { + return io.grpc.stub.ClientCalls.blockingV2UnaryCall( + getChannel(), getCheckMethod(), getCallOptions(), request); + } + } + + /** + * A stub to allow clients to do limited synchronous rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationBlockingStub + extends io.grpc.stub.AbstractBlockingStub { + private AuthorizationBlockingStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationBlockingStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationBlockingStub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public io.envoyproxy.envoy.service.auth.v3.CheckResponse check(io.envoyproxy.envoy.service.auth.v3.CheckRequest request) { + return io.grpc.stub.ClientCalls.blockingUnaryCall( + getChannel(), getCheckMethod(), getCallOptions(), request); + } + } + + /** + * A stub to allow clients to do ListenableFuture-style rpc calls to service Authorization. + *
+   * A generic interface for performing authorization check on incoming
+   * requests to a networked service.
+   * 
+ */ + public static final class AuthorizationFutureStub + extends io.grpc.stub.AbstractFutureStub { + private AuthorizationFutureStub( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + super(channel, callOptions); + } + + @java.lang.Override + protected AuthorizationFutureStub build( + io.grpc.Channel channel, io.grpc.CallOptions callOptions) { + return new AuthorizationFutureStub(channel, callOptions); + } + + /** + *
+     * Performs authorization check based on the attributes associated with the
+     * incoming request, and returns status `OK` or not `OK`.
+     * 
+ */ + public com.google.common.util.concurrent.ListenableFuture check( + io.envoyproxy.envoy.service.auth.v3.CheckRequest request) { + return io.grpc.stub.ClientCalls.futureUnaryCall( + getChannel().newCall(getCheckMethod(), getCallOptions()), request); + } + } + + private static final int METHODID_CHECK = 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) { + case METHODID_CHECK: + serviceImpl.check((io.envoyproxy.envoy.service.auth.v3.CheckRequest) request, + (io.grpc.stub.StreamObserver) responseObserver); + break; + default: + throw new AssertionError(); + } + } + + @java.lang.Override + @java.lang.SuppressWarnings("unchecked") + public io.grpc.stub.StreamObserver invoke( + io.grpc.stub.StreamObserver responseObserver) { + switch (methodId) { + default: + throw new AssertionError(); + } + } + } + + public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) { + return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor()) + .addMethod( + getCheckMethod(), + io.grpc.stub.ServerCalls.asyncUnaryCall( + new MethodHandlers< + io.envoyproxy.envoy.service.auth.v3.CheckRequest, + io.envoyproxy.envoy.service.auth.v3.CheckResponse>( + service, METHODID_CHECK))) + .build(); + } + + private static abstract class AuthorizationBaseDescriptorSupplier + implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier { + AuthorizationBaseDescriptorSupplier() {} + + @java.lang.Override + public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() { + return io.envoyproxy.envoy.service.auth.v3.ExternalAuthProto.getDescriptor(); + } + + @java.lang.Override + public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() { + return getFileDescriptor().findServiceByName("Authorization"); + } + } + + private static final class AuthorizationFileDescriptorSupplier + extends AuthorizationBaseDescriptorSupplier { + AuthorizationFileDescriptorSupplier() {} + } + + private static final class AuthorizationMethodDescriptorSupplier + extends AuthorizationBaseDescriptorSupplier + implements io.grpc.protobuf.ProtoMethodDescriptorSupplier { + private final java.lang.String methodName; + + AuthorizationMethodDescriptorSupplier(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 (AuthorizationGrpc.class) { + result = serviceDescriptor; + if (result == null) { + serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME) + .setSchemaDescriptor(new AuthorizationFileDescriptorSupplier()) + .addMethod(getCheckMethod()) + .build(); + } + } + } + return result; + } +} 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/AddressFilter.java b/xds/src/main/java/io/grpc/xds/AddressFilter.java index 841e96d06bb..8008f638565 100644 --- a/xds/src/main/java/io/grpc/xds/AddressFilter.java +++ b/xds/src/main/java/io/grpc/xds/AddressFilter.java @@ -24,11 +24,12 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.ListIterator; import javax.annotation.Nullable; final class AddressFilter { @ResolutionResultAttr - private static final Attributes.Key PATH_CHAIN_KEY = + static final Attributes.Key PATH_CHAIN_KEY = Attributes.Key.create("io.grpc.xds.AddressFilter.PATH_CHAIN_KEY"); // Prevent instantiation. @@ -41,19 +42,25 @@ private AddressFilter() {} static EquivalentAddressGroup setPathFilter(EquivalentAddressGroup address, List names) { checkNotNull(address, "address"); checkNotNull(names, "names"); - Attributes.Builder attrBuilder = address.getAttributes().toBuilder().discard(PATH_CHAIN_KEY); - PathChain pathChain = null; - for (String name : names) { - if (pathChain == null) { - pathChain = new PathChain(name); - attrBuilder.set(PATH_CHAIN_KEY, pathChain); - } else { - pathChain.next = new PathChain(name); - } - } + Attributes.Builder attrBuilder = address.getAttributes().toBuilder() + .set(PATH_CHAIN_KEY, createPathChain(names)); return new EquivalentAddressGroup(address.getAddresses(), attrBuilder.build()); } + /** + * Creates a PathChain that can be set in an EquivalentAddressGroup's Attributes as a value of + * PATH_CHAIN_KEY. + */ + @Nullable static PathChain createPathChain(List names) { + checkNotNull(names, "names"); + PathChain current = null; + ListIterator iter = names.listIterator(names.size()); + while (iter.hasPrevious()) { + current = new PathChain(iter.previous(), current); + } + return current; + } + /** * Returns the next level hierarchical addresses derived from the given hierarchical addresses * with the given filter name (any non-hierarchical addresses in the input will be ignored). @@ -75,12 +82,13 @@ static List filter(List addresse return Collections.unmodifiableList(filteredAddresses); } - private static final class PathChain { + static final class PathChain { final String name; - @Nullable PathChain next; + @Nullable final PathChain next; - PathChain(String name) { + PathChain(String name, @Nullable PathChain next) { this.name = checkNotNull(name, "name"); + this.next = next; } @Override diff --git a/xds/src/main/java/io/grpc/xds/CdsLoadBalancer2.java b/xds/src/main/java/io/grpc/xds/CdsLoadBalancer2.java index 87963476265..fff22dad960 100644 --- a/xds/src/main/java/io/grpc/xds/CdsLoadBalancer2.java +++ b/xds/src/main/java/io/grpc/xds/CdsLoadBalancer2.java @@ -19,20 +19,36 @@ import static com.google.common.base.Preconditions.checkNotNull; import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; import static io.grpc.xds.XdsLbPolicies.CDS_POLICY_NAME; -import static io.grpc.xds.XdsLbPolicies.CLUSTER_RESOLVER_POLICY_NAME; import static io.grpc.xds.XdsLbPolicies.PRIORITY_POLICY_NAME; +import com.google.common.collect.ImmutableMap; +import com.google.common.primitives.UnsignedInts; import com.google.errorprone.annotations.CheckReturnValue; +import io.grpc.Attributes; +import io.grpc.ConnectivityState; +import io.grpc.EquivalentAddressGroup; +import io.grpc.HttpConnectProxiedSocketAddress; +import io.grpc.InternalEquivalentAddressGroup; import io.grpc.InternalLogId; import io.grpc.LoadBalancer; +import io.grpc.LoadBalancerProvider; import io.grpc.LoadBalancerRegistry; import io.grpc.NameResolver; import io.grpc.Status; import io.grpc.StatusOr; +import io.grpc.internal.GrpcUtil; +import io.grpc.util.ForwardingLoadBalancerHelper; import io.grpc.util.GracefulSwitchLoadBalancer; +import io.grpc.util.OutlierDetectionLoadBalancer.OutlierDetectionLoadBalancerConfig; import io.grpc.xds.CdsLoadBalancerProvider.CdsConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig.DiscoveryMechanism; +import io.grpc.xds.ClusterImplLoadBalancerProvider.ClusterImplConfig; +import io.grpc.xds.Endpoints.DropOverload; +import io.grpc.xds.Endpoints.LbEndpoint; +import io.grpc.xds.Endpoints.LocalityLbEndpoints; +import io.grpc.xds.EnvoyServerProtoData.FailurePercentageEjection; +import io.grpc.xds.EnvoyServerProtoData.OutlierDetection; +import io.grpc.xds.EnvoyServerProtoData.SuccessRateEjection; +import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig; import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig.PriorityChildConfig; import io.grpc.xds.XdsClusterResource.CdsUpdate; import io.grpc.xds.XdsClusterResource.CdsUpdate.ClusterType; @@ -40,13 +56,22 @@ import io.grpc.xds.XdsConfig.XdsClusterConfig; import io.grpc.xds.XdsConfig.XdsClusterConfig.AggregateConfig; import io.grpc.xds.XdsConfig.XdsClusterConfig.EndpointConfig; +import io.grpc.xds.XdsEndpointResource.EdsUpdate; +import io.grpc.xds.client.Locality; import io.grpc.xds.client.XdsLogger; import io.grpc.xds.client.XdsLogger.XdsLogLevel; +import io.grpc.xds.internal.XdsInternalAttributes; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeMap; /** * Load balancer for cds_experimental LB policy. One instance per top-level cluster. @@ -54,10 +79,16 @@ * by a group of sub-clusters in a tree hierarchy. */ final class CdsLoadBalancer2 extends LoadBalancer { + static boolean pickFirstWeightedShuffling = + GrpcUtil.getFlag("GRPC_EXPERIMENTAL_PF_WEIGHTED_SHUFFLING", true); + private final XdsLogger logger; private final Helper helper; private final LoadBalancerRegistry lbRegistry; + private final ClusterState clusterState = new ClusterState(); + private final CdsLbHelper cdsLbHelper = new CdsLbHelper(); private GracefulSwitchLoadBalancer delegate; + private boolean addBackendServicePickDetailsLabel; // Following fields are effectively final. private String clusterName; private Subscription clusterSubscription; @@ -65,7 +96,7 @@ final class CdsLoadBalancer2 extends LoadBalancer { CdsLoadBalancer2(Helper helper, LoadBalancerRegistry lbRegistry) { this.helper = checkNotNull(helper, "helper"); this.lbRegistry = checkNotNull(lbRegistry, "lbRegistry"); - this.delegate = new GracefulSwitchLoadBalancer(helper); + this.delegate = new GracefulSwitchLoadBalancer(cdsLbHelper); logger = XdsLogger.withLogId(InternalLogId.allocate("cds-lb", helper.getAuthority())); logger.log(XdsLogLevel.INFO, "Created"); } @@ -99,46 +130,44 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { } XdsClusterConfig clusterConfig = clusterConfigOr.getValue(); - NameResolver.ConfigOrError configOrError; - Object gracefulConfig; if (clusterConfig.getChildren() instanceof EndpointConfig) { - // The LB policy config is provided in service_config.proto/JSON format. - configOrError = - GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( - Arrays.asList(clusterConfig.getClusterResource().lbPolicyConfig()), - lbRegistry); - if (configOrError.getError() != null) { - // Should be impossible, because XdsClusterResource validated this - return fail(Status.INTERNAL.withDescription( - errorPrefix() + "Unable to parse the LB config: " + configOrError.getError())); - } - CdsUpdate result = clusterConfig.getClusterResource(); - DiscoveryMechanism instance; - if (result.clusterType() == ClusterType.EDS) { - instance = DiscoveryMechanism.forEds( - clusterName, - result.edsServiceName(), - result.lrsServerInfo(), - result.maxConcurrentRequests(), - result.upstreamTlsContext(), - result.filterMetadata(), - result.outlierDetection()); - } else { - instance = DiscoveryMechanism.forLogicalDns( - clusterName, - result.dnsHostName(), - result.lrsServerInfo(), - result.maxConcurrentRequests(), - result.upstreamTlsContext(), - result.filterMetadata()); - } - gracefulConfig = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - lbRegistry.getProvider(CLUSTER_RESOLVER_POLICY_NAME), - new ClusterResolverConfig( - instance, - configOrError.getConfig(), - clusterConfig.getClusterResource().isHttp11ProxyAvailable())); + addBackendServicePickDetailsLabel = true; + StatusOr edsUpdate = getEdsUpdate(xdsConfig, clusterName); + StatusOr statusOrResult = clusterState.edsUpdateToResult( + clusterName, + clusterConfig.getClusterResource(), + clusterConfig.getClusterResource().lbPolicyConfig(), + edsUpdate); + if (!statusOrResult.hasValue()) { + Status status = Status.UNAVAILABLE + .withDescription(statusOrResult.getStatus().getDescription()) + .withCause(statusOrResult.getStatus().getCause()); + delegate.handleNameResolutionError(status); + return status; + } + ClusterResolutionResult result = statusOrResult.getValue(); + List addresses = result.addresses; + if (addresses.isEmpty()) { + Status status = Status.UNAVAILABLE + .withDescription("No usable endpoint from cluster: " + clusterName); + delegate.handleNameResolutionError(status); + return status; + } + Object gracefulConfig = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( + lbRegistry.getProvider(PRIORITY_POLICY_NAME), + new PriorityLbConfig( + Collections.unmodifiableMap(result.priorityChildConfigs), + Collections.unmodifiableList(result.priorities))); + return delegate.acceptResolvedAddresses( + resolvedAddresses.toBuilder() + .setLoadBalancingPolicyConfig(gracefulConfig) + .setAddresses(Collections.unmodifiableList(addresses)) + .setAttributes(resolvedAddresses.getAttributes().toBuilder() + .set(NameResolver.ATTR_BACKEND_SERVICE, clusterName) + .build()) + .build()); } else if (clusterConfig.getChildren() instanceof AggregateConfig) { + addBackendServicePickDetailsLabel = false; Map priorityChildConfigs = new HashMap<>(); List leafClusters = ((AggregateConfig) clusterConfig.getChildren()).getLeafNames(); for (String childCluster: leafClusters) { @@ -149,18 +178,17 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { new CdsConfig(childCluster)), false)); } - gracefulConfig = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( + Object gracefulConfig = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( lbRegistry.getProvider(PRIORITY_POLICY_NAME), new PriorityLoadBalancerProvider.PriorityLbConfig( Collections.unmodifiableMap(priorityChildConfigs), leafClusters)); + return delegate.acceptResolvedAddresses( + resolvedAddresses.toBuilder().setLoadBalancingPolicyConfig(gracefulConfig).build()); } else { return fail(Status.INTERNAL.withDescription( errorPrefix() + "Unexpected cluster children type: " + clusterConfig.getChildren().getClass())); } - - return delegate.acceptResolvedAddresses( - resolvedAddresses.toBuilder().setLoadBalancingPolicyConfig(gracefulConfig).build()); } @Override @@ -178,7 +206,8 @@ public void handleNameResolutionError(Status error) { public void shutdown() { logger.log(XdsLogLevel.INFO, "Shutdown"); delegate.shutdown(); - delegate = new GracefulSwitchLoadBalancer(helper); + delegate = new GracefulSwitchLoadBalancer(cdsLbHelper); + addBackendServicePickDetailsLabel = false; if (clusterSubscription != null) { clusterSubscription.close(); clusterSubscription = null; @@ -188,6 +217,7 @@ public void shutdown() { @CheckReturnValue // don't forget to return up the stack after the fail call private Status fail(Status error) { delegate.shutdown(); + addBackendServicePickDetailsLabel = false; helper.updateBalancingState( TRANSIENT_FAILURE, new FixedResultPicker(PickResult.withError(error))); return Status.OK; // XdsNameResolver isn't a polling NR, so this value doesn't matter @@ -196,4 +226,426 @@ private Status fail(Status error) { private String errorPrefix() { return "CdsLb for " + clusterName + ": "; } + + private final class CdsLbHelper extends ForwardingLoadBalancerHelper { + @Override + public void updateBalancingState(ConnectivityState newState, SubchannelPicker newPicker) { + if (addBackendServicePickDetailsLabel) { + newPicker = new BackendServiceMetricLabelSubchannelPicker(newPicker, clusterName); + } + delegate().updateBalancingState(newState, newPicker); + } + + @Override + protected Helper delegate() { + return helper; + } + } + + private static final class BackendServiceMetricLabelSubchannelPicker extends SubchannelPicker { + private final SubchannelPicker delegate; + private final String backendService; + + private BackendServiceMetricLabelSubchannelPicker( + SubchannelPicker delegate, String backendService) { + this.delegate = checkNotNull(delegate, "delegate"); + this.backendService = checkNotNull(backendService, "backendService"); + } + + @Override + public PickResult pickSubchannel(PickSubchannelArgs args) { + args.getPickDetailsConsumer().addOptionalLabel("grpc.lb.backend_service", backendService); + return delegate.pickSubchannel(args); + } + } + + /** + * The number of bits assigned to the fractional part of fixed-point values. We normalize weights + * to a fixed-point number between 0 and 1, representing that item's proportion of traffic (1 == + * 100% of traffic). We reserve at least one bit for the whole number so that we don't need to + * special case a single item, and so that we can round up very low values without risking uint32 + * overflow of the sum of weights. + */ + private static final int FIXED_POINT_FRACTIONAL_BITS = 31; + + /** Divide two uint32s and produce a fixed-point uint32 result. */ + private static long fractionToFixedPoint(long numerator, long denominator) { + long one = 1L << FIXED_POINT_FRACTIONAL_BITS; + return numerator * one / denominator; + } + + /** Multiply two uint32 fixed-point numbers, returning a uint32 fixed-point. */ + private static long fixedPointMultiply(long a, long b) { + return (a * b) >> FIXED_POINT_FRACTIONAL_BITS; + } + + private static StatusOr getEdsUpdate(XdsConfig xdsConfig, String cluster) { + StatusOr clusterConfig = xdsConfig.getClusters().get(cluster); + if (clusterConfig == null) { + return StatusOr.fromStatus(Status.INTERNAL + .withDescription("BUG: cluster resolver could not find cluster in xdsConfig")); + } + if (!clusterConfig.hasValue()) { + return StatusOr.fromStatus(clusterConfig.getStatus()); + } + if (!(clusterConfig.getValue().getChildren() instanceof XdsClusterConfig.EndpointConfig)) { + return StatusOr.fromStatus(Status.INTERNAL + .withDescription("BUG: cluster resolver cluster with children of unknown type")); + } + XdsClusterConfig.EndpointConfig endpointConfig = + (XdsClusterConfig.EndpointConfig) clusterConfig.getValue().getChildren(); + return endpointConfig.getEndpoint(); + } + + /** + * Generates a string that represents the priority in the LB policy config. The string is unique + * across priorities in all clusters and priorityName(c, p1) < priorityName(c, p2) iff p1 < p2. + * The ordering is undefined for priorities in different clusters. + */ + private static String priorityName(String cluster, int priority) { + return cluster + "[child" + priority + "]"; + } + + /** + * Generates a string that represents the locality in the LB policy config. The string is unique + * across all localities in all clusters. + */ + private static String localityName(Locality locality) { + return "{region=\"" + locality.region() + + "\", zone=\"" + locality.zone() + + "\", sub_zone=\"" + locality.subZone() + + "\"}"; + } + + private final class ClusterState { + private Map localityPriorityNames = Collections.emptyMap(); + int priorityNameGenId = 1; + + StatusOr edsUpdateToResult( + String clusterName, + CdsUpdate discovery, + Object lbConfig, + StatusOr updateOr) { + if (!updateOr.hasValue()) { + return StatusOr.fromStatus(updateOr.getStatus()); + } + EdsUpdate update = updateOr.getValue(); + logger.log(XdsLogLevel.DEBUG, "Received endpoint update {0}", update); + if (logger.isLoggable(XdsLogLevel.INFO)) { + logger.log(XdsLogLevel.INFO, "Cluster {0}: {1} localities, {2} drop categories", + clusterName, update.localityLbEndpointsMap.size(), + update.dropPolicies.size()); + } + Map localityLbEndpoints = + update.localityLbEndpointsMap; + List dropOverloads = update.dropPolicies; + List addresses = new ArrayList<>(); + Map> prioritizedLocalityWeights = new HashMap<>(); + List sortedPriorityNames = + generatePriorityNames(clusterName, localityLbEndpoints); + Map priorityLocalityWeightSums; + if (pickFirstWeightedShuffling) { + priorityLocalityWeightSums = new HashMap<>(sortedPriorityNames.size() * 2); + for (Locality locality : localityLbEndpoints.keySet()) { + LocalityLbEndpoints localityLbInfo = localityLbEndpoints.get(locality); + String priorityName = localityPriorityNames.get(locality); + Long sum = priorityLocalityWeightSums.get(priorityName); + if (sum == null) { + sum = 0L; + } + long weight = UnsignedInts.toLong(localityLbInfo.localityWeight()); + priorityLocalityWeightSums.put(priorityName, sum + weight); + } + } else { + priorityLocalityWeightSums = null; + } + + for (Locality locality : localityLbEndpoints.keySet()) { + LocalityLbEndpoints localityLbInfo = localityLbEndpoints.get(locality); + String priorityName = localityPriorityNames.get(locality); + String localityName = localityName(locality); + AddressFilter.PathChain pathChain = + AddressFilter.createPathChain(Arrays.asList(priorityName, localityName)); + + boolean discard = true; + // These sums _should_ fit in uint32, but XdsEndpointResource isn't actually verifying that + // is true today. Since we are using long to avoid signedness trouble, the math happens to + // still work if it turns out the sums exceed uint32. + long localityWeightSum = 0; + long endpointWeightSum = 0; + if (pickFirstWeightedShuffling) { + localityWeightSum = priorityLocalityWeightSums.get(priorityName); + for (LbEndpoint endpoint : localityLbInfo.endpoints()) { + if (endpoint.isHealthy()) { + endpointWeightSum += UnsignedInts.toLong(endpoint.loadBalancingWeight()); + } + } + } + for (LbEndpoint endpoint : localityLbInfo.endpoints()) { + if (endpoint.isHealthy()) { + discard = false; + long weight; + if (pickFirstWeightedShuffling) { + // Combine locality and endpoint weights as defined by gRFC A113 + long localityWeight = fractionToFixedPoint( + UnsignedInts.toLong(localityLbInfo.localityWeight()), localityWeightSum); + long endpointWeight = fractionToFixedPoint( + UnsignedInts.toLong(endpoint.loadBalancingWeight()), endpointWeightSum); + weight = fixedPointMultiply(localityWeight, endpointWeight); + if (weight == 0) { + weight = 1; + } + } else { + weight = localityLbInfo.localityWeight(); + if (endpoint.loadBalancingWeight() != 0) { + weight *= endpoint.loadBalancingWeight(); + } + } + + Attributes attr = + endpoint.eag().getAttributes().toBuilder() + .set(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE, clusterName) + .set(io.grpc.xds.XdsAttributes.ATTR_LOCALITY, locality) + .set(EquivalentAddressGroup.ATTR_LOCALITY_NAME, localityName) + .set(io.grpc.xds.XdsAttributes.ATTR_LOCALITY_WEIGHT, + localityLbInfo.localityWeight()) + .set(io.grpc.xds.XdsAttributes.ATTR_SERVER_WEIGHT, weight) + .set(XdsInternalAttributes.ATTR_ADDRESS_NAME, endpoint.hostname()) + .set(AddressFilter.PATH_CHAIN_KEY, pathChain) + .build(); + EquivalentAddressGroup eag; + if (discovery.isHttp11ProxyAvailable()) { + List rewrittenAddresses = new ArrayList<>(); + for (SocketAddress addr : endpoint.eag().getAddresses()) { + rewrittenAddresses.add(rewriteAddress( + addr, endpoint.endpointMetadata(), localityLbInfo.localityMetadata())); + } + eag = new EquivalentAddressGroup(rewrittenAddresses, attr); + } else { + eag = new EquivalentAddressGroup(endpoint.eag().getAddresses(), attr); + } + addresses.add(eag); + } + } + if (discard) { + logger.log(XdsLogLevel.INFO, + "Discard locality {0} with 0 healthy endpoints", locality); + continue; + } + if (!prioritizedLocalityWeights.containsKey(priorityName)) { + prioritizedLocalityWeights.put(priorityName, new HashMap()); + } + prioritizedLocalityWeights.get(priorityName).put( + locality, localityLbInfo.localityWeight()); + } + if (prioritizedLocalityWeights.isEmpty()) { + // Will still update the result, as if the cluster resource is revoked. + logger.log(XdsLogLevel.INFO, + "Cluster {0} has no usable priority/locality/endpoint", clusterName); + } + sortedPriorityNames.retainAll(prioritizedLocalityWeights.keySet()); + Map priorityChildConfigs = + generatePriorityChildConfigs( + clusterName, discovery, lbConfig, lbRegistry, + prioritizedLocalityWeights, dropOverloads); + return StatusOr.fromValue(new ClusterResolutionResult(addresses, priorityChildConfigs, + sortedPriorityNames)); + } + + private SocketAddress rewriteAddress(SocketAddress addr, + ImmutableMap endpointMetadata, + ImmutableMap localityMetadata) { + if (!(addr instanceof InetSocketAddress)) { + return addr; + } + + SocketAddress proxyAddress; + try { + proxyAddress = (SocketAddress) endpointMetadata.get( + "envoy.http11_proxy_transport_socket.proxy_address"); + if (proxyAddress == null) { + proxyAddress = (SocketAddress) localityMetadata.get( + "envoy.http11_proxy_transport_socket.proxy_address"); + } + } catch (ClassCastException e) { + return addr; + } + + if (proxyAddress == null) { + return addr; + } + + return HttpConnectProxiedSocketAddress.newBuilder() + .setTargetAddress((InetSocketAddress) addr) + .setProxyAddress(proxyAddress) + .build(); + } + + private List generatePriorityNames(String name, + Map localityLbEndpoints) { + TreeMap> todo = new TreeMap<>(); + for (Locality locality : localityLbEndpoints.keySet()) { + int priority = localityLbEndpoints.get(locality).priority(); + if (!todo.containsKey(priority)) { + todo.put(priority, new ArrayList<>()); + } + todo.get(priority).add(locality); + } + Map newNames = new HashMap<>(); + Set usedNames = new HashSet<>(); + List ret = new ArrayList<>(); + for (Integer priority: todo.keySet()) { + String foundName = ""; + for (Locality locality : todo.get(priority)) { + if (localityPriorityNames.containsKey(locality) + && usedNames.add(localityPriorityNames.get(locality))) { + foundName = localityPriorityNames.get(locality); + break; + } + } + if ("".equals(foundName)) { + foundName = priorityName(name, priorityNameGenId++); + } + for (Locality locality : todo.get(priority)) { + newNames.put(locality, foundName); + } + ret.add(foundName); + } + localityPriorityNames = newNames; + return ret; + } + } + + private static class ClusterResolutionResult { + // Endpoint addresses. + private final List addresses; + // Config (include load balancing policy/config) for each priority in the cluster. + private final Map priorityChildConfigs; + // List of priority names ordered in descending priorities. + private final List priorities; + + ClusterResolutionResult(List addresses, + Map configs, List priorities) { + this.addresses = addresses; + this.priorityChildConfigs = configs; + this.priorities = priorities; + } + } + + /** + * Generates configs to be used in the priority LB policy for priorities in a cluster. + * + *

priority LB -> cluster_impl LB (one per priority) -> (weighted_target LB + * -> round_robin / least_request_experimental (one per locality)) / ring_hash_experimental + */ + private static Map generatePriorityChildConfigs( + String clusterName, + CdsUpdate discovery, + Object endpointLbConfig, + LoadBalancerRegistry lbRegistry, + Map> prioritizedLocalityWeights, + List dropOverloads) { + Map configs = new HashMap<>(); + for (String priority : prioritizedLocalityWeights.keySet()) { + ClusterImplConfig clusterImplConfig = + new ClusterImplConfig( + clusterName, discovery.edsServiceName(), discovery.lrsServerInfo(), + discovery.maxConcurrentRequests(), dropOverloads, endpointLbConfig, + discovery.upstreamTlsContext(), discovery.filterMetadata(), + discovery.backendMetricPropagation()); + LoadBalancerProvider clusterImplLbProvider = + lbRegistry.getProvider(XdsLbPolicies.CLUSTER_IMPL_POLICY_NAME); + Object priorityChildPolicy = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( + clusterImplLbProvider, clusterImplConfig); + + // If outlier detection has been configured we wrap the child policy in the outlier detection + // load balancer. + if (discovery.outlierDetection() != null) { + LoadBalancerProvider outlierDetectionProvider = lbRegistry.getProvider( + "outlier_detection_experimental"); + priorityChildPolicy = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( + outlierDetectionProvider, + buildOutlierDetectionLbConfig(discovery.outlierDetection(), priorityChildPolicy)); + } + + boolean isEds = discovery.clusterType() == ClusterType.EDS; + PriorityChildConfig priorityChildConfig = + new PriorityChildConfig(priorityChildPolicy, isEds /* ignoreReresolution */); + configs.put(priority, priorityChildConfig); + } + return configs; + } + + /** + * Converts {@link OutlierDetection} that represents the xDS configuration to {@link + * OutlierDetectionLoadBalancerConfig} that the {@link io.grpc.util.OutlierDetectionLoadBalancer} + * understands. + */ + private static OutlierDetectionLoadBalancerConfig buildOutlierDetectionLbConfig( + OutlierDetection outlierDetection, Object childConfig) { + OutlierDetectionLoadBalancerConfig.Builder configBuilder + = new OutlierDetectionLoadBalancerConfig.Builder(); + + configBuilder.setChildConfig(childConfig); + + if (outlierDetection.intervalNanos() != null) { + configBuilder.setIntervalNanos(outlierDetection.intervalNanos()); + } + if (outlierDetection.baseEjectionTimeNanos() != null) { + configBuilder.setBaseEjectionTimeNanos(outlierDetection.baseEjectionTimeNanos()); + } + if (outlierDetection.maxEjectionTimeNanos() != null) { + configBuilder.setMaxEjectionTimeNanos(outlierDetection.maxEjectionTimeNanos()); + } + if (outlierDetection.maxEjectionPercent() != null) { + configBuilder.setMaxEjectionPercent(outlierDetection.maxEjectionPercent()); + } + + SuccessRateEjection successRate = outlierDetection.successRateEjection(); + if (successRate != null) { + OutlierDetectionLoadBalancerConfig.SuccessRateEjection.Builder + successRateConfigBuilder = new OutlierDetectionLoadBalancerConfig + .SuccessRateEjection.Builder(); + + if (successRate.stdevFactor() != null) { + successRateConfigBuilder.setStdevFactor(successRate.stdevFactor()); + } + if (successRate.enforcementPercentage() != null) { + successRateConfigBuilder.setEnforcementPercentage(successRate.enforcementPercentage()); + } + if (successRate.minimumHosts() != null) { + successRateConfigBuilder.setMinimumHosts(successRate.minimumHosts()); + } + if (successRate.requestVolume() != null) { + successRateConfigBuilder.setRequestVolume(successRate.requestVolume()); + } + + configBuilder.setSuccessRateEjection(successRateConfigBuilder.build()); + } + + FailurePercentageEjection failurePercentage = outlierDetection.failurePercentageEjection(); + if (failurePercentage != null) { + OutlierDetectionLoadBalancerConfig.FailurePercentageEjection.Builder + failurePercentageConfigBuilder = new OutlierDetectionLoadBalancerConfig + .FailurePercentageEjection.Builder(); + + if (failurePercentage.threshold() != null) { + failurePercentageConfigBuilder.setThreshold(failurePercentage.threshold()); + } + if (failurePercentage.enforcementPercentage() != null) { + failurePercentageConfigBuilder.setEnforcementPercentage( + failurePercentage.enforcementPercentage()); + } + if (failurePercentage.minimumHosts() != null) { + failurePercentageConfigBuilder.setMinimumHosts(failurePercentage.minimumHosts()); + } + if (failurePercentage.requestVolume() != null) { + failurePercentageConfigBuilder.setRequestVolume(failurePercentage.requestVolume()); + } + + configBuilder.setFailurePercentageEjection(failurePercentageConfigBuilder.build()); + } + + return configBuilder.build(); + } } diff --git a/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancer.java b/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancer.java index fba66e2e8d7..81d28168737 100644 --- a/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancer.java +++ b/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancer.java @@ -17,6 +17,7 @@ package io.grpc.xds; import static com.google.common.base.Preconditions.checkNotNull; +import static io.grpc.xds.client.LoadStatsManager2.isEnabledOrcaLrsPropagation; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; @@ -32,11 +33,9 @@ import io.grpc.InternalLogId; import io.grpc.LoadBalancer; import io.grpc.Metadata; -import io.grpc.NameResolver; import io.grpc.Status; import io.grpc.internal.ForwardingClientStreamTracer; import io.grpc.internal.GrpcUtil; -import io.grpc.internal.ObjectPool; import io.grpc.services.MetricReport; import io.grpc.util.ForwardingLoadBalancerHelper; import io.grpc.util.ForwardingSubchannel; @@ -46,6 +45,7 @@ import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; import io.grpc.xds.ThreadSafeRandom.ThreadSafeRandomImpl; import io.grpc.xds.XdsNameResolverProvider.CallCounterProvider; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.Bootstrapper.ServerInfo; import io.grpc.xds.client.LoadStatsManager2.ClusterDropStats; import io.grpc.xds.client.LoadStatsManager2.ClusterLocalityStats; @@ -53,6 +53,7 @@ import io.grpc.xds.client.XdsClient; import io.grpc.xds.client.XdsLogger; import io.grpc.xds.client.XdsLogger.XdsLogLevel; +import io.grpc.xds.internal.XdsInternalAttributes; import io.grpc.xds.internal.security.SecurityProtocolNegotiators; import io.grpc.xds.internal.security.SslContextProviderSupplier; import io.grpc.xds.orca.OrcaPerRequestUtil; @@ -85,6 +86,9 @@ final class ClusterImplLoadBalancer extends LoadBalancer { private static final Attributes.Key> ATTR_CLUSTER_LOCALITY = Attributes.Key.create("io.grpc.xds.ClusterImplLoadBalancer.clusterLocality"); + @VisibleForTesting + static final Attributes.Key ATTR_SUBCHANNEL_ADDRESS_NAME = + Attributes.Key.create("io.grpc.xds.ClusterImplLoadBalancer.addressName"); private final XdsLogger logger; private final Helper helper; @@ -93,7 +97,6 @@ final class ClusterImplLoadBalancer extends LoadBalancer { private String cluster; @Nullable private String edsServiceName; - private ObjectPool xdsClientPool; private XdsClient xdsClient; private CallCounterProvider callCounterProvider; private ClusterDropStats dropStats; @@ -116,13 +119,11 @@ final class ClusterImplLoadBalancer extends LoadBalancer { public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { logger.log(XdsLogLevel.DEBUG, "Received resolution result: {0}", resolvedAddresses); Attributes attributes = resolvedAddresses.getAttributes(); - if (xdsClientPool == null) { - xdsClientPool = attributes.get(XdsAttributes.XDS_CLIENT_POOL); - assert xdsClientPool != null; - xdsClient = xdsClientPool.getObject(); + if (xdsClient == null) { + xdsClient = checkNotNull(attributes.get(io.grpc.xds.XdsAttributes.XDS_CLIENT), "xdsClient"); } if (callCounterProvider == null) { - callCounterProvider = attributes.get(XdsAttributes.CALL_COUNTER_PROVIDER); + callCounterProvider = attributes.get(io.grpc.xds.XdsAttributes.CALL_COUNTER_PROVIDER); } ClusterImplConfig config = @@ -148,15 +149,12 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { childLbHelper.updateMaxConcurrentRequests(config.maxConcurrentRequests); childLbHelper.updateSslContextProviderSupplier(config.tlsContext); childLbHelper.updateFilterMetadata(config.filterMetadata); + childLbHelper.updateBackendMetricPropagation(config.backendMetricPropagation); - childSwitchLb.handleResolvedAddresses( + return childSwitchLb.acceptResolvedAddresses( resolvedAddresses.toBuilder() - .setAttributes(attributes.toBuilder() - .set(NameResolver.ATTR_BACKEND_SERVICE, cluster) - .build()) .setLoadBalancingPolicyConfig(config.childConfig) .build()); - return Status.OK; } @Override @@ -188,9 +186,7 @@ public void shutdown() { childLbHelper = null; } } - if (xdsClient != null) { - xdsClient = xdsClientPool.returnObject(xdsClient); - } + xdsClient = null; } /** @@ -208,6 +204,8 @@ private final class ClusterImplLbHelper extends ForwardingLoadBalancerHelper { private Map filterMetadata = ImmutableMap.of(); @Nullable private final ServerInfo lrsServerInfo; + @Nullable + private BackendMetricPropagation backendMetricPropagation; private ClusterImplLbHelper(AtomicLong inFlights, @Nullable ServerInfo lrsServerInfo) { this.inFlights = checkNotNull(inFlights, "inFlights"); @@ -241,50 +239,63 @@ public Subchannel createSubchannel(CreateSubchannelArgs args) { .set(ATTR_CLUSTER_LOCALITY, localityAtomicReference); if (GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE", false)) { String hostname = args.getAddresses().get(0).getAttributes() - .get(XdsAttributes.ATTR_ADDRESS_NAME); + .get(XdsInternalAttributes.ATTR_ADDRESS_NAME); if (hostname != null) { - attrsBuilder.set(XdsAttributes.ATTR_ADDRESS_NAME, hostname); + attrsBuilder.set(ATTR_SUBCHANNEL_ADDRESS_NAME, hostname); } } args = args.toBuilder().setAddresses(addresses).setAttributes(attrsBuilder.build()).build(); final Subchannel subchannel = delegate().createSubchannel(args); - return new ForwardingSubchannel() { - @Override - public void start(SubchannelStateListener listener) { - delegate().start(new SubchannelStateListener() { - @Override - public void onSubchannelState(ConnectivityStateInfo newState) { - // Do nothing if LB has been shutdown - if (xdsClient != null && newState.getState().equals(ConnectivityState.READY)) { - // Get locality based on the connected address attributes - ClusterLocality updatedClusterLocality = createClusterLocalityFromAttributes( - subchannel.getConnectedAddressAttributes()); - ClusterLocality oldClusterLocality = localityAtomicReference - .getAndSet(updatedClusterLocality); - oldClusterLocality.release(); + return new ClusterImplSubchannel(subchannel, localityAtomicReference); + } + + private final class ClusterImplSubchannel extends ForwardingSubchannel { + private final Subchannel delegate; + private final AtomicReference localityAtomicReference; + + private ClusterImplSubchannel( + Subchannel delegate, AtomicReference localityAtomicReference) { + this.delegate = delegate; + this.localityAtomicReference = localityAtomicReference; + } + + @Override + public void start(SubchannelStateListener listener) { + delegate().start( + new SubchannelStateListener() { + @Override + public void onSubchannelState(ConnectivityStateInfo newState) { + // Do nothing if LB has been shutdown + if (xdsClient != null && newState.getState().equals(ConnectivityState.READY)) { + // Get locality based on the connected address attributes + ClusterLocality updatedClusterLocality = + createClusterLocalityFromAttributes( + delegate.getConnectedAddressAttributes()); + ClusterLocality oldClusterLocality = + localityAtomicReference.getAndSet(updatedClusterLocality); + oldClusterLocality.release(); + } + listener.onSubchannelState(newState); } - listener.onSubchannelState(newState); - } - }); - } + }); + } - @Override - public void shutdown() { - localityAtomicReference.get().release(); - delegate().shutdown(); - } + @Override + public void shutdown() { + localityAtomicReference.get().release(); + delegate().shutdown(); + } - @Override - public void updateAddresses(List addresses) { - delegate().updateAddresses(withAdditionalAttributes(addresses)); - } + @Override + public void updateAddresses(List addresses) { + delegate().updateAddresses(withAdditionalAttributes(addresses)); + } - @Override - protected Subchannel delegate() { - return subchannel; - } - }; + @Override + protected Subchannel delegate() { + return delegate; + } } private List withAdditionalAttributes( @@ -292,7 +303,7 @@ private List withAdditionalAttributes( List newAddresses = new ArrayList<>(); for (EquivalentAddressGroup eag : addresses) { Attributes.Builder attrBuilder = eag.getAttributes().toBuilder().set( - XdsAttributes.ATTR_CLUSTER_NAME, cluster); + io.grpc.xds.XdsAttributes.ATTR_CLUSTER_NAME, cluster); if (sslContextProviderSupplier != null) { attrBuilder.set( SecurityProtocolNegotiators.ATTR_SSL_CONTEXT_PROVIDER_SUPPLIER, @@ -304,7 +315,7 @@ private List withAdditionalAttributes( } private ClusterLocality createClusterLocalityFromAttributes(Attributes addressAttributes) { - Locality locality = addressAttributes.get(XdsAttributes.ATTR_LOCALITY); + Locality locality = addressAttributes.get(io.grpc.xds.XdsAttributes.ATTR_LOCALITY); String localityName = addressAttributes.get(EquivalentAddressGroup.ATTR_LOCALITY_NAME); // Endpoint addresses resolved by ClusterResolverLoadBalancer should always contain @@ -320,7 +331,7 @@ private ClusterLocality createClusterLocalityFromAttributes(Attributes addressAt (lrsServerInfo == null) ? null : xdsClient.addClusterLocalityStats(lrsServerInfo, cluster, - edsServiceName, locality); + edsServiceName, locality, backendMetricPropagation); return new ClusterLocality(localityStats, localityName); } @@ -370,6 +381,11 @@ private void updateFilterMetadata(Map filterMetadata) { this.filterMetadata = ImmutableMap.copyOf(filterMetadata); } + private void updateBackendMetricPropagation( + @Nullable BackendMetricPropagation backendMetricPropagation) { + this.backendMetricPropagation = backendMetricPropagation; + } + private class RequestLimitingSubchannelPicker extends SubchannelPicker { private final SubchannelPicker delegate; private final List dropPolicies; @@ -389,7 +405,6 @@ private RequestLimitingSubchannelPicker(SubchannelPicker delegate, public PickResult pickSubchannel(PickSubchannelArgs args) { args.getCallOptions().getOption(ClusterImplLoadBalancerProvider.FILTER_METADATA_CONSUMER) .accept(filterMetadata); - args.getPickDetailsConsumer().addOptionalLabel("grpc.lb.backend_service", cluster); for (DropOverload dropOverload : dropPolicies) { int rand = random.nextInt(1_000_000); if (rand < dropOverload.dropsPerMillion()) { @@ -404,6 +419,11 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { } PickResult result = delegate.pickSubchannel(args); if (result.getStatus().isOk() && result.getSubchannel() != null) { + Subchannel subchannel = result.getSubchannel(); + if (subchannel instanceof ClusterImplLbHelper.ClusterImplSubchannel) { + subchannel = ((ClusterImplLbHelper.ClusterImplSubchannel) subchannel).delegate(); + result = result.copyWithSubchannel(subchannel); + } if (enableCircuitBreaking) { if (inFlights.get() >= maxConcurrentRequests) { if (dropStats != null) { @@ -429,16 +449,14 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { stats, inFlights, result.getStreamTracerFactory()); ClientStreamTracer.Factory orcaTracerFactory = OrcaPerRequestUtil.getInstance() .newOrcaClientStreamTracerFactory(tracerFactory, new OrcaPerRpcListener(stats)); - result = PickResult.withSubchannel(result.getSubchannel(), - orcaTracerFactory); + result = result.copyWithStreamTracerFactory(orcaTracerFactory); } } if (args.getCallOptions().getOption(XdsNameResolver.AUTO_HOST_REWRITE_KEY) != null && args.getCallOptions().getOption(XdsNameResolver.AUTO_HOST_REWRITE_KEY)) { result = PickResult.withSubchannel(result.getSubchannel(), result.getStreamTracerFactory(), - result.getSubchannel().getAttributes().get( - XdsAttributes.ATTR_ADDRESS_NAME)); + result.getSubchannel().getAttributes().get(ATTR_SUBCHANNEL_ADDRESS_NAME)); } } return result; @@ -505,11 +523,19 @@ private OrcaPerRpcListener(ClusterLocalityStats stats) { } /** - * Copies {@link MetricReport#getNamedMetrics()} to {@link ClusterLocalityStats} such that it is - * included in the snapshot for the LRS report sent to the LRS server. + * Copies ORCA metrics from {@link MetricReport} to {@link ClusterLocalityStats} + * such that they are included in the snapshot for the LRS report sent to the LRS server. + * This includes both top-level metrics (CPU, memory, application utilization) and named + * metrics, filtered according to the backend metric propagation configuration. */ @Override public void onLoadReport(MetricReport report) { + if (isEnabledOrcaLrsPropagation) { + stats.recordTopLevelMetrics( + report.getCpuUtilization(), + report.getMemoryUtilization(), + report.getApplicationUtilization()); + } stats.recordBackendLoadMetricStats(report.getNamedMetrics()); } } diff --git a/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancerProvider.java b/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancerProvider.java index 4c9c14ba5f5..f369c3b99b4 100644 --- a/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancerProvider.java +++ b/xds/src/main/java/io/grpc/xds/ClusterImplLoadBalancerProvider.java @@ -31,6 +31,7 @@ import io.grpc.Status; import io.grpc.xds.Endpoints.DropOverload; import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.Bootstrapper.ServerInfo; import java.util.ArrayList; import java.util.Collections; @@ -98,11 +99,14 @@ static final class ClusterImplConfig { // Provides the direct child policy and its config. final Object childConfig; final Map filterMetadata; + @Nullable + final BackendMetricPropagation backendMetricPropagation; ClusterImplConfig(String cluster, @Nullable String edsServiceName, @Nullable ServerInfo lrsServerInfo, @Nullable Long maxConcurrentRequests, List dropCategories, Object childConfig, - @Nullable UpstreamTlsContext tlsContext, Map filterMetadata) { + @Nullable UpstreamTlsContext tlsContext, Map filterMetadata, + @Nullable BackendMetricPropagation backendMetricPropagation) { this.cluster = checkNotNull(cluster, "cluster"); this.edsServiceName = edsServiceName; this.lrsServerInfo = lrsServerInfo; @@ -112,6 +116,7 @@ static final class ClusterImplConfig { this.dropCategories = Collections.unmodifiableList( new ArrayList<>(checkNotNull(dropCategories, "dropCategories"))); this.childConfig = checkNotNull(childConfig, "childConfig"); + this.backendMetricPropagation = backendMetricPropagation; } @Override diff --git a/xds/src/main/java/io/grpc/xds/ClusterResolverLoadBalancer.java b/xds/src/main/java/io/grpc/xds/ClusterResolverLoadBalancer.java deleted file mode 100644 index 06fafbb6cf1..00000000000 --- a/xds/src/main/java/io/grpc/xds/ClusterResolverLoadBalancer.java +++ /dev/null @@ -1,453 +0,0 @@ -/* - * Copyright 2020 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 static io.grpc.xds.XdsLbPolicies.PRIORITY_POLICY_NAME; - -import com.google.common.collect.ImmutableMap; -import io.grpc.Attributes; -import io.grpc.EquivalentAddressGroup; -import io.grpc.HttpConnectProxiedSocketAddress; -import io.grpc.InternalLogId; -import io.grpc.LoadBalancer; -import io.grpc.LoadBalancerProvider; -import io.grpc.LoadBalancerRegistry; -import io.grpc.Status; -import io.grpc.StatusOr; -import io.grpc.util.GracefulSwitchLoadBalancer; -import io.grpc.util.OutlierDetectionLoadBalancer.OutlierDetectionLoadBalancerConfig; -import io.grpc.xds.ClusterImplLoadBalancerProvider.ClusterImplConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig.DiscoveryMechanism; -import io.grpc.xds.Endpoints.DropOverload; -import io.grpc.xds.Endpoints.LbEndpoint; -import io.grpc.xds.Endpoints.LocalityLbEndpoints; -import io.grpc.xds.EnvoyServerProtoData.FailurePercentageEjection; -import io.grpc.xds.EnvoyServerProtoData.OutlierDetection; -import io.grpc.xds.EnvoyServerProtoData.SuccessRateEjection; -import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig; -import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig.PriorityChildConfig; -import io.grpc.xds.XdsConfig.XdsClusterConfig; -import io.grpc.xds.XdsEndpointResource.EdsUpdate; -import io.grpc.xds.client.Locality; -import io.grpc.xds.client.XdsLogger; -import io.grpc.xds.client.XdsLogger.XdsLogLevel; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; - -/** - * Load balancer for cluster_resolver_experimental LB policy. This LB policy is the child LB policy - * of the cds_experimental LB policy and the parent LB policy of the priority_experimental LB - * policy in the xDS load balancing hierarchy. This policy converts endpoints of non-aggregate - * clusters (e.g., EDS or Logical DNS) and groups endpoints in priorities and localities to be - * used in the downstream LB policies for fine-grained load balancing purposes. - */ -final class ClusterResolverLoadBalancer extends LoadBalancer { - private final XdsLogger logger; - private final LoadBalancerRegistry lbRegistry; - private final LoadBalancer delegate; - private ClusterState clusterState; - - ClusterResolverLoadBalancer(Helper helper, LoadBalancerRegistry lbRegistry) { - this.delegate = lbRegistry.getProvider(PRIORITY_POLICY_NAME).newLoadBalancer(helper); - this.lbRegistry = checkNotNull(lbRegistry, "lbRegistry"); - logger = XdsLogger.withLogId( - InternalLogId.allocate("cluster-resolver-lb", helper.getAuthority())); - logger.log(XdsLogLevel.INFO, "Created"); - } - - @Override - public void handleNameResolutionError(Status error) { - logger.log(XdsLogLevel.WARNING, "Received name resolution error: {0}", error); - delegate.handleNameResolutionError(error); - } - - @Override - public void shutdown() { - logger.log(XdsLogLevel.INFO, "Shutdown"); - delegate.shutdown(); - } - - @Override - public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { - logger.log(XdsLogLevel.DEBUG, "Received resolution result: {0}", resolvedAddresses); - ClusterResolverConfig config = - (ClusterResolverConfig) resolvedAddresses.getLoadBalancingPolicyConfig(); - XdsConfig xdsConfig = resolvedAddresses.getAttributes().get(XdsAttributes.XDS_CONFIG); - - DiscoveryMechanism instance = config.discoveryMechanism; - String cluster = instance.cluster; - if (clusterState == null) { - clusterState = new ClusterState(); - } - - StatusOr edsUpdate = getEdsUpdate(xdsConfig, cluster); - StatusOr statusOrResult = - clusterState.edsUpdateToResult(config, instance, edsUpdate); - if (!statusOrResult.hasValue()) { - Status status = Status.UNAVAILABLE - .withDescription(statusOrResult.getStatus().getDescription()) - .withCause(statusOrResult.getStatus().getCause()); - delegate.handleNameResolutionError(status); - return status; - } - ClusterResolutionResult result = statusOrResult.getValue(); - List addresses = result.addresses; - if (addresses.isEmpty()) { - Status status = Status.UNAVAILABLE - .withDescription("No usable endpoint from cluster: " + cluster); - delegate.handleNameResolutionError(status); - return status; - } - PriorityLbConfig childConfig = - new PriorityLbConfig( - Collections.unmodifiableMap(result.priorityChildConfigs), - Collections.unmodifiableList(result.priorities)); - return delegate.acceptResolvedAddresses( - resolvedAddresses.toBuilder() - .setLoadBalancingPolicyConfig(childConfig) - .setAddresses(Collections.unmodifiableList(addresses)) - .build()); - } - - private static StatusOr getEdsUpdate(XdsConfig xdsConfig, String cluster) { - StatusOr clusterConfig = xdsConfig.getClusters().get(cluster); - if (clusterConfig == null) { - return StatusOr.fromStatus(Status.INTERNAL - .withDescription("BUG: cluster resolver could not find cluster in xdsConfig")); - } - if (!clusterConfig.hasValue()) { - return StatusOr.fromStatus(clusterConfig.getStatus()); - } - if (!(clusterConfig.getValue().getChildren() instanceof XdsClusterConfig.EndpointConfig)) { - return StatusOr.fromStatus(Status.INTERNAL - .withDescription("BUG: cluster resolver cluster with children of unknown type")); - } - XdsClusterConfig.EndpointConfig endpointConfig = - (XdsClusterConfig.EndpointConfig) clusterConfig.getValue().getChildren(); - return endpointConfig.getEndpoint(); - } - - private final class ClusterState { - private Map localityPriorityNames = Collections.emptyMap(); - int priorityNameGenId = 1; - - StatusOr edsUpdateToResult( - ClusterResolverConfig config, DiscoveryMechanism discovery, StatusOr updateOr) { - if (!updateOr.hasValue()) { - return StatusOr.fromStatus(updateOr.getStatus()); - } - EdsUpdate update = updateOr.getValue(); - logger.log(XdsLogLevel.DEBUG, "Received endpoint update {0}", update); - if (logger.isLoggable(XdsLogLevel.INFO)) { - logger.log(XdsLogLevel.INFO, "Cluster {0}: {1} localities, {2} drop categories", - discovery.cluster, update.localityLbEndpointsMap.size(), - update.dropPolicies.size()); - } - Map localityLbEndpoints = - update.localityLbEndpointsMap; - List dropOverloads = update.dropPolicies; - List addresses = new ArrayList<>(); - Map> prioritizedLocalityWeights = new HashMap<>(); - List sortedPriorityNames = - generatePriorityNames(discovery.cluster, localityLbEndpoints); - for (Locality locality : localityLbEndpoints.keySet()) { - LocalityLbEndpoints localityLbInfo = localityLbEndpoints.get(locality); - String priorityName = localityPriorityNames.get(locality); - boolean discard = true; - for (LbEndpoint endpoint : localityLbInfo.endpoints()) { - if (endpoint.isHealthy()) { - discard = false; - long weight = localityLbInfo.localityWeight(); - if (endpoint.loadBalancingWeight() != 0) { - weight *= endpoint.loadBalancingWeight(); - } - String localityName = localityName(locality); - Attributes attr = - endpoint.eag().getAttributes().toBuilder() - .set(XdsAttributes.ATTR_LOCALITY, locality) - .set(EquivalentAddressGroup.ATTR_LOCALITY_NAME, localityName) - .set(XdsAttributes.ATTR_LOCALITY_WEIGHT, - localityLbInfo.localityWeight()) - .set(XdsAttributes.ATTR_SERVER_WEIGHT, weight) - .set(XdsAttributes.ATTR_ADDRESS_NAME, endpoint.hostname()) - .build(); - EquivalentAddressGroup eag; - if (config.isHttp11ProxyAvailable()) { - List rewrittenAddresses = new ArrayList<>(); - for (SocketAddress addr : endpoint.eag().getAddresses()) { - rewrittenAddresses.add(rewriteAddress( - addr, endpoint.endpointMetadata(), localityLbInfo.localityMetadata())); - } - eag = new EquivalentAddressGroup(rewrittenAddresses, attr); - } else { - eag = new EquivalentAddressGroup(endpoint.eag().getAddresses(), attr); - } - eag = AddressFilter.setPathFilter(eag, Arrays.asList(priorityName, localityName)); - addresses.add(eag); - } - } - if (discard) { - logger.log(XdsLogLevel.INFO, - "Discard locality {0} with 0 healthy endpoints", locality); - continue; - } - if (!prioritizedLocalityWeights.containsKey(priorityName)) { - prioritizedLocalityWeights.put(priorityName, new HashMap()); - } - prioritizedLocalityWeights.get(priorityName).put( - locality, localityLbInfo.localityWeight()); - } - if (prioritizedLocalityWeights.isEmpty()) { - // Will still update the result, as if the cluster resource is revoked. - logger.log(XdsLogLevel.INFO, - "Cluster {0} has no usable priority/locality/endpoint", discovery.cluster); - } - sortedPriorityNames.retainAll(prioritizedLocalityWeights.keySet()); - Map priorityChildConfigs = - generatePriorityChildConfigs( - discovery, config.lbConfig, lbRegistry, - prioritizedLocalityWeights, dropOverloads); - return StatusOr.fromValue(new ClusterResolutionResult(addresses, priorityChildConfigs, - sortedPriorityNames)); - } - - private SocketAddress rewriteAddress(SocketAddress addr, - ImmutableMap endpointMetadata, - ImmutableMap localityMetadata) { - if (!(addr instanceof InetSocketAddress)) { - return addr; - } - - SocketAddress proxyAddress; - try { - proxyAddress = (SocketAddress) endpointMetadata.get( - "envoy.http11_proxy_transport_socket.proxy_address"); - if (proxyAddress == null) { - proxyAddress = (SocketAddress) localityMetadata.get( - "envoy.http11_proxy_transport_socket.proxy_address"); - } - } catch (ClassCastException e) { - return addr; - } - - if (proxyAddress == null) { - return addr; - } - - return HttpConnectProxiedSocketAddress.newBuilder() - .setTargetAddress((InetSocketAddress) addr) - .setProxyAddress(proxyAddress) - .build(); - } - - private List generatePriorityNames(String name, - Map localityLbEndpoints) { - TreeMap> todo = new TreeMap<>(); - for (Locality locality : localityLbEndpoints.keySet()) { - int priority = localityLbEndpoints.get(locality).priority(); - if (!todo.containsKey(priority)) { - todo.put(priority, new ArrayList<>()); - } - todo.get(priority).add(locality); - } - Map newNames = new HashMap<>(); - Set usedNames = new HashSet<>(); - List ret = new ArrayList<>(); - for (Integer priority: todo.keySet()) { - String foundName = ""; - for (Locality locality : todo.get(priority)) { - if (localityPriorityNames.containsKey(locality) - && usedNames.add(localityPriorityNames.get(locality))) { - foundName = localityPriorityNames.get(locality); - break; - } - } - if ("".equals(foundName)) { - foundName = priorityName(name, priorityNameGenId++); - } - for (Locality locality : todo.get(priority)) { - newNames.put(locality, foundName); - } - ret.add(foundName); - } - localityPriorityNames = newNames; - return ret; - } - } - - private static class ClusterResolutionResult { - // Endpoint addresses. - private final List addresses; - // Config (include load balancing policy/config) for each priority in the cluster. - private final Map priorityChildConfigs; - // List of priority names ordered in descending priorities. - private final List priorities; - - ClusterResolutionResult(List addresses, - Map configs, List priorities) { - this.addresses = addresses; - this.priorityChildConfigs = configs; - this.priorities = priorities; - } - } - - /** - * Generates configs to be used in the priority LB policy for priorities in a cluster. - * - *

priority LB -> cluster_impl LB (one per priority) -> (weighted_target LB - * -> round_robin / least_request_experimental (one per locality)) / ring_hash_experimental - */ - private static Map generatePriorityChildConfigs( - DiscoveryMechanism discovery, - Object endpointLbConfig, - LoadBalancerRegistry lbRegistry, - Map> prioritizedLocalityWeights, - List dropOverloads) { - Map configs = new HashMap<>(); - for (String priority : prioritizedLocalityWeights.keySet()) { - ClusterImplConfig clusterImplConfig = - new ClusterImplConfig( - discovery.cluster, discovery.edsServiceName, discovery.lrsServerInfo, - discovery.maxConcurrentRequests, dropOverloads, endpointLbConfig, - discovery.tlsContext, discovery.filterMetadata); - LoadBalancerProvider clusterImplLbProvider = - lbRegistry.getProvider(XdsLbPolicies.CLUSTER_IMPL_POLICY_NAME); - Object priorityChildPolicy = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - clusterImplLbProvider, clusterImplConfig); - - // If outlier detection has been configured we wrap the child policy in the outlier detection - // load balancer. - if (discovery.outlierDetection != null) { - LoadBalancerProvider outlierDetectionProvider = lbRegistry.getProvider( - "outlier_detection_experimental"); - priorityChildPolicy = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - outlierDetectionProvider, - buildOutlierDetectionLbConfig(discovery.outlierDetection, priorityChildPolicy)); - } - - boolean isEds = discovery.type == DiscoveryMechanism.Type.EDS; - PriorityChildConfig priorityChildConfig = - new PriorityChildConfig(priorityChildPolicy, isEds /* ignoreReresolution */); - configs.put(priority, priorityChildConfig); - } - return configs; - } - - /** - * Converts {@link OutlierDetection} that represents the xDS configuration to {@link - * OutlierDetectionLoadBalancerConfig} that the {@link io.grpc.util.OutlierDetectionLoadBalancer} - * understands. - */ - private static OutlierDetectionLoadBalancerConfig buildOutlierDetectionLbConfig( - OutlierDetection outlierDetection, Object childConfig) { - OutlierDetectionLoadBalancerConfig.Builder configBuilder - = new OutlierDetectionLoadBalancerConfig.Builder(); - - configBuilder.setChildConfig(childConfig); - - if (outlierDetection.intervalNanos() != null) { - configBuilder.setIntervalNanos(outlierDetection.intervalNanos()); - } - if (outlierDetection.baseEjectionTimeNanos() != null) { - configBuilder.setBaseEjectionTimeNanos(outlierDetection.baseEjectionTimeNanos()); - } - if (outlierDetection.maxEjectionTimeNanos() != null) { - configBuilder.setMaxEjectionTimeNanos(outlierDetection.maxEjectionTimeNanos()); - } - if (outlierDetection.maxEjectionPercent() != null) { - configBuilder.setMaxEjectionPercent(outlierDetection.maxEjectionPercent()); - } - - SuccessRateEjection successRate = outlierDetection.successRateEjection(); - if (successRate != null) { - OutlierDetectionLoadBalancerConfig.SuccessRateEjection.Builder - successRateConfigBuilder = new OutlierDetectionLoadBalancerConfig - .SuccessRateEjection.Builder(); - - if (successRate.stdevFactor() != null) { - successRateConfigBuilder.setStdevFactor(successRate.stdevFactor()); - } - if (successRate.enforcementPercentage() != null) { - successRateConfigBuilder.setEnforcementPercentage(successRate.enforcementPercentage()); - } - if (successRate.minimumHosts() != null) { - successRateConfigBuilder.setMinimumHosts(successRate.minimumHosts()); - } - if (successRate.requestVolume() != null) { - successRateConfigBuilder.setRequestVolume(successRate.requestVolume()); - } - - configBuilder.setSuccessRateEjection(successRateConfigBuilder.build()); - } - - FailurePercentageEjection failurePercentage = outlierDetection.failurePercentageEjection(); - if (failurePercentage != null) { - OutlierDetectionLoadBalancerConfig.FailurePercentageEjection.Builder - failurePercentageConfigBuilder = new OutlierDetectionLoadBalancerConfig - .FailurePercentageEjection.Builder(); - - if (failurePercentage.threshold() != null) { - failurePercentageConfigBuilder.setThreshold(failurePercentage.threshold()); - } - if (failurePercentage.enforcementPercentage() != null) { - failurePercentageConfigBuilder.setEnforcementPercentage( - failurePercentage.enforcementPercentage()); - } - if (failurePercentage.minimumHosts() != null) { - failurePercentageConfigBuilder.setMinimumHosts(failurePercentage.minimumHosts()); - } - if (failurePercentage.requestVolume() != null) { - failurePercentageConfigBuilder.setRequestVolume(failurePercentage.requestVolume()); - } - - configBuilder.setFailurePercentageEjection(failurePercentageConfigBuilder.build()); - } - - return configBuilder.build(); - } - - /** - * Generates a string that represents the priority in the LB policy config. The string is unique - * across priorities in all clusters and priorityName(c, p1) < priorityName(c, p2) iff p1 < p2. - * The ordering is undefined for priorities in different clusters. - */ - private static String priorityName(String cluster, int priority) { - return cluster + "[child" + priority + "]"; - } - - /** - * Generates a string that represents the locality in the LB policy config. The string is unique - * across all localities in all clusters. - */ - private static String localityName(Locality locality) { - return "{region=\"" + locality.region() - + "\", zone=\"" + locality.zone() - + "\", sub_zone=\"" + locality.subZone() - + "\"}"; - } -} diff --git a/xds/src/main/java/io/grpc/xds/ClusterResolverLoadBalancerProvider.java b/xds/src/main/java/io/grpc/xds/ClusterResolverLoadBalancerProvider.java deleted file mode 100644 index 8cff272fcba..00000000000 --- a/xds/src/main/java/io/grpc/xds/ClusterResolverLoadBalancerProvider.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright 2020 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.base.MoreObjects; -import com.google.common.collect.ImmutableMap; -import com.google.protobuf.Struct; -import io.grpc.Internal; -import io.grpc.LoadBalancer; -import io.grpc.LoadBalancer.Helper; -import io.grpc.LoadBalancerProvider; -import io.grpc.LoadBalancerRegistry; -import io.grpc.NameResolver.ConfigOrError; -import io.grpc.Status; -import io.grpc.xds.EnvoyServerProtoData.OutlierDetection; -import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; -import io.grpc.xds.client.Bootstrapper.ServerInfo; -import java.util.Map; -import java.util.Objects; -import javax.annotation.Nullable; - -/** - * The provider for the cluster_resolver load balancing policy. This class should not be directly - * referenced in code. The policy should be accessed through - * {@link io.grpc.LoadBalancerRegistry#getProvider} with the name "cluster_resolver_experimental". - */ -@Internal -public final class ClusterResolverLoadBalancerProvider extends LoadBalancerProvider { - private final LoadBalancerRegistry lbRegistry; - - public ClusterResolverLoadBalancerProvider() { - this.lbRegistry = null; - } - - ClusterResolverLoadBalancerProvider(LoadBalancerRegistry lbRegistry) { - this.lbRegistry = checkNotNull(lbRegistry, "lbRegistry"); - } - - @Override - public boolean isAvailable() { - return true; - } - - @Override - public int getPriority() { - return 5; - } - - @Override - public String getPolicyName() { - return XdsLbPolicies.CLUSTER_RESOLVER_POLICY_NAME; - } - - @Override - public ConfigOrError parseLoadBalancingPolicyConfig(Map rawLoadBalancingPolicyConfig) { - return ConfigOrError.fromError( - Status.INTERNAL.withDescription(getPolicyName() + " cannot be used from service config")); - } - - @Override - public LoadBalancer newLoadBalancer(Helper helper) { - LoadBalancerRegistry lbRegistry = this.lbRegistry; - if (lbRegistry == null) { - lbRegistry = LoadBalancerRegistry.getDefaultRegistry(); - } - return new ClusterResolverLoadBalancer(helper, lbRegistry); - } - - static final class ClusterResolverConfig { - // Cluster to be resolved. - final DiscoveryMechanism discoveryMechanism; - // GracefulSwitch configuration - final Object lbConfig; - private final boolean isHttp11ProxyAvailable; - - ClusterResolverConfig(DiscoveryMechanism discoveryMechanism, Object lbConfig, - boolean isHttp11ProxyAvailable) { - this.discoveryMechanism = checkNotNull(discoveryMechanism, "discoveryMechanism"); - this.lbConfig = checkNotNull(lbConfig, "lbConfig"); - this.isHttp11ProxyAvailable = isHttp11ProxyAvailable; - } - - boolean isHttp11ProxyAvailable() { - return isHttp11ProxyAvailable; - } - - @Override - public int hashCode() { - return Objects.hash(discoveryMechanism, lbConfig, isHttp11ProxyAvailable); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - ClusterResolverConfig that = (ClusterResolverConfig) o; - return discoveryMechanism.equals(that.discoveryMechanism) - && lbConfig.equals(that.lbConfig) - && isHttp11ProxyAvailable == that.isHttp11ProxyAvailable; - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("discoveryMechanism", discoveryMechanism) - .add("lbConfig", lbConfig) - .add("isHttp11ProxyAvailable", isHttp11ProxyAvailable) - .toString(); - } - - // Describes the mechanism for a specific cluster. - static final class DiscoveryMechanism { - // Name of the cluster to resolve. - final String cluster; - // Type of the cluster. - final Type type; - // Load reporting server info. Null if not enabled. - @Nullable - final ServerInfo lrsServerInfo; - // Cluster-level max concurrent request threshold. Null if not specified. - @Nullable - final Long maxConcurrentRequests; - // TLS context for connections to endpoints in the cluster. - @Nullable - final UpstreamTlsContext tlsContext; - // Resource name for resolving endpoints via EDS. Only valid for EDS clusters. - @Nullable - final String edsServiceName; - // Hostname for resolving endpoints via DNS. Only valid for LOGICAL_DNS clusters. - @Nullable - final String dnsHostName; - @Nullable - final OutlierDetection outlierDetection; - final Map filterMetadata; - - enum Type { - EDS, - LOGICAL_DNS, - } - - private DiscoveryMechanism(String cluster, Type type, @Nullable String edsServiceName, - @Nullable String dnsHostName, @Nullable ServerInfo lrsServerInfo, - @Nullable Long maxConcurrentRequests, @Nullable UpstreamTlsContext tlsContext, - Map filterMetadata, @Nullable OutlierDetection outlierDetection) { - this.cluster = checkNotNull(cluster, "cluster"); - this.type = checkNotNull(type, "type"); - this.edsServiceName = edsServiceName; - this.dnsHostName = dnsHostName; - this.lrsServerInfo = lrsServerInfo; - this.maxConcurrentRequests = maxConcurrentRequests; - this.tlsContext = tlsContext; - this.filterMetadata = ImmutableMap.copyOf(checkNotNull(filterMetadata, "filterMetadata")); - this.outlierDetection = outlierDetection; - } - - static DiscoveryMechanism forEds(String cluster, @Nullable String edsServiceName, - @Nullable ServerInfo lrsServerInfo, @Nullable Long maxConcurrentRequests, - @Nullable UpstreamTlsContext tlsContext, Map filterMetadata, - OutlierDetection outlierDetection) { - return new DiscoveryMechanism(cluster, Type.EDS, edsServiceName, null, lrsServerInfo, - maxConcurrentRequests, tlsContext, filterMetadata, outlierDetection); - } - - static DiscoveryMechanism forLogicalDns(String cluster, String dnsHostName, - @Nullable ServerInfo lrsServerInfo, @Nullable Long maxConcurrentRequests, - @Nullable UpstreamTlsContext tlsContext, Map filterMetadata) { - return new DiscoveryMechanism(cluster, Type.LOGICAL_DNS, null, dnsHostName, - lrsServerInfo, maxConcurrentRequests, tlsContext, filterMetadata, null); - } - - @Override - public int hashCode() { - return Objects.hash(cluster, type, lrsServerInfo, maxConcurrentRequests, tlsContext, - edsServiceName, dnsHostName, filterMetadata, outlierDetection); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - DiscoveryMechanism that = (DiscoveryMechanism) o; - return cluster.equals(that.cluster) - && type == that.type - && Objects.equals(edsServiceName, that.edsServiceName) - && Objects.equals(dnsHostName, that.dnsHostName) - && Objects.equals(lrsServerInfo, that.lrsServerInfo) - && Objects.equals(maxConcurrentRequests, that.maxConcurrentRequests) - && Objects.equals(tlsContext, that.tlsContext) - && Objects.equals(filterMetadata, that.filterMetadata) - && Objects.equals(outlierDetection, that.outlierDetection); - } - - @Override - public String toString() { - MoreObjects.ToStringHelper toStringHelper = - MoreObjects.toStringHelper(this) - .add("cluster", cluster) - .add("type", type) - .add("edsServiceName", edsServiceName) - .add("dnsHostName", dnsHostName) - .add("lrsServerInfo", lrsServerInfo) - // Exclude tlsContext as its string representation is cumbersome. - .add("maxConcurrentRequests", maxConcurrentRequests) - .add("filterMetadata", filterMetadata) - // Exclude outlierDetection as its string representation is long. - ; - return toStringHelper.toString(); - } - } - } -} diff --git a/xds/src/main/java/io/grpc/xds/Endpoints.java b/xds/src/main/java/io/grpc/xds/Endpoints.java index dcb72f3e90d..558e3932ddc 100644 --- a/xds/src/main/java/io/grpc/xds/Endpoints.java +++ b/xds/src/main/java/io/grpc/xds/Endpoints.java @@ -59,7 +59,7 @@ abstract static class LbEndpoint { // The endpoint address to be connected to. abstract EquivalentAddressGroup eag(); - // Endpoint's weight for load balancing. If unspecified, value of 0 is returned. + // Endpoint's weight for load balancing. Guaranteed not to be 0. abstract int loadBalancingWeight(); // Whether the endpoint is healthy. @@ -71,6 +71,9 @@ abstract static class LbEndpoint { static LbEndpoint create(EquivalentAddressGroup eag, int loadBalancingWeight, boolean isHealthy, String hostname, ImmutableMap endpointMetadata) { + if (loadBalancingWeight == 0) { + loadBalancingWeight = 1; + } return new AutoValue_Endpoints_LbEndpoint( eag, loadBalancingWeight, isHealthy, hostname, endpointMetadata); } diff --git a/xds/src/main/java/io/grpc/xds/EnvoyServerProtoData.java b/xds/src/main/java/io/grpc/xds/EnvoyServerProtoData.java index 9c2ee641423..3cf28d23578 100644 --- a/xds/src/main/java/io/grpc/xds/EnvoyServerProtoData.java +++ b/xds/src/main/java/io/grpc/xds/EnvoyServerProtoData.java @@ -73,20 +73,81 @@ public int hashCode() { public static final class UpstreamTlsContext extends BaseTlsContext { + private final String sni; + private final boolean autoHostSni; + private final boolean autoSniSanValidation; + @VisibleForTesting public UpstreamTlsContext(CommonTlsContext commonTlsContext) { + this(commonTlsContext, "", false, false); + } + + @VisibleForTesting + public UpstreamTlsContext( + CommonTlsContext commonTlsContext, String sni, boolean autoHostSni, + boolean autoSniSanValidation) { super(commonTlsContext); + this.sni = sni == null ? "" : sni; + this.autoHostSni = autoHostSni; + this.autoSniSanValidation = autoSniSanValidation; + } + + @VisibleForTesting + public UpstreamTlsContext( + io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + upstreamTlsContext) { + super(upstreamTlsContext.getCommonTlsContext()); + this.sni = upstreamTlsContext.getSni(); + this.autoHostSni = upstreamTlsContext.getAutoHostSni(); + this.autoSniSanValidation = upstreamTlsContext.getAutoSniSanValidation(); } public static UpstreamTlsContext fromEnvoyProtoUpstreamTlsContext( io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext upstreamTlsContext) { - return new UpstreamTlsContext(upstreamTlsContext.getCommonTlsContext()); + return new UpstreamTlsContext(upstreamTlsContext); + } + + public String getSni() { + return sni; + } + + public boolean getAutoHostSni() { + return autoHostSni; + } + + public boolean getAutoSniSanValidation() { + return autoSniSanValidation; } @Override public String toString() { - return "UpstreamTlsContext{" + "commonTlsContext=" + commonTlsContext + '}'; + return "UpstreamTlsContext{" + + "commonTlsContext=" + commonTlsContext + + "\nsni=" + sni + + "\nauto_host_sni=" + autoHostSni + + "\nauto_sni_san_validation=" + autoSniSanValidation + + "}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UpstreamTlsContext that = (UpstreamTlsContext) o; + return autoHostSni == that.autoHostSni + && autoSniSanValidation == that.autoSniSanValidation + && Objects.equals(commonTlsContext, that.commonTlsContext) + && Objects.equals(sni, that.sni); + } + + @Override + public int hashCode() { + return Objects.hash(commonTlsContext, sni, autoHostSni, autoSniSanValidation); } } diff --git a/xds/src/main/java/io/grpc/xds/ExtAuthzConfigParser.java b/xds/src/main/java/io/grpc/xds/ExtAuthzConfigParser.java new file mode 100644 index 00000000000..853e8a5c03a --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/ExtAuthzConfigParser.java @@ -0,0 +1,103 @@ +/* + * 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/ExternalProcessorClientInterceptor.java b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java new file mode 100644 index 00000000000..f61e1002926 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/ExternalProcessorClientInterceptor.java @@ -0,0 +1,1355 @@ +/* + * 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 static io.grpc.xds.internal.extproc.ExternalProcessorUtil.applyHeaderMutations; +import static io.grpc.xds.internal.extproc.ExternalProcessorUtil.collectAttributes; +import static io.grpc.xds.internal.extproc.ExternalProcessorUtil.markDataPlaneCallClosed; +import static io.grpc.xds.internal.extproc.ExternalProcessorUtil.markExtProcStreamCompleted; +import static io.grpc.xds.internal.extproc.ExternalProcessorUtil.markExtProcStreamFailed; +import static io.grpc.xds.internal.extproc.ExternalProcessorUtil.outboundStreamToByteString; +import static io.grpc.xds.internal.extproc.ExternalProcessorUtil.toHeaderMap; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.ByteString; +import com.google.protobuf.Struct; +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.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.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Context; +import io.grpc.Deadline; +import io.grpc.DoubleHistogramMetricInstrument; +import io.grpc.ForwardingClientCall.SimpleForwardingClientCall; +import io.grpc.ForwardingClientCallListener.SimpleForwardingClientCallListener; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.MetricInstrumentRegistry; +import io.grpc.MetricRecorder; +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.ExternalProcessorFilter.ExternalProcessorFilterConfig; +import io.grpc.xds.Filter.FilterContext; +import io.grpc.xds.internal.extproc.DataPlaneCallState; +import io.grpc.xds.internal.extproc.EventType; +import io.grpc.xds.internal.extproc.ExtProcStreamState; +import io.grpc.xds.internal.extproc.KnownLengthInputStream; +import io.grpc.xds.internal.grpcservice.CachedChannelManager; +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.HeaderMutator; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +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 java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nullable; + +/** + * Client-side interceptor for external processing filter. + */ +final class ExternalProcessorClientInterceptor implements ClientInterceptor { + + @VisibleForTesting + static DoubleHistogramMetricInstrument clientHeadersDuration; + @VisibleForTesting + static DoubleHistogramMetricInstrument clientHalfCloseDuration; + @VisibleForTesting + static DoubleHistogramMetricInstrument serverHeadersDuration; + @VisibleForTesting + static DoubleHistogramMetricInstrument serverTrailersDuration; + + // Copied from io.grpc.opentelemetry.internal.OpenTelemetryConstants.LATENCY_BUCKETS + private static final List LATENCY_BUCKETS = ImmutableList.of( + 0d, 0.00001d, 0.00005d, 0.0001d, 0.0003d, 0.0006d, 0.0008d, 0.001d, 0.002d, + 0.003d, 0.004d, 0.005d, 0.006d, 0.008d, 0.01d, 0.013d, 0.016d, 0.02d, + 0.025d, 0.03d, 0.04d, 0.05d, 0.065d, 0.08d, 0.1d, 0.13d, 0.16d, + 0.2d, 0.25d, 0.3d, 0.4d, 0.5d, 0.65d, 0.8d, 1d, 2d, + 5d, 10d, 20d, 50d, 100d); + + static { + initMetricInstruments(); + } + + static synchronized void initMetricInstruments() { + if (io.grpc.internal.GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false)) { + if (clientHeadersDuration == null) { + MetricInstrumentRegistry registry = MetricInstrumentRegistry.getDefaultRegistry(); + + clientHeadersDuration = registry.registerDoubleHistogram( + "grpc.client_ext_proc.client_headers_duration", + "Time between when the ext_proc filter sees the client's headers and when " + + "it allows those headers to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of("grpc.target"), + ImmutableList.of("grpc.lb.backend_service"), + true); + + clientHalfCloseDuration = registry.registerDoubleHistogram( + "grpc.client_ext_proc.client_half_close_duration", + "Time between when the ext_proc filter sees the client's half-close and when " + + "it allows that half-close to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of("grpc.target"), + ImmutableList.of("grpc.lb.backend_service"), + true); + + serverHeadersDuration = registry.registerDoubleHistogram( + "grpc.client_ext_proc.server_headers_duration", + "Time between when the ext_proc filter sees the server's headers and when " + + "it allows those headers to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of("grpc.target"), + ImmutableList.of("grpc.lb.backend_service"), + true); + + serverTrailersDuration = registry.registerDoubleHistogram( + "grpc.client_ext_proc.server_trailers_duration", + "Time between when the ext_proc filter sees the server's trailers and when " + + "it allows those trailers to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of("grpc.target"), + ImmutableList.of("grpc.lb.backend_service"), + true); + } + } + } + + private final ExternalProcessorFilterConfig filterConfig; + private final ScheduledExecutorService scheduler; + private final MetricRecorder metricsRecorder; + private final ManagedChannel extProcChannel; + + @VisibleForTesting + ExternalProcessorClientInterceptor(ExternalProcessorFilterConfig filterConfig, + CachedChannelManager cachedChannelManager, + ScheduledExecutorService scheduler, + FilterContext context) { + this.filterConfig = filterConfig; + checkNotNull(cachedChannelManager, "cachedChannelManager"); + this.scheduler = checkNotNull(scheduler, "scheduler"); + this.metricsRecorder = checkNotNull(context.metricsRecorder(), "metricsRecorder"); + this.extProcChannel = cachedChannelManager.getChannel(filterConfig.getGrpcServiceConfig()); + } + + @VisibleForTesting + ExternalProcessorFilterConfig getFilterConfig() { + return filterConfig; + } + + @VisibleForTesting + ManagedChannel getExtProcChannel() { + return extProcChannel; + } + + @Override + @SuppressWarnings("unchecked") + public ClientCall interceptCall( + MethodDescriptor method, + CallOptions callOptions, + Channel next) { + SerializingExecutor serializingExecutor = new SerializingExecutor(callOptions.getExecutor()); + + ExternalProcessorGrpc.ExternalProcessorStub extProcStub = ExternalProcessorGrpc.newStub( + extProcChannel) + .withExecutor(serializingExecutor); + + if (filterConfig.getGrpcServiceConfig().timeout().isPresent()) { + long timeoutNanos = filterConfig.getGrpcServiceConfig().timeout().get().toNanos(); + if (timeoutNanos > 0) { + extProcStub = extProcStub.withDeadlineAfter(timeoutNanos, TimeUnit.NANOSECONDS); + } + } + if (filterConfig.getGrpcServiceConfig().initialMetadata() != null + && !filterConfig.getGrpcServiceConfig().initialMetadata().isEmpty()) { + Metadata extraHeaders = new Metadata(); + for (HeaderValue headerValue : filterConfig.getGrpcServiceConfig().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); + extraHeaders.put(metadataKey, headerValue.rawValue().get().toByteArray()); + } + } else { + if (headerValue.value().isPresent()) { + Metadata.Key metadataKey = + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + extraHeaders.put(metadataKey, headerValue.value().get()); + } + } + } + extProcStub = extProcStub.withInterceptors( + io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor(extraHeaders)); + } + + + // The filter chain is preceded by RawMessageClientInterceptor, so ReqT and RespT are + // InputStream. + MethodDescriptor rawMethod = + (MethodDescriptor) (MethodDescriptor) method; + ClientCall rawCall = + (ClientCall) (ClientCall) + next.newCall(method, 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, rawMethod, next, metricsRecorder, next.authority(), + callOptions.getOption(XdsNameResolver.CLUSTER_SELECTION_KEY)); + + return (ClientCall) (ClientCall) dataPlaneCall; + } + + // --- SHARED UTILITY METHODS --- + + /** + * 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("ext_proc", 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 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(); + @Nullable private volatile EventType expectedRequestResponse; + @Nullable private volatile EventType expectedResponseResponse; + @Nullable private volatile ClientCallStreamObserver + extProcClientCallRequestObserver; + private final Queue pendingDrainingMessages = + new ConcurrentLinkedQueue<>(); + @Nullable 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 final MetricRecorder metricsRecorder; + private final String target; + private final String backendService; + private volatile Context callContext = Context.ROOT; + + private long clientHeadersStartNanos; + private long clientHalfCloseStartNanos; + private long serverHeadersStartNanos; + private long serverTrailersStartNanos; + + private boolean protocolConfigSent = false; + private ImmutableMap collectedAttributes; + private boolean requestAttributesSent = false; + @Nullable private volatile Metadata requestHeaders; + final AtomicReference dataPlaneCallState = + new AtomicReference<>(DataPlaneCallState.IDLE); + final AtomicReference extProcStreamState = + new AtomicReference<>(ExtProcStreamState.ACTIVE); + final AtomicBoolean passThroughMode = new AtomicBoolean(false); + final AtomicBoolean requestSideClosed = new AtomicBoolean(false); + final AtomicBoolean isProcessingTrailers = new AtomicBoolean(false); + final AtomicBoolean pendingHalfClose = new AtomicBoolean(false); + final AtomicBoolean bodyMessageSentToExtProc = new AtomicBoolean(false); + + protected DataPlaneClientCall( + DataPlaneDelayedCall delayedCall, + ClientCall rawCall, + ExternalProcessorGrpc.ExternalProcessorStub stub, + ExternalProcessorFilterConfig config, + Optional mutationRulesConfig, + ScheduledExecutorService scheduler, + MethodDescriptor method, + Channel channel, + MetricRecorder metricsRecorder, + String target, + String backendService) { + 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; + this.metricsRecorder = checkNotNull(metricsRecorder, "metricsRecorder"); + this.target = checkNotNull(target, "target"); + this.backendService = checkNotNull(backendService, "backendService"); + } + + + + private void activateCall() { + if ((extProcStreamState.get() == ExtProcStreamState.FAILED + && !config.getFailureModeAllow() + && !config.getObservabilityMode()) + || !dataPlaneCallState.compareAndSet( + DataPlaneCallState.IDLE, DataPlaneCallState.ACTIVE)) { + return; + } + if (clientHeadersStartNanos > 0) { + long durationNanos = System.nanoTime() - clientHeadersStartNanos; + recordDuration(clientHeadersDuration, durationNanos); + clientHeadersStartNanos = 0; + } + Runnable toRun = delayedCall.setCall(rawCall); + if (toRun != null) { + callContext.run(toRun); + } + drainPendingRequests(); + onReadyNotify(); + } + + private void recordDuration(DoubleHistogramMetricInstrument instrument, long durationNanos) { + if (instrument != null) { + double durationSecs = (double) durationNanos / 1_000_000_000.0; + metricsRecorder.recordDoubleHistogram( + instrument, + durationSecs, + ImmutableList.of(target), + ImmutableList.of(backendService)); + } + } + + /** + * Validates whether the body response uses unsupported gRPC message compression. + * If compression is unsupported, this method will cancel the call, transition the + * stream to a failed state, send an error to the external processor, and return false. + * + * @param bodyResponse the response to validate + * @return true if validation passes (compression is supported or not used), + * false if validation fails + */ + private boolean validateCompressionSupport(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(); + synchronized (streamLock) { + if (!extProcStreamState.get().isCompleted() + && extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onError(ex); + } + } + activateCall(); + markExtProcStreamFailed(extProcStreamState); + delayedCall.cancel("gRPC message compression not supported in ext_proc", ex); + closeExtProcStream(); + return false; + } + } + return true; + } + + + + @Override + public void start(Listener responseListener, Metadata headers) { + this.callContext = Context.current(); + clientHeadersStartNanos = System.nanoTime(); + 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; + } + requestStream.setOnReadyHandler(DataPlaneClientCall.this::onExtProcStreamReady); + } + + @Override + public void onNext(ProcessingResponse response) { + try { + if (config.getObservabilityMode()) { + return; + } + + if (response.hasImmediateResponse()) { + if (config.getDisableImmediateResponse()) { + internalOnError(Status.UNAVAILABLE + .withDescription( + "Immediate response is disabled but received from external processor") + .asRuntimeException()); + return; + } + handleImmediateResponse(response.getImmediateResponse(), wrappedListener); + return; + } + + if (response.hasRequestHeaders()) { + EventType expected = expectedRequestResponse; + if (expected == null || expected != EventType.REQUEST_HEADERS) { + internalOnError(Status.UNAVAILABLE + .withDescription("Protocol error: received response out of order. Expected: " + + expected + ", Received: REQUEST_HEADERS") + .asRuntimeException()); + return; + } + expectedRequestResponse = null; + } else if (response.hasResponseHeaders()) { + EventType expected = expectedResponseResponse; + if (expected == null || expected != EventType.RESPONSE_HEADERS) { + internalOnError(Status.UNAVAILABLE + .withDescription("Protocol error: received response out of order. Expected: " + + expected + ", Received: RESPONSE_HEADERS") + .asRuntimeException()); + return; + } + expectedResponseResponse = null; + } else if (response.hasResponseTrailers()) { + EventType expected = expectedResponseResponse; + if (expected == null || expected != EventType.RESPONSE_TRAILERS) { + internalOnError(Status.UNAVAILABLE + .withDescription("Protocol error: received response out of order. Expected: " + + expected + ", Received: RESPONSE_TRAILERS") + .asRuntimeException()); + return; + } + expectedResponseResponse = null; + } else if (response.hasRequestBody()) { + EventType expected = expectedRequestResponse; + if (expected == EventType.REQUEST_HEADERS) { + internalOnError(Status.UNAVAILABLE + .withDescription( + "Protocol error: received request_body before request_headers response.") + .asRuntimeException()); + return; + } + } else if (response.hasResponseBody()) { + EventType expected = expectedResponseResponse; + if (expected == EventType.RESPONSE_HEADERS) { + internalOnError(Status.UNAVAILABLE + .withDescription( + "Protocol error: received response_body before headers response.") + .asRuntimeException()); + return; + } + } + + if (response.getRequestDrain()) { + extProcStreamState.set(ExtProcStreamState.DRAINING); + halfCloseExtProcStream(); + activateCall(); + } + + // 1. Client Headers + if (response.hasRequestHeaders()) { + if (response.getRequestHeaders().hasResponse()) { + if (response.getRequestHeaders().getResponse().getStatus() + == CommonResponse.ResponseStatus.CONTINUE_AND_REPLACE) { + internalOnError(Status.UNAVAILABLE + .withDescription("CONTINUE_AND_REPLACE is not supported") + .asRuntimeException()); + return; + } + applyHeaderMutations( + requestHeaders, + response.getRequestHeaders().getResponse().getHeaderMutation(), + mutationFilter, + mutator); + } + activateCall(); + } + // 2. Client Message (Request Body) + else if (response.hasRequestBody()) { + if (validateCompressionSupport(response.getRequestBody())) { + handleRequestBodyResponse(response.getRequestBody()); + } + } + // 4. Server Headers + else if (response.hasResponseHeaders()) { + if (response.getResponseHeaders().hasResponse()) { + if (response.getResponseHeaders().getResponse().getStatus() + == CommonResponse.ResponseStatus.CONTINUE_AND_REPLACE) { + internalOnError(Status.UNAVAILABLE + .withDescription("CONTINUE_AND_REPLACE is not supported") + .asRuntimeException()); + return; + } + Metadata target = wrappedListener.isTrailersOnly() + ? wrappedListener.getSavedTrailers() : wrappedListener.getSavedHeaders(); + applyHeaderMutations( + target, + response.getResponseHeaders().getResponse().getHeaderMutation(), + mutationFilter, + mutator); + } + if (wrappedListener.isTrailersOnly()) { + wrappedListener.proceedWithClose(); + } else { + wrappedListener.proceedWithHeaders(); + } + } + // 5. Server Message (Response Body) + else if (response.hasResponseBody()) { + if (validateCompressionSupport(response.getResponseBody())) { + handleResponseBodyResponse(response.getResponseBody(), wrappedListener); + } + } + // 6. Response Trailers + else if (response.hasResponseTrailers()) { + if (response.getResponseTrailers().hasHeaderMutation()) { + applyHeaderMutations( + wrappedListener.getSavedTrailers(), + response.getResponseTrailers().getHeaderMutation(), + mutationFilter, + mutator); + } + wrappedListener.proceedWithClose(); + } + + checkEndOfStream(response); + } catch (Throwable t) { + internalOnError(t); + } + } + + @Override + public void onError(Throwable t) { + if (markExtProcStreamFailed(extProcStreamState)) { + synchronized (streamLock) { + extProcClientCallRequestObserver = null; + } + if (config.getObservabilityMode() + || (config.getFailureModeAllow() && !bodyMessageSentToExtProc.get())) { + handleFailOpen(wrappedListener); + } else { + String message = "External processor stream failed"; + delayedCall.cancel(message, t); + wrappedListener.proceedWithClose(); + } + } + } + + @Override + public void onCompleted() { + if (markExtProcStreamCompleted(extProcStreamState)) { + handleFailOpen(wrappedListener); + } + } + }); + + this.collectedAttributes = collectAttributes( + config.getRequestAttributes(), method, channel.authority(), headers); + + 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()) + .build()); + } + + if (config.getObservabilityMode() || !sendRequestHeaders) { + activateCall(); + } + } + + private void sendToExtProc(ProcessingRequest request) { + synchronized (streamLock) { + if (extProcStreamState.get().isCompleted()) { + return; + } + + if (request.hasRequestHeaders()) { + expectedRequestResponse = EventType.REQUEST_HEADERS; + } else if (request.hasResponseHeaders()) { + expectedResponseResponse = EventType.RESPONSE_HEADERS; + } else if (request.hasResponseTrailers()) { + expectedResponseResponse = EventType.RESPONSE_TRAILERS; + } + + ProcessingRequest requestToSend = request; + if (!protocolConfigSent) { + requestToSend = ProcessingRequest.newBuilder(requestToSend) + .setProtocolConfig(ProtocolConfiguration.newBuilder() + .setRequestBodyMode(currentProcessingMode.getRequestBodyMode()) + .setResponseBodyMode(currentProcessingMode.getResponseBodyMode()) + .build()) + .build(); + protocolConfigSent = true; + } + + boolean isClientServerMessage = + requestToSend.hasRequestHeaders() || requestToSend.hasRequestBody(); + if (isClientServerMessage + && !requestAttributesSent + && collectedAttributes != null + && !collectedAttributes.isEmpty()) { + requestToSend = ProcessingRequest.newBuilder(requestToSend) + .putAllAttributes(collectedAttributes) + .build(); + requestAttributesSent = true; + } + + if (config.getObservabilityMode()) { + requestToSend = ProcessingRequest.newBuilder(requestToSend) + .setObservabilityMode(true) + .build(); + } + + extProcClientCallRequestObserver.onNext(requestToSend); + } + } + + 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 (markExtProcStreamCompleted(extProcStreamState)) { + if (extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onCompleted(); + } + } + } + } + + private void internalOnError(Throwable t) { + if (markExtProcStreamFailed(extProcStreamState)) { + synchronized (streamLock) { + if (extProcClientCallRequestObserver != null) { + try { + extProcClientCallRequestObserver.onError(t); + } catch (Throwable ignored) { + // Ignore exceptions during cancel/onError propagation + } + extProcClientCallRequestObserver = null; + } + } + if (config.getObservabilityMode() + || (config.getFailureModeAllow() && !bodyMessageSentToExtProc.get())) { + handleFailOpen(wrappedListener); + } else { + String message = "External processor stream failed"; + delayedCall.cancel(message, t); + wrappedListener.proceedWithClose(); + } + } + } + + private void halfCloseExtProcStream() { + synchronized (streamLock) { + if (!extProcStreamState.get().isCompleted() && extProcClientCallRequestObserver != null) { + extProcClientCallRequestObserver.onCompleted(); + } + } + } + + private void onReadyNotify() { + wrappedListener.onReadyNotify(); + } + + private boolean isSidecarReady() { + ExtProcStreamState state = extProcStreamState.get(); + if (state.isCompleted()) { + return true; + } + if (state.isDraining()) { + return false; + } + synchronized (streamLock) { + ClientCallStreamObserver observer = extProcClientCallRequestObserver; + return observer != null && observer.isReady(); + } + } + + @Override + public boolean isReady() { + if (passThroughMode.get()) { + return super.isReady(); + } + if (extProcStreamState.get().isCompleted()) { + return super.isReady(); + } + if (dataPlaneCallState.get() == DataPlaneCallState.IDLE && !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() || extProcStreamState.get().isCompleted()) { + 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()) { + super.sendMessage(message); + return; + } + + synchronized (streamLock) { + if (passThroughMode.get()) { + super.sendMessage(message); + return; + } + + ExtProcStreamState state = extProcStreamState.get(); + if (state.isDraining() || state.isCompleted()) { + try { + ByteString copiedBody = ByteString.readFrom(message); + pendingDrainingMessages.add(new KnownLengthInputStream(copiedBody)); + } catch (IOException e) { + rawCall.cancel("Failed to copy outbound message for buffering", e); + } + return; + } + } + + if (currentProcessingMode.getRequestBodyMode() == ProcessingMode.BodySendMode.NONE) { + super.sendMessage(message); + return; + } + + // Mode is GRPC + try { + ByteString bodyByteString = outboundStreamToByteString(message); + sendToExtProc(ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setBody(bodyByteString) + .setEndOfStream(false) + .build()) + .build()); + bodyMessageSentToExtProc.set(true); + + if (config.getObservabilityMode()) { + super.sendMessage(new KnownLengthInputStream(bodyByteString)); + } + } catch (IOException e) { + rawCall.cancel("Failed to serialize message for External Processor", e); + } + } + + private void proceedWithHalfClose() { + if (clientHalfCloseStartNanos > 0) { + long durationNanos = System.nanoTime() - clientHalfCloseStartNanos; + recordDuration(clientHalfCloseDuration, durationNanos); + clientHalfCloseStartNanos = 0; + } + super.halfClose(); + } + + @Override + public void halfClose() { + clientHalfCloseStartNanos = System.nanoTime(); + if (passThroughMode.get()) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } + return; + } + + pendingHalfClose.set(true); + + if (extProcStreamState.get().isCompleted()) { + return; + } + + if (extProcStreamState.get().isDraining()) { + return; + } + + if (currentProcessingMode.getRequestBodyMode() == ProcessingMode.BodySendMode.NONE) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } + return; + } + + // Mode is GRPC + sendToExtProc(ProcessingRequest.newBuilder() + .setRequestBody(HttpBody.newBuilder() + .setEndOfStreamWithoutMessage(true) + .build()) + .build()); + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + synchronized (streamLock) { + if (!extProcStreamState.get().isCompleted() && 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.getEndOfStreamWithoutMessage()) { + super.sendMessage(new KnownLengthInputStream(streamed.getBody())); + } + if (streamed.getEndOfStream() || streamed.getEndOfStreamWithoutMessage()) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } + } + } + } + } + + 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(); + listener.onExternalBody(streamed.getBody()); + } + } + } + + 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(), mutationFilter, mutator); + } + + listener.setImmediateResponse(status, 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 drainPendingDrainingMessages() { + synchronized (streamLock) { + InputStream msg; + while ((msg = pendingDrainingMessages.poll()) != null) { + super.sendMessage(msg); + } + passThroughMode.set(true); + if (pendingHalfClose.get()) { + if (requestSideClosed.compareAndSet(false, true)) { + proceedWithHalfClose(); + } + } + } + } + + private void handleFailOpen(DataPlaneListener listener) { + activateCall(); + drainPendingRequests(); + listener.unblockAfterStreamComplete(); + closeExtProcStream(); + } + + private void checkEndOfStream(ProcessingResponse response) { + boolean terminal = false; + if (response.hasResponseTrailers()) { + terminal = true; + } else if (response.hasResponseHeaders() && wrappedListener.isTrailersOnly()) { + terminal = true; + } + + if (terminal) { + wrappedListener.unblockAfterStreamComplete(); + closeExtProcStream(); + } + } + + long getServerHeadersStartNanos() { + return serverHeadersStartNanos; + } + + void setServerHeadersStartNanos(long serverHeadersStartNanos) { + this.serverHeadersStartNanos = serverHeadersStartNanos; + } + + long getServerTrailersStartNanos() { + return serverTrailersStartNanos; + } + + void setServerTrailersStartNanos(long serverTrailersStartNanos) { + this.serverTrailersStartNanos = serverTrailersStartNanos; + } + + AtomicReference getExtProcStreamState() { + return extProcStreamState; + } + + ProcessingMode getCurrentProcessingMode() { + return currentProcessingMode; + } + + AtomicBoolean getPassThroughMode() { + return passThroughMode; + } + + ExternalProcessorFilterConfig getConfig() { + return config; + } + + Context getCallContext() { + return callContext; + } + + ScheduledExecutorService getScheduler() { + return scheduler; + } + + AtomicBoolean getIsProcessingTrailers() { + return isProcessingTrailers; + } + } + + private static class DataPlaneListener extends SimpleForwardingClientCallListener { + private final ClientCall rawCall; + private final DataPlaneClientCall dataPlaneClientCall; + private final Queue savedMessages = new ConcurrentLinkedQueue<>(); + private boolean inboundPassThrough = false; + @Nullable private volatile Metadata savedHeaders; + @Nullable private volatile Metadata savedTrailers; + @Nullable 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) { + super(delegate); + this.rawCall = rawCall; + this.dataPlaneClientCall = dataPlaneClientCall; + } + + boolean isTrailersOnly() { + return trailersOnly.get(); + } + + Metadata getSavedHeaders() { + return savedHeaders; + } + + Metadata getSavedTrailers() { + return savedTrailers; + } + + void setImmediateResponse(Status status, Metadata trailers) { + this.savedStatus = status; + this.savedTrailers = trailers; + } + + @Override + public void onReady() { + dataPlaneClientCall.drainPendingRequests(); + onReadyNotify(); + } + + @Override + public void onHeaders(Metadata headers) { + dataPlaneClientCall.setServerHeadersStartNanos(System.nanoTime()); + responseHeadersSent.set(true); + if (dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { + this.savedHeaders = headers; + return; + } + boolean sendResponseHeaders = + dataPlaneClientCall.getCurrentProcessingMode().getResponseHeaderMode() + == ProcessingMode.HeaderSendMode.SEND + || dataPlaneClientCall.getCurrentProcessingMode().getResponseHeaderMode() + == ProcessingMode.HeaderSendMode.DEFAULT; + + if (dataPlaneClientCall.getPassThroughMode().get() + || dataPlaneClientCall.getExtProcStreamState().get().isCompleted() + || !sendResponseHeaders) { + proceedWithHeaders(headers); + return; + } + + this.savedHeaders = headers; + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseHeaders(HttpHeaders.newBuilder() + .setHeaders( + toHeaderMap(headers, dataPlaneClientCall.getConfig().getForwardRulesConfig())) + .build()) + .build()); + + if (dataPlaneClientCall.getConfig().getObservabilityMode()) { + proceedWithHeaders(); + } + } + + @Override + public void onMessage(InputStream message) { + synchronized (savedMessages) { + if (inboundPassThrough) { + dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); + return; + } + + if (savedHeaders != null + || dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { + try { + ByteString copiedBody = ByteString.readFrom(message); + savedMessages.add(new KnownLengthInputStream(copiedBody)); + } catch (IOException e) { + rawCall.cancel("Failed to copy inbound message for buffering", e); + } + return; + } + } + + if (dataPlaneClientCall.getPassThroughMode().get()) { + dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); + return; + } + + if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() + || dataPlaneClientCall.getCurrentProcessingMode().getResponseBodyMode() + != ProcessingMode.BodySendMode.GRPC) { + dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(message)); + return; + } + + try { + ByteString bodyByteString = ByteString.readFrom(message); + sendResponseBodyToExtProc(bodyByteString, false); + dataPlaneClientCall.bodyMessageSentToExtProc.set(true); + + if (dataPlaneClientCall.getConfig().getObservabilityMode()) { + // If needed, downstream reading can be made more optimal by creating a wrapped + // Inputstream wraps the underlying bytestring and that implements HasByteBuffer, + // Detachable, KnownLength + dataPlaneClientCall.getCallContext().run( + () -> delegate().onMessage(bodyByteString.newInput())); + } + } catch (IOException e) { + rawCall.cancel("Failed to read server response", e); + } + } + + @Override + public void onClose(Status status, Metadata trailers) { + dataPlaneClientCall.setServerTrailersStartNanos(System.nanoTime()); + ExtProcStreamState extProcStreamState = + dataPlaneClientCall.getExtProcStreamState().get(); + if (extProcStreamState.isFailed() + && !dataPlaneClientCall.getConfig().getObservabilityMode() + && (!dataPlaneClientCall.getConfig().getFailureModeAllow() + || dataPlaneClientCall.bodyMessageSentToExtProc.get())) { + if (markDataPlaneCallClosed(dataPlaneClientCall.dataPlaneCallState)) { + proceedWithClose(Status.INTERNAL.withDescription("External processor stream failed") + .withCause(status.getCause()), new Metadata()); + } + return; + } + if (dataPlaneClientCall.getPassThroughMode().get()) { + if (markDataPlaneCallClosed(dataPlaneClientCall.dataPlaneCallState)) { + proceedWithClose(status, trailers); + } + return; + } + + if (this.savedStatus == null) { + this.savedStatus = status; + this.savedTrailers = trailers; + } + + // If we are still waiting for the external processor to validate response headers, + // buffer the close status/trailers and defer the close until headers are processed. + if (savedHeaders != null) { + return; + } + + if (dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { + return; + } + + if (!responseHeadersSent.get()) { + trailersOnly.set(true); + } + + triggerCloseHandshake(); + + if (dataPlaneClientCall.getConfig().getObservabilityMode()) { + proceedWithClose(); + @SuppressWarnings("unused") + ScheduledFuture unused = dataPlaneClientCall.getScheduler().schedule( + dataPlaneClientCall::closeExtProcStream, + dataPlaneClientCall.getConfig().getDeferredCloseTimeoutNanos(), + TimeUnit.NANOSECONDS); + } + } + + void onReadyNotify() { + dataPlaneClientCall.getCallContext().run(() -> delegate().onReady()); + } + + void proceedWithHeaders() { + if (savedHeaders != null) { + proceedWithHeaders(savedHeaders); + synchronized (savedMessages) { + savedHeaders = null; + if (!dataPlaneClientCall.getExtProcStreamState().get().isDraining()) { + InputStream msg; + while ((msg = savedMessages.poll()) != null) { + onMessage(msg); + } + } + } + onReadyNotify(); + if (savedStatus != null) { + triggerCloseHandshake(); + } + } + } + + private void proceedWithHeaders(Metadata headers) { + if (dataPlaneClientCall.getServerHeadersStartNanos() > 0) { + long durationNanos = System.nanoTime() - dataPlaneClientCall.getServerHeadersStartNanos(); + dataPlaneClientCall.recordDuration(serverHeadersDuration, durationNanos); + dataPlaneClientCall.setServerHeadersStartNanos(0); + } + dataPlaneClientCall.getCallContext().run(() -> delegate().onHeaders(headers)); + } + + void proceedWithClose() { + if (savedStatus != null) { + if (markDataPlaneCallClosed(dataPlaneClientCall.dataPlaneCallState)) { + proceedWithClose(savedStatus, savedTrailers); + } + savedStatus = null; + savedTrailers = null; + } + } + + private void proceedWithClose(Status status, Metadata trailers) { + if (dataPlaneClientCall.getServerTrailersStartNanos() > 0) { + long durationNanos = System.nanoTime() - dataPlaneClientCall.getServerTrailersStartNanos(); + dataPlaneClientCall.recordDuration(serverTrailersDuration, durationNanos); + dataPlaneClientCall.setServerTrailersStartNanos(0); + } + dataPlaneClientCall.getCallContext().run(() -> delegate().onClose(status, trailers)); + } + + void onExternalBody(ByteString body) { + // If needed, downstream reading can be made more optimal by creating a wrapped + // Inputstream wraps the underlying bytestring and that implements HasByteBuffer, + // Detachable, KnownLength + dataPlaneClientCall.getCallContext().run( + () -> delegate().onMessage(body.newInput())); + } + + void unblockAfterStreamComplete() { + proceedWithHeaders(); + proceedWithSavedMessages(); + dataPlaneClientCall.drainPendingDrainingMessages(); + proceedWithClose(); + } + + private void proceedWithSavedMessages() { + synchronized (savedMessages) { + InputStream msg; + while ((msg = savedMessages.poll()) != null) { + final InputStream finalMsg = msg; + dataPlaneClientCall.getCallContext().run(() -> delegate().onMessage(finalMsg)); + } + inboundPassThrough = true; + } + } + + private void triggerCloseHandshake() { + if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() + || !terminationTriggered.compareAndSet(false, true)) { + return; + } + + boolean sendResponseHeaders = + dataPlaneClientCall.getCurrentProcessingMode().getResponseHeaderMode() + == ProcessingMode.HeaderSendMode.SEND + || dataPlaneClientCall.getCurrentProcessingMode().getResponseHeaderMode() + == ProcessingMode.HeaderSendMode.DEFAULT; + + boolean sendResponseTrailers = + dataPlaneClientCall.getCurrentProcessingMode().getResponseTrailerMode() + == ProcessingMode.HeaderSendMode.SEND; + + if (trailersOnly.get()) { + if (sendResponseHeaders) { + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseHeaders(HttpHeaders.newBuilder() + .setHeaders( + toHeaderMap( + savedTrailers, + dataPlaneClientCall.getConfig().getForwardRulesConfig())) + .setEndOfStream(true) + .build()) + .build()); + } else { + proceedWithClose(); + if (!dataPlaneClientCall.getConfig().getObservabilityMode()) { + dataPlaneClientCall.closeExtProcStream(); + } + } + } else if (sendResponseTrailers) { + dataPlaneClientCall.getIsProcessingTrailers().set(true); + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseTrailers(HttpTrailers.newBuilder() + .setTrailers( + toHeaderMap( + savedTrailers, + dataPlaneClientCall.getConfig().getForwardRulesConfig())) + .build()) + .build()); + } else { + proceedWithClose(); + if (!dataPlaneClientCall.getConfig().getObservabilityMode()) { + dataPlaneClientCall.closeExtProcStream(); + } + } + } + + private void sendResponseBodyToExtProc( + @Nullable ByteString bodyByteString, boolean endOfStream) { + if (dataPlaneClientCall.getExtProcStreamState().get().isCompleted() + || dataPlaneClientCall.getCurrentProcessingMode().getResponseBodyMode() + != ProcessingMode.BodySendMode.GRPC) { + return; + } + + HttpBody.Builder bodyBuilder = + HttpBody.newBuilder(); + if (bodyByteString != null) { + bodyBuilder.setBody(bodyByteString); + } + bodyBuilder.setEndOfStream(endOfStream); + + dataPlaneClientCall.sendToExtProc(ProcessingRequest.newBuilder() + .setResponseBody(bodyBuilder.build()) + .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..db7007a291b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/ExternalProcessorFilter.java @@ -0,0 +1,396 @@ +/* + * 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.protobuf.Any; +import com.google.protobuf.Duration; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.Durations; +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.ProcessingMode; +import io.grpc.ClientInterceptor; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.HeaderForwardingRulesConfig; +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.headermutations.HeaderMutationRulesConfig; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesParseException; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesParser; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +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; + private final FilterContext context; + + public ExternalProcessorFilter(FilterContext context) { + this(context, new CachedChannelManager()); + } + + @VisibleForTesting + ExternalProcessorFilter(FilterContext context, CachedChannelManager cachedChannelManager) { + this.context = checkNotNull(context, "context"); + 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 GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", false); + } + + @Override + public ExternalProcessorFilter newInstance(FilterContext context) { + return new ExternalProcessorFilter(context); + } + + @Override + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterConfigParseContext 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, FilterConfigParseContext 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 ExternalProcessorClientInterceptor( + extProcFilterConfig, cachedChannelManager, scheduler, context); + } + + 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, + FilterConfigParseContext 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."); + } + + if (mode.getResponseBodyMode() == ProcessingMode.BodySendMode.GRPC + && mode.getResponseTrailerMode() != ProcessingMode.HeaderSendMode.SEND) { + return ConfigOrError.fromError( + "Invalid response_trailer_mode: " + mode.getResponseTrailerMode() + + ". response_trailer_mode must be SEND if response_body_mode is GRPC."); + } + + try { + if (grpcService != null && grpcService.hasGoogleGrpc()) { + GrpcServiceConfig grpcServiceConfig = GrpcServiceConfigParser.parse( + grpcService, context.bootstrapInfo(), context.serverInfo()); + return ConfigOrError.fromConfig(Optional.of(grpcServiceConfig)); + } else if (isParent) { + return ConfigOrError.fromError("Error parsing GrpcService config: " + + "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcService); + } + return ConfigOrError.fromConfig(Optional.empty()); + } 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, FilterConfigParseContext 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, FilterConfigParseContext 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; + } + } + + +} diff --git a/xds/src/main/java/io/grpc/xds/FaultFilter.java b/xds/src/main/java/io/grpc/xds/FaultFilter.java index 0f3bb5b0557..4aded91547f 100644 --- a/xds/src/main/java/io/grpc/xds/FaultFilter.java +++ b/xds/src/main/java/io/grpc/xds/FaultFilter.java @@ -45,6 +45,8 @@ import io.grpc.internal.GrpcUtil; import io.grpc.xds.FaultConfig.FaultAbort; import io.grpc.xds.FaultConfig.FaultDelay; +import io.grpc.xds.Filter.FilterConfigParseContext; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.ThreadSafeRandom.ThreadSafeRandomImpl; import java.util.Locale; import java.util.concurrent.Executor; @@ -99,12 +101,13 @@ public boolean isClientFilter() { } @Override - public FaultFilter newInstance(String name) { + public FaultFilter newInstance(FilterContext context) { return INSTANCE; } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterConfigParseContext context) { HTTPFault httpFaultProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -119,8 +122,9 @@ public ConfigOrError parseFilterConfig(Message rawProtoMessage) { } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { - return parseFilterConfig(rawProtoMessage); + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterConfigParseContext context) { + return parseFilterConfig(rawProtoMessage, context); } private static ConfigOrError parseHttpFault(HTTPFault httpFault) { @@ -410,7 +414,7 @@ private final class DelayInjectedCall extends DelayedClientCall> callSupplier) { - super(callExecutor, scheduler, deadline); + super("httpfault_filter", callExecutor, scheduler, deadline); activeFaultCounter.incrementAndGet(); ScheduledFuture task = scheduler.schedule( new Runnable() { diff --git a/xds/src/main/java/io/grpc/xds/Filter.java b/xds/src/main/java/io/grpc/xds/Filter.java index 416d929becf..dc452533dbd 100644 --- a/xds/src/main/java/io/grpc/xds/Filter.java +++ b/xds/src/main/java/io/grpc/xds/Filter.java @@ -16,10 +16,15 @@ 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.MetricRecorder; 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; @@ -87,19 +92,21 @@ default boolean isServerFilter() { *

  • Filter name+typeUrl in FilterChain's HCM.http_filters.
  • * */ - Filter newInstance(String name); + Filter newInstance(FilterContext context); /** * 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, FilterConfigParseContext 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, FilterConfigParseContext context); } /** Uses the FilterConfigs produced above to produce an HTTP filter interceptor for clients. */ @@ -125,6 +132,39 @@ default ServerInterceptor buildServerInterceptor( @Override default void close() {} + /** Context carrying dynamic metadata for a filter. */ + @AutoValue + abstract static class FilterConfigParseContext { + abstract BootstrapInfo bootstrapInfo(); + + abstract ServerInfo serverInfo(); + + static Builder builder() { + return new AutoValue_Filter_FilterConfigParseContext.Builder(); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder bootstrapInfo(BootstrapInfo info); + + abstract Builder serverInfo(ServerInfo info); + + abstract FilterConfigParseContext build(); + } + } + + /** Context containing naming and metrics reporting objects for a filter instance. */ + @AutoValue + abstract static class FilterContext { + abstract String filterName(); + + abstract MetricRecorder metricsRecorder(); + + static FilterContext create(String filterName, MetricRecorder metricsRecorder) { + return new AutoValue_Filter_FilterContext(filterName, metricsRecorder); + } + } + /** 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 1fbccea8000..adeaec41081 100644 --- a/xds/src/main/java/io/grpc/xds/FilterRegistry.java +++ b/xds/src/main/java/io/grpc/xds/FilterRegistry.java @@ -17,7 +17,6 @@ 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; @@ -33,22 +32,23 @@ final class FilterRegistry { private FilterRegistry() {} - static boolean isEnabledGcpAuthnFilter = - GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER", false); - static synchronized FilterRegistry getDefaultRegistry() { if (instance == null) { instance = newRegistry().register( new FaultFilter.Provider(), new RouterFilter.Provider(), - new RbacFilter.Provider()); - if (isEnabledGcpAuthnFilter) { - instance.register(new GcpAuthenticationFilter.Provider()); - } + new RbacFilter.Provider(), + new GcpAuthenticationFilter.Provider(), + new ExternalProcessorFilter.Provider()); } return instance; } + @VisibleForTesting + static synchronized void reset() { + instance = null; + } + @VisibleForTesting static FilterRegistry newRegistry() { return new FilterRegistry(); diff --git a/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java b/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java index dc133eaaf1a..9c4d4664836 100644 --- a/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java +++ b/xds/src/main/java/io/grpc/xds/GcpAuthenticationFilter.java @@ -17,7 +17,6 @@ package io.grpc.xds; import static com.google.common.base.Preconditions.checkNotNull; -import static io.grpc.xds.FilterRegistry.isEnabledGcpAuthnFilter; import static io.grpc.xds.XdsNameResolver.CLUSTER_SELECTION_KEY; import static io.grpc.xds.XdsNameResolver.XDS_CONFIG_CALL_OPTION_KEY; @@ -42,6 +41,8 @@ import io.grpc.Status; import io.grpc.StatusOr; import io.grpc.auth.MoreCallCredentials; +import io.grpc.xds.Filter.FilterConfigParseContext; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.GcpAuthenticationFilter.AudienceMetadataParser.AudienceWrapper; import io.grpc.xds.MetadataRegistry.MetadataValueParser; import io.grpc.xds.XdsConfig.XdsClusterConfig; @@ -82,12 +83,13 @@ public boolean isClientFilter() { } @Override - public GcpAuthenticationFilter newInstance(String name) { - return new GcpAuthenticationFilter(name, cacheSize); + public GcpAuthenticationFilter newInstance(FilterContext context) { + return new GcpAuthenticationFilter(context.filterName(), cacheSize); } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterConfigParseContext context) { GcpAuthnFilterConfig gcpAuthnProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -122,8 +124,8 @@ public ConfigOrError parseFilterConfig(Message rawProto @Override public ConfigOrError parseFilterConfigOverride( - Message rawProtoMessage) { - return parseFilterConfig(rawProtoMessage); + Message rawProtoMessage, FilterConfigParseContext context) { + return parseFilterConfig(rawProtoMessage, context); } } @@ -313,10 +315,6 @@ public String getTypeUrl() { public AudienceWrapper parse(Any any) throws ResourceInvalidException { Audience audience; try { - if (!isEnabledGcpAuthnFilter) { - throw new InvalidProtocolBufferException("Environment variable for GCP Authentication " - + "Filter is Not Set"); - } audience = any.unpack(Audience.class); } catch (InvalidProtocolBufferException ex) { throw new ResourceInvalidException("Invalid Resource in address proto", ex); diff --git a/xds/src/main/java/io/grpc/xds/GrpcBootstrapImplConfig.java b/xds/src/main/java/io/grpc/xds/GrpcBootstrapImplConfig.java new file mode 100644 index 00000000000..e119321fb6c --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/GrpcBootstrapImplConfig.java @@ -0,0 +1,34 @@ +/* + * 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.auto.value.AutoValue; +import io.grpc.Internal; +import io.grpc.xds.client.AllowedGrpcServices; + +/** + * Custom configuration for gRPC xDS bootstrap implementation. + */ +@Internal +@AutoValue +public abstract class GrpcBootstrapImplConfig { + public abstract AllowedGrpcServices allowedGrpcServices(); + + public static GrpcBootstrapImplConfig create(AllowedGrpcServices services) { + return new AutoValue_GrpcBootstrapImplConfig(services); + } +} diff --git a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java index f61fab42cae..00a2e0d48d6 100644 --- a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java @@ -18,14 +18,21 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableMap; +import com.google.errorprone.annotations.concurrent.GuardedBy; +import io.grpc.CallCredentials; import io.grpc.ChannelCredentials; import io.grpc.internal.JsonUtil; +import io.grpc.xds.client.AllowedGrpcServices; +import io.grpc.xds.client.AllowedGrpcServices.AllowedGrpcService; import io.grpc.xds.client.BootstrapperImpl; +import io.grpc.xds.client.ConfiguredChannelCredentials; +import io.grpc.xds.client.ConfiguredChannelCredentials.ChannelCredsConfig; import io.grpc.xds.client.XdsInitializationException; import io.grpc.xds.client.XdsLogger; import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nullable; class GrpcBootstrapperImpl extends BootstrapperImpl { @@ -48,7 +55,11 @@ class GrpcBootstrapperImpl extends BootstrapperImpl { @Override public BootstrapInfo bootstrap(Map rawData) throws XdsInitializationException { - return super.bootstrap(rawData); + BootstrapInfo info = super.bootstrap(rawData); + if (info.servers().isEmpty()) { + throw new XdsInitializationException("Invalid bootstrap: 'xds_servers' is empty"); + } + return info; } /** @@ -92,29 +103,50 @@ protected String getJsonContent() throws XdsInitializationException, IOException @Override protected Object getImplSpecificConfig(Map serverConfig, String serverUri) throws XdsInitializationException { - return getChannelCredentials(serverConfig, serverUri); + ConfiguredChannelCredentials configuredChannel = getChannelCredentials(serverConfig, serverUri); + return configuredChannel != null ? configuredChannel.channelCredentials() : null; + } + + @GuardedBy("GrpcBootstrapperImpl.class") + private static Map defaultBootstrapOverride; + @GuardedBy("GrpcBootstrapperImpl.class") + private static BootstrapInfo defaultBootstrap; + + static synchronized void setDefaultBootstrapOverride(Map rawBootstrap) { + defaultBootstrapOverride = rawBootstrap; + } + + static synchronized BootstrapInfo defaultBootstrap() throws XdsInitializationException { + if (defaultBootstrap == null) { + if (defaultBootstrapOverride == null) { + defaultBootstrap = new GrpcBootstrapperImpl().bootstrap(); + } else { + defaultBootstrap = new GrpcBootstrapperImpl().bootstrap(defaultBootstrapOverride); + } + } + return defaultBootstrap; } - private static ChannelCredentials getChannelCredentials(Map serverConfig, - String serverUri) + private static ConfiguredChannelCredentials getChannelCredentials(Map serverConfig, + String serverUri) throws XdsInitializationException { List rawChannelCredsList = JsonUtil.getList(serverConfig, "channel_creds"); if (rawChannelCredsList == null || rawChannelCredsList.isEmpty()) { throw new XdsInitializationException( "Invalid bootstrap: server " + serverUri + " 'channel_creds' required"); } - ChannelCredentials channelCredentials = + ConfiguredChannelCredentials credentials = parseChannelCredentials(JsonUtil.checkObjectList(rawChannelCredsList), serverUri); - if (channelCredentials == null) { + if (credentials == null) { throw new XdsInitializationException( "Server " + serverUri + ": no supported channel credentials found"); } - return channelCredentials; + return credentials; } @Nullable - private static ChannelCredentials parseChannelCredentials(List> jsonList, - String serverUri) + private static ConfiguredChannelCredentials parseChannelCredentials(List> jsonList, + String serverUri) throws XdsInitializationException { for (Map channelCreds : jsonList) { String type = JsonUtil.getString(channelCreds, "type"); @@ -130,9 +162,95 @@ private static ChannelCredentials parseChannelCredentials(List> j config = ImmutableMap.of(); } - return provider.newChannelCredentials(config); + ChannelCredentials creds = provider.newChannelCredentials(config); + if (creds == null) { + return null; + } + return ConfiguredChannelCredentials.create(creds, new JsonChannelCredsConfig(type, config)); } } return null; } + + @Override + protected Optional parseImplSpecificObject( + @Nullable Map rawAllowedGrpcServices) + throws XdsInitializationException { + if (rawAllowedGrpcServices == null || rawAllowedGrpcServices.isEmpty()) { + return Optional.of(GrpcBootstrapImplConfig.create(AllowedGrpcServices.empty())); + } + + ImmutableMap.Builder builder = + ImmutableMap.builder(); + for (String targetUri : rawAllowedGrpcServices.keySet()) { + Map serviceConfig = JsonUtil.getObject(rawAllowedGrpcServices, targetUri); + if (serviceConfig == null) { + throw new XdsInitializationException( + "Invalid allowed_grpc_services config for " + targetUri); + } + ConfiguredChannelCredentials configuredChannel = + getChannelCredentials(serviceConfig, targetUri); + + Optional callCredentials = Optional.empty(); + List rawCallCredsList = JsonUtil.getList(serviceConfig, "call_creds"); + if (rawCallCredsList != null && !rawCallCredsList.isEmpty()) { + callCredentials = + parseCallCredentials(JsonUtil.checkObjectList(rawCallCredsList), targetUri); + } + + AllowedGrpcService.Builder b = AllowedGrpcService.builder() + .configuredChannelCredentials(configuredChannel); + callCredentials.ifPresent(b::callCredentials); + builder.put(targetUri, b.build()); + } + GrpcBootstrapImplConfig customConfig = + GrpcBootstrapImplConfig.create(AllowedGrpcServices.create(builder.build())); + return Optional.of(customConfig); + } + + @SuppressWarnings("unused") + private static Optional parseCallCredentials(List> jsonList, + String targetUri) + throws XdsInitializationException { + // TODO(sauravzg): Currently no xDS call credentials providers are implemented (no + // XdsCallCredentialsRegistry). + // As per A102/A97, we should just ignore unsupported call credentials types + // without throwing an exception. + return Optional.empty(); + } + + private static final class JsonChannelCredsConfig implements ChannelCredsConfig { + private final String type; + private final Map config; + + JsonChannelCredsConfig(String type, Map config) { + this.type = type; + this.config = config; + } + + @Override + public String type() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + JsonChannelCredsConfig that = (JsonChannelCredsConfig) o; + return java.util.Objects.equals(type, that.type) + && java.util.Objects.equals(config, that.config); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(type, config); + } + } + } + diff --git a/xds/src/main/java/io/grpc/xds/GrpcServiceConfigParser.java b/xds/src/main/java/io/grpc/xds/GrpcServiceConfigParser.java new file mode 100644 index 00000000000..c7ffaf43d52 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/GrpcServiceConfigParser.java @@ -0,0 +1,343 @@ +/* + * 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.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.Durations; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +import io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3.AccessTokenCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.CallCredentials; +import io.grpc.CompositeCallCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import io.grpc.NameResolverRegistry; +import io.grpc.SecurityLevel; +import io.grpc.alts.GoogleDefaultChannelCredentials; +import io.grpc.auth.MoreCallCredentials; +import io.grpc.xds.client.AllowedGrpcServices; +import io.grpc.xds.client.AllowedGrpcServices.AllowedGrpcService; +import io.grpc.xds.client.Bootstrapper; +import io.grpc.xds.client.ConfiguredChannelCredentials; +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.grpcservice.HeaderValueValidationUtils; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; + +/** + * Parser for {@link io.envoyproxy.envoy.config.core.v3.GrpcService} and related protos. + */ +final class GrpcServiceConfigParser { + + static final String TLS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "tls.v3.TlsCredentials"; + static final String LOCAL_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "local.v3.LocalCredentials"; + static final String XDS_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "xds.v3.XdsCredentials"; + static final String INSECURE_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "insecure.v3.InsecureCredentials"; + static final String GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL = + "type.googleapis.com/envoy.extensions.grpc_service.channel_credentials." + + "google_default.v3.GoogleDefaultCredentials"; + + + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService} proto to create a + * {@link GrpcServiceConfig} instance. + * + * @param grpcServiceProto The proto to parse. + * @return A {@link GrpcServiceConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid or uses unsupported features. + */ + public static GrpcServiceConfig parse(GrpcService grpcServiceProto, + Bootstrapper.BootstrapInfo bootstrapInfo, Bootstrapper.ServerInfo serverInfo) + throws GrpcServiceParseException { + if (!grpcServiceProto.hasGoogleGrpc()) { + throw new GrpcServiceParseException( + "Unsupported: GrpcService must have GoogleGrpc, got: " + grpcServiceProto); + } + GrpcServiceConfig.GoogleGrpcConfig googleGrpcConfig = + parseGoogleGrpcConfig(grpcServiceProto.getGoogleGrpc(), bootstrapInfo, serverInfo); + + GrpcServiceConfig.Builder builder = GrpcServiceConfig.builder().googleGrpc(googleGrpcConfig); + + ImmutableList.Builder initialMetadata = ImmutableList.builder(); + for (io.envoyproxy.envoy.config.core.v3.HeaderValue header : grpcServiceProto + .getInitialMetadataList()) { + String key = header.getKey(); + HeaderValue headerValue; + try { + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + headerValue = HeaderValue.create(key, header.getRawValue()); + } else { + headerValue = HeaderValue.create(key, header.getValue()); + } + } catch (IllegalArgumentException e) { + throw new GrpcServiceParseException("Invalid initial metadata header: " + key, e); + } + if (HeaderValueValidationUtils.isDisallowed(headerValue)) { + throw new GrpcServiceParseException("Invalid initial metadata header: " + key); + } + initialMetadata.add(headerValue); + } + builder.initialMetadata(initialMetadata.build()); + + if (grpcServiceProto.hasTimeout()) { + com.google.protobuf.Duration timeout = grpcServiceProto.getTimeout(); + if (!Durations.isValid(timeout) || Durations.compare(timeout, Durations.ZERO) <= 0) { + throw new GrpcServiceParseException("Timeout must be strictly positive and valid"); + } + builder.timeout(Duration.ofSeconds(timeout.getSeconds(), timeout.getNanos())); + } + return builder.build(); + } + + /** + * Parses the {@link io.envoyproxy.envoy.config.core.v3.GrpcService.GoogleGrpc} proto to create a + * {@link GrpcServiceConfig.GoogleGrpcConfig} instance. + * + * @param googleGrpcProto The proto to parse. + * @return A {@link GrpcServiceConfig.GoogleGrpcConfig} instance. + * @throws GrpcServiceParseException if the proto is invalid. + */ + public static GrpcServiceConfig.GoogleGrpcConfig parseGoogleGrpcConfig( + GrpcService.GoogleGrpc googleGrpcProto, Bootstrapper.BootstrapInfo bootstrapInfo, + Bootstrapper.ServerInfo serverInfo) throws GrpcServiceParseException { + + String targetUri = googleGrpcProto.getTargetUri(); + + AllowedGrpcServices allowedGrpcServices = + bootstrapInfo.implSpecificObject() + .filter(GrpcBootstrapImplConfig.class::isInstance) + .map(GrpcBootstrapImplConfig.class::cast) + .map(GrpcBootstrapImplConfig::allowedGrpcServices) + .orElse(AllowedGrpcServices.empty()); + + boolean isTrustedControlPlane = serverInfo.isTrustedXdsServer(); + Optional override = + Optional.ofNullable(allowedGrpcServices.services().get(targetUri)); + + boolean isTargetUriSchemeSupported = false; + try { + URI uri = new URI(targetUri); + String scheme = uri.getScheme(); + if (scheme == null) { + scheme = NameResolverRegistry.getDefaultRegistry().getDefaultScheme(); + } + if (scheme != null) { + isTargetUriSchemeSupported = + NameResolverRegistry.getDefaultRegistry().getProviderForScheme(scheme) != null; + } + } catch (URISyntaxException e) { + // Fallback or ignore if not a valid URI + } + + if (!isTargetUriSchemeSupported) { + throw new GrpcServiceParseException("Target URI scheme is not resolvable: " + targetUri); + } + + if (!isTrustedControlPlane) { + if (!override.isPresent()) { + throw new GrpcServiceParseException( + "Untrusted xDS server & URI not found in allowed_grpc_services: " + targetUri); + } + + GrpcServiceConfig.GoogleGrpcConfig.Builder builder = + GrpcServiceConfig.GoogleGrpcConfig.builder().target(targetUri) + .configuredChannelCredentials(override.get().configuredChannelCredentials()); + if (override.get().callCredentials().isPresent()) { + builder.callCredentials(override.get().callCredentials().get()); + } + return builder.build(); + } + + ConfiguredChannelCredentials channelCreds = + extractChannelCredentials(googleGrpcProto.getChannelCredentialsPluginList()); + + Optional callCreds = + extractCallCredentials(googleGrpcProto.getCallCredentialsPluginList()); + + GrpcServiceConfig.GoogleGrpcConfig.Builder builder = + GrpcServiceConfig.GoogleGrpcConfig.builder().target(googleGrpcProto.getTargetUri()) + .configuredChannelCredentials(channelCreds); + if (callCreds.isPresent()) { + builder.callCredentials(callCreds.get()); + } + return builder.build(); + } + + private static Optional channelCredsFromProto(Any cred) + throws GrpcServiceParseException { + String typeUrl = cred.getTypeUrl(); + try { + switch (typeUrl) { + case GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL: + return Optional + .of(ConfiguredChannelCredentials.create(GoogleDefaultChannelCredentials.create(), + new ProtoChannelCredsConfig(typeUrl, cred))); + case INSECURE_CREDENTIALS_TYPE_URL: + return Optional.of(ConfiguredChannelCredentials.create( + InsecureChannelCredentials.create(), new ProtoChannelCredsConfig(typeUrl, cred))); + case XDS_CREDENTIALS_TYPE_URL: + XdsCredentials xdsConfig = cred.unpack(XdsCredentials.class); + Optional fallbackCreds = + channelCredsFromProto(xdsConfig.getFallbackCredentials()); + if (!fallbackCreds.isPresent()) { + throw new GrpcServiceParseException( + "Unsupported fallback credentials type for XdsCredentials"); + } + return Optional.of(ConfiguredChannelCredentials.create( + XdsChannelCredentials.create(fallbackCreds.get().channelCredentials()), + new ProtoChannelCredsConfig(typeUrl, cred))); + case LOCAL_CREDENTIALS_TYPE_URL: + throw new GrpcServiceParseException( + "LocalCredentials are not supported in grpc-java. " + + "See https://github.com/grpc/grpc-java/issues/8928"); + case TLS_CREDENTIALS_TYPE_URL: + // For this PR, we establish this structural skeleton, + // but throw an GrpcServiceParseException until the exact stream conversions are + // merged. + throw new GrpcServiceParseException( + "TlsCredentials input stream construction pending."); + default: + return Optional.empty(); + } + } catch (InvalidProtocolBufferException e) { + throw new GrpcServiceParseException("Failed to parse channel credentials: " + e.getMessage()); + } + } + + private static ConfiguredChannelCredentials extractChannelCredentials( + List channelCredentialPlugins) throws GrpcServiceParseException { + for (Any cred : channelCredentialPlugins) { + Optional parsed = channelCredsFromProto(cred); + if (parsed.isPresent()) { + return parsed.get(); + } + } + throw new GrpcServiceParseException("No valid supported channel_credentials found"); + } + + private static Optional callCredsFromProto(Any cred) + throws GrpcServiceParseException { + if (cred.is(AccessTokenCredentials.class)) { + try { + AccessTokenCredentials accessToken = cred.unpack(AccessTokenCredentials.class); + if (accessToken.getToken().isEmpty()) { + throw new GrpcServiceParseException("Missing or empty access token in call credentials."); + } + return Optional + .of(new SecurityAwareAccessTokenCredentials(MoreCallCredentials.from(OAuth2Credentials + .create(new AccessToken(accessToken.getToken(), new Date(Long.MAX_VALUE)))))); + } catch (InvalidProtocolBufferException e) { + throw new GrpcServiceParseException( + "Failed to parse access token credentials: " + e.getMessage()); + } + } + return Optional.empty(); + } + + private static Optional extractCallCredentials(List callCredentialPlugins) + throws GrpcServiceParseException { + List creds = new ArrayList<>(); + for (Any cred : callCredentialPlugins) { + Optional parsed = callCredsFromProto(cred); + if (parsed.isPresent()) { + creds.add(parsed.get()); + } + } + return creds.stream().reduce(CompositeCallCredentials::new); + } + + private static final class SecurityAwareAccessTokenCredentials extends CallCredentials { + + private final CallCredentials delegate; + + SecurityAwareAccessTokenCredentials(CallCredentials delegate) { + this.delegate = delegate; + } + + @Override + public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, + MetadataApplier applier) { + if (requestInfo.getSecurityLevel() == SecurityLevel.PRIVACY_AND_INTEGRITY) { + delegate.applyRequestMetadata(requestInfo, appExecutor, applier); + } else { + applier.apply(new Metadata()); + } + } + } + + static final class ProtoChannelCredsConfig + implements ConfiguredChannelCredentials.ChannelCredsConfig { + private final String type; + private final Any configProto; + + ProtoChannelCredsConfig(String type, Any configProto) { + this.type = type; + this.configProto = configProto; + } + + @Override + public String type() { + return type; + } + + Any configProto() { + return configProto; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProtoChannelCredsConfig that = (ProtoChannelCredsConfig) o; + return java.util.Objects.equals(type, that.type) + && java.util.Objects.equals(configProto, that.configProto); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(type, configProto); + } + } + + + +} diff --git a/xds/src/main/java/io/grpc/xds/InternalGrpcBootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/InternalGrpcBootstrapperImpl.java index 929619c11d7..7bbc2a6dfca 100644 --- a/xds/src/main/java/io/grpc/xds/InternalGrpcBootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/InternalGrpcBootstrapperImpl.java @@ -17,8 +17,9 @@ package io.grpc.xds; import io.grpc.Internal; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; import io.grpc.xds.client.XdsInitializationException; -import java.io.IOException; +import java.util.Map; /** * Internal accessors for GrpcBootstrapperImpl. @@ -27,7 +28,8 @@ public final class InternalGrpcBootstrapperImpl { private InternalGrpcBootstrapperImpl() {} // prevent instantiation - public static String getJsonContent() throws XdsInitializationException, IOException { - return new GrpcBootstrapperImpl().getJsonContent(); + public static BootstrapInfo parseBootstrap(Map bootstrap) + throws XdsInitializationException { + return new GrpcBootstrapperImpl().bootstrap(bootstrap); } } diff --git a/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java b/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java index 476adbf9cfd..70d918a391f 100644 --- a/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java +++ b/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java @@ -19,6 +19,7 @@ import io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC; import io.grpc.Internal; import io.grpc.ServerInterceptor; +import io.grpc.xds.Filter.FilterContext; /** This class exposes some functionality in RbacFilter to other packages. */ @Internal @@ -33,7 +34,8 @@ public static ServerInterceptor createInterceptor(RBAC rbac) { throw new IllegalArgumentException( String.format("Failed to parse Rbac policy: %s", filterConfig.errorDetail)); } - return new RbacFilter.Provider().newInstance("internalRbacFilter") + return new RbacFilter.Provider().newInstance( + FilterContext.create("internalRbacFilter", new io.grpc.MetricRecorder() {})) .buildServerInterceptor(filterConfig.config, null); } } diff --git a/xds/src/main/java/io/grpc/xds/InternalSharedXdsClientPoolProvider.java b/xds/src/main/java/io/grpc/xds/InternalSharedXdsClientPoolProvider.java index 9c98bba93cf..cc5ff128274 100644 --- a/xds/src/main/java/io/grpc/xds/InternalSharedXdsClientPoolProvider.java +++ b/xds/src/main/java/io/grpc/xds/InternalSharedXdsClientPoolProvider.java @@ -20,6 +20,7 @@ import io.grpc.Internal; import io.grpc.MetricRecorder; import io.grpc.internal.ObjectPool; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; import io.grpc.xds.client.XdsClient; import io.grpc.xds.client.XdsInitializationException; import java.util.Map; @@ -32,24 +33,79 @@ public final class InternalSharedXdsClientPoolProvider { // Prevent instantiation private InternalSharedXdsClientPoolProvider() {} + /** + * Override the global bootstrap. + * + * @deprecated Use InternalGrpcBootstrapperImpl.parseBootstrap() and pass the result to + * getOrCreate(). + */ + @Deprecated public static void setDefaultProviderBootstrapOverride(Map bootstrap) { - SharedXdsClientPoolProvider.getDefaultProvider().setBootstrapOverride(bootstrap); + GrpcBootstrapperImpl.setDefaultBootstrapOverride(bootstrap); } + /** + * Get an XdsClient pool. + * + * @deprecated Use InternalGrpcBootstrapperImpl.parseBootstrap() and pass the result to the other + * getOrCreate(). + */ + @Deprecated public static ObjectPool getOrCreate(String target) throws XdsInitializationException { return getOrCreate(target, new MetricRecorder() {}); } + /** + * Get an XdsClient pool. + * + * @deprecated Use InternalGrpcBootstrapperImpl.parseBootstrap() and pass the result to the other + * getOrCreate(). + */ + @Deprecated public static ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) throws XdsInitializationException { return getOrCreate(target, metricRecorder, null); } + /** + * Get an XdsClient pool. + * + * @deprecated Use InternalGrpcBootstrapperImpl.parseBootstrap() and pass the result to the other + * getOrCreate(). + */ + @Deprecated public static ObjectPool getOrCreate( String target, MetricRecorder metricRecorder, CallCredentials transportCallCredentials) throws XdsInitializationException { return SharedXdsClientPoolProvider.getDefaultProvider() .getOrCreate(target, metricRecorder, transportCallCredentials); } + + public static XdsClientResult getOrCreate( + String target, BootstrapInfo bootstrapInfo, MetricRecorder metricRecorder, + CallCredentials transportCallCredentials) { + return new XdsClientResult(SharedXdsClientPoolProvider.getDefaultProvider() + .getOrCreate(target, bootstrapInfo, metricRecorder, transportCallCredentials)); + } + + /** + * An ObjectPool, except without exposing io.grpc.internal, which must not be used for + * cross-package APIs. + */ + public static final class XdsClientResult { + private final ObjectPool xdsClientPool; + + XdsClientResult(ObjectPool xdsClientPool) { + this.xdsClientPool = xdsClientPool; + } + + public XdsClient getObject() { + return xdsClientPool.getObject(); + } + + public XdsClient returnObject(XdsClient xdsClient) { + return xdsClientPool.returnObject(xdsClient); + } + } } diff --git a/xds/src/main/java/io/grpc/xds/LeastRequestLoadBalancer.java b/xds/src/main/java/io/grpc/xds/LeastRequestLoadBalancer.java index dda2ad177e6..ddaeb4f4be5 100644 --- a/xds/src/main/java/io/grpc/xds/LeastRequestLoadBalancer.java +++ b/xds/src/main/java/io/grpc/xds/LeastRequestLoadBalancer.java @@ -54,7 +54,7 @@ final class LeastRequestLoadBalancer extends MultiChildLoadBalancer { private final ThreadSafeRandom random; - private SubchannelPicker currentPicker = new EmptyPicker(); + private SubchannelPicker currentPicker = new FixedResultPicker(PickResult.withNoResult()); private int choiceCount = DEFAULT_CHOICE_COUNT; LeastRequestLoadBalancer(Helper helper) { @@ -113,7 +113,7 @@ protected void updateOverallBalancingState() { } } if (isConnecting) { - updateBalancingState(CONNECTING, new EmptyPicker()); + updateBalancingState(CONNECTING, new FixedResultPicker(PickResult.withNoResult())); } else { // Give it all the failing children and let it randomly pick among them updateBalancingState(TRANSIENT_FAILURE, @@ -299,6 +299,20 @@ static final class LeastRequestConfig { this.choiceCount = Math.min(choiceCount, MAX_CHOICE_COUNT); } + @Override + public boolean equals(Object o) { + if (!(o instanceof LeastRequestConfig)) { + return false; + } + LeastRequestConfig that = (LeastRequestConfig) o; + return this.choiceCount == that.choiceCount; + } + + @Override + public int hashCode() { + return choiceCount; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/xds/src/main/java/io/grpc/xds/LoadBalancerConfigFactory.java b/xds/src/main/java/io/grpc/xds/LoadBalancerConfigFactory.java index e08ea0fab43..3693405a877 100644 --- a/xds/src/main/java/io/grpc/xds/LoadBalancerConfigFactory.java +++ b/xds/src/main/java/io/grpc/xds/LoadBalancerConfigFactory.java @@ -91,6 +91,7 @@ class LoadBalancerConfigFactory { static final String SHUFFLE_ADDRESS_LIST_FIELD_NAME = "shuffleAddressList"; static final String ERROR_UTILIZATION_PENALTY = "errorUtilizationPenalty"; + static final String METRIC_NAMES_FOR_COMPUTING_UTILIZATION = "metricNamesForComputingUtilization"; /** * Factory method for creating a new {link LoadBalancerConfigConverter} for a given xDS {@link @@ -134,11 +135,9 @@ class LoadBalancerConfigFactory { * the given config values. */ private static ImmutableMap buildWrrConfig(String blackoutPeriod, - String weightExpirationPeriod, - String oobReportingPeriod, - Boolean enableOobLoadReport, - String weightUpdatePeriod, - Float errorUtilizationPenalty) { + String weightExpirationPeriod, String oobReportingPeriod, Boolean enableOobLoadReport, + String weightUpdatePeriod, Float errorUtilizationPenalty, + ImmutableList metricNamesForComputingUtilization) { ImmutableMap.Builder configBuilder = ImmutableMap.builder(); if (blackoutPeriod != null) { configBuilder.put(BLACK_OUT_PERIOD, blackoutPeriod); @@ -156,7 +155,11 @@ class LoadBalancerConfigFactory { configBuilder.put(WEIGHT_UPDATE_PERIOD, weightUpdatePeriod); } if (errorUtilizationPenalty != null) { - configBuilder.put(ERROR_UTILIZATION_PENALTY, errorUtilizationPenalty); + configBuilder.put(ERROR_UTILIZATION_PENALTY, errorUtilizationPenalty.doubleValue()); + } + if (metricNamesForComputingUtilization != null + && !metricNamesForComputingUtilization.isEmpty()) { + configBuilder.put(METRIC_NAMES_FOR_COMPUTING_UTILIZATION, metricNamesForComputingUtilization); } return ImmutableMap.of(WeightedRoundRobinLoadBalancerProvider.SCHEME, configBuilder.buildOrThrow()); @@ -284,7 +287,7 @@ static class LoadBalancingPolicyConverter { } private static ImmutableMap convertWeightedRoundRobinConfig( - ClientSideWeightedRoundRobin wrr) throws ResourceInvalidException { + ClientSideWeightedRoundRobin wrr) throws ResourceInvalidException { try { return buildWrrConfig( wrr.hasBlackoutPeriod() ? Durations.toString(wrr.getBlackoutPeriod()) : null, @@ -293,7 +296,8 @@ static class LoadBalancingPolicyConverter { wrr.hasOobReportingPeriod() ? Durations.toString(wrr.getOobReportingPeriod()) : null, wrr.hasEnableOobLoadReport() ? wrr.getEnableOobLoadReport().getValue() : null, wrr.hasWeightUpdatePeriod() ? Durations.toString(wrr.getWeightUpdatePeriod()) : null, - wrr.hasErrorUtilizationPenalty() ? wrr.getErrorUtilizationPenalty().getValue() : null); + wrr.hasErrorUtilizationPenalty() ? wrr.getErrorUtilizationPenalty().getValue() : null, + ImmutableList.copyOf(wrr.getMetricNamesForComputingUtilizationList())); } catch (IllegalArgumentException ex) { throw new ResourceInvalidException("Invalid duration in weighted round robin config: " + ex.getMessage()); diff --git a/xds/src/main/java/io/grpc/xds/PriorityLoadBalancer.java b/xds/src/main/java/io/grpc/xds/PriorityLoadBalancer.java index 6e4566de76d..ca142af0af3 100644 --- a/xds/src/main/java/io/grpc/xds/PriorityLoadBalancer.java +++ b/xds/src/main/java/io/grpc/xds/PriorityLoadBalancer.java @@ -28,6 +28,7 @@ import io.grpc.Status; import io.grpc.SynchronizationContext; import io.grpc.SynchronizationContext.ScheduledHandle; +import io.grpc.internal.GrpcUtil; import io.grpc.util.ForwardingLoadBalancerHelper; import io.grpc.util.GracefulSwitchLoadBalancer; import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig; @@ -73,6 +74,8 @@ final class PriorityLoadBalancer extends LoadBalancer { private SubchannelPicker currentPicker; // Set to true if currently in the process of handling resolved addresses. private boolean handlingResolvedAddresses; + static boolean enablePriorityLbChildPolicyCache = + GrpcUtil.getFlag("GRPC_EXPERIMENTAL_ENABLE_PRIORITY_LB_CHILD_POLICY_CACHE", false); PriorityLoadBalancer(Helper helper) { this.helper = checkNotNull(helper, "helper"); @@ -98,7 +101,12 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { if (!prioritySet.contains(priority)) { ChildLbState childLbState = children.get(priority); if (childLbState != null) { - childLbState.deactivate(); + if (enablePriorityLbChildPolicyCache) { + childLbState.deactivate(); + } else { + childLbState.tearDown(); + children.remove(priority); + } } } } diff --git a/xds/src/main/java/io/grpc/xds/RbacFilter.java b/xds/src/main/java/io/grpc/xds/RbacFilter.java index 91df1e68802..38b2db7ebca 100644 --- a/xds/src/main/java/io/grpc/xds/RbacFilter.java +++ b/xds/src/main/java/io/grpc/xds/RbacFilter.java @@ -33,6 +33,8 @@ import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.Status; +import io.grpc.xds.Filter.FilterConfigParseContext; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.internal.MatcherParser; import io.grpc.xds.internal.Matchers; import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine; @@ -89,12 +91,13 @@ public boolean isServerFilter() { } @Override - public RbacFilter newInstance(String name) { + public RbacFilter newInstance(FilterContext context) { return INSTANCE; } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterConfigParseContext context) { RBAC rbacProto; if (!(rawProtoMessage instanceof Any)) { return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass()); @@ -109,7 +112,8 @@ public ConfigOrError parseFilterConfig(Message rawProtoMessage) { } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterConfigParseContext 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/RingHashLoadBalancer.java b/xds/src/main/java/io/grpc/xds/RingHashLoadBalancer.java index 21ee914ff8f..513f4d643ea 100644 --- a/xds/src/main/java/io/grpc/xds/RingHashLoadBalancer.java +++ b/xds/src/main/java/io/grpc/xds/RingHashLoadBalancer.java @@ -214,12 +214,32 @@ protected void updateOverallBalancingState() { overallState = TRANSIENT_FAILURE; } + // gRFC A61: if the aggregated connectivity state is TRANSIENT_FAILURE or CONNECTING and + // there are no endpoints in CONNECTING state, the ring_hash policy will choose one of + // the endpoints in IDLE state (if any) to trigger a connection attempt on + if (numReady == 0 && numTF > 0 && numConnecting == 0 && numIdle > 0) { + triggerIdleChildConnection(); + } + RingHashPicker picker = new RingHashPicker(syncContext, ring, getChildLbStates(), requestHashHeaderKey, random); getHelper().updateBalancingState(overallState, picker); this.currentConnectivityState = overallState; } + + /** + * Triggers a connection attempt for the first IDLE child load balancer. + */ + private void triggerIdleChildConnection() { + for (ChildLbState child : getChildLbStates()) { + if (child.getCurrentState() == ConnectivityState.IDLE) { + child.getLb().requestConnection(); + return; + } + } + } + @Override protected ChildLbState createChildLbState(Object key) { return new ChildLbState(key, lazyLbFactory); diff --git a/xds/src/main/java/io/grpc/xds/RouterFilter.java b/xds/src/main/java/io/grpc/xds/RouterFilter.java index 504c4213149..4623fa9b31d 100644 --- a/xds/src/main/java/io/grpc/xds/RouterFilter.java +++ b/xds/src/main/java/io/grpc/xds/RouterFilter.java @@ -17,6 +17,8 @@ package io.grpc.xds; import com.google.protobuf.Message; +import io.grpc.xds.Filter.FilterConfigParseContext; +import io.grpc.xds.Filter.FilterContext; /** * Router filter implementation. Currently this filter does not parse any field in the config. @@ -56,18 +58,19 @@ public boolean isServerFilter() { } @Override - public RouterFilter newInstance(String name) { + public RouterFilter newInstance(FilterContext context) { return INSTANCE; } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig( + Message rawProtoMessage, FilterConfigParseContext context) { return ConfigOrError.fromConfig(ROUTER_CONFIG); } @Override public ConfigOrError parseFilterConfigOverride( - Message rawProtoMessage) { + Message rawProtoMessage, FilterConfigParseContext context) { return ConfigOrError.fromError("Router Filter should not have override config"); } } diff --git a/xds/src/main/java/io/grpc/xds/SharedXdsClientPoolProvider.java b/xds/src/main/java/io/grpc/xds/SharedXdsClientPoolProvider.java index 5302880d48c..45c379244af 100644 --- a/xds/src/main/java/io/grpc/xds/SharedXdsClientPoolProvider.java +++ b/xds/src/main/java/io/grpc/xds/SharedXdsClientPoolProvider.java @@ -37,7 +37,6 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -55,59 +54,59 @@ final class SharedXdsClientPoolProvider implements XdsClientPoolFactory { private static final ExponentialBackoffPolicy.Provider BACKOFF_POLICY_PROVIDER = new ExponentialBackoffPolicy.Provider(); + @Nullable private final Bootstrapper bootstrapper; private final Object lock = new Object(); - private final AtomicReference> bootstrapOverride = new AtomicReference<>(); private final Map> targetToXdsClientMap = new ConcurrentHashMap<>(); SharedXdsClientPoolProvider() { - this(new GrpcBootstrapperImpl()); + this(null); } @VisibleForTesting - SharedXdsClientPoolProvider(Bootstrapper bootstrapper) { - this.bootstrapper = checkNotNull(bootstrapper, "bootstrapper"); + SharedXdsClientPoolProvider(@Nullable Bootstrapper bootstrapper) { + this.bootstrapper = bootstrapper; } static SharedXdsClientPoolProvider getDefaultProvider() { return SharedXdsClientPoolProviderHolder.instance; } - @Override - public void setBootstrapOverride(Map bootstrap) { - bootstrapOverride.set(bootstrap); - } - @Override @Nullable public ObjectPool get(String target) { return targetToXdsClientMap.get(target); } - @Override - public ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) + @Deprecated + public ObjectPool getOrCreate( + String target, MetricRecorder metricRecorder, CallCredentials transportCallCredentials) throws XdsInitializationException { - return getOrCreate(target, metricRecorder, null); + BootstrapInfo bootstrapInfo; + if (bootstrapper != null) { + bootstrapInfo = bootstrapper.bootstrap(); + } else { + bootstrapInfo = GrpcBootstrapperImpl.defaultBootstrap(); + } + return getOrCreate(target, bootstrapInfo, metricRecorder, transportCallCredentials); } + @Override public ObjectPool getOrCreate( - String target, MetricRecorder metricRecorder, CallCredentials transportCallCredentials) - throws XdsInitializationException { + String target, BootstrapInfo bootstrapInfo, MetricRecorder metricRecorder) { + return getOrCreate(target, bootstrapInfo, metricRecorder, null); + } + + public ObjectPool getOrCreate( + String target, + BootstrapInfo bootstrapInfo, + MetricRecorder metricRecorder, + CallCredentials transportCallCredentials) { ObjectPool ref = targetToXdsClientMap.get(target); if (ref == null) { synchronized (lock) { ref = targetToXdsClientMap.get(target); if (ref == null) { - BootstrapInfo bootstrapInfo; - Map rawBootstrap = bootstrapOverride.get(); - if (rawBootstrap != null) { - bootstrapInfo = bootstrapper.bootstrap(rawBootstrap); - } else { - bootstrapInfo = bootstrapper.bootstrap(); - } - if (bootstrapInfo.servers().isEmpty()) { - throw new XdsInitializationException("No xDS server provided"); - } ref = new RefCountedXdsClientObjectPool( bootstrapInfo, target, metricRecorder, transportCallCredentials); @@ -157,9 +156,9 @@ class RefCountedXdsClientObjectPool implements ObjectPool { String target, MetricRecorder metricRecorder, CallCredentials transportCallCredentials) { - this.bootstrapInfo = checkNotNull(bootstrapInfo); + this.bootstrapInfo = checkNotNull(bootstrapInfo, "bootstrapInfo"); this.target = target; - this.metricRecorder = metricRecorder; + this.metricRecorder = checkNotNull(metricRecorder, "metricRecorder"); this.transportCallCredentials = transportCallCredentials; } @@ -203,6 +202,10 @@ public XdsClient returnObject(Object object) { metricReporter = null; targetToXdsClientMap.remove(target); scheduler = SharedResourceHolder.release(GrpcUtil.TIMER_SERVICE, scheduler); + } else if (refCount < 0) { + assert false; // We want our tests to fail + log.log(Level.SEVERE, "Negative reference count. File a bug", new Exception()); + refCount = 0; } return null; } diff --git a/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancer.java b/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancer.java index 6cf3189d587..75a8411b5a4 100644 --- a/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancer.java +++ b/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancer.java @@ -40,6 +40,8 @@ import io.grpc.services.MetricReport; import io.grpc.util.ForwardingSubchannel; import io.grpc.util.MultiChildLoadBalancer; +import io.grpc.xds.internal.MetricReportUtils; +import io.grpc.xds.internal.MetricReportUtils.ParsedMetricName; import io.grpc.xds.orca.OrcaOobUtil; import io.grpc.xds.orca.OrcaOobUtil.OrcaOobReportListener; import io.grpc.xds.orca.OrcaPerRequestUtil; @@ -49,6 +51,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.OptionalDouble; import java.util.Random; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; @@ -189,7 +192,7 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { this.backendService = ""; } config = - (WeightedRoundRobinLoadBalancerConfig) resolvedAddresses.getLoadBalancingPolicyConfig(); + (WeightedRoundRobinLoadBalancerConfig) resolvedAddresses.getLoadBalancingPolicyConfig(); if (weightUpdateTimer != null && weightUpdateTimer.isPending()) { weightUpdateTimer.cancel(); @@ -236,7 +239,8 @@ protected void updateOverallBalancingState() { private SubchannelPicker createReadyPicker(Collection activeList) { WeightedRoundRobinPicker picker = new WeightedRoundRobinPicker(ImmutableList.copyOf(activeList), - config.enableOobLoadReport, config.errorUtilizationPenalty, sequence); + config.enableOobLoadReport, config.errorUtilizationPenalty, sequence, + config.parsedMetricNamesForComputingUtilization); updateWeight(picker); return picker; } @@ -325,12 +329,16 @@ public void addSubchannel(WrrSubchannel wrrSubchannel) { subchannels.add(wrrSubchannel); } - public OrcaReportListener getOrCreateOrcaListener(float errorUtilizationPenalty) { + public OrcaReportListener getOrCreateOrcaListener(float errorUtilizationPenalty, + ImmutableList parsedMetricNamesForComputingUtilization) { if (orcaReportListener != null - && orcaReportListener.errorUtilizationPenalty == errorUtilizationPenalty) { + && orcaReportListener.errorUtilizationPenalty == errorUtilizationPenalty + && orcaReportListener.parsedMetricNamesForComputingUtilization + .equals(parsedMetricNamesForComputingUtilization)) { return orcaReportListener; } - orcaReportListener = new OrcaReportListener(errorUtilizationPenalty); + orcaReportListener = + new OrcaReportListener(errorUtilizationPenalty, parsedMetricNamesForComputingUtilization); return orcaReportListener; } @@ -355,18 +363,19 @@ public void updateBalancingState(ConnectivityState newState, SubchannelPicker ne final class OrcaReportListener implements OrcaPerRequestReportListener, OrcaOobReportListener { private final float errorUtilizationPenalty; + private final ImmutableList parsedMetricNamesForComputingUtilization; - OrcaReportListener(float errorUtilizationPenalty) { + OrcaReportListener(float errorUtilizationPenalty, + ImmutableList parsedMetricNamesForComputingUtilization) { this.errorUtilizationPenalty = errorUtilizationPenalty; + this.parsedMetricNamesForComputingUtilization = parsedMetricNamesForComputingUtilization; } @Override public void onLoadReport(MetricReport report) { + double utilization = getUtilization(report); + double newWeight = 0; - // Prefer application utilization and fallback to CPU utilization if unset. - double utilization = - report.getApplicationUtilization() > 0 ? report.getApplicationUtilization() - : report.getCpuUtilization(); if (utilization > 0 && report.getQps() > 0) { double penalty = 0; if (report.getEps() > 0 && errorUtilizationPenalty > 0) { @@ -383,6 +392,44 @@ public void onLoadReport(MetricReport report) { lastUpdated = ticker.nanoTime(); weight = newWeight; } + + /** + * Returns the utilization value computed from the specified metric names. If the custom + * metrics are present and valid, the maximum of the custom metrics is returned. Otherwise, + * if application utilization is > 0, it is returned. If neither are present, the CPU + * utilization is returned. + */ + private double getUtilization(MetricReport report) { + OptionalDouble customUtil = getCustomMetricUtilization(report); + if (customUtil.isPresent()) { + return customUtil.getAsDouble(); + } + double appUtil = report.getApplicationUtilization(); + if (appUtil > 0) { + return appUtil; + } + return report.getCpuUtilization(); + } + + /** + * Returns the maximum utilization value among the parsed metric names. + * Returns OptionalDouble.empty() if NONE of the specified metrics are present in the report, + * or if all present metrics are NaN or non positive. + */ + private OptionalDouble getCustomMetricUtilization(MetricReport report) { + OptionalDouble max = OptionalDouble.empty(); + for (int i = 0; i < parsedMetricNamesForComputingUtilization.size(); i++) { + OptionalDouble opt = MetricReportUtils.getMetricValue(report, + parsedMetricNamesForComputingUtilization.get(i)); + if (opt.isPresent()) { + double d = opt.getAsDouble(); + if (!Double.isNaN(d) && d > 0 && (!max.isPresent() || d > max.getAsDouble())) { + max = opt; + } + } + } + return max; + } } } @@ -403,10 +450,10 @@ private void createAndApplyOrcaListeners() { for (WrrSubchannel weightedSubchannel : wChild.subchannels) { if (config.enableOobLoadReport) { OrcaOobUtil.setListener(weightedSubchannel, - wChild.getOrCreateOrcaListener(config.errorUtilizationPenalty), + wChild.getOrCreateOrcaListener(config.errorUtilizationPenalty, + config.parsedMetricNamesForComputingUtilization), OrcaOobUtil.OrcaReportingConfig.newBuilder() - .setReportInterval(config.oobReportingPeriodNanos, TimeUnit.NANOSECONDS) - .build()); + .setReportInterval(config.oobReportingPeriodNanos, TimeUnit.NANOSECONDS).build()); } else { OrcaOobUtil.setListener(weightedSubchannel, null, null); } @@ -473,7 +520,8 @@ static final class WeightedRoundRobinPicker extends SubchannelPicker { private volatile StaticStrideScheduler scheduler; WeightedRoundRobinPicker(List children, boolean enableOobLoadReport, - float errorUtilizationPenalty, AtomicInteger sequence) { + float errorUtilizationPenalty, AtomicInteger sequence, + ImmutableList parsedMetricNamesForComputingUtilization) { checkNotNull(children, "children"); Preconditions.checkArgument(!children.isEmpty(), "empty child list"); this.children = children; @@ -482,7 +530,8 @@ static final class WeightedRoundRobinPicker extends SubchannelPicker { for (ChildLbState child : children) { WeightedChildLbState wChild = (WeightedChildLbState) child; pickers.add(wChild.getCurrentPicker()); - reportListeners.add(wChild.getOrCreateOrcaListener(errorUtilizationPenalty)); + reportListeners.add(wChild.getOrCreateOrcaListener(errorUtilizationPenalty, + parsedMetricNamesForComputingUtilization)); } this.pickers = pickers; this.reportListeners = reportListeners; @@ -508,12 +557,15 @@ public PickResult pickSubchannel(PickSubchannelArgs args) { if (subchannel == null) { return pickResult; } + + subchannel = ((WrrSubchannel) subchannel).delegate(); if (!enableOobLoadReport) { - return PickResult.withSubchannel(subchannel, - OrcaPerRequestUtil.getInstance().newOrcaClientStreamTracerFactory( - reportListeners.get(pick))); + return pickResult.copyWithSubchannel(subchannel) + .copyWithStreamTracerFactory( + OrcaPerRequestUtil.getInstance().newOrcaClientStreamTracerFactory( + reportListeners.get(pick))); } else { - return PickResult.withSubchannel(subchannel); + return pickResult.copyWithSubchannel(subchannel); } } @@ -720,23 +772,36 @@ static final class WeightedRoundRobinLoadBalancerConfig { final long oobReportingPeriodNanos; final long weightUpdatePeriodNanos; final float errorUtilizationPenalty; + final ImmutableList parsedMetricNamesForComputingUtilization; public static Builder newBuilder() { return new Builder(); } private WeightedRoundRobinLoadBalancerConfig(long blackoutPeriodNanos, - long weightExpirationPeriodNanos, - boolean enableOobLoadReport, - long oobReportingPeriodNanos, - long weightUpdatePeriodNanos, - float errorUtilizationPenalty) { + long weightExpirationPeriodNanos, boolean enableOobLoadReport, long oobReportingPeriodNanos, + long weightUpdatePeriodNanos, float errorUtilizationPenalty, + ImmutableList metricNamesForComputingUtilization) { this.blackoutPeriodNanos = blackoutPeriodNanos; this.weightExpirationPeriodNanos = weightExpirationPeriodNanos; this.enableOobLoadReport = enableOobLoadReport; this.oobReportingPeriodNanos = oobReportingPeriodNanos; this.weightUpdatePeriodNanos = weightUpdatePeriodNanos; this.errorUtilizationPenalty = errorUtilizationPenalty; + + ImmutableList.Builder builder = ImmutableList.builder(); + if (metricNamesForComputingUtilization != null) { + for (int i = 0; i < metricNamesForComputingUtilization.size(); i++) { + String metricName = metricNamesForComputingUtilization.get(i); + ParsedMetricName parsed = MetricReportUtils.ParsedMetricName.parse(metricName); + if (parsed.getMetricType() != MetricReportUtils.MetricType.INVALID) { + builder.add(parsed); + } else { + log.log(Level.FINE, "Invalid custom metric name configured and ignored: " + metricName); + } + } + } + this.parsedMetricNamesForComputingUtilization = builder.build(); } @Override @@ -751,27 +816,39 @@ public boolean equals(Object o) { && this.oobReportingPeriodNanos == that.oobReportingPeriodNanos && this.weightUpdatePeriodNanos == that.weightUpdatePeriodNanos // Float.compare considers NaNs equal - && Float.compare(this.errorUtilizationPenalty, that.errorUtilizationPenalty) == 0; + && Float.compare(this.errorUtilizationPenalty, that.errorUtilizationPenalty) == 0 + && Objects.equals(this.parsedMetricNamesForComputingUtilization, + that.parsedMetricNamesForComputingUtilization); } @Override public int hashCode() { - return Objects.hash( - blackoutPeriodNanos, - weightExpirationPeriodNanos, - enableOobLoadReport, - oobReportingPeriodNanos, - weightUpdatePeriodNanos, - errorUtilizationPenalty); + return Objects.hash(blackoutPeriodNanos, weightExpirationPeriodNanos, enableOobLoadReport, + oobReportingPeriodNanos, weightUpdatePeriodNanos, errorUtilizationPenalty, + parsedMetricNamesForComputingUtilization); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("blackoutPeriodNanos", blackoutPeriodNanos) + .add("weightExpirationPeriodNanos", weightExpirationPeriodNanos) + .add("enableOobLoadReport", enableOobLoadReport) + .add("oobReportingPeriodNanos", oobReportingPeriodNanos) + .add("weightUpdatePeriodNanos", weightUpdatePeriodNanos) + .add("errorUtilizationPenalty", errorUtilizationPenalty) + .add("parsedMetricNamesForComputingUtilization", parsedMetricNamesForComputingUtilization) + .toString(); } static final class Builder { long blackoutPeriodNanos = 10_000_000_000L; // 10s - long weightExpirationPeriodNanos = 180_000_000_000L; //3min + long weightExpirationPeriodNanos = 180_000_000_000L; // 3min boolean enableOobLoadReport = false; long oobReportingPeriodNanos = 10_000_000_000L; // 10s long weightUpdatePeriodNanos = 1_000_000_000L; // 1s float errorUtilizationPenalty = 1.0F; + ImmutableList metricNamesForComputingUtilization = ImmutableList.of(); private Builder() { @@ -809,10 +886,17 @@ Builder setErrorUtilizationPenalty(float errorUtilizationPenalty) { return this; } + Builder setMetricNamesForComputingUtilization( + List metricNamesForComputingUtilization) { + this.metricNamesForComputingUtilization = + ImmutableList.copyOf(metricNamesForComputingUtilization); + return this; + } + WeightedRoundRobinLoadBalancerConfig build() { return new WeightedRoundRobinLoadBalancerConfig(blackoutPeriodNanos, - weightExpirationPeriodNanos, enableOobLoadReport, oobReportingPeriodNanos, - weightUpdatePeriodNanos, errorUtilizationPenalty); + weightExpirationPeriodNanos, enableOobLoadReport, oobReportingPeriodNanos, + weightUpdatePeriodNanos, errorUtilizationPenalty, metricNamesForComputingUtilization); } } } diff --git a/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProvider.java b/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProvider.java index 433ea34b857..b7b496529a9 100644 --- a/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProvider.java +++ b/xds/src/main/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProvider.java @@ -24,8 +24,10 @@ import io.grpc.LoadBalancerProvider; import io.grpc.NameResolver.ConfigOrError; import io.grpc.Status; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.JsonUtil; import io.grpc.xds.WeightedRoundRobinLoadBalancer.WeightedRoundRobinLoadBalancerConfig; +import java.util.List; import java.util.Map; /** @@ -73,14 +75,17 @@ public ConfigOrError parseLoadBalancingPolicyConfig(Map rawConfig) { private ConfigOrError parseLoadBalancingPolicyConfigInternal(Map rawConfig) { Long blackoutPeriodNanos = JsonUtil.getStringAsDuration(rawConfig, "blackoutPeriod"); Long weightExpirationPeriodNanos = - JsonUtil.getStringAsDuration(rawConfig, "weightExpirationPeriod"); + JsonUtil.getStringAsDuration(rawConfig, "weightExpirationPeriod"); Long oobReportingPeriodNanos = JsonUtil.getStringAsDuration(rawConfig, "oobReportingPeriod"); Boolean enableOobLoadReport = JsonUtil.getBoolean(rawConfig, "enableOobLoadReport"); Long weightUpdatePeriodNanos = JsonUtil.getStringAsDuration(rawConfig, "weightUpdatePeriod"); - Float errorUtilizationPenalty = JsonUtil.getNumberAsFloat(rawConfig, "errorUtilizationPenalty"); + Double errorUtilizationPenalty = + JsonUtil.getNumberAsDouble(rawConfig, "errorUtilizationPenalty"); + List metricNamesForComputingUtilization = JsonUtil.getListOfStrings(rawConfig, + "metricNamesForComputingUtilization"); WeightedRoundRobinLoadBalancerConfig.Builder configBuilder = - WeightedRoundRobinLoadBalancerConfig.newBuilder(); + WeightedRoundRobinLoadBalancerConfig.newBuilder(); if (blackoutPeriodNanos != null) { configBuilder.setBlackoutPeriodNanos(blackoutPeriodNanos); } @@ -100,7 +105,11 @@ private ConfigOrError parseLoadBalancingPolicyConfigInternal(Map rawC } } if (errorUtilizationPenalty != null) { - configBuilder.setErrorUtilizationPenalty(errorUtilizationPenalty); + configBuilder.setErrorUtilizationPenalty(errorUtilizationPenalty.floatValue()); + } + if (metricNamesForComputingUtilization != null + && GrpcUtil.getFlag("GRPC_EXPERIMENTAL_WRR_CUSTOM_METRICS", false)) { + configBuilder.setMetricNamesForComputingUtilization(metricNamesForComputingUtilization); } return ConfigOrError.fromConfig(configBuilder.build()); } diff --git a/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancer.java b/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancer.java index ce7427e5c2c..9468a9daf9d 100644 --- a/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancer.java +++ b/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancer.java @@ -128,6 +128,8 @@ public void handleNameResolutionError(Status error) { } @Override + @Deprecated + @SuppressWarnings("InlineMeSuggester") public boolean canHandleEmptyAddressListFromNameResolution() { return true; } diff --git a/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancerProvider.java b/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancerProvider.java index 55f33fb11aa..15318693aca 100644 --- a/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancerProvider.java +++ b/xds/src/main/java/io/grpc/xds/WeightedTargetLoadBalancerProvider.java @@ -25,6 +25,7 @@ import io.grpc.LoadBalancerRegistry; import io.grpc.NameResolver.ConfigOrError; import io.grpc.Status; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.JsonUtil; import io.grpc.util.GracefulSwitchLoadBalancer; import java.util.LinkedHashMap; @@ -99,9 +100,10 @@ public ConfigOrError parseLoadBalancingPolicyConfig(Map rawConfig) { ConfigOrError childConfig = GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( JsonUtil.getListOfObjects(rawWeightedTarget, "childPolicy"), lbRegistry); if (childConfig.getError() != null) { - return ConfigOrError.fromError(Status.INTERNAL - .withDescription("Could not parse weighted_target's child policy:" + name) - .withCause(childConfig.getError().asRuntimeException())); + return ConfigOrError.fromError(GrpcUtil.statusWithDetails( + Status.Code.INTERNAL, + "Could not parse weighted_target's child policy: " + name, + childConfig.getError())); } parsedChildConfigs.put(name, new WeightedPolicySelection(weight, childConfig.getConfig())); } diff --git a/xds/src/main/java/io/grpc/xds/WrrLocalityLoadBalancerProvider.java b/xds/src/main/java/io/grpc/xds/WrrLocalityLoadBalancerProvider.java index 384831b8a05..3693df9208a 100644 --- a/xds/src/main/java/io/grpc/xds/WrrLocalityLoadBalancerProvider.java +++ b/xds/src/main/java/io/grpc/xds/WrrLocalityLoadBalancerProvider.java @@ -23,6 +23,7 @@ import io.grpc.LoadBalancerRegistry; import io.grpc.NameResolver.ConfigOrError; import io.grpc.Status; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.JsonUtil; import io.grpc.util.GracefulSwitchLoadBalancer; import io.grpc.xds.WrrLocalityLoadBalancer.WrrLocalityConfig; @@ -62,9 +63,10 @@ public ConfigOrError parseLoadBalancingPolicyConfig(Map rawConfig) { ConfigOrError childConfig = GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( JsonUtil.getListOfObjects(rawConfig, "childPolicy")); if (childConfig.getError() != null) { - return ConfigOrError.fromError(Status.INTERNAL - .withDescription("Failed to parse child policy in wrr_locality LB policy: " + rawConfig) - .withCause(childConfig.getError().asRuntimeException())); + return ConfigOrError.fromError(GrpcUtil.statusWithDetails( + Status.Code.INTERNAL, + "Failed to parse child policy in wrr_locality LB policy", + childConfig.getError())); } return ConfigOrError.fromConfig(new WrrLocalityConfig(childConfig.getConfig())); } catch (RuntimeException e) { diff --git a/xds/src/main/java/io/grpc/xds/XdsAttributes.java b/xds/src/main/java/io/grpc/xds/XdsAttributes.java index 2e165201e5f..d3fe8d4619c 100644 --- a/xds/src/main/java/io/grpc/xds/XdsAttributes.java +++ b/xds/src/main/java/io/grpc/xds/XdsAttributes.java @@ -19,8 +19,8 @@ import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.Grpc; +import io.grpc.InternalEquivalentAddressGroup; import io.grpc.NameResolver; -import io.grpc.internal.ObjectPool; import io.grpc.xds.XdsNameResolverProvider.CallCounterProvider; import io.grpc.xds.client.Locality; import io.grpc.xds.client.XdsClient; @@ -33,8 +33,8 @@ final class XdsAttributes { * Attribute key for passing around the XdsClient object pool across NameResolver/LoadBalancers. */ @NameResolver.ResolutionResultAttr - static final Attributes.Key> XDS_CLIENT_POOL = - Attributes.Key.create("io.grpc.xds.XdsAttributes.xdsClientPool"); + static final Attributes.Key XDS_CLIENT = + Attributes.Key.create("io.grpc.xds.XdsAttributes.xdsClient"); /** * Attribute key for passing around the latest XdsConfig across NameResolver/LoadBalancers. @@ -85,13 +85,7 @@ final class XdsAttributes { * Endpoint weight for load balancing purposes. */ @EquivalentAddressGroup.Attr - static final Attributes.Key ATTR_SERVER_WEIGHT = - Attributes.Key.create("io.grpc.xds.XdsAttributes.serverWeight"); - - /** Name associated with individual address, if available (e.g., DNS name). */ - @EquivalentAddressGroup.Attr - static final Attributes.Key ATTR_ADDRESS_NAME = - Attributes.Key.create("io.grpc.xds.XdsAttributes.addressName"); + static final Attributes.Key ATTR_SERVER_WEIGHT = InternalEquivalentAddressGroup.ATTR_WEIGHT; /** * Filter chain match for network filters. diff --git a/xds/src/main/java/io/grpc/xds/XdsClientPoolFactory.java b/xds/src/main/java/io/grpc/xds/XdsClientPoolFactory.java index f10d6504d79..6df8d566a7a 100644 --- a/xds/src/main/java/io/grpc/xds/XdsClientPoolFactory.java +++ b/xds/src/main/java/io/grpc/xds/XdsClientPoolFactory.java @@ -18,20 +18,17 @@ import io.grpc.MetricRecorder; import io.grpc.internal.ObjectPool; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; import io.grpc.xds.client.XdsClient; -import io.grpc.xds.client.XdsInitializationException; import java.util.List; -import java.util.Map; import javax.annotation.Nullable; interface XdsClientPoolFactory { - void setBootstrapOverride(Map bootstrap); - @Nullable ObjectPool get(String target); - ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) - throws XdsInitializationException; + ObjectPool getOrCreate( + String target, BootstrapInfo bootstrapInfo, MetricRecorder metricRecorder); List getTargets(); } diff --git a/xds/src/main/java/io/grpc/xds/XdsClusterResource.java b/xds/src/main/java/io/grpc/xds/XdsClusterResource.java index a5220515b6c..34035ab1da8 100644 --- a/xds/src/main/java/io/grpc/xds/XdsClusterResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsClusterResource.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import static io.grpc.xds.client.Bootstrapper.ServerInfo; +import static io.grpc.xds.client.LoadStatsManager2.isEnabledOrcaLrsPropagation; import com.google.auto.value.AutoValue; import com.google.common.annotations.VisibleForTesting; @@ -42,11 +43,11 @@ import io.grpc.LoadBalancerRegistry; import io.grpc.NameResolver; import io.grpc.internal.GrpcUtil; -import io.grpc.internal.ServiceConfigUtil; -import io.grpc.internal.ServiceConfigUtil.LbConfig; +import io.grpc.util.GracefulSwitchLoadBalancer; import io.grpc.xds.EnvoyServerProtoData.OutlierDetection; import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; import io.grpc.xds.XdsClusterResource.CdsUpdate; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.XdsClient.ResourceUpdate; import io.grpc.xds.client.XdsResourceType; import io.grpc.xds.internal.security.CommonTlsContextUtil; @@ -64,7 +65,7 @@ class XdsClusterResource extends XdsResourceType { System.getProperty("io.grpc.xds.experimentalEnableLeastRequest", "true")); @VisibleForTesting public static boolean enableSystemRootCerts = - GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_SYSTEM_ROOT_CERTS", false); + GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_SYSTEM_ROOT_CERTS", true); static boolean isEnabledXdsHttpConnect = GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_HTTP_CONNECT", false); @@ -78,9 +79,6 @@ class XdsClusterResource extends XdsResourceType { "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext"; private static final String TYPE_URL_UPSTREAM_TLS_CONTEXT_V2 = "type.googleapis.com/envoy.api.v2.auth.UpstreamTlsContext"; - static final String TRANSPORT_SOCKET_NAME_HTTP11_PROXY = - "type.googleapis.com/envoy.extensions.transport_sockets.http_11_proxy.v3" - + ".Http11ProxyUpstreamTransport"; private final LoadBalancerRegistry loadBalancerRegistry = LoadBalancerRegistry.getDefaultRegistry(); @@ -166,18 +164,16 @@ static CdsUpdate processCluster(Cluster cluster, ImmutableMap lbPolicyConfig = LoadBalancerConfigFactory.newConfig(cluster, enableLeastRequest); - // Validate the LB config by trying to parse it with the corresponding LB provider. - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(lbPolicyConfig); - NameResolver.ConfigOrError configOrError = loadBalancerRegistry.getProvider( - lbConfig.getPolicyName()).parseLoadBalancingPolicyConfig( - lbConfig.getRawConfigValue()); + NameResolver.ConfigOrError configOrError + = GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( + ImmutableList.of(lbPolicyConfig), loadBalancerRegistry); if (configOrError.getError() != null) { throw new ResourceInvalidException( "Failed to parse lb config for cluster '" + cluster.getName() + "': " + configOrError.getError()); } - updateBuilder.lbPolicyConfig(lbPolicyConfig); + updateBuilder.lbPolicyConfig(configOrError.getConfig(), lbPolicyConfig); updateBuilder.filterMetadata( ImmutableMap.copyOf(cluster.getMetadata().getFilterMetadataMap())); @@ -227,6 +223,12 @@ private static StructOrError parseNonAggregateCluster( UpstreamTlsContext upstreamTlsContext = null; OutlierDetection outlierDetection = null; boolean isHttp11ProxyAvailable = false; + BackendMetricPropagation backendMetricPropagation = null; + + if (isEnabledOrcaLrsPropagation && !cluster.getLrsReportEndpointMetricsList().isEmpty()) { + backendMetricPropagation = BackendMetricPropagation.fromMetricSpecs( + cluster.getLrsReportEndpointMetricsList()); + } if (cluster.hasLrsServer()) { if (!cluster.getLrsServer().hasSelf()) { return StructOrError.fromError( @@ -253,14 +255,14 @@ private static StructOrError parseNonAggregateCluster( TransportSocket transportSocket = cluster.getTransportSocket(); if (hasTransportSocket && !TRANSPORT_SOCKET_NAME_TLS.equals(transportSocket.getName()) - && !(isEnabledXdsHttpConnect - && TRANSPORT_SOCKET_NAME_HTTP11_PROXY.equals(transportSocket.getName()))) { + && !(isEnabledXdsHttpConnect && transportSocket.getTypedConfig().is( + Http11ProxyUpstreamTransport.class))) { return StructOrError.fromError( "transport-socket with name " + transportSocket.getName() + " not supported."); } - if (hasTransportSocket && isEnabledXdsHttpConnect - && TRANSPORT_SOCKET_NAME_HTTP11_PROXY.equals(transportSocket.getName())) { + if (hasTransportSocket && isEnabledXdsHttpConnect && transportSocket.getTypedConfig().is( + Http11ProxyUpstreamTransport.class)) { isHttp11ProxyAvailable = true; try { Http11ProxyUpstreamTransport wrappedTransportSocket = transportSocket @@ -326,7 +328,7 @@ private static StructOrError parseNonAggregateCluster( return StructOrError.fromStruct(CdsUpdate.forEds( clusterName, edsServiceName, lrsServerInfo, maxConcurrentRequests, upstreamTlsContext, - outlierDetection, isHttp11ProxyAvailable)); + outlierDetection, isHttp11ProxyAvailable, backendMetricPropagation)); } else if (type.equals(Cluster.DiscoveryType.LOGICAL_DNS)) { if (!cluster.hasLoadAssignment()) { return StructOrError.fromError( @@ -362,7 +364,7 @@ private static StructOrError parseNonAggregateCluster( Locale.US, "%s:%d", socketAddress.getAddress(), socketAddress.getPortValue()); return StructOrError.fromStruct(CdsUpdate.forLogicalDns( clusterName, dnsHostName, lrsServerInfo, maxConcurrentRequests, - upstreamTlsContext, isHttp11ProxyAvailable)); + upstreamTlsContext, isHttp11ProxyAvailable, backendMetricPropagation)); } return StructOrError.fromError( "Cluster " + clusterName + ": unsupported built-in discovery type: " + type); @@ -533,7 +535,12 @@ private static String getIdentityCertInstanceName(CommonTlsContext commonTlsCont if (commonTlsContext.hasTlsCertificateProviderInstance()) { return commonTlsContext.getTlsCertificateProviderInstance().getInstanceName(); } - return null; + // Fall back to deprecated field (field 11) for backward compatibility with Istio + @SuppressWarnings("deprecation") + String instanceName = commonTlsContext.hasTlsCertificateCertificateProviderInstance() + ? commonTlsContext.getTlsCertificateCertificateProviderInstance().getInstanceName() + : null; + return instanceName; } private static String getRootCertInstanceName(CommonTlsContext commonTlsContext) { @@ -551,6 +558,16 @@ private static String getRootCertInstanceName(CommonTlsContext commonTlsContext) return combinedCertificateValidationContext.getDefaultValidationContext() .getCaCertificateProviderInstance().getInstanceName(); } + // Fall back to deprecated field (field 4) in CombinedValidationContext + @SuppressWarnings("deprecation") + String instanceName = combinedCertificateValidationContext + .hasValidationContextCertificateProviderInstance() + ? combinedCertificateValidationContext.getValidationContextCertificateProviderInstance() + .getInstanceName() + : null; + if (instanceName != null) { + return instanceName; + } } return null; } @@ -562,16 +579,22 @@ abstract static class CdsUpdate implements ResourceUpdate { abstract ClusterType clusterType(); - abstract ImmutableMap lbPolicyConfig(); - - // Only valid if lbPolicy is "ring_hash_experimental". - abstract long minRingSize(); + /** Graceful switch configuration. */ + Object lbPolicyConfig() { + return internalLbPolicyConfig().getValue(); + } - // Only valid if lbPolicy is "ring_hash_experimental". - abstract long maxRingSize(); + /** + * Use {@link #lbPolicyConfig()} instead. This avoids using the LB policy configs' equals() when + * XdsClient squelches config updates that are identical to the current value. LB policies are + * not required to implement equals for their configs. Instead, {link + * #internalLbPolicyConfigJson()} is used to detect changes. + */ + abstract IgnoreEquals internalLbPolicyConfig(); - // Only valid if lbPolicy is "least_request_experimental". - abstract int choiceCount(); + /** Use {@code lbPolicyConfig} instead. */ + @Nullable + abstract ImmutableMap internalLbPolicyConfigJson(); // Alternative resource name to be used in EDS requests. /// Only valid for EDS cluster. @@ -614,15 +637,16 @@ abstract static class CdsUpdate implements ResourceUpdate { abstract ImmutableMap parsedMetadata(); + @Nullable + abstract BackendMetricPropagation backendMetricPropagation(); + private static Builder newBuilder(String clusterName) { return new AutoValue_XdsClusterResource_CdsUpdate.Builder() .clusterName(clusterName) - .minRingSize(0) - .maxRingSize(0) - .choiceCount(0) .filterMetadata(ImmutableMap.of()) .parsedMetadata(ImmutableMap.of()) - .isHttp11ProxyAvailable(false); + .isHttp11ProxyAvailable(false) + .backendMetricPropagation(null); } static Builder forAggregate(String clusterName, List prioritizedClusterNames) { @@ -636,7 +660,8 @@ static Builder forEds(String clusterName, @Nullable String edsServiceName, @Nullable ServerInfo lrsServerInfo, @Nullable Long maxConcurrentRequests, @Nullable UpstreamTlsContext upstreamTlsContext, @Nullable OutlierDetection outlierDetection, - boolean isHttp11ProxyAvailable) { + boolean isHttp11ProxyAvailable, + BackendMetricPropagation backendMetricPropagation) { return newBuilder(clusterName) .clusterType(ClusterType.EDS) .edsServiceName(edsServiceName) @@ -644,21 +669,24 @@ static Builder forEds(String clusterName, @Nullable String edsServiceName, .maxConcurrentRequests(maxConcurrentRequests) .upstreamTlsContext(upstreamTlsContext) .outlierDetection(outlierDetection) - .isHttp11ProxyAvailable(isHttp11ProxyAvailable); + .isHttp11ProxyAvailable(isHttp11ProxyAvailable) + .backendMetricPropagation(backendMetricPropagation); } static Builder forLogicalDns(String clusterName, String dnsHostName, @Nullable ServerInfo lrsServerInfo, @Nullable Long maxConcurrentRequests, @Nullable UpstreamTlsContext upstreamTlsContext, - boolean isHttp11ProxyAvailable) { + boolean isHttp11ProxyAvailable, + BackendMetricPropagation backendMetricPropagation) { return newBuilder(clusterName) .clusterType(ClusterType.LOGICAL_DNS) .dnsHostName(dnsHostName) .lrsServerInfo(lrsServerInfo) .maxConcurrentRequests(maxConcurrentRequests) .upstreamTlsContext(upstreamTlsContext) - .isHttp11ProxyAvailable(isHttp11ProxyAvailable); + .isHttp11ProxyAvailable(isHttp11ProxyAvailable) + .backendMetricPropagation(backendMetricPropagation); } enum ClusterType { @@ -675,10 +703,7 @@ public final String toString() { return MoreObjects.toStringHelper(this) .add("clusterName", clusterName()) .add("clusterType", clusterType()) - .add("lbPolicyConfig", lbPolicyConfig()) - .add("minRingSize", minRingSize()) - .add("maxRingSize", maxRingSize()) - .add("choiceCount", choiceCount()) + .add("lbPolicyConfigJson", internalLbPolicyConfigJson()) .add("edsServiceName", edsServiceName()) .add("dnsHostName", dnsHostName()) .add("lrsServerInfo", lrsServerInfo()) @@ -697,31 +722,31 @@ abstract static class Builder { // Private, use one of the static factory methods instead. protected abstract Builder clusterType(ClusterType clusterType); - protected abstract Builder lbPolicyConfig(ImmutableMap lbPolicyConfig); - - Builder roundRobinLbPolicy() { - return this.lbPolicyConfig(ImmutableMap.of("round_robin", ImmutableMap.of())); - } - - Builder ringHashLbPolicy(Long minRingSize, Long maxRingSize) { - return this.lbPolicyConfig(ImmutableMap.of("ring_hash_experimental", - ImmutableMap.of("minRingSize", minRingSize.doubleValue(), "maxRingSize", - maxRingSize.doubleValue()))); + /** + * The config to use, and the JSON representation that produced that config. The JSON + * representation is only used to detect if the configuration changed, since LB policies don't + * have to implement equals() for their parsed configs. + */ + protected Builder lbPolicyConfig( + Object gracefulSwitchConfig, ImmutableMap jsonConfig) { + return internalLbPolicyConfig(new IgnoreEquals<>(gracefulSwitchConfig)) + .internalLbPolicyConfigJson(jsonConfig); } - Builder leastRequestLbPolicy(Integer choiceCount) { - return this.lbPolicyConfig(ImmutableMap.of("least_request_experimental", - ImmutableMap.of("choiceCount", choiceCount.doubleValue()))); + protected Builder lbPolicyConfigJsonForTesting(ImmutableMap jsonConfig) { + NameResolver.ConfigOrError result = + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig( + ImmutableList.of(jsonConfig)); + if (result.getError() != null) { + throw new IllegalArgumentException( + "Bad JSON config: " + result.getError() + " json: " + jsonConfig); + } + return lbPolicyConfig(result.getConfig(), jsonConfig); } - // Private, use leastRequestLbPolicy(int). - protected abstract Builder choiceCount(int choiceCount); - - // Private, use ringHashLbPolicy(long, long). - protected abstract Builder minRingSize(long minRingSize); + protected abstract Builder internalLbPolicyConfig(IgnoreEquals gracefulSwitchConfig); - // Private, use ringHashLbPolicy(long, long). - protected abstract Builder maxRingSize(long maxRingSize); + protected abstract Builder internalLbPolicyConfigJson(ImmutableMap jsonConfig); // Private, use CdsUpdate.forEds() instead. protected abstract Builder edsServiceName(String edsServiceName); @@ -749,7 +774,41 @@ Builder leastRequestLbPolicy(Integer choiceCount) { protected abstract Builder parsedMetadata(ImmutableMap parsedMetadata); + protected abstract Builder backendMetricPropagation( + BackendMetricPropagation backendMetricPropagation); + abstract CdsUpdate build(); } } + + /** Always equal to this same type. */ + static final class IgnoreEquals { + private final V value; + + IgnoreEquals(V value) { + this.value = value; + } + + public V getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof IgnoreEquals)) { + return false; + } + return true; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public String toString() { + return "IgnoreEquals{" + value + "}"; + } + } } diff --git a/xds/src/main/java/io/grpc/xds/XdsConfig.java b/xds/src/main/java/io/grpc/xds/XdsConfig.java index d184f08de55..9da5f970475 100644 --- a/xds/src/main/java/io/grpc/xds/XdsConfig.java +++ b/xds/src/main/java/io/grpc/xds/XdsConfig.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkNotNull; +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import io.grpc.StatusOr; @@ -76,14 +77,12 @@ public int hashCode() { @Override public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("XdsConfig{") - .append("\n listener=").append(listener) - .append(",\n route=").append(route) - .append(",\n virtualHost=").append(virtualHost) - .append(",\n clusters=").append(clusters) - .append("\n}"); - return builder.toString(); + return MoreObjects.toStringHelper(this) + .add("listener", listener) + .add("route", route) + .add("virtualHost", virtualHost) + .add("clusters", clusters) + .toString(); } public LdsUpdate getListener() { diff --git a/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java b/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java index 9dfefaf1a65..9dd77a400cd 100644 --- a/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java +++ b/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java @@ -29,6 +29,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.ServiceLoader; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -109,8 +110,10 @@ public static synchronized XdsCredentialsRegistry getDefaultRegistry() { if (instance == null) { List providerList = InternalServiceProviders.loadAll( XdsCredentialsProvider.class, - getHardCodedClasses(), - XdsCredentialsProvider.class.getClassLoader(), + ServiceLoader + .load(XdsCredentialsProvider.class, XdsCredentialsProvider.class.getClassLoader()) + .iterator(), + XdsCredentialsRegistry::getHardCodedClasses, new XdsCredentialsProviderPriorityAccessor()); if (providerList.isEmpty()) { logger.warning("No XdsCredsRegistry found via ServiceLoader, including for GoogleDefault, " diff --git a/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java b/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java index 21b0ad7dc66..a0af5974175 100644 --- a/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java +++ b/xds/src/main/java/io/grpc/xds/XdsDependencyManager.java @@ -30,6 +30,7 @@ import io.grpc.Status; import io.grpc.StatusOr; import io.grpc.SynchronizationContext; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.RetryingNameResolver; import io.grpc.xds.Endpoints.LocalityLbEndpoints; import io.grpc.xds.VirtualHost.Route.RouteAction.ClusterWeight; @@ -632,6 +633,9 @@ private abstract class XdsWatcherBase @Nullable private StatusOr data; + @Nullable + @SuppressWarnings("unused") + private Status ambientError; private XdsWatcherBase(XdsResourceType type, String resourceName) { @@ -640,42 +644,36 @@ private XdsWatcherBase(XdsResourceType type, String resourceName) { } @Override - public void onError(Status error) { - checkNotNull(error, "error"); + public void onResourceChanged(StatusOr update) { if (cancelled) { return; } - // Don't update configuration on error, if we've already received configuration - if (!hasDataValue()) { - this.data = StatusOr.fromStatus(Status.UNAVAILABLE.withDescription( - String.format("Error retrieving %s: %s: %s", - toContextString(), error.getCode(), error.getDescription()))); - maybePublishConfig(); - } - } + ambientError = null; + if (update.hasValue()) { + data = update; + subscribeToChildren(update.getValue()); + } else { + Status translatedStatus = GrpcUtil.statusWithDetails( + Status.Code.UNAVAILABLE, + "Error retrieving " + toContextString() + nodeInfo(), + update.getStatus()); - @Override - public void onResourceDoesNotExist(String resourceName) { - if (cancelled) { - return; + data = StatusOr.fromStatus(translatedStatus); } - - checkArgument(this.resourceName.equals(resourceName), "Resource name does not match"); - this.data = StatusOr.fromStatus(Status.UNAVAILABLE.withDescription( - toContextString() + " does not exist" + nodeInfo())); maybePublishConfig(); } @Override - public void onChanged(T update) { - checkNotNull(update, "update"); + public void onAmbientError(Status error) { if (cancelled) { return; } - - this.data = StatusOr.fromValue(update); - subscribeToChildren(update); - maybePublishConfig(); + ambientError = error.withDescription( + String.format("Ambient error for %s: %s. Details: %s%s", + toContextString(), + error.getCode(), + error.getDescription() != null ? error.getDescription() : "", + nodeInfo())); } protected abstract void subscribeToChildren(T update); diff --git a/xds/src/main/java/io/grpc/xds/XdsLbPolicies.java b/xds/src/main/java/io/grpc/xds/XdsLbPolicies.java index dcca2fbfff3..ae5ac38b471 100644 --- a/xds/src/main/java/io/grpc/xds/XdsLbPolicies.java +++ b/xds/src/main/java/io/grpc/xds/XdsLbPolicies.java @@ -19,7 +19,6 @@ final class XdsLbPolicies { static final String CLUSTER_MANAGER_POLICY_NAME = "cluster_manager_experimental"; static final String CDS_POLICY_NAME = "cds_experimental"; - static final String CLUSTER_RESOLVER_POLICY_NAME = "cluster_resolver_experimental"; static final String PRIORITY_POLICY_NAME = "priority_experimental"; static final String CLUSTER_IMPL_POLICY_NAME = "cluster_impl_experimental"; static final String WEIGHTED_TARGET_POLICY_NAME = "weighted_target_experimental"; diff --git a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java index 041b659b4c3..ccb88a8e543 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.FilterConfigParseContext filterContext = Filter.FilterConfigParseContext.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 c3bc7c2e326..3265995af41 100644 --- a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java @@ -51,6 +51,7 @@ import io.grpc.internal.ObjectPool; import io.grpc.xds.ClusterSpecifierPlugin.PluginConfig; import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.Filter.NamedFilterConfig; import io.grpc.xds.RouteLookupServiceClusterSpecifierPlugin.RlsPluginConfig; import io.grpc.xds.ThreadSafeRandom.ThreadSafeRandomImpl; @@ -64,9 +65,10 @@ import io.grpc.xds.client.Bootstrapper.AuthorityInfo; import io.grpc.xds.client.Bootstrapper.BootstrapInfo; import io.grpc.xds.client.XdsClient; +import io.grpc.xds.client.XdsInitializationException; import io.grpc.xds.client.XdsLogger; import io.grpc.xds.client.XdsLogger.XdsLogLevel; -import java.net.URI; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -80,6 +82,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import javax.annotation.Nullable; /** @@ -91,7 +94,6 @@ * @see XdsNameResolverProvider */ final class XdsNameResolver extends NameResolver { - static final CallOptions.Key CLUSTER_SELECTION_KEY = CallOptions.Key.create("io.grpc.xds.CLUSTER_SELECTION_KEY"); static final CallOptions.Key XDS_CONFIG_CALL_OPTION_KEY = @@ -109,25 +111,22 @@ final class XdsNameResolver extends NameResolver { private final XdsLogger logger; @Nullable private final String targetAuthority; - private final String target; 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; private final ServiceConfigParser serviceConfigParser; private final SynchronizationContext syncContext; private final ScheduledExecutorService scheduler; - private final XdsClientPoolFactory xdsClientPoolFactory; + private final XdsClientPool xdsClientPool; private final ThreadSafeRandom random; private final FilterRegistry filterRegistry; private final XxHash64 hashFunc = XxHash64.INSTANCE; - // Clusters (with reference counts) to which new/existing requests can be/are routed. // put()/remove() must be called in SyncContext, and get() can be called in any thread. private final ConcurrentMap clusterRefs = new ConcurrentHashMap<>(); private final ConfigSelector configSelector = new ConfigSelector(); private final long randomChannelId; - private final MetricRecorder metricRecorder; private final Args nameResolverArgs; // Must be accessed in syncContext. // Filter instances are unique per channel, and per filter (name+typeUrl). @@ -136,49 +135,63 @@ final class XdsNameResolver extends NameResolver { private volatile RoutingConfig routingConfig; private Listener2 listener; - private ObjectPool xdsClientPool; private XdsClient xdsClient; private CallCounterProvider callCounterProvider; private ResolveState resolveState; + + /** + * Constructs a new instance. + * + * @param target the target URI to resolve + * @param targetAuthority the authority component of `target`, possibly the empty string, or null + * if 'target' has no such component + */ XdsNameResolver( - URI targetUri, String name, @Nullable String overrideAuthority, - ServiceConfigParser serviceConfigParser, + String target, @Nullable String targetAuthority, String name, + @Nullable String overrideAuthority, ServiceConfigParser serviceConfigParser, SynchronizationContext syncContext, ScheduledExecutorService scheduler, @Nullable Map bootstrapOverride, MetricRecorder metricRecorder, Args nameResolverArgs) { - this(targetUri, targetUri.getAuthority(), name, overrideAuthority, serviceConfigParser, - syncContext, scheduler, SharedXdsClientPoolProvider.getDefaultProvider(), + this(target, targetAuthority, name, overrideAuthority, serviceConfigParser, + syncContext, scheduler, + bootstrapOverride == null + ? SharedXdsClientPoolProvider.getDefaultProvider() + : new SharedXdsClientPoolProvider(), ThreadSafeRandomImpl.instance, FilterRegistry.getDefaultRegistry(), bootstrapOverride, metricRecorder, nameResolverArgs); } @VisibleForTesting XdsNameResolver( - URI targetUri, @Nullable String targetAuthority, String name, + String target, @Nullable String targetAuthority, String name, @Nullable String overrideAuthority, ServiceConfigParser serviceConfigParser, SynchronizationContext syncContext, ScheduledExecutorService scheduler, XdsClientPoolFactory xdsClientPoolFactory, ThreadSafeRandom random, FilterRegistry filterRegistry, @Nullable Map bootstrapOverride, MetricRecorder metricRecorder, Args nameResolverArgs) { this.targetAuthority = targetAuthority; - target = targetUri.toString(); // 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"); this.syncContext = checkNotNull(syncContext, "syncContext"); this.scheduler = checkNotNull(scheduler, "scheduler"); - this.xdsClientPoolFactory = bootstrapOverride == null ? checkNotNull(xdsClientPoolFactory, - "xdsClientPoolFactory") : new SharedXdsClientPoolProvider(); - this.xdsClientPoolFactory.setBootstrapOverride(bootstrapOverride); + Supplier xdsClientSupplierArg = + nameResolverArgs.getArg(XdsNameResolverProvider.XDS_CLIENT_SUPPLIER); + if (xdsClientSupplierArg != null) { + this.xdsClientPool = new SupplierXdsClientPool(xdsClientSupplierArg); + } else { + checkNotNull(xdsClientPoolFactory, "xdsClientPoolFactory"); + this.xdsClientPool = new BootstrappingXdsClientPool( + xdsClientPoolFactory, target, bootstrapOverride, metricRecorder); + } this.random = checkNotNull(random, "random"); this.filterRegistry = checkNotNull(filterRegistry, "filterRegistry"); - this.metricRecorder = metricRecorder; this.nameResolverArgs = checkNotNull(nameResolverArgs, "nameResolverArgs"); randomChannelId = random.nextLong(); @@ -196,16 +209,17 @@ public String getServiceAuthority() { public void start(Listener2 listener) { this.listener = checkNotNull(listener, "listener"); try { - xdsClientPool = xdsClientPoolFactory.getOrCreate(target, metricRecorder); + xdsClient = xdsClientPool.getObject(); } catch (Exception e) { listener.onError( Status.UNAVAILABLE.withDescription("Failed to initialize xDS").withCause(e)); return; } - xdsClient = xdsClientPool.getObject(); BootstrapInfo bootstrapInfo = xdsClient.getBootstrapInfo(); String listenerNameTemplate; - if (targetAuthority == null) { + if (targetAuthority == null || targetAuthority.isEmpty()) { + // Both https://github.com/grpc/proposal/blob/master/A27-xds-global-load-balancing.md and + // A47-xds-federation.md seem to treat an empty authority the same as an undefined one. listenerNameTemplate = bootstrapInfo.clientDefaultListenerResourceNameTemplate(); } else { AuthorityInfo authorityInfo = bootstrapInfo.authorities().get(targetAuthority); @@ -222,7 +236,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; @@ -319,7 +333,7 @@ private void updateResolutionResult(XdsConfig xdsConfig) { ConfigOrError parsedServiceConfig = serviceConfigParser.parseServiceConfig(rawServiceConfig); Attributes attrs = Attributes.newBuilder() - .set(XdsAttributes.XDS_CLIENT_POOL, xdsClientPool) + .set(XdsAttributes.XDS_CLIENT, xdsClient) .set(XdsAttributes.XDS_CONFIG, xdsConfig) .set(XdsAttributes.XDS_CLUSTER_SUBSCRIPT_REGISTRY, resolveState.xdsDependencyManager) .set(XdsAttributes.CALL_COUNTER_PROVIDER, callCounterProvider) @@ -525,7 +539,7 @@ public void onClose(Status status, Metadata trailers) { Result.newBuilder() .setConfig(config) .setInterceptor(combineInterceptors( - ImmutableList.of(filters, new ClusterSelectionInterceptor()))) + ImmutableList.of(new ClusterSelectionInterceptor(), filters))) .build(); } @@ -730,7 +744,9 @@ private void updateActiveFilters(@Nullable List filterConfigs Filter.Provider provider = filterRegistry.get(typeUrl); checkNotNull(provider, "provider %s", typeUrl); Filter filter = activeFilters.computeIfAbsent( - filterKey, k -> provider.newInstance(namedFilter.name)); + filterKey, k -> provider.newInstance( + FilterContext.create( + namedFilter.name, nameResolverArgs.getMetricRecorder()))); checkNotNull(filter, "filter %s", filterKey); filtersToShutdown.remove(filterKey); } @@ -888,9 +904,10 @@ private ClientInterceptor createFilters( } } - // Combine interceptors produced by different filters into a single one that executes - // them sequentially. The order is preserved. - return combineInterceptors(filterInterceptors.build()); + ImmutableList.Builder withRawMessage = ImmutableList.builder(); + withRawMessage.add(new RawMessageClientInterceptor()); + withRawMessage.addAll(filterInterceptors.build()); + return combineInterceptors(withRawMessage.build()); } private void cleanUpRoutes(Status error) { @@ -909,8 +926,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()); } @@ -1034,4 +1051,156 @@ static ClusterRefState forRlsPlugin( return new ClusterRefState(refCount, null, rlsPluginConfig, null); } } + + /** An ObjectPool, except it can throw an exception. */ + private interface XdsClientPool { + XdsClient getObject() throws XdsInitializationException; + + XdsClient returnObject(XdsClient xdsClient); + } + + private static final class BootstrappingXdsClientPool implements XdsClientPool { + private final XdsClientPoolFactory xdsClientPoolFactory; + private final String target; + private final @Nullable Map bootstrapOverride; + private final MetricRecorder metricRecorder; + private ObjectPool xdsClientPool; + + BootstrappingXdsClientPool( + XdsClientPoolFactory xdsClientPoolFactory, + String target, + @Nullable Map bootstrapOverride, + MetricRecorder metricRecorder) { + this.xdsClientPoolFactory = checkNotNull(xdsClientPoolFactory, "xdsClientPoolFactory"); + this.target = checkNotNull(target, "target"); + this.bootstrapOverride = bootstrapOverride; + this.metricRecorder = checkNotNull(metricRecorder, "metricRecorder"); + } + + @Override + public XdsClient getObject() throws XdsInitializationException { + if (xdsClientPool == null) { + BootstrapInfo bootstrapInfo; + if (bootstrapOverride == null) { + bootstrapInfo = GrpcBootstrapperImpl.defaultBootstrap(); + } else { + bootstrapInfo = new GrpcBootstrapperImpl().bootstrap(bootstrapOverride); + } + this.xdsClientPool = + xdsClientPoolFactory.getOrCreate(target, bootstrapInfo, metricRecorder); + } + return xdsClientPool.getObject(); + } + + @Override + public XdsClient returnObject(XdsClient xdsClient) { + return xdsClientPool.returnObject(xdsClient); + } + } + + private static final class SupplierXdsClientPool implements XdsClientPool { + private final Supplier xdsClientSupplier; + + SupplierXdsClientPool(Supplier xdsClientSupplier) { + this.xdsClientSupplier = checkNotNull(xdsClientSupplier, "xdsClientSupplier"); + } + + @Override + public XdsClient getObject() throws XdsInitializationException { + XdsClient xdsClient = xdsClientSupplier.get(); + if (xdsClient == null) { + throw new XdsInitializationException("Caller failed to initialize XDS_CLIENT_SUPPLIER"); + } + return xdsClient; + } + + @Override + public XdsClient returnObject(XdsClient xdsClient) { + return null; + } + } + + static final class RawMessageClientInterceptor implements ClientInterceptor { + 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; + } + }; + + @Override + public ClientCall interceptCall( + final MethodDescriptor method, CallOptions callOptions, Channel next) { + MethodDescriptor rawMethod = + method.toBuilder(RAW_MARSHALLER, RAW_MARSHALLER).build(); + final ClientCall rawCall = next.newCall(rawMethod, callOptions); + return new ClientCall() { + @Override + public void start(final Listener responseListener, Metadata headers) { + rawCall.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) { + rawCall.request(numMessages); + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + rawCall.cancel(message, cause); + } + + @Override + public void halfClose() { + rawCall.halfClose(); + } + + @Override + public void sendMessage(ReqT message) { + rawCall.sendMessage(method.getRequestMarshaller().stream(message)); + } + + @Override + public boolean isReady() { + return rawCall.isReady(); + } + + @Override + public void setMessageCompression(boolean enabled) { + rawCall.setMessageCompression(enabled); + } + + @Override + public Attributes getAttributes() { + return rawCall.getAttributes(); + } + }; + } + } } diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java b/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java index eb3887396a0..51b1ff49bf0 100644 --- a/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java @@ -22,6 +22,8 @@ import io.grpc.Internal; import io.grpc.NameResolver.Args; import io.grpc.NameResolverProvider; +import io.grpc.Uri; +import io.grpc.xds.client.XdsClient; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; @@ -29,6 +31,7 @@ import java.util.Collections; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; import javax.annotation.Nullable; /** @@ -43,6 +46,13 @@ */ @Internal public final class XdsNameResolverProvider extends NameResolverProvider { + /** + * If provided, the suppler must return non-null when lb.start() is called (which implies not + * throwing), and the XdsClient must remain alive until lb.shutdown() returns. It may only be + * called from the synchronization context. + */ + public static final Args.Key> XDS_CLIENT_SUPPLIER = + Args.Key.create("io.grpc.xds.XdsNameResolverProvider.XDS_CLIENT_SUPPLIER"); private static final String SCHEME = "xds"; private final String scheme; @@ -77,16 +87,43 @@ public XdsNameResolver newNameResolver(URI targetUri, Args args) { targetPath, targetUri); String name = targetPath.substring(1); - return new XdsNameResolver( - targetUri, name, args.getOverrideAuthority(), - args.getServiceConfigParser(), args.getSynchronizationContext(), - args.getScheduledExecutorService(), - bootstrapOverride, - args.getMetricRecorder(), args); + // TODO(jdcormie): java.net.URI#getAuthority incorrectly returns null for both xds:///service + // and xds:/service. This doesn't matter for now since XdsNameResolver treats them the same + // anyway and all this code will go away once newNameResolver(io.grpc.Uri) launches. + String targetAuthority = targetUri.getAuthority(); + return newNameResolver(targetUri.toString(), targetAuthority, name, args); + } + return null; + } + + @Override + public XdsNameResolver newNameResolver(Uri targetUri, Args args) { + if (scheme.equals(targetUri.getScheme())) { + Preconditions.checkArgument( + targetUri.isPathAbsolute(), + "the path component of the target (%s) must start with '/'", + targetUri); + return newNameResolver( + targetUri.toString(), targetUri.getAuthority(), targetUri.getPath().substring(1), args); } return null; } + private XdsNameResolver newNameResolver( + String targetUri, String targetAuthority, String name, Args args) { + return new XdsNameResolver( + targetUri.toString(), + targetAuthority, + name, + args.getOverrideAuthority(), + args.getServiceConfigParser(), + args.getSynchronizationContext(), + args.getScheduledExecutorService(), + bootstrapOverride, + args.getMetricRecorder(), + args); + } + @Override public String getDefaultScheme() { return scheme; diff --git a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java index 2ee326435c4..730d301c3ec 100644 --- a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java @@ -36,7 +36,6 @@ import io.envoyproxy.envoy.config.route.v3.ClusterSpecifierPlugin; import io.envoyproxy.envoy.config.route.v3.RetryPolicy.RetryBackOff; import io.envoyproxy.envoy.config.route.v3.RouteConfiguration; -import io.envoyproxy.envoy.type.v3.FractionalPercent; import io.grpc.Status; import io.grpc.internal.GrpcUtil; import io.grpc.xds.ClusterSpecifierPlugin.NamedPluginConfig; @@ -69,8 +68,8 @@ class XdsRouteConfigureResource extends XdsResourceType { - private static final String GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE = - "GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE"; + private static final boolean isXdsAuthorityRewriteEnabled = GrpcUtil.getFlag( + "GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE", true); @VisibleForTesting static boolean enableRouteLookup = GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_RLS_LB", true); @@ -198,7 +197,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 +209,12 @@ private static StructOrError parseVirtualHost( @VisibleForTesting static StructOrError> parseOverrideFilterConfigs( - Map rawFilterConfigMap, FilterRegistry filterRegistry) { + Map rawFilterConfigMap, FilterRegistry filterRegistry, + XdsResourceType.Args args) { + Filter.FilterConfigParseContext context = Filter.FilterConfigParseContext.builder() + .bootstrapInfo(args.getBootstrapInfo()) + .serverInfo(args.getServerInfo()) + .build(); Map overrideConfigs = new HashMap<>(); for (String name : rawFilterConfigMap.keySet()) { Any anyConfig = rawFilterConfigMap.get(name); @@ -254,7 +258,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 +285,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: " @@ -331,12 +335,12 @@ static StructOrError parseRouteMatch( FractionMatcher fractionMatch = null; if (proto.hasRuntimeFraction()) { - StructOrError parsedFraction = - parseFractionMatcher(proto.getRuntimeFraction().getDefaultValue()); - if (parsedFraction.getErrorDetail() != null) { - return StructOrError.fromError(parsedFraction.getErrorDetail()); + try { + fractionMatch = + MatcherParser.parseFractionMatcher(proto.getRuntimeFraction().getDefaultValue()); + } catch (IllegalArgumentException e) { + return StructOrError.fromError(e.getMessage()); } - fractionMatch = parsedFraction.getStruct(); } List headerMatchers = new ArrayList<>(); @@ -377,26 +381,7 @@ static StructOrError parsePathMatcher( } } - private static StructOrError parseFractionMatcher(FractionalPercent proto) { - int numerator = proto.getNumerator(); - int denominator = 0; - switch (proto.getDenominator()) { - case HUNDRED: - denominator = 100; - break; - case TEN_THOUSAND: - denominator = 10_000; - break; - case MILLION: - denominator = 1_000_000; - break; - case UNRECOGNIZED: - default: - return StructOrError.fromError( - "Unrecognized fractional percent denominator: " + proto.getDenominator()); - } - return StructOrError.fromStruct(FractionMatcher.create(numerator, denominator)); - } + @VisibleForTesting static StructOrError parseHeaderMatcher( @@ -475,8 +460,8 @@ static StructOrError parseRouteAction( case CLUSTER: return StructOrError.fromStruct(RouteAction.forCluster( proto.getCluster(), hashPolicies, timeoutNano, retryPolicy, - GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE, false) - && args.getServerInfo().isTrustedXdsServer() && proto.getAutoHostRewrite().getValue())); + isXdsAuthorityRewriteEnabled && args.getServerInfo().isTrustedXdsServer() + && proto.getAutoHostRewrite().getValue())); case CLUSTER_HEADER: return null; case WEIGHTED_CLUSTERS: @@ -490,7 +475,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()); @@ -510,8 +495,8 @@ static StructOrError parseRouteAction( } return StructOrError.fromStruct(VirtualHost.Route.RouteAction.forWeightedClusters( weightedClusters, hashPolicies, timeoutNano, retryPolicy, - GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE, false) - && args.getServerInfo().isTrustedXdsServer() && proto.getAutoHostRewrite().getValue())); + isXdsAuthorityRewriteEnabled && args.getServerInfo().isTrustedXdsServer() + && proto.getAutoHostRewrite().getValue())); case CLUSTER_SPECIFIER_PLUGIN: if (enableRouteLookup) { String pluginName = proto.getClusterSpecifierPlugin(); @@ -527,8 +512,7 @@ static StructOrError parseRouteAction( NamedPluginConfig namedPluginConfig = NamedPluginConfig.create(pluginName, pluginConfig); return StructOrError.fromStruct(VirtualHost.Route.RouteAction.forClusterSpecifierPlugin( namedPluginConfig, hashPolicies, timeoutNano, retryPolicy, - GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_AUTHORITY_REWRITE, false) - && args.getServerInfo().isTrustedXdsServer() + isXdsAuthorityRewriteEnabled && args.getServerInfo().isTrustedXdsServer() && proto.getAutoHostRewrite().getValue())); } else { return null; @@ -600,9 +584,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/XdsServerBuilder.java b/xds/src/main/java/io/grpc/xds/XdsServerBuilder.java index 928f22c4d5e..4a4fb71aa84 100644 --- a/xds/src/main/java/io/grpc/xds/XdsServerBuilder.java +++ b/xds/src/main/java/io/grpc/xds/XdsServerBuilder.java @@ -55,6 +55,7 @@ public final class XdsServerBuilder extends ForwardingServerBuilder bootstrapOverride; private long drainGraceTime = 10; private TimeUnit drainGraceTimeUnit = TimeUnit.MINUTES; @@ -127,7 +128,7 @@ public Server build() { } InternalNettyServerBuilder.eagAttributes(delegate, builder.build()); return new XdsServerWrapper("0.0.0.0:" + port, delegate, xdsServingStatusListener, - filterChainSelectorManager, xdsClientPoolFactory, filterRegistry); + filterChainSelectorManager, xdsClientPoolFactory, bootstrapOverride, filterRegistry); } @VisibleForTesting @@ -140,11 +141,10 @@ XdsServerBuilder xdsClientPoolFactory(XdsClientPoolFactory xdsClientPoolFactory) * Allows providing bootstrap override, useful for testing. */ public XdsServerBuilder overrideBootstrapForTest(Map bootstrapOverride) { - checkNotNull(bootstrapOverride, "bootstrapOverride"); + this.bootstrapOverride = checkNotNull(bootstrapOverride, "bootstrapOverride"); if (this.xdsClientPoolFactory == SharedXdsClientPoolProvider.getDefaultProvider()) { this.xdsClientPoolFactory = new SharedXdsClientPoolProvider(); } - this.xdsClientPoolFactory.setBootstrapOverride(bootstrapOverride); return this; } diff --git a/xds/src/main/java/io/grpc/xds/XdsServerWrapper.java b/xds/src/main/java/io/grpc/xds/XdsServerWrapper.java index be64b56eef5..bed8e1d7ca6 100644 --- a/xds/src/main/java/io/grpc/xds/XdsServerWrapper.java +++ b/xds/src/main/java/io/grpc/xds/XdsServerWrapper.java @@ -42,6 +42,7 @@ import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.StatusException; +import io.grpc.StatusOr; import io.grpc.SynchronizationContext; import io.grpc.SynchronizationContext.ScheduledHandle; import io.grpc.internal.GrpcUtil; @@ -49,6 +50,7 @@ import io.grpc.internal.SharedResourceHolder; import io.grpc.xds.EnvoyServerProtoData.FilterChain; import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.Filter.NamedFilterConfig; import io.grpc.xds.FilterChainMatchingProtocolNegotiators.FilterChainMatchingHandler.FilterChainSelector; import io.grpc.xds.ThreadSafeRandom.ThreadSafeRandomImpl; @@ -56,6 +58,7 @@ import io.grpc.xds.XdsListenerResource.LdsUpdate; import io.grpc.xds.XdsRouteConfigureResource.RdsUpdate; import io.grpc.xds.XdsServerBuilder.XdsServingStatusListener; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; import io.grpc.xds.client.XdsClient; import io.grpc.xds.client.XdsClient.ResourceWatcher; import io.grpc.xds.internal.security.SslContextProviderSupplier; @@ -103,6 +106,7 @@ public void uncaughtException(Thread t, Throwable e) { private final FilterRegistry filterRegistry; private final ThreadSafeRandom random = ThreadSafeRandomImpl.instance; private final XdsClientPoolFactory xdsClientPoolFactory; + private final @Nullable Map bootstrapOverride; private final XdsServingStatusListener listener; private final FilterChainSelectorManager filterChainSelectorManager; private final AtomicBoolean started = new AtomicBoolean(false); @@ -131,9 +135,17 @@ public void uncaughtException(Thread t, Throwable e) { XdsServingStatusListener listener, FilterChainSelectorManager filterChainSelectorManager, XdsClientPoolFactory xdsClientPoolFactory, + @Nullable Map bootstrapOverride, FilterRegistry filterRegistry) { - this(listenerAddress, delegateBuilder, listener, filterChainSelectorManager, - xdsClientPoolFactory, filterRegistry, SharedResourceHolder.get(GrpcUtil.TIMER_SERVICE)); + this( + listenerAddress, + delegateBuilder, + listener, + filterChainSelectorManager, + xdsClientPoolFactory, + bootstrapOverride, + filterRegistry, + SharedResourceHolder.get(GrpcUtil.TIMER_SERVICE)); sharedTimeService = true; } @@ -144,6 +156,7 @@ public void uncaughtException(Thread t, Throwable e) { XdsServingStatusListener listener, FilterChainSelectorManager filterChainSelectorManager, XdsClientPoolFactory xdsClientPoolFactory, + @Nullable Map bootstrapOverride, FilterRegistry filterRegistry, ScheduledExecutorService timeService) { this.listenerAddress = checkNotNull(listenerAddress, "listenerAddress"); @@ -153,6 +166,7 @@ public void uncaughtException(Thread t, Throwable e) { this.filterChainSelectorManager = checkNotNull(filterChainSelectorManager, "filterChainSelectorManager"); this.xdsClientPoolFactory = checkNotNull(xdsClientPoolFactory, "xdsClientPoolFactory"); + this.bootstrapOverride = bootstrapOverride; this.timeService = checkNotNull(timeService, "timeService"); this.filterRegistry = checkNotNull(filterRegistry,"filterRegistry"); this.delegate = delegateBuilder.build(); @@ -182,7 +196,14 @@ public void run() { private void internalStart() { try { - xdsClientPool = xdsClientPoolFactory.getOrCreate("#server", new MetricRecorder() {}); + BootstrapInfo bootstrapInfo; + if (bootstrapOverride == null) { + bootstrapInfo = GrpcBootstrapperImpl.defaultBootstrap(); + } else { + bootstrapInfo = new GrpcBootstrapperImpl().bootstrap(bootstrapOverride); + } + xdsClientPool = xdsClientPoolFactory.getOrCreate( + "#server", bootstrapInfo, new MetricRecorder() {}); } catch (Exception e) { StatusException statusException = Status.UNAVAILABLE.withDescription( "Failed to initialize xDS").withCause(e).asException(); @@ -382,18 +403,30 @@ private DiscoveryState(String resourceName) { } @Override - public void onChanged(final LdsUpdate update) { + public void onResourceChanged(final StatusOr update) { if (stopped) { return; } - logger.log(Level.FINEST, "Received Lds update {0}", update); - if (update.listener() == null) { - onResourceDoesNotExist("Non-API"); + + if (!update.hasValue()) { + Status status = update.getStatus(); + StatusException statusException = Status.UNAVAILABLE.withDescription( + String.format("Listener %s unavailable: %s", resourceName, status.getDescription())) + .withCause(status.asException()) + .asException(); + handleConfigNotFoundOrMismatch(statusException); return; } - String ldsAddress = update.listener().address(); - if (ldsAddress == null || update.listener().protocol() != Protocol.TCP + final LdsUpdate ldsUpdate = update.getValue(); + logger.log(Level.FINEST, "Received Lds update {0}", ldsUpdate); + if (ldsUpdate.listener() == null) { + handleConfigNotFoundOrMismatch( + Status.NOT_FOUND.withDescription("Listener is null in LdsUpdate").asException()); + return; + } + String ldsAddress = ldsUpdate.listener().address(); + if (ldsAddress == null || ldsUpdate.listener().protocol() != Protocol.TCP || !ipAddressesMatch(ldsAddress)) { handleConfigNotFoundOrMismatch( Status.UNKNOWN.withDescription( @@ -402,16 +435,15 @@ public void onChanged(final LdsUpdate update) { listenerAddress, ldsAddress)).asException()); return; } + if (!pendingRds.isEmpty()) { // filter chain state has not yet been applied to filterChainSelectorManager and there - // are two sets of sslContextProviderSuppliers, so we release the old ones. releaseSuppliersInFlight(); pendingRds.clear(); } - filterChains = update.listener().filterChains(); - defaultFilterChain = update.listener().defaultFilterChain(); - // Filters are loaded even if the server isn't serving yet. + filterChains = ldsUpdate.listener().filterChains(); + defaultFilterChain = ldsUpdate.listener().defaultFilterChain(); updateActiveFilters(); List allFilterChains = filterChains; @@ -450,31 +482,8 @@ public void onChanged(final LdsUpdate update) { } } - private boolean ipAddressesMatch(String ldsAddress) { - HostAndPort ldsAddressHnP = HostAndPort.fromString(ldsAddress); - HostAndPort listenerAddressHnP = HostAndPort.fromString(listenerAddress); - if (!ldsAddressHnP.hasPort() || !listenerAddressHnP.hasPort() - || ldsAddressHnP.getPort() != listenerAddressHnP.getPort()) { - return false; - } - InetAddress listenerIp = InetAddresses.forString(listenerAddressHnP.getHost()); - InetAddress ldsIp = InetAddresses.forString(ldsAddressHnP.getHost()); - return listenerIp.equals(ldsIp); - } - - @Override - public void onResourceDoesNotExist(final String resourceName) { - if (stopped) { - return; - } - StatusException statusException = Status.UNAVAILABLE.withDescription( - String.format("Listener %s unavailable, xDS node ID: %s", resourceName, - xdsClient.getBootstrapInfo().node().getId())).asException(); - handleConfigNotFoundOrMismatch(statusException); - } - @Override - public void onError(final Status error) { + public void onAmbientError(final Status error) { if (stopped) { return; } @@ -482,11 +491,24 @@ public void onError(final Status error) { Status errorWithNodeId = error.withDescription( description + "xDS node ID: " + xdsClient.getBootstrapInfo().node().getId()); logger.log(Level.FINE, "Error from XdsClient", errorWithNodeId); + if (!isServing) { listener.onNotServing(errorWithNodeId.asException()); } } + private boolean ipAddressesMatch(String ldsAddress) { + HostAndPort ldsAddressHnP = HostAndPort.fromString(ldsAddress); + HostAndPort listenerAddressHnP = HostAndPort.fromString(listenerAddress); + if (!ldsAddressHnP.hasPort() || !listenerAddressHnP.hasPort() + || ldsAddressHnP.getPort() != listenerAddressHnP.getPort()) { + return false; + } + InetAddress listenerIp = InetAddresses.forString(listenerAddressHnP.getHost()); + InetAddress ldsIp = InetAddresses.forString(ldsAddressHnP.getHost()); + return listenerIp.equals(ldsIp); + } + private void shutdown() { stopped = true; cleanUpRouteDiscoveryStates(); @@ -591,7 +613,8 @@ private void updateActiveFiltersForChain( Filter.Provider provider = filterRegistry.get(typeUrl); checkNotNull(provider, "provider %s", typeUrl); Filter filter = chainFilters.computeIfAbsent( - filterKey, k -> provider.newInstance(namedFilter.name)); + filterKey, k -> provider.newInstance( + FilterContext.create(namedFilter.name, new MetricRecorder() {}))); checkNotNull(filter, "filter %s", filterKey); filtersToShutdown.remove(filterKey); } @@ -775,54 +798,42 @@ private RouteDiscoveryState(String resourceName) { } @Override - public void onChanged(final RdsUpdate update) { - syncContext.execute(new Runnable() { - @Override - public void run() { - if (!routeDiscoveryStates.containsKey(resourceName)) { - return; - } - if (savedVirtualHosts == null && !isPending) { - logger.log(Level.WARNING, "Received valid Rds {0} configuration.", resourceName); - } - savedVirtualHosts = ImmutableList.copyOf(update.virtualHosts); - updateRdsRoutingConfig(); - maybeUpdateSelector(); + public void onResourceChanged(final StatusOr update) { + syncContext.execute(() -> { + if (!routeDiscoveryStates.containsKey(resourceName)) { + return; // Watcher has been cancelled. } - }); - } - @Override - public void onResourceDoesNotExist(final String resourceName) { - syncContext.execute(new Runnable() { - @Override - public void run() { - if (!routeDiscoveryStates.containsKey(resourceName)) { - return; + if (update.hasValue()) { + if (savedVirtualHosts == null && !isPending) { + logger.log(Level.WARNING, "Received valid Rds {0} configuration.", resourceName); } - logger.log(Level.WARNING, "Rds {0} unavailable", resourceName); + savedVirtualHosts = ImmutableList.copyOf(update.getValue().virtualHosts); + } else { + logger.log(Level.WARNING, "Rds {0} unavailable: {1}", + new Object[]{resourceName, update.getStatus()}); savedVirtualHosts = null; - updateRdsRoutingConfig(); - maybeUpdateSelector(); } + // In both cases, a change has occurred that requires a config update. + updateRdsRoutingConfig(); + maybeUpdateSelector(); }); } @Override - public void onError(final Status error) { - syncContext.execute(new Runnable() { - @Override - public void run() { - if (!routeDiscoveryStates.containsKey(resourceName)) { - return; - } - String description = error.getDescription() == null ? "" : error.getDescription() + " "; - Status errorWithNodeId = error.withDescription( - description + "xDS node ID: " + xdsClient.getBootstrapInfo().node().getId()); - logger.log(Level.WARNING, "Error loading RDS resource {0} from XdsClient: {1}.", - new Object[]{resourceName, errorWithNodeId}); - maybeUpdateSelector(); + public void onAmbientError(final Status error) { + syncContext.execute(() -> { + if (!routeDiscoveryStates.containsKey(resourceName)) { + return; // Watcher has been cancelled. } + String description = error.getDescription() == null ? "" : error.getDescription() + " "; + Status errorWithNodeId = error.withDescription( + description + "xDS node ID: " + xdsClient.getBootstrapInfo().node().getId()); + logger.log(Level.WARNING, "Error loading RDS resource {0} from XdsClient: {1}.", + new Object[]{resourceName, errorWithNodeId}); + + // Per gRFC A88, ambient errors should not trigger a configuration change. + // Therefore, we do NOT call maybeUpdateSelector() here. }); } diff --git a/xds/src/main/java/io/grpc/xds/client/AllowedGrpcServices.java b/xds/src/main/java/io/grpc/xds/client/AllowedGrpcServices.java new file mode 100644 index 00000000000..e2d77689fca --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/client/AllowedGrpcServices.java @@ -0,0 +1,66 @@ +/* + * 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.client; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableMap; +import io.grpc.CallCredentials; +import io.grpc.Internal; +import java.util.Map; +import java.util.Optional; + +/** + * Wrapper for allowed gRPC services keyed by target URI. + */ +@Internal +@AutoValue +public abstract class AllowedGrpcServices { + public abstract ImmutableMap services(); + + public static AllowedGrpcServices create(Map services) { + return new AutoValue_AllowedGrpcServices(ImmutableMap.copyOf(services)); + } + + public static AllowedGrpcServices empty() { + return create(ImmutableMap.of()); + } + + /** + * Represents an allowed gRPC service configuration with call credentials. + */ + @Internal + @AutoValue + public abstract static class AllowedGrpcService { + public abstract ConfiguredChannelCredentials configuredChannelCredentials(); + + public abstract Optional callCredentials(); + + public static Builder builder() { + return new AutoValue_AllowedGrpcServices_AllowedGrpcService.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder configuredChannelCredentials( + ConfiguredChannelCredentials credentials); + + public abstract Builder callCredentials(CallCredentials callCredentials); + + public abstract AllowedGrpcService build(); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/client/BackendMetricPropagation.java b/xds/src/main/java/io/grpc/xds/client/BackendMetricPropagation.java new file mode 100644 index 00000000000..f0e2c9484b4 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/client/BackendMetricPropagation.java @@ -0,0 +1,133 @@ +/* + * 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.client; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableSet; +import io.grpc.Internal; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Represents the configuration for which ORCA metrics should be propagated from backend + * to LRS load reports, as defined in gRFC A85. + */ +@Internal +public final class BackendMetricPropagation { + + public final boolean propagateCpuUtilization; + public final boolean propagateMemUtilization; + public final boolean propagateApplicationUtilization; + + private final boolean propagateAllNamedMetrics; + private final ImmutableSet namedMetricKeys; + + private BackendMetricPropagation( + boolean propagateCpuUtilization, + boolean propagateMemUtilization, + boolean propagateApplicationUtilization, + boolean propagateAllNamedMetrics, + ImmutableSet namedMetricKeys) { + this.propagateCpuUtilization = propagateCpuUtilization; + this.propagateMemUtilization = propagateMemUtilization; + this.propagateApplicationUtilization = propagateApplicationUtilization; + this.propagateAllNamedMetrics = propagateAllNamedMetrics; + this.namedMetricKeys = checkNotNull(namedMetricKeys, "namedMetricKeys"); + } + + /** + * Creates a BackendMetricPropagation from a list of metric specifications. + * + * @param metricSpecs list of metric specification strings from CDS resource + * @return BackendMetricPropagation instance + */ + public static BackendMetricPropagation fromMetricSpecs( + @Nullable java.util.List metricSpecs) { + if (metricSpecs == null || metricSpecs.isEmpty()) { + return new BackendMetricPropagation(false, false, false, false, ImmutableSet.of()); + } + + boolean propagateCpuUtilization = false; + boolean propagateMemUtilization = false; + boolean propagateApplicationUtilization = false; + boolean propagateAllNamedMetrics = false; + ImmutableSet.Builder namedMetricKeysBuilder = ImmutableSet.builder(); + for (String spec : metricSpecs) { + if (spec == null) { + continue; + } + switch (spec) { + case "cpu_utilization": + propagateCpuUtilization = true; + break; + case "mem_utilization": + propagateMemUtilization = true; + break; + case "application_utilization": + propagateApplicationUtilization = true; + break; + case "named_metrics.*": + propagateAllNamedMetrics = true; + break; + default: + if (spec.startsWith("named_metrics.")) { + String metricKey = spec.substring("named_metrics.".length()); + if (!metricKey.isEmpty()) { + namedMetricKeysBuilder.add(metricKey); + } + } + } + } + + return new BackendMetricPropagation( + propagateCpuUtilization, + propagateMemUtilization, + propagateApplicationUtilization, + propagateAllNamedMetrics, + namedMetricKeysBuilder.build()); + } + + /** + * Returns whether the given named metric key should be propagated. + */ + public boolean shouldPropagateNamedMetric(String metricKey) { + return propagateAllNamedMetrics || namedMetricKeys.contains(metricKey); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BackendMetricPropagation that = (BackendMetricPropagation) o; + return propagateCpuUtilization == that.propagateCpuUtilization + && propagateMemUtilization == that.propagateMemUtilization + && propagateApplicationUtilization == that.propagateApplicationUtilization + && propagateAllNamedMetrics == that.propagateAllNamedMetrics + && Objects.equals(namedMetricKeys, that.namedMetricKeys); + } + + @Override + public int hashCode() { + return Objects.hash(propagateCpuUtilization, propagateMemUtilization, + propagateApplicationUtilization, propagateAllNamedMetrics, namedMetricKeys); + } +} \ No newline at end of file diff --git a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java index 4fa75f6b335..b8d6444e3b3 100644 --- a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java +++ b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java @@ -26,6 +26,7 @@ import io.grpc.xds.client.EnvoyProtoData.Node; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.Nullable; /** @@ -65,18 +66,22 @@ public abstract static class ServerInfo { public abstract boolean resourceTimerIsTransientError(); + public abstract boolean failOnDataErrors(); + @VisibleForTesting public static ServerInfo create(String target, @Nullable Object implSpecificConfig) { return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, - false, false, false); + false, false, false, false); } @VisibleForTesting public static ServerInfo create( - String target, Object implSpecificConfig, boolean ignoreResourceDeletion, - boolean isTrustedXdsServer, boolean resourceTimerIsTransientError) { + String target, Object implSpecificConfig, + boolean ignoreResourceDeletion, boolean isTrustedXdsServer, + boolean resourceTimerIsTransientError, boolean failOnDataErrors) { return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, - ignoreResourceDeletion, isTrustedXdsServer, resourceTimerIsTransientError); + ignoreResourceDeletion, isTrustedXdsServer, + resourceTimerIsTransientError, failOnDataErrors); } } @@ -201,11 +206,18 @@ public abstract static class BootstrapInfo { */ public abstract ImmutableMap authorities(); + /** + * Parsed configuration for implementation-specific extensions. + * Returns an opaque object containing the parsed configuration. + */ + public abstract Optional implSpecificObject(); + @VisibleForTesting public static Builder builder() { return new AutoValue_Bootstrapper_BootstrapInfo.Builder() .clientDefaultListenerResourceNameTemplate("%s") - .authorities(ImmutableMap.of()); + .authorities(ImmutableMap.of()) + .implSpecificObject(Optional.empty()); } @AutoValue.Builder @@ -227,7 +239,10 @@ public abstract Builder clientDefaultListenerResourceNameTemplate( public abstract Builder authorities(Map authorities); + public abstract Builder implSpecificObject(Optional implSpecificObject); + public abstract BootstrapInfo build(); } } + } diff --git a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java index 423c1a118e8..3f4ea8eb5c6 100644 --- a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java @@ -34,6 +34,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; /** * A {@link Bootstrapper} implementation that reads xDS configurations from local file system. @@ -58,6 +60,7 @@ public abstract class BootstrapperImpl extends Bootstrapper { private static final String SERVER_FEATURE_TRUSTED_XDS_SERVER = "trusted_xds_server"; private static final String SERVER_FEATURE_RESOURCE_TIMER_IS_TRANSIENT_ERROR = "resource_timer_is_transient_error"; + private static final String SERVER_FEATURE_FAIL_ON_DATA_ERRORS = "fail_on_data_errors"; @VisibleForTesting static boolean enableXdsFallback = GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_FALLBACK, true); @@ -238,9 +241,18 @@ protected BootstrapInfo.Builder bootstrapBuilder(Map rawData) builder.authorities(authorityInfoMapBuilder.buildOrThrow()); } + Map rawAllowedGrpcServices = JsonUtil.getObject(rawData, "allowed_grpc_services"); + builder.implSpecificObject(parseImplSpecificObject(rawAllowedGrpcServices)); + return builder; } + protected Optional parseImplSpecificObject( + @Nullable Map rawAllowedGrpcServices) + throws XdsInitializationException { + return Optional.empty(); + } + private List parseServerInfos(List rawServerConfigs, XdsLogger logger) throws XdsInitializationException { logger.log(XdsLogLevel.INFO, "Configured with {0} xDS servers", rawServerConfigs.size()); @@ -257,20 +269,25 @@ private List parseServerInfos(List rawServerConfigs, XdsLogger lo boolean resourceTimerIsTransientError = false; boolean ignoreResourceDeletion = false; + boolean failOnDataErrors = false; // "For forward compatibility reasons, the client will ignore any entry in the list that it // does not understand, regardless of type." List serverFeatures = JsonUtil.getList(serverConfig, "server_features"); if (serverFeatures != null) { logger.log(XdsLogLevel.INFO, "Server features: {0}", serverFeatures); - ignoreResourceDeletion = serverFeatures.contains(SERVER_FEATURE_IGNORE_RESOURCE_DELETION); + if (serverFeatures.contains(SERVER_FEATURE_IGNORE_RESOURCE_DELETION)) { + ignoreResourceDeletion = true; + } resourceTimerIsTransientError = xdsDataErrorHandlingEnabled && serverFeatures.contains(SERVER_FEATURE_RESOURCE_TIMER_IS_TRANSIENT_ERROR); + failOnDataErrors = xdsDataErrorHandlingEnabled + && serverFeatures.contains(SERVER_FEATURE_FAIL_ON_DATA_ERRORS); } servers.add( ServerInfo.create(serverUri, implSpecificConfig, ignoreResourceDeletion, serverFeatures != null && serverFeatures.contains(SERVER_FEATURE_TRUSTED_XDS_SERVER), - resourceTimerIsTransientError)); + resourceTimerIsTransientError, failOnDataErrors)); } return servers.build(); } diff --git a/xds/src/main/java/io/grpc/xds/client/ConfiguredChannelCredentials.java b/xds/src/main/java/io/grpc/xds/client/ConfiguredChannelCredentials.java new file mode 100644 index 00000000000..c6b9d774b4d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/client/ConfiguredChannelCredentials.java @@ -0,0 +1,48 @@ +/* + * 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.client; + +import com.google.auto.value.AutoValue; +import io.grpc.ChannelCredentials; +import io.grpc.Internal; + +/** + * Composition of {@link ChannelCredentials} and {@link ChannelCredsConfig}. + */ +@Internal +@AutoValue +public abstract class ConfiguredChannelCredentials { + public abstract ChannelCredentials channelCredentials(); + + public abstract ChannelCredsConfig channelCredsConfig(); + + public static ConfiguredChannelCredentials create(ChannelCredentials creds, + ChannelCredsConfig config) { + return new AutoValue_ConfiguredChannelCredentials(creds, config); + } + + /** + * Configuration for channel credentials. + */ + @Internal + public interface ChannelCredsConfig { + /** + * Returns the type of the credentials. + */ + String type(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/client/ControlPlaneClient.java b/xds/src/main/java/io/grpc/xds/client/ControlPlaneClient.java index 82a0e2f9220..981db516e5b 100644 --- a/xds/src/main/java/io/grpc/xds/client/ControlPlaneClient.java +++ b/xds/src/main/java/io/grpc/xds/client/ControlPlaneClient.java @@ -160,6 +160,31 @@ void adjustResourceSubscription(XdsResourceType resourceType) { } Collection resources = resourceStore.getSubscribedResources(serverInfo, resourceType); + if (resources == null && !adsStream.sentTypes.contains(resourceType)) { + // No subscription for this type on this server, and we have never sent a DiscoveryRequest + // of this type on the current stream — the server has no subscription state to clear. + // + // Per the ResourceStore contract in XdsClient.java, a null return means "no subscription"; + // an empty collection means wildcard subscription, which is a real subscription and must + // not be skipped here. + // + // We track sent types per-stream rather than gating on `versions` because `versions` is + // only populated on ACK. If a watch is canceled after the initial DiscoveryRequest goes + // out but before any response is ACKed, `versions` would still have no entry for the + // type, and gating on it would suppress the empty unsubscribe — leaving the server with + // a stale subscription until the stream resets. + // + // Without this skip, sendDiscoveryRequests() iterates over every globally-subscribed + // resource type when a stream becomes ready and emits an empty DiscoveryRequest for types + // that have no subscription on this server. Per A47 (xDS Federation) servers may be + // authority-specific (e.g. an EDS-only control plane) and reject DiscoveryRequests for + // types they do not handle, tearing down the stream. + // + // Mirrors grpc-go's behavior in + // internal/xds/clients/xdsclient/ads_stream.go:sendExisting, which skips types with no + // subscription. + return; + } if (resources == null) { resources = Collections.emptyList(); } @@ -319,6 +344,11 @@ private class AdsStream implements XdsTransportFactory.EventHandler respNonces = new HashMap<>(); + // Resource types for which a DiscoveryRequest has been sent on this stream. Used by + // adjustResourceSubscription() to decide whether an empty unsubscribe must be sent on the + // wire: the server only has subscription state to clear for types we have actually sent a + // request for on this stream. Cleared implicitly when the stream is replaced. + private final Set> sentTypes = new HashSet<>(); private final StreamingCall call; private final MethodDescriptor methodDescriptor = AggregatedDiscoveryServiceGrpc.getStreamAggregatedResourcesMethod(); @@ -358,6 +388,7 @@ void sendDiscoveryRequest(XdsResourceType type, String versionInfo, } DiscoveryRequest request = builder.build(); call.sendMessage(request); + sentTypes.add(type); if (logger.isLoggable(XdsLogLevel.DEBUG)) { logger.log(XdsLogLevel.DEBUG, "Sent DiscoveryRequest\n{0}", messagePrinter.print(request)); } @@ -457,10 +488,10 @@ private void handleRpcStreamClosed(Status status) { if (responseReceived) { // A closed ADS stream after a successful response is not considered an error. Servers may // close streams for various reasons during normal operation, such as load balancing or - // underlying connection hitting its max connection age limit (see gRFC A9). + // underlying connection hitting its max connection age limit (see gRFC A9). if (!status.isOk()) { newStatus = Status.OK; - logger.log( XdsLogLevel.DEBUG, "ADS stream closed with error {0}: {1}. However, a " + logger.log(XdsLogLevel.DEBUG, "ADS stream closed with error {0}: {1}. However, a " + "response was received, so this will not be treated as an error. Cause: {2}", status.getCode(), status.getDescription(), status.getCause()); } else { diff --git a/xds/src/main/java/io/grpc/xds/client/LoadStatsManager2.java b/xds/src/main/java/io/grpc/xds/client/LoadStatsManager2.java index be9d3587d14..c2e3e8f3f86 100644 --- a/xds/src/main/java/io/grpc/xds/client/LoadStatsManager2.java +++ b/xds/src/main/java/io/grpc/xds/client/LoadStatsManager2.java @@ -25,6 +25,7 @@ import com.google.common.collect.Sets; import io.grpc.Internal; import io.grpc.Status; +import io.grpc.internal.GrpcUtil; import io.grpc.xds.client.Stats.BackendLoadMetricStats; import io.grpc.xds.client.Stats.ClusterStats; import io.grpc.xds.client.Stats.DroppedRequests; @@ -57,6 +58,8 @@ public final class LoadStatsManager2 { private final Map>>> allLoadStats = new HashMap<>(); private final Supplier stopwatchSupplier; + public static boolean isEnabledOrcaLrsPropagation = + GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_ORCA_LRS_PROPAGATION", true); @VisibleForTesting public LoadStatsManager2(Supplier stopwatchSupplier) { @@ -98,13 +101,20 @@ private synchronized void releaseClusterDropCounter( /** * Gets or creates the stats object for recording loads for the specified locality (in the - * specified cluster with edsServiceName). The returned object is reference counted and the - * caller should use {@link ClusterLocalityStats#release} to release its hard reference + * specified cluster with edsServiceName) with the specified backend metric propagation + * configuration. The returned object is reference counted and the caller should + * use {@link ClusterLocalityStats#release} to release its hard reference * when it is safe to discard the future stats for the locality. */ @VisibleForTesting public synchronized ClusterLocalityStats getClusterLocalityStats( String cluster, @Nullable String edsServiceName, Locality locality) { + return getClusterLocalityStats(cluster, edsServiceName, locality, null); + } + + public synchronized ClusterLocalityStats getClusterLocalityStats( + String cluster, @Nullable String edsServiceName, Locality locality, + @Nullable BackendMetricPropagation backendMetricPropagation) { if (!allLoadStats.containsKey(cluster)) { allLoadStats.put( cluster, @@ -121,8 +131,8 @@ public synchronized ClusterLocalityStats getClusterLocalityStats( if (!localityStats.containsKey(locality)) { localityStats.put( locality, - ReferenceCounted.wrap(new ClusterLocalityStats( - cluster, edsServiceName, locality, stopwatchSupplier.get()))); + ReferenceCounted.wrap(new ClusterLocalityStats(cluster, edsServiceName, + locality, stopwatchSupplier.get(), backendMetricPropagation))); } ReferenceCounted ref = localityStats.get(locality); ref.retain(); @@ -325,6 +335,8 @@ public final class ClusterLocalityStats { private final String edsServiceName; private final Locality locality; private final Stopwatch stopwatch; + @Nullable + private final BackendMetricPropagation backendMetricPropagation; private final AtomicLong callsInProgress = new AtomicLong(); private final AtomicLong callsSucceeded = new AtomicLong(); private final AtomicLong callsFailed = new AtomicLong(); @@ -333,11 +345,14 @@ public final class ClusterLocalityStats { private ClusterLocalityStats( String clusterName, @Nullable String edsServiceName, Locality locality, - Stopwatch stopwatch) { + Stopwatch stopwatch, @Nullable BackendMetricPropagation backendMetricPropagation) { this.clusterName = checkNotNull(clusterName, "clusterName"); this.edsServiceName = edsServiceName; this.locality = checkNotNull(locality, "locality"); this.stopwatch = checkNotNull(stopwatch, "stopwatch"); + this.backendMetricPropagation = backendMetricPropagation != null + ? backendMetricPropagation + : BackendMetricPropagation.fromMetricSpecs(null); stopwatch.reset().start(); } @@ -367,17 +382,51 @@ public void recordCallFinished(Status status) { * requests counter of 1 and the {@code value} if the key is not present in the map. Otherwise, * increments the finished requests counter and adds the {@code value} to the existing * {@link BackendLoadMetricStats}. + * Metrics are filtered based on the backend metric propagation configuration if configured. */ public synchronized void recordBackendLoadMetricStats(Map namedMetrics) { + if (!isEnabledOrcaLrsPropagation) { + namedMetrics.forEach((name, value) -> updateLoadMetricStats(name, value)); + return; + } + namedMetrics.forEach((name, value) -> { - if (!loadMetricStatsMap.containsKey(name)) { - loadMetricStatsMap.put(name, new BackendLoadMetricStats(1, value)); - } else { - loadMetricStatsMap.get(name).addMetricValueAndIncrementRequestsFinished(value); + if (backendMetricPropagation.shouldPropagateNamedMetric(name)) { + updateLoadMetricStats("named_metrics." + name, value); } }); } + private void updateLoadMetricStats(String metricName, double value) { + if (!loadMetricStatsMap.containsKey(metricName)) { + loadMetricStatsMap.put(metricName, new BackendLoadMetricStats(1, value)); + } else { + loadMetricStatsMap.get(metricName).addMetricValueAndIncrementRequestsFinished(value); + } + } + + /** + * Records top-level ORCA metrics (CPU, memory, application utilization) for per-call load + * reporting. Metrics are filtered based on the backend metric propagation configuration + * if configured. + * + * @param cpuUtilization CPU utilization metric value + * @param memUtilization Memory utilization metric value + * @param applicationUtilization Application utilization metric value + */ + public synchronized void recordTopLevelMetrics(double cpuUtilization, double memUtilization, + double applicationUtilization) { + if (backendMetricPropagation.propagateCpuUtilization && cpuUtilization > 0) { + updateLoadMetricStats("cpu_utilization", cpuUtilization); + } + if (backendMetricPropagation.propagateMemUtilization && memUtilization > 0) { + updateLoadMetricStats("mem_utilization", memUtilization); + } + if (backendMetricPropagation.propagateApplicationUtilization && applicationUtilization > 0) { + updateLoadMetricStats("application_utilization", applicationUtilization); + } + } + /** * Release the hard reference for this stats object (previously obtained via {@link * LoadStatsManager2#getClusterLocalityStats}). The object may still be diff --git a/xds/src/main/java/io/grpc/xds/client/XdsClient.java b/xds/src/main/java/io/grpc/xds/client/XdsClient.java index e2dd4169bca..982fb6651a9 100644 --- a/xds/src/main/java/io/grpc/xds/client/XdsClient.java +++ b/xds/src/main/java/io/grpc/xds/client/XdsClient.java @@ -27,6 +27,7 @@ import com.google.protobuf.Any; import io.grpc.ExperimentalApi; import io.grpc.Status; +import io.grpc.StatusOr; import io.grpc.xds.client.Bootstrapper.ServerInfo; import java.net.URI; import java.net.URISyntaxException; @@ -139,30 +140,29 @@ public interface ResourceUpdate {} /** * Watcher interface for a single requested xDS resource. + * + *

    Note that we expect that the implementer to: + * - Comply with the guarantee to not generate certain statuses by the library: + * https://grpc.github.io/grpc/core/md_doc_statuscodes.html. If the code needs to be + * propagated to the channel, override it with {@link io.grpc.Status.Code#UNAVAILABLE}. + * - Keep {@link Status} description in one form or another, as it contains valuable debugging + * information. */ @ExperimentalApi("https://github.com/grpc/grpc-java/issues/10862") public interface ResourceWatcher { /** - * Called when the resource discovery RPC encounters some transient error. - * - *

    Note that we expect that the implementer to: - * - Comply with the guarantee to not generate certain statuses by the library: - * https://grpc.github.io/grpc/core/md_doc_statuscodes.html. If the code needs to be - * propagated to the channel, override it with {@link io.grpc.Status.Code#UNAVAILABLE}. - * - Keep {@link Status} description in one form or another, as it contains valuable debugging - * information. + * Called to deliver a resource update or an error. If an error is passed after a valid + * resource has been delivered, the watcher should stop using the previously delivered + * resource. */ - void onError(Status error); + void onResourceChanged(StatusOr update); /** - * Called when the requested resource is not available. - * - * @param resourceName name of the resource requested in discovery request. - */ - void onResourceDoesNotExist(String resourceName); - - void onChanged(T update); + * Called to deliver a transient error that should not affect the watcher's use of any + * previously received resource. + * */ + void onAmbientError(Status error); } /** @@ -388,6 +388,23 @@ public LoadStatsManager2.ClusterDropStats addClusterDropStats( public LoadStatsManager2.ClusterLocalityStats addClusterLocalityStats( Bootstrapper.ServerInfo serverInfo, String clusterName, @Nullable String edsServiceName, Locality locality) { + return addClusterLocalityStats(serverInfo, clusterName, edsServiceName, locality, null); + } + + /** + * Adds load stats for the specified locality (in the specified cluster with edsServiceName) by + * using the returned object to record RPCs. Load stats recorded with the returned object will + * be reported to the load reporting server. The returned object is reference counted and the + * caller should use {@link LoadStatsManager2.ClusterLocalityStats#release} to release its + * hard reference when it is safe to stop reporting RPC loads for the specified locality + * in the future. + * + * @param backendMetricPropagation Configuration for which backend metrics should be propagated + * to LRS load reports. If null, all metrics will be propagated (legacy behavior). + */ + public LoadStatsManager2.ClusterLocalityStats addClusterLocalityStats( + Bootstrapper.ServerInfo serverInfo, String clusterName, @Nullable String edsServiceName, + Locality locality, @Nullable BackendMetricPropagation backendMetricPropagation) { throw new UnsupportedOperationException(); } diff --git a/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java b/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java index 4c6823f844a..0584a3dbfdd 100644 --- a/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java @@ -33,6 +33,7 @@ import io.grpc.Internal; import io.grpc.InternalLogId; import io.grpc.Status; +import io.grpc.StatusOr; import io.grpc.SynchronizationContext; import io.grpc.SynchronizationContext.ScheduledHandle; import io.grpc.internal.BackoffPolicy; @@ -409,9 +410,22 @@ public void run() { public LoadStatsManager2.ClusterLocalityStats addClusterLocalityStats( final ServerInfo serverInfo, String clusterName, @Nullable String edsServiceName, Locality locality) { + return addClusterLocalityStats(serverInfo, clusterName, edsServiceName, locality, null); + } + + @Override + public LoadStatsManager2.ClusterLocalityStats addClusterLocalityStats( + final ServerInfo serverInfo, + String clusterName, + @Nullable String edsServiceName, + Locality locality, + @Nullable BackendMetricPropagation backendMetricPropagation) { LoadStatsManager2 loadStatsManager = loadStatsManagerMap.get(serverInfo); + LoadStatsManager2.ClusterLocalityStats loadCounter = - loadStatsManager.getClusterLocalityStats(clusterName, edsServiceName, locality); + loadStatsManager.getClusterLocalityStats( + clusterName, edsServiceName, locality, backendMetricPropagation); + syncContext.execute(new Runnable() { @Override public void run() { @@ -589,16 +603,20 @@ private void handleResourceUpdate( } if (invalidResources.contains(resourceName)) { - // The resource update is invalid. Capture the error without notifying the watchers. + // The resource update is invalid (NACK). Handle as a data error. subscriber.onRejected(args.versionInfo, updateTime, errorDetail); - } - - if (invalidResources.contains(resourceName)) { - // The resource is missing. Reuse the cached resource if possible. - if (subscriber.data == null) { - // No cached data. Notify the watchers of an invalid update. - subscriber.onError(Status.UNAVAILABLE.withDescription(errorDetail), processingTracker); + + // Handle data errors (NACKs) based on fail_on_data_errors server feature. + // When xdsDataErrorHandlingEnabled is true and fail_on_data_errors is present, + // delete cached data so onError will call onResourceChanged instead of onAmbientError. + // When xdsDataErrorHandlingEnabled is false, use old behavior (always keep cached data). + if (BootstrapperImpl.xdsDataErrorHandlingEnabled && subscriber.data != null + && args.serverInfo.failOnDataErrors()) { + subscriber.data = null; } + // Call onError, which will decide whether to call onResourceChanged or onAmbientError + // based on whether data exists after the above deletion. + subscriber.onError(Status.UNAVAILABLE.withDescription(errorDetail), processingTracker); continue; } @@ -717,17 +735,20 @@ void addWatcher(ResourceWatcher watcher, Executor watcherExecutor) { Status savedError = lastError; watcherExecutor.execute(() -> { if (errorDescription != null) { - watcher.onError(Status.INVALID_ARGUMENT.withDescription(errorDescription)); - return; - } - if (savedError != null) { - watcher.onError(savedError); + watcher.onResourceChanged(StatusOr.fromStatus( + Status.INVALID_ARGUMENT.withDescription(errorDescription))); return; } if (savedData != null) { - notifyWatcher(watcher, savedData); + watcher.onResourceChanged(StatusOr.fromValue(savedData)); + if (savedError != null) { + watcher.onAmbientError(savedError); + } + } else if (savedError != null) { + watcher.onResourceChanged(StatusOr.fromStatus(savedError)); } else if (savedAbsent) { - watcher.onResourceDoesNotExist(resource); + watcher.onResourceChanged(StatusOr.fromStatus( + Status.NOT_FOUND.withDescription("Resource " + resource + " does not exist"))); } }); } @@ -755,8 +776,8 @@ class ResourceNotFound implements Runnable { public void run() { logger.log(XdsLogLevel.INFO, "{0} resource {1} initial fetch timeout", type, resource); - respTimer = null; onAbsent(null, activeCpc.getServerInfo()); + respTimer = null; } @Override @@ -812,8 +833,8 @@ void onData(ParsedResource parsedResource, String version, long updateTime, } ResourceUpdate oldData = this.data; this.data = parsedResource.getResourceUpdate(); - this.metadata = ResourceMetadata - .newResourceMetadataAcked(parsedResource.getRawResource(), version, updateTime); + this.metadata = ResourceMetadata.newResourceMetadataAcked( + parsedResource.getRawResource(), version, updateTime); absent = false; lastError = null; if (resourceDeletionIgnored) { @@ -823,11 +844,12 @@ void onData(ParsedResource parsedResource, String version, long updateTime, resourceDeletionIgnored = false; } if (!Objects.equals(oldData, data)) { + StatusOr update = StatusOr.fromValue(data); for (ResourceWatcher watcher : watchers.keySet()) { processingTracker.startTask(); watchers.get(watcher).execute(() -> { try { - notifyWatcher(watcher, data); + watcher.onResourceChanged(update); } finally { processingTracker.onComplete(); } @@ -848,17 +870,42 @@ void onAbsent(@Nullable ProcessingTracker processingTracker, ServerInfo serverIn return; } - // Ignore deletion of State of the World resources when this feature is on, - // and the resource is reusable. + // Handle data errors (resource deletions) based on fail_on_data_errors server feature. + // When xdsDataErrorHandlingEnabled is true and fail_on_data_errors is not present, + // we treat deletions as ambient errors and keep using the cached resource. + // When fail_on_data_errors is present, we delete the cached resource and fail. + // When xdsDataErrorHandlingEnabled is false, use the old behavior (ignore_resource_deletion). boolean ignoreResourceDeletionEnabled = serverInfo.ignoreResourceDeletion(); - if (ignoreResourceDeletionEnabled && type.isFullStateOfTheWorld() && data != null) { - if (!resourceDeletionIgnored) { - logger.log(XdsLogLevel.FORCE_WARNING, - "xds server {0}: ignoring deletion for resource type {1} name {2}}", - serverInfo.target(), type, resource); - resourceDeletionIgnored = true; + boolean failOnDataErrors = serverInfo.failOnDataErrors(); + boolean xdsDataErrorHandlingEnabled = BootstrapperImpl.xdsDataErrorHandlingEnabled; + + if (type.isFullStateOfTheWorld() && data != null) { + // New behavior (per gRFC A88): Default is to treat deletions as ambient errors + if (xdsDataErrorHandlingEnabled && !failOnDataErrors) { + if (!resourceDeletionIgnored) { + logger.log(XdsLogLevel.FORCE_WARNING, + "xds server {0}: ignoring deletion for resource type {1} name {2}}", + serverInfo.target(), type, resource); + resourceDeletionIgnored = true; + } + Status deletionStatus = Status.NOT_FOUND.withDescription( + "Resource " + resource + " deleted from server"); + onAmbientError(deletionStatus, processingTracker); + return; + } + // Old behavior: Use ignore_resource_deletion server feature + if (!xdsDataErrorHandlingEnabled && ignoreResourceDeletionEnabled) { + if (!resourceDeletionIgnored) { + logger.log(XdsLogLevel.FORCE_WARNING, + "xds server {0}: ignoring deletion for resource type {1} name {2}}", + serverInfo.target(), type, resource); + resourceDeletionIgnored = true; + } + Status deletionStatus = Status.NOT_FOUND.withDescription( + "Resource " + resource + " deleted from server"); + onAmbientError(deletionStatus, processingTracker); + return; } - return; } logger.log(XdsLogLevel.INFO, "Conclude {0} resource {1} not exist", type, resource); @@ -866,21 +913,30 @@ void onAbsent(@Nullable ProcessingTracker processingTracker, ServerInfo serverIn data = null; absent = true; lastError = null; - metadata = serverInfo.resourceTimerIsTransientError() - ? ResourceMetadata.newResourceMetadataTimeout() - : ResourceMetadata.newResourceMetadataDoesNotExist(); - for (ResourceWatcher watcher : watchers.keySet()) { + + Status status; + if (respTimer == null) { + status = Status.NOT_FOUND.withDescription("Resource " + resource + " does not exist"); + metadata = ResourceMetadata.newResourceMetadataDoesNotExist(); + } else { + status = serverInfo.resourceTimerIsTransientError() + ? Status.UNAVAILABLE.withDescription( + "Timed out waiting for resource " + resource + " from xDS server") + : Status.NOT_FOUND.withDescription( + "Timed out waiting for resource " + resource + " from xDS server"); + metadata = serverInfo.resourceTimerIsTransientError() + ? ResourceMetadata.newResourceMetadataTimeout() + : ResourceMetadata.newResourceMetadataDoesNotExist(); + } + + StatusOr update = StatusOr.fromStatus(status); + for (Map.Entry, Executor> entry : watchers.entrySet()) { if (processingTracker != null) { processingTracker.startTask(); } - watchers.get(watcher).execute(() -> { + entry.getValue().execute(() -> { try { - if (serverInfo.resourceTimerIsTransientError()) { - watcher.onError(Status.UNAVAILABLE.withDescription( - "Timed out waiting for resource " + resource + " from xDS server")); - } else { - watcher.onResourceDoesNotExist(resource); - } + entry.getKey().onResourceChanged(update); } finally { if (processingTracker != null) { processingTracker.onComplete(); @@ -905,13 +961,37 @@ void onError(Status error, @Nullable ProcessingTracker tracker) { .withCause(error.getCause()); this.lastError = errorAugmented; - for (ResourceWatcher watcher : watchers.keySet()) { + if (data != null) { + // We have cached data, so this is an ambient error. + onAmbientError(errorAugmented, tracker); + } else { + // No data, this is a definitive resource error. + StatusOr update = StatusOr.fromStatus(errorAugmented); + for (Map.Entry, Executor> entry : watchers.entrySet()) { + if (tracker != null) { + tracker.startTask(); + } + entry.getValue().execute(() -> { + try { + entry.getKey().onResourceChanged(update); + } finally { + if (tracker != null) { + tracker.onComplete(); + } + } + }); + } + } + } + + private void onAmbientError(Status error, @Nullable ProcessingTracker tracker) { + for (Map.Entry, Executor> entry : watchers.entrySet()) { if (tracker != null) { tracker.startTask(); } - watchers.get(watcher).execute(() -> { + entry.getValue().execute(() -> { try { - watcher.onError(errorAugmented); + entry.getKey().onAmbientError(error); } finally { if (tracker != null) { tracker.onComplete(); @@ -926,10 +1006,6 @@ void onRejected(String rejectedVersion, long rejectedTime, String rejectedDetail .newResourceMetadataNacked(metadata, rejectedVersion, rejectedTime, rejectedDetails, data != null); } - - private void notifyWatcher(ResourceWatcher watcher, T update) { - watcher.onChanged(update); - } } private class ResponseHandler implements XdsResponseHandler { @@ -978,7 +1054,12 @@ public void handleStreamClosed(Status status, boolean shouldTryFallback) { for (Map> subscriberMap : resourceSubscribers.values()) { for (ResourceSubscriber subscriber : subscriberMap.values()) { - if (subscriber.hasResult() || !authoritiesForClosedCpc.contains(subscriber.authority)) { + if (!authoritiesForClosedCpc.contains(subscriber.authority)) { + continue; + } + // If subscriber already has data, this is an ambient error. + if (subscriber.hasResult()) { + subscriber.onError(status, null); continue; } @@ -995,7 +1076,6 @@ public void handleStreamClosed(Status status, boolean shouldTryFallback) { } } } - } private static class CpcWithFallbackState { diff --git a/xds/src/main/java/io/grpc/xds/client/XdsResourceType.java b/xds/src/main/java/io/grpc/xds/client/XdsResourceType.java index ccb622ff168..4d6e75b1809 100644 --- a/xds/src/main/java/io/grpc/xds/client/XdsResourceType.java +++ b/xds/src/main/java/io/grpc/xds/client/XdsResourceType.java @@ -33,10 +33,14 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.annotation.Nullable; @ExperimentalApi("https://github.com/grpc/grpc-java/issues/10847") public abstract class XdsResourceType { + private static final Logger log = Logger.getLogger(XdsResourceType.class.getName()); + static final String TYPE_URL_RESOURCE = "type.googleapis.com/envoy.service.discovery.v3.Resource"; protected static final String TRANSPORT_SOCKET_NAME_TLS = "envoy.transport_sockets.tls"; @@ -176,6 +180,16 @@ ValidatedResourceUpdate parse(Args args, List resources) { typeName(), unpackedClassName().getSimpleName(), cname, e.getMessage())); invalidResources.add(cname); continue; + } catch (Throwable t) { + log.log(Level.FINE, "Unexpected error in doParse()", t); + String errorMessage = t.getClass().getSimpleName(); + if (t.getMessage() != null) { + errorMessage = errorMessage + ": " + t.getMessage(); + } + errors.add(String.format("%s response '%s' unexpected error: %s", + typeName(), cname, errorMessage)); + invalidResources.add(cname); + continue; } // Resource parsed successfully. diff --git a/xds/src/main/java/io/grpc/xds/internal/HeaderForwardingRulesConfig.java b/xds/src/main/java/io/grpc/xds/internal/HeaderForwardingRulesConfig.java new file mode 100644 index 00000000000..6efce185b40 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/HeaderForwardingRulesConfig.java @@ -0,0 +1,74 @@ +/* + * 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; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import io.envoyproxy.envoy.extensions.filters.http.ext_proc.v3.HeaderForwardingRules; +import java.util.Locale; + +/** + * Configuration for header forwarding rules in external processing. + */ +public final class HeaderForwardingRulesConfig { + private final ImmutableList allowedHeaders; + private final ImmutableList disallowedHeaders; + + public HeaderForwardingRulesConfig( + ImmutableList allowedHeaders, + ImmutableList disallowedHeaders) { + this.allowedHeaders = checkNotNull(allowedHeaders, "allowedHeaders"); + this.disallowedHeaders = checkNotNull(disallowedHeaders, "disallowedHeaders"); + } + + public 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); + } + + public 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; + } +} 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 fb291efc461..205d00b015b 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) { @@ -90,11 +101,65 @@ public static Matchers.StringMatcher parseStringMatcher( return Matchers.StringMatcher.forSafeRegEx( Pattern.compile(proto.getSafeRegex().getRegex())); case CONTAINS: - return Matchers.StringMatcher.forContains(proto.getContains()); + return Matchers.StringMatcher.forContains(proto.getContains(), proto.getIgnoreCase()); case MATCHPATTERN_NOT_SET: default: throw new IllegalArgumentException( "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); } } + + /** Translate StringMatcher xDS proto to internal StringMatcher. */ + public static Matchers.StringMatcher parseStringMatcher( + com.github.xds.type.matcher.v3.StringMatcher proto) { + switch (proto.getMatchPatternCase()) { + case EXACT: + return Matchers.StringMatcher.forExact(proto.getExact(), proto.getIgnoreCase()); + case PREFIX: + return Matchers.StringMatcher.forPrefix( + checkNonEmpty(proto.getPrefix(), "prefix"), proto.getIgnoreCase()); + case SUFFIX: + return Matchers.StringMatcher.forSuffix( + checkNonEmpty(proto.getSuffix(), "suffix"), proto.getIgnoreCase()); + case SAFE_REGEX: + String regex = checkNonEmpty(proto.getSafeRegex().getRegex(), "regex"); + return Matchers.StringMatcher.forSafeRegEx(Pattern.compile(regex)); + case CONTAINS: + return Matchers.StringMatcher.forContains( + checkNonEmpty(proto.getContains(), "contains"), proto.getIgnoreCase()); + case MATCHPATTERN_NOT_SET: + default: + throw new IllegalArgumentException( + "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); + } + } + + private static String checkNonEmpty(String value, String name) { + if (value.isEmpty()) { + throw new IllegalArgumentException("StringMatcher " + name + + " (match_pattern) must be non-empty"); + } + return value; + } + + /** Translates envoy proto FractionalPercent to internal FractionMatcher. */ + public static Matchers.FractionMatcher parseFractionMatcher( + io.envoyproxy.envoy.type.v3.FractionalPercent proto) { + int denominator; + switch (proto.getDenominator()) { + case HUNDRED: + denominator = 100; + break; + case TEN_THOUSAND: + denominator = 10_000; + break; + case MILLION: + denominator = 1_000_000; + break; + case UNRECOGNIZED: + default: + throw new IllegalArgumentException("Unknown denominator type: " + proto.getDenominator()); + } + return Matchers.FractionMatcher.create(proto.getNumerator(), denominator); + } } diff --git a/xds/src/main/java/io/grpc/xds/internal/Matchers.java b/xds/src/main/java/io/grpc/xds/internal/Matchers.java index 228b20cfcd7..4b0babdfca6 100644 --- a/xds/src/main/java/io/grpc/xds/internal/Matchers.java +++ b/xds/src/main/java/io/grpc/xds/internal/Matchers.java @@ -257,10 +257,15 @@ public static StringMatcher forSafeRegEx(Pattern regEx) { } /** The input string should contain this substring. */ - public static StringMatcher forContains(String contains) { + public static StringMatcher forContains(String contains, boolean ignoreCase) { checkNotNull(contains, "contains"); return StringMatcher.create(null, null, null, null, contains, - false/* doesn't matter */); + ignoreCase); + } + + /** The input string should contain this substring. */ + public static StringMatcher forContains(String contains) { + return forContains(contains, false); } /** Returns the matching result for this string. */ @@ -281,7 +286,9 @@ public boolean matches(String args) { ? args.toLowerCase(Locale.ROOT).endsWith(suffix().toLowerCase(Locale.ROOT)) : args.endsWith(suffix()); } else if (contains() != null) { - return args.contains(contains()); + return ignoreCase() + ? args.toLowerCase(Locale.ROOT).contains(contains().toLowerCase(Locale.ROOT)) + : args.contains(contains()); } return regEx().matches(args); } diff --git a/xds/src/main/java/io/grpc/xds/internal/MetricReportUtils.java b/xds/src/main/java/io/grpc/xds/internal/MetricReportUtils.java new file mode 100644 index 00000000000..4194cab76d3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/MetricReportUtils.java @@ -0,0 +1,119 @@ +/* + * 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; + +import com.google.auto.value.AutoValue; +import io.grpc.services.MetricReport; +import java.util.Optional; +import java.util.OptionalDouble; + + +/** + * Utilities for parsing and resolving metrics from {@link MetricReport}. + */ +public final class MetricReportUtils { + + private MetricReportUtils() {} + + public enum MetricType { + CPU_UTILIZATION, + APPLICATION_UTILIZATION, + MEMORY_UTILIZATION, + UTILIZATION, + NAMED_METRICS, + INVALID + } + + @AutoValue + public abstract static class ParsedMetricName { + public abstract MetricType getMetricType(); + + public abstract Optional getKey(); + + public static ParsedMetricName create(MetricType metricType, Optional key) { + return new AutoValue_MetricReportUtils_ParsedMetricName(metricType, key); + } + + /** + * Pre-parses a custom metric name into a {@link ParsedMetricName}. + * + * @param name The custom metric name to parse. + * @return The parsed metric name. + */ + public static ParsedMetricName parse(String name) { + if (name.equals("cpu_utilization")) { + return create(MetricType.CPU_UTILIZATION, Optional.empty()); + } + if (name.equals("application_utilization")) { + return create(MetricType.APPLICATION_UTILIZATION, Optional.empty()); + } + if (name.equals("mem_utilization")) { + return create(MetricType.MEMORY_UTILIZATION, Optional.empty()); + } + if (name.startsWith("utilization.")) { + return create(MetricType.UTILIZATION, Optional.of(name.substring("utilization.".length()))); + } + if (name.startsWith("named_metrics.")) { + return create(MetricType.NAMED_METRICS, + Optional.of(name.substring("named_metrics.".length()))); + } + return create(MetricType.INVALID, Optional.empty()); + } + + } + + /** + * Resolves a custom metric value for `parsedMetric` + * Returns OptionalDouble.empty() if the metric is absent or invalid. + * + * @param report The metric report to query. + * @param parsedMetric The parsed metric to lookup. + * @return The metric value wrapped in an OptionalDouble, or empty if absent. + */ + + public static OptionalDouble getMetricValue(MetricReport report, ParsedMetricName parsedMetric) { + switch (parsedMetric.getMetricType()) { + case CPU_UTILIZATION: + return OptionalDouble.of(report.getCpuUtilization()); + case APPLICATION_UTILIZATION: + return OptionalDouble.of(report.getApplicationUtilization()); + case MEMORY_UTILIZATION: + return OptionalDouble.of(report.getMemoryUtilization()); + case UTILIZATION: + if (parsedMetric.getKey().isPresent()) { + String key = parsedMetric.getKey().get(); + Double val = report.getUtilizationMetrics().get(key); + if (val != null) { + return OptionalDouble.of(val); + } + } + return OptionalDouble.empty(); + case NAMED_METRICS: + if (parsedMetric.getKey().isPresent()) { + String key = parsedMetric.getKey().get(); + Double val = report.getNamedMetrics().get(key); + if (val != null) { + return OptionalDouble.of(val); + } + } + return OptionalDouble.empty(); + case INVALID: + default: + return OptionalDouble.empty(); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/XdsInternalAttributes.java b/xds/src/main/java/io/grpc/xds/internal/XdsInternalAttributes.java new file mode 100644 index 00000000000..b05230ea30b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/XdsInternalAttributes.java @@ -0,0 +1,27 @@ +/* + * 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; + +import io.grpc.Attributes; +import io.grpc.EquivalentAddressGroup; + +public final class XdsInternalAttributes { + /** Name associated with individual address, if available (e.g., DNS name). */ + @EquivalentAddressGroup.Attr + public static final Attributes.Key ATTR_ADDRESS_NAME = + Attributes.Key.create("io.grpc.xds.XdsAttributes.addressName"); +} 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 new file mode 100644 index 00000000000..5aeb44c6e2a --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzConfig.java @@ -0,0 +1,145 @@ +/* + * 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/extauthz/ExtAuthzParseException.java b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java new file mode 100644 index 00000000000..78edea5c305 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extauthz/ExtAuthzParseException.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * A custom exception for signaling errors during the parsing of external authorization + * (ext_authz) configurations. + */ +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); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extproc/DataPlaneCallState.java b/xds/src/main/java/io/grpc/xds/internal/extproc/DataPlaneCallState.java new file mode 100644 index 00000000000..b69ccee941e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extproc/DataPlaneCallState.java @@ -0,0 +1,26 @@ +/* + * 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.extproc; + +/** + * States of the call inside the data plane. + */ +public enum DataPlaneCallState { + IDLE, + ACTIVE, + CLOSED +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extproc/EventType.java b/xds/src/main/java/io/grpc/xds/internal/extproc/EventType.java new file mode 100644 index 00000000000..815e7be6ad7 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extproc/EventType.java @@ -0,0 +1,28 @@ +/* + * 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.extproc; + +/** + * The event type representing the phase of external processing. + */ +public enum EventType { + REQUEST_HEADERS, + REQUEST_BODY, + RESPONSE_HEADERS, + RESPONSE_BODY, + RESPONSE_TRAILERS +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extproc/ExtProcStreamState.java b/xds/src/main/java/io/grpc/xds/internal/extproc/ExtProcStreamState.java new file mode 100644 index 00000000000..f69772bd0b3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extproc/ExtProcStreamState.java @@ -0,0 +1,39 @@ +/* + * 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.extproc; + +/** + * States of the stream with the external processor. + */ +public enum ExtProcStreamState { + ACTIVE, + DRAINING, + COMPLETED, + FAILED; + + public boolean isCompleted() { + return this == COMPLETED || this == FAILED; + } + + public boolean isFailed() { + return this == FAILED; + } + + public boolean isDraining() { + return this == DRAINING; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extproc/ExternalProcessorServerInterceptorMetricInstruments.java b/xds/src/main/java/io/grpc/xds/internal/extproc/ExternalProcessorServerInterceptorMetricInstruments.java new file mode 100644 index 00000000000..8230c16cc28 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extproc/ExternalProcessorServerInterceptorMetricInstruments.java @@ -0,0 +1,100 @@ +/* + * 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.extproc; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import io.grpc.DoubleHistogramMetricInstrument; +import io.grpc.MetricInstrumentRegistry; +import io.grpc.internal.GrpcUtil; +import java.util.List; + +/** + * Holds metric instrument definitions for server-side external processing. + */ +public final class ExternalProcessorServerInterceptorMetricInstruments { + @VisibleForTesting + public static DoubleHistogramMetricInstrument clientHeadersDuration; + @VisibleForTesting + public static DoubleHistogramMetricInstrument clientHalfCloseDuration; + @VisibleForTesting + public static DoubleHistogramMetricInstrument serverHeadersDuration; + @VisibleForTesting + public static DoubleHistogramMetricInstrument serverTrailersDuration; + + // Copied from io.grpc.opentelemetry.internal.OpenTelemetryConstants.LATENCY_BUCKETS + private static final List LATENCY_BUCKETS = ImmutableList.of( + 0d, 0.00001d, 0.00005d, 0.0001d, 0.0003d, 0.0006d, 0.0008d, 0.001d, 0.002d, + 0.003d, 0.004d, 0.005d, 0.006d, 0.008d, 0.01d, 0.013d, 0.016d, 0.02d, + 0.025d, 0.03d, 0.04d, 0.05d, 0.065d, 0.08d, 0.1d, 0.13d, 0.16d, + 0.2d, 0.25d, 0.3d, 0.4d, 0.5d, 0.65d, 0.8d, 1d, 2d, + 5d, 10d, 20d, 50d, 100d); + + static { + initMetricInstruments(); + } + + public static synchronized void initMetricInstruments() { + if (GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_SERVER", false)) { + if (clientHeadersDuration == null) { + MetricInstrumentRegistry registry = MetricInstrumentRegistry.getDefaultRegistry(); + + clientHeadersDuration = registry.registerDoubleHistogram( + "grpc.server_ext_proc.client_headers_duration", + "Time between when the ext_proc filter sees the client's headers and when " + + "it allows those headers to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of(), + ImmutableList.of(), + true); + + clientHalfCloseDuration = registry.registerDoubleHistogram( + "grpc.server_ext_proc.client_half_close_duration", + "Time between when the ext_proc filter sees the client's half-close and when " + + "it allows that half-close to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of(), + ImmutableList.of(), + true); + + serverHeadersDuration = registry.registerDoubleHistogram( + "grpc.server_ext_proc.server_headers_duration", + "Time between when the ext_proc filter sees the server's headers and when " + + "it allows those headers to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of(), + ImmutableList.of(), + true); + + serverTrailersDuration = registry.registerDoubleHistogram( + "grpc.server_ext_proc.server_trailers_duration", + "Time between when the ext_proc filter sees the server's trailers and when " + + "it allows those trailers to continue on to the next filter", + "s", + LATENCY_BUCKETS, + ImmutableList.of(), + ImmutableList.of(), + true); + } + } + } + + private ExternalProcessorServerInterceptorMetricInstruments() {} +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extproc/ExternalProcessorUtil.java b/xds/src/main/java/io/grpc/xds/internal/extproc/ExternalProcessorUtil.java new file mode 100644 index 00000000000..ae8904829b2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extproc/ExternalProcessorUtil.java @@ -0,0 +1,316 @@ +/* + * 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.extproc; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.ByteString; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import io.envoyproxy.envoy.config.core.v3.HeaderMap; +import io.envoyproxy.envoy.service.ext_proc.v3.HeaderMutation; +import io.grpc.Drainable; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.xds.internal.HeaderForwardingRulesConfig; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils; +import io.grpc.xds.internal.headermutations.HeaderMutationDisallowedException; +import io.grpc.xds.internal.headermutations.HeaderMutationFilter; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderMutator; +import io.grpc.xds.internal.headermutations.HeaderValueOption; +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.concurrent.atomic.AtomicReference; +import javax.annotation.Nullable; + +/** + * Utility methods for external processing. + */ +public final class ExternalProcessorUtil { + private ExternalProcessorUtil() {} + + /** + * Reads an InputStream into a ByteString. + */ + public static ByteString outboundStreamToByteString(InputStream message) throws IOException { + if (message instanceof Drainable) { + int size = message.available(); + ByteString.Output output = + size > 0 ? ByteString.newOutput(size) : ByteString.newOutput(); + ((Drainable) message).drainTo(output); + return output.toByteString(); + } + return ByteString.readFrom(message); + } + + /** + * Collects request attributes and structures them as Struct map. + */ + public static ImmutableMap collectAttributes( + ImmutableList requestedAttributes, + MethodDescriptor method, + String authority, + 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(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(); + } + + /** + * Encodes a single string attribute as Struct. + */ + public static Struct encodeAttribute(String value) { + return Struct.newBuilder() + .putFields("", Value.newBuilder().setStringValue(value).build()) + .build(); + } + + /** + * Encodes metadata headers as Struct. + */ + public 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(); + } + + /** + * Gets a header value from metadata. + */ + @Nullable + public static String getHeaderValue(Metadata headers, String headerName) { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Metadata.Key key = Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER); + Iterable values = headers.getAll(key); + if (values == null) { + return null; + } + List encoded = new ArrayList<>(); + for (byte[] v : values) { + encoded.add(BaseEncoding.base64().omitPadding().encode(v)); + } + return Joiner.on(",").join(encoded); + } + Metadata.Key key = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + Iterable values = headers.getAll(key); + return values == null ? null : Joiner.on(",").join(values); + } + + /** + * Transitions ExtProcStreamState to COMPLETED. + */ + public static boolean markExtProcStreamCompleted( + AtomicReference extProcStreamState) { + while (true) { + ExtProcStreamState current = extProcStreamState.get(); + if (current == ExtProcStreamState.COMPLETED || current == ExtProcStreamState.FAILED) { + return false; + } + if (extProcStreamState.compareAndSet(current, ExtProcStreamState.COMPLETED)) { + return true; + } + } + } + + /** + * Transitions ExtProcStreamState to FAILED. + */ + public static boolean markExtProcStreamFailed( + AtomicReference extProcStreamState) { + while (true) { + ExtProcStreamState current = extProcStreamState.get(); + if (current == ExtProcStreamState.COMPLETED || current == ExtProcStreamState.FAILED) { + return false; + } + if (extProcStreamState.compareAndSet(current, ExtProcStreamState.FAILED)) { + return true; + } + } + } + + /** + * Transitions DataPlaneCallState to CLOSED. + */ + public static boolean markDataPlaneCallClosed( + AtomicReference dataPlaneCallState) { + while (true) { + DataPlaneCallState current = dataPlaneCallState.get(); + if (current == DataPlaneCallState.CLOSED) { + return false; + } + if (dataPlaneCallState.compareAndSet(current, DataPlaneCallState.CLOSED)) { + return true; + } + } + } + + /** + * Converts Metadata to Envoy HeaderMap based on forwarding rules. + */ + public 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; + } + // Map binary headers using raw_value + 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 base64Value = 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)) + .setRawValue(ByteString.copyFromUtf8(base64Value)) + .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)) + .setRawValue(ByteString.copyFromUtf8(value)) + .build(); + builder.addHeaders(headerValue); + } + } + } + } + return builder.build(); + } + + /** + * Applies header mutations to metadata under the validation of mutationFilter and mutator. + */ + public static void applyHeaderMutations( + Metadata metadata, + HeaderMutation mutation, + HeaderMutationFilter mutationFilter, + HeaderMutator mutator) + 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(); + String key = protoHeader.getKey(); + HeaderValueValidationUtils.validateHeaderKey(key); + + ByteString rawBytes = protoHeader.getRawValue(); + if (rawBytes.isEmpty()) { + rawBytes = ByteString.copyFromUtf8(protoHeader.getValue()); + } + + if (rawBytes.size() > HeaderValueValidationUtils.MAX_HEADER_LENGTH) { + throw new IllegalArgumentException( + "Header value length exceeds maximum allowed length: " + rawBytes.size()); + } + + HeaderValue headerValue; + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + byte[] decodedBytes = BaseEncoding.base64().decode(rawBytes.toStringUtf8()); + headerValue = HeaderValue.create(key, ByteString.copyFrom(decodedBytes)); + } else { + headerValue = HeaderValue.create(key, rawBytes.toStringUtf8()); + } + headersToModify.add(HeaderValueOption.create( + headerValue, + HeaderValueOption.HeaderAppendAction.valueOf(protoOption.getAppendAction().name()))); + } + + ImmutableList.Builder headersToRemove = ImmutableList.builder(); + for (String headerToRemove : mutation.getRemoveHeadersList()) { + HeaderValueValidationUtils.validateHeaderKey(headerToRemove); + headersToRemove.add(headerToRemove); + } + + HeaderMutations mutations = HeaderMutations.create( + headersToModify.build(), + headersToRemove.build()); + + HeaderMutations filteredMutations = mutationFilter.filter(mutations); + mutator.applyMutations(filteredMutations, metadata); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/extproc/KnownLengthInputStream.java b/xds/src/main/java/io/grpc/xds/internal/extproc/KnownLengthInputStream.java new file mode 100644 index 00000000000..28efeca69de --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/extproc/KnownLengthInputStream.java @@ -0,0 +1,53 @@ +/* + * 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.extproc; + +import com.google.protobuf.ByteString; +import io.grpc.KnownLength; +import java.io.IOException; +import java.io.InputStream; + +/** + * An {@link InputStream} backed by a {@link ByteString} that implements {@link KnownLength}. + */ +public final class KnownLengthInputStream extends InputStream implements KnownLength { + private final InputStream delegate; + + public KnownLengthInputStream(ByteString byteString) { + this.delegate = byteString.newInput(); + } + + @Override + public int read() throws IOException { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return delegate.read(b, off, len); + } + + @Override + public int available() throws IOException { + return delegate.available(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } +} 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..db4a856d12a --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/CachedChannelManager.java @@ -0,0 +1,63 @@ +/* + * 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.common.annotations.VisibleForTesting; +import io.grpc.ManagedChannel; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig.GoogleGrpcConfig; +import java.util.function.Function; + +/** + * Concrete class managing the lifecycle of a single ManagedChannel for a GrpcServiceConfig. + */ +public class CachedChannelManager { + private final Function channelCreator; + + /** + * 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. + */ + public ManagedChannel getChannel(GrpcServiceConfig config) { + return channelCreator.apply(config); + } + + /** Removes underlying resources on shutdown. */ + public void close() { + // No-op as channel caching and lifecycle management is removed. + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java new file mode 100644 index 00000000000..cefc235e9eb --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceConfig.java @@ -0,0 +1,87 @@ +/* + * 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.grpcservice; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import io.grpc.CallCredentials; +import io.grpc.xds.client.ConfiguredChannelCredentials; +import java.time.Duration; +import java.util.Optional; + + +/** + * This class encapsulates the configuration for a gRPC service, including target URI, credentials, + * and other settings. This class is immutable and uses the AutoValue library for its + * implementation. + */ +@AutoValue +public abstract class GrpcServiceConfig { + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig.Builder(); + } + + public abstract GoogleGrpcConfig googleGrpc(); + + public abstract Optional timeout(); + + public abstract ImmutableList initialMetadata(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder googleGrpc(GoogleGrpcConfig googleGrpc); + + public abstract Builder timeout(Duration timeout); + + public abstract Builder initialMetadata(ImmutableList initialMetadata); + + public abstract GrpcServiceConfig build(); + } + + /** + * This class encapsulates settings specific to Google's gRPC implementation, such as target URI + * and credentials. + */ + @AutoValue + public abstract static class GoogleGrpcConfig { + + public static Builder builder() { + return new AutoValue_GrpcServiceConfig_GoogleGrpcConfig.Builder(); + } + + public abstract String target(); + + public abstract ConfiguredChannelCredentials configuredChannelCredentials(); + + public abstract Optional callCredentials(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder target(String target); + + public abstract Builder configuredChannelCredentials( + ConfiguredChannelCredentials channelCredentials); + + public abstract Builder callCredentials(CallCredentials callCredentials); + + public abstract GoogleGrpcConfig build(); + } + } + + +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java new file mode 100644 index 00000000000..319ad3d07e3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/GrpcServiceParseException.java @@ -0,0 +1,33 @@ +/* + * 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.grpcservice; + +/** + * Exception thrown when there is an error parsing the gRPC service config. + */ +public class GrpcServiceParseException extends Exception { + + private static final long serialVersionUID = 1L; + + public GrpcServiceParseException(String message) { + super(message); + } + + public GrpcServiceParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java new file mode 100644 index 00000000000..1ebeb80f566 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValue.java @@ -0,0 +1,45 @@ +/* + * 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.grpcservice; + +import com.google.auto.value.AutoValue; +import com.google.protobuf.ByteString; +import java.util.Optional; + +/** + * Represents a header to be mutated or added as part of xDS configuration. + * Avoids direct dependency on Envoy's proto objects while providing an immutable representation. + */ +@AutoValue +public abstract class HeaderValue { + + public static HeaderValue create(String key, String value) { + HeaderValueValidationUtils.validateHeaderValue(key, value); + return new AutoValue_HeaderValue(key, Optional.of(value), Optional.empty()); + } + + public static HeaderValue create(String key, ByteString rawValue) { + HeaderValueValidationUtils.validateHeaderValue(key, rawValue); + return new AutoValue_HeaderValue(key, Optional.empty(), Optional.of(rawValue)); + } + + public abstract String key(); + + public abstract Optional value(); + + public abstract Optional rawValue(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java new file mode 100644 index 00000000000..376a7254926 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtils.java @@ -0,0 +1,114 @@ +/* + * 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.grpcservice; + +import com.google.protobuf.ByteString; +import java.util.Locale; + +/** + * Utility class for validating HTTP headers. + */ +public final class HeaderValueValidationUtils { + public static final int MAX_HEADER_LENGTH = 16384; + + private HeaderValueValidationUtils() {} + + /** + * Validates that the header key is non-empty and within allowed length. + * Throws {@link IllegalArgumentException} if invalid. + */ + public static void validateHeaderKey(String key) { + if (key == null || key.isEmpty() || key.length() > MAX_HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid header key: " + key); + } + } + + /** + * Validates that the header value is within allowed length and contains valid ASCII characters. + * Throws {@link IllegalArgumentException} if invalid. + */ + public static void validateHeaderValue(String key, String value) { + validateHeaderKey(key); + if (value == null || value.length() > MAX_HEADER_LENGTH) { + throw new IllegalArgumentException("Header value length exceeds maximum allowed length"); + } + if (!key.endsWith("-bin") && !isValidAsciiHeaderValue(value)) { + throw new IllegalArgumentException( + "Invalid ASCII characters in header value for key: " + key); + } + } + + /** + * Validates that the raw header value is within allowed length and contains valid ASCII + * characters. Throws {@link IllegalArgumentException} if invalid. + */ + public static void validateHeaderValue(String key, ByteString rawValue) { + validateHeaderKey(key); + if (rawValue == null || rawValue.size() > MAX_HEADER_LENGTH) { + throw new IllegalArgumentException("Header value length exceeds maximum allowed length"); + } + if (!key.endsWith("-bin") && !isValidAsciiHeaderValue(rawValue.toStringUtf8())) { + throw new IllegalArgumentException( + "Invalid ASCII characters in header value for key: " + key); + } + } + + /** + * Returns true if the header key is disallowed for mutations. + * + * @param key The header key (e.g., "content-type") + */ + public static boolean isDisallowed(String key) { + if (key.isEmpty() || key.length() > MAX_HEADER_LENGTH) { + return true; + } + if (!key.equals(key.toLowerCase(Locale.ROOT))) { + return true; + } + if (key.startsWith("grpc-")) { + return true; + } + if (key.startsWith(":") || key.equals("host")) { + return true; + } + return false; + } + + /** + * Returns true if the header is disallowed for mutations. + * + * @param header The HeaderValue + */ + public static boolean isDisallowed(HeaderValue header) { + return isDisallowed(header.key()); + } + + /** + * Validates that the header value contains only allowed ASCII characters as specified by + * {@link io.grpc.Metadata.AsciiMarshaller}: horizontal tab (0x09), space (0x20), and visible + * ASCII characters (0x21 - 0x7E). + */ + private static boolean isValidAsciiHeaderValue(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c != 0x09 && (c < 0x20 || c > 0x7E)) { + return false; + } + } + return true; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java new file mode 100644 index 00000000000..b8d4eb582fb --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationDisallowedException.java @@ -0,0 +1,32 @@ +/* + * 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.internal.headermutations; + +import io.grpc.Status; +import io.grpc.StatusException; + +/** + * Exception thrown when a header mutation is disallowed. + */ +public final class HeaderMutationDisallowedException extends StatusException { + + private static final long serialVersionUID = 1L; + + public HeaderMutationDisallowedException(String message) { + super(Status.INTERNAL.withDescription(message)); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java new file mode 100644 index 00000000000..35cab17d928 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationFilter.java @@ -0,0 +1,114 @@ +/* + * 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.headermutations; + +import com.google.common.collect.ImmutableList; +import io.grpc.xds.internal.grpcservice.HeaderValueValidationUtils; +import java.util.Collection; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * The HeaderMutationFilter class is responsible for filtering header mutations based on a given set + * of rules. + */ +public class HeaderMutationFilter { + private final Optional mutationRules; + + + + public HeaderMutationFilter(Optional mutationRules) { + this.mutationRules = mutationRules; + } + + /** + * Filters the given header mutations based on the configured rules and returns the allowed + * mutations. + * + * @param mutations The header mutations to filter + * @return The allowed header mutations. + * @throws HeaderMutationDisallowedException if a disallowed mutation is encountered and the rules + * specify that this should be an error. + */ + public HeaderMutations filter(HeaderMutations mutations) + throws HeaderMutationDisallowedException { + ImmutableList allowedHeaders = + filterCollection(mutations.headers(), this::isDisallowed, this::isHeaderMutationAllowed); + ImmutableList allowedHeadersToRemove = + filterCollection(mutations.headersToRemove(), this::isDisallowed, + this::isHeaderMutationAllowed); + return HeaderMutations.create(allowedHeaders, allowedHeadersToRemove); + } + + /** + * A generic helper to filter a collection based on a predicate. + */ + private ImmutableList filterCollection(Collection items, + Predicate isIgnoredPredicate, Predicate isAllowedPredicate) + throws HeaderMutationDisallowedException { + ImmutableList.Builder allowed = ImmutableList.builder(); + for (T item : items) { + boolean isIgnored = isIgnoredPredicate.test(item); + boolean isAllowed = isAllowedPredicate.test(item); + + // TODO(sauravzg): The specification is ambiguous regarding whether system headers + // should be silently ignored or trigger an error when disallowIsError is enabled. + // We default to triggering errors matching Envoy's implementation. + // Ref: https://github.com/grpc/proposal/pull/481#discussion_r3124453674 + if (!isIgnored && isAllowed) { + allowed.add(item); + } else if (disallowIsError()) { + throw new HeaderMutationDisallowedException("Header mutation disallowed"); + } + } + return allowed.build(); + } + + private boolean isDisallowed(String key) { + return HeaderValueValidationUtils.isDisallowed(key); + } + + private boolean isDisallowed(HeaderValueOption option) { + return HeaderValueValidationUtils.isDisallowed(option.header()); + } + + private boolean isHeaderMutationAllowed(HeaderValueOption option) { + return isHeaderMutationAllowed(option.header().key()); + } + + private boolean isHeaderMutationAllowed(String headerName) { + return mutationRules.map(rules -> isHeaderMutationAllowed(headerName, rules)) + .orElse(true); + } + + private boolean isHeaderMutationAllowed(String headerName, + HeaderMutationRulesConfig rules) { + if (rules.disallowExpression().isPresent() + && rules.disallowExpression().get().matcher(headerName).matches()) { + return false; + } + if (rules.allowExpression().isPresent() + && rules.allowExpression().get().matcher(headerName).matches()) { + return true; + } + return !rules.disallowAll(); + } + + private boolean disallowIsError() { + return mutationRules.map(HeaderMutationRulesConfig::disallowIsError).orElse(false); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java new file mode 100644 index 00000000000..b16ec7948ed --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfig.java @@ -0,0 +1,77 @@ +/* + * 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.headermutations; + +import com.google.auto.value.AutoValue; +import com.google.re2j.Pattern; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import java.util.Optional; + +/** + * Represents the configuration for header mutation rules, as defined in the + * {@link io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules} proto. + */ +@AutoValue +public abstract class HeaderMutationRulesConfig { + /** Creates a new builder for creating {@link HeaderMutationRulesConfig} instances. */ + public static Builder builder() { + return new AutoValue_HeaderMutationRulesConfig.Builder().disallowAll(false) + .disallowIsError(false); + } + + /** + * If set, allows any header that matches this regular expression. + * + * @see HeaderMutationRules#getAllowExpression() + */ + public abstract Optional allowExpression(); + + /** + * If set, disallows any header that matches this regular expression. + * + * @see HeaderMutationRules#getDisallowExpression() + */ + public abstract Optional disallowExpression(); + + /** + * If true, disallows all header mutations. + * + * @see HeaderMutationRules#getDisallowAll() + */ + public abstract boolean disallowAll(); + + /** + * If true, a disallowed header mutation will result in an error instead of being ignored. + * + * @see HeaderMutationRules#getDisallowIsError() + */ + public abstract boolean disallowIsError(); + + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder allowExpression(Pattern matcher); + + public abstract Builder disallowExpression(Pattern matcher); + + public abstract Builder disallowAll(boolean disallowAll); + + public abstract Builder disallowIsError(boolean disallowIsError); + + public abstract HeaderMutationRulesConfig build(); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParseException.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParseException.java new file mode 100644 index 00000000000..3782e84a54b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParseException.java @@ -0,0 +1,32 @@ +/* + * 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.headermutations; + +/** + * Exception thrown when parsing header mutation rules fails. + */ +public final class HeaderMutationRulesParseException extends Exception { + private static final long serialVersionUID = 1L; + + public HeaderMutationRulesParseException(String message) { + super(message); + } + + public HeaderMutationRulesParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java new file mode 100644 index 00000000000..f6bb2ec508d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParser.java @@ -0,0 +1,55 @@ +/* + * 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.headermutations; + +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; + +/** + * Parser for {@link io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules}. + */ +public final class HeaderMutationRulesParser { + + private HeaderMutationRulesParser() {} + + public static HeaderMutationRulesConfig parse(HeaderMutationRules proto) + throws HeaderMutationRulesParseException { + HeaderMutationRulesConfig.Builder builder = HeaderMutationRulesConfig.builder(); + builder.disallowAll(proto.getDisallowAll().getValue()); + builder.disallowIsError(proto.getDisallowIsError().getValue()); + if (proto.hasAllowExpression()) { + builder.allowExpression( + parseRegex(proto.getAllowExpression().getRegex(), "allow_expression")); + } + if (proto.hasDisallowExpression()) { + builder.disallowExpression( + parseRegex(proto.getDisallowExpression().getRegex(), "disallow_expression")); + } + return builder.build(); + } + + private static Pattern parseRegex(String regex, String fieldName) + throws HeaderMutationRulesParseException { + try { + return Pattern.compile(regex); + } catch (PatternSyntaxException e) { + throw new HeaderMutationRulesParseException( + "Invalid regex pattern for " + fieldName + ": " + e.getMessage(), e); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java new file mode 100644 index 00000000000..a456413c899 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutations.java @@ -0,0 +1,34 @@ +/* + * 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.headermutations; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; + +/** A collection of header mutations. */ +@AutoValue +public abstract class HeaderMutations { + + public static HeaderMutations create(ImmutableList headers, + ImmutableList headersToRemove) { + return new AutoValue_HeaderMutations(headers, headersToRemove); + } + + public abstract ImmutableList headers(); + + public abstract ImmutableList headersToRemove(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java new file mode 100644 index 00000000000..ef13590e2c0 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderMutator.java @@ -0,0 +1,116 @@ +/* + * 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.headermutations; + + +import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import java.util.logging.Logger; + +/** + * The HeaderMutator provides methods to apply header mutations to a given set of headers based on a + * given set of rules. + */ +public class HeaderMutator { + + private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName()); + + /** + * Creates a new instance of {@code HeaderMutator}. + */ + public static HeaderMutator create() { + return new HeaderMutator(); + } + + HeaderMutator() {} + + /** + * Applies the given header mutations to the provided metadata headers. + * + * @param mutations The header mutations to apply. + * @param headers The metadata headers to which the mutations will be applied. + */ + public void applyMutations(final HeaderMutations mutations, Metadata headers) { + // TODO(sauravzg): The specification is not clear on order of header removals and additions. + // in case of conflicts. Copying the order from Envoy here, which does removals at the end. + applyHeaderUpdates(mutations.headers(), headers); + for (String headerToRemove : mutations.headersToRemove()) { + Metadata.Key key = headerToRemove.endsWith(Metadata.BINARY_HEADER_SUFFIX) + ? Metadata.Key.of(headerToRemove, Metadata.BINARY_BYTE_MARSHALLER) + : Metadata.Key.of(headerToRemove, Metadata.ASCII_STRING_MARSHALLER); + headers.discardAll(key); + } + } + + private void applyHeaderUpdates(final Iterable headerOptions, + Metadata headers) { + for (HeaderValueOption headerOption : headerOptions) { + updateHeader(headerOption, headers); + } + } + + private void updateHeader(final HeaderValueOption option, Metadata mutableHeaders) { + HeaderValue header = option.header(); + HeaderAppendAction action = option.appendAction(); + + if (header.key().endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + if (header.rawValue().isPresent()) { + Metadata.Key key = Metadata.Key.of(header.key(), Metadata.BINARY_BYTE_MARSHALLER); + updateHeader(action, key, header.rawValue().get().toByteArray(), mutableHeaders); + } else { + logger.fine("Missing binary rawValue for header: " + header.key()); + } + } else { + if (header.value().isPresent()) { + Metadata.Key key = Metadata.Key.of(header.key(), Metadata.ASCII_STRING_MARSHALLER); + updateHeader(action, key, header.value().get(), mutableHeaders); + } else { + logger.fine("Missing value for header: " + header.key()); + } + } + } + + private void updateHeader(final HeaderAppendAction action, final Metadata.Key key, + final T value, Metadata mutableHeaders) { + switch (action) { + case APPEND_IF_EXISTS_OR_ADD: + mutableHeaders.put(key, value); + break; + case ADD_IF_ABSENT: + if (!mutableHeaders.containsKey(key)) { + mutableHeaders.put(key, value); + } + break; + case OVERWRITE_IF_EXISTS_OR_ADD: + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + break; + case OVERWRITE_IF_EXISTS: + if (mutableHeaders.containsKey(key)) { + mutableHeaders.discardAll(key); + mutableHeaders.put(key, value); + } + break; + + default: + // Should be unreachable unless there's a proto schema mismatch. + logger.fine("Unknown HeaderAppendAction: " + action); + } + } +} + diff --git a/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java new file mode 100644 index 00000000000..e9b58338972 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/headermutations/HeaderValueOption.java @@ -0,0 +1,48 @@ +/* + * 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.headermutations; + +import com.google.auto.value.AutoValue; +import io.grpc.xds.internal.grpcservice.HeaderValue; + +/** + * Represents a header option to be appended or mutated as part of xDS configuration. + * Avoids direct dependency on Envoy's proto objects. + */ +@AutoValue +public abstract class HeaderValueOption { + + public static HeaderValueOption create( + HeaderValue header, HeaderAppendAction appendAction) { + return new AutoValue_HeaderValueOption(header, appendAction); + } + + public abstract HeaderValue header(); + + public abstract HeaderAppendAction appendAction(); + + /** + * Defines the action to take when appending headers. + * Mirrors io.envoyproxy.envoy.config.core.v3.HeaderValueOption.HeaderAppendAction. + */ + public enum HeaderAppendAction { + APPEND_IF_EXISTS_OR_ADD, + ADD_IF_ABSENT, + OVERWRITE_IF_EXISTS_OR_ADD, + OVERWRITE_IF_EXISTS + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java new file mode 100644 index 00000000000..60884af14c5 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelCommon.java @@ -0,0 +1,134 @@ +/* + * 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.matcher; + +import com.google.common.collect.ImmutableSet; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.ast.CelReference; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import dev.cel.runtime.CelStandardFunctions; +import dev.cel.runtime.CelStandardFunctions.StandardFunction; +import dev.cel.runtime.standard.AddOperator.AddOverload; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Shared utilities for CEL-based matchers and extractors. + */ +final class CelCommon { + private static final CelOptions CEL_OPTIONS = CelOptions.newBuilder() + .enableComprehension(false) + .maxRegexProgramSize(100) + .build(); + private static final String REQUEST_VARIABLE = "request"; + private static final CelStandardFunctions FUNCTIONS = + CelStandardFunctions.newBuilder() + .filterFunctions((func, over) -> { + if (func == StandardFunction.STRING) { + return false; + } + if (func == StandardFunction.ADD) { + return !over.equals(AddOverload.ADD_STRING) + && !over.equals(AddOverload.ADD_LIST); + } + return true; + }) + .build(); + + + + private static final ImmutableSet ALLOWED_EXACT_OVERLOAD_IDS = ImmutableSet.of( + "equals", "not_equals", "logical_and", "logical_or", "logical_not"); + + /** + * Regular expression pattern to validate internal CEL overload IDs. + * + *

    Standard CEL operators and conversion functions often have empty names in the + * AST and are identified solely by their overload IDs (e.g., {@code equals} for + * {@code ==}, {@code divide_int64} for {@code /}). + * + *

    This pattern matches allowed overload IDs by their prefixes (e.g., + * {@code divide}, {@code size}), optionally followed by numeric types + * (e.g., {@code int64}) and type-specific suffixes (e.g., {@code _string}, + * {@code _int64}). + */ + private static final Pattern ALLOWED_OVERLOAD_ID_PREFIX_PATTERN = Pattern.compile( + "^(size|matches|contains|startsWith|endsWith|starts_with|ends_with|" + + "timestamp|duration|in|index|has|int|uint|double|string|bytes|bool|" + + "less|less_equals|greater|greater_equals|" + + "add|subtract|multiply|divide|modulo|negate)" + + "[0-9]*(_.*)?$"); + + static final CelRuntime RUNTIME = CelRuntimeFactory.standardCelRuntimeBuilder() + .setStandardEnvironmentEnabled(false) + .setStandardFunctions(FUNCTIONS) + .setOptions(CEL_OPTIONS) + .build(); + + private CelCommon() {} + + /** + * Validates that the AST only references the allowed variable ("request") + * and supported functions as defined in gRFC A106. + */ + static void checkAllowedReferences(CelAbstractSyntaxTree ast) { + for (Map.Entry entry : ast.getReferenceMap().entrySet()) { + CelReference ref = entry.getValue(); + + // Check for variables (where overloadIds is empty) + if (!ref.value().isPresent() && ref.overloadIds().isEmpty()) { + if (!REQUEST_VARIABLE.equals(ref.name())) { + throw new IllegalArgumentException( + "CEL expression references unknown variable: " + ref.name()); + } + } else if (!ref.overloadIds().isEmpty()) { + String name = ref.name(); + if (name.isEmpty()) { + boolean allowed = false; + for (String id : ref.overloadIds()) { + if (id.equals("add_string") || id.equals("add_list") || id.endsWith("_to_string")) { + allowed = false; + break; + } + if (ALLOWED_EXACT_OVERLOAD_IDS.contains(id) + || ALLOWED_OVERLOAD_ID_PREFIX_PATTERN.matcher(id).matches()) { + allowed = true; + break; + } + } + if (!allowed) { + throw new IllegalArgumentException( + "CEL expression references unknown function with overload IDs: " + + ref.overloadIds()); + } + } else { + // Standard conversion functions (like string(x)) are named in the AST. + // We must explicitly reject 'string' here since it's disabled in the environment. + if (name.equals("string")) { + throw new IllegalArgumentException( + "CEL expression references unknown function with overload IDs: " + + ref.overloadIds()); + } + throw new IllegalArgumentException( + "CEL expression references unsupported named function: " + name); + } + } + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java new file mode 100644 index 00000000000..ce07c929abd --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelMatcher.java @@ -0,0 +1,71 @@ +/* + * 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.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelVariableResolver; + +/** + * Executes compiled CEL expressions. + */ +final class CelMatcher { + private final CelRuntime.Program program; + + private CelMatcher(CelRuntime.Program program) { + this.program = program; + } + + /** + * Compiles the AST into a CelMatcher. + * Throws an Exception if evaluation fails during compilation setup. + */ + static CelMatcher compile(CelAbstractSyntaxTree ast) + throws CelEvaluationException { + // CelEvaluationException -> inside cel-runtime -> Allowed in production signatures + // CelValidationException -> inside cel-compiler -> Forbidden in production signatures + if (ast.getResultType() != SimpleType.BOOL) { + throw new IllegalArgumentException( + "CEL expression must evaluate to boolean, got: " + ast.getResultType()); + } + CelCommon.checkAllowedReferences(ast); + CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); + return new CelMatcher(program); + } + + /** + * Evaluates the CEL expression against the input activation. + */ + boolean match(Object input) throws CelEvaluationException { + Object result; + if (input instanceof CelVariableResolver) { + result = program.eval((CelVariableResolver) input); + } else { + throw new CelEvaluationException( + "Unsupported input type for CEL evaluation: " + + (input == null ? "null" : input.getClass().getName())); + } + + if (result instanceof Boolean) { + return (Boolean) result; + } + throw new CelEvaluationException( + "CEL expression must evaluate to boolean, got: " + result.getClass().getName()); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java new file mode 100644 index 00000000000..110ab751da7 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStateMatcher.java @@ -0,0 +1,79 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.v3.CelExpression; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.runtime.CelEvaluationException; + +/** + * Matcher for CEL expressions handling xDS CEL Matcher extension. + */ +final class CelStateMatcher implements Matcher { + private final CelMatcher compiledEndpoint; + static final String TYPE_URL = "type.googleapis.com/xds.type.matcher.v3.CelMatcher"; + + CelStateMatcher(CelMatcher compiledEndpoint) { + this.compiledEndpoint = compiledEndpoint; + } + + @Override + public boolean match(Object value) { + try { + return compiledEndpoint.match(value); + } catch (CelEvaluationException e) { + return false; + } + } + + @Override + public Class inputType() { + return GrpcCelEnvironment.class; + } + + static final class Provider implements MatcherProvider { + @Override + public CelStateMatcher getMatcher(TypedExtensionConfig config) { + try { + com.github.xds.type.matcher.v3.CelMatcher celProto = config.getTypedConfig() + .unpack(com.github.xds.type.matcher.v3.CelMatcher.class); + if (!celProto.hasExprMatch()) { + throw new IllegalArgumentException("CelMatcher must have expr_match"); + } + CelExpression expr = celProto.getExprMatch(); + if (!expr.hasCelExprChecked()) { + throw new IllegalArgumentException("CelMatcher must have cel_expr_checked"); + } + CelAbstractSyntaxTree ast = + CelProtoAbstractSyntaxTree.fromCheckedExpr( + expr.getCelExprChecked()).getAst(); + CelMatcher compiled = CelMatcher.compile(ast); + + return new CelStateMatcher(compiled); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid CelMatcher config", e); + } + } + + @Override + public String typeUrl() { + return TYPE_URL; + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java new file mode 100644 index 00000000000..d1a9ebd82fa --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java @@ -0,0 +1,88 @@ +/* + * 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.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelVariableResolver; +import javax.annotation.Nullable; + +/** + * Executes compiled CEL expressions that extract a string. + */ +final class CelStringExtractor { + private final CelRuntime.Program program; + @Nullable + private final String defaultValue; + + private CelStringExtractor(CelRuntime.Program program, @Nullable String defaultValue) { + this.program = program; + this.defaultValue = defaultValue; + } + + /** + * Compiles the AST into a CelStringExtractor with an optional default value. + * Throws an Exception if evaluation fails during compilation setup. + */ + static CelStringExtractor compile(CelAbstractSyntaxTree ast, @Nullable String defaultValue) + throws CelEvaluationException { + if (ast.getResultType() != SimpleType.STRING && ast.getResultType() != SimpleType.DYN) { + throw new IllegalArgumentException( + "CEL expression must evaluate to string, got: " + ast.getResultType()); + } + CelCommon.checkAllowedReferences(ast); + CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast); + return new CelStringExtractor(program, defaultValue); + } + + /** + * Compiles the AST into a CelStringExtractor with no default value. + * Throws an Exception if evaluation fails during compilation setup. + */ + static CelStringExtractor compile(CelAbstractSyntaxTree ast) + throws CelEvaluationException { + return compile(ast, null); + } + + /** + * Evaluates the CEL expression and returns the string result. + * Returns the default value if the result is not a string or if evaluation + * fails. + */ + String extract(Object input) throws CelEvaluationException { + if (input instanceof CelVariableResolver) { + try { + Object result = program.eval((CelVariableResolver) input); + + if (result instanceof String) { + return (String) result; + } + } catch (CelEvaluationException e) { + if (defaultValue == null) { + throw e; + } + } + } else if (defaultValue == null) { + throw new CelEvaluationException( + "Unsupported input type for CEL evaluation: " + + (input == null ? "null" : input.getClass().getName())); + } + return defaultValue; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java new file mode 100644 index 00000000000..2d90d86d955 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/GrpcCelEnvironment.java @@ -0,0 +1,133 @@ +/* + * 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.matcher; + +import com.google.common.collect.ImmutableSet; +import dev.cel.runtime.CelVariableResolver; +import io.grpc.Metadata; +import java.util.AbstractMap; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * CEL Environment for gRPC xDS matching. + */ +final class GrpcCelEnvironment implements CelVariableResolver { + private final MatchContext context; + + GrpcCelEnvironment(MatchContext context) { + this.context = context; + } + + @Override + public Optional find(String name) { + if (name.equals("request")) { + return Optional.of(new LazyRequestMap(this)); + } + return Optional.empty(); + } + + @Nullable + private Object getRequestField(String requestField) { + switch (requestField) { + case "headers": return new HeadersWrapper(context); + case "host": return orEmpty(context.getHost()); + case "id": return getHeader("x-request-id"); + case "method": return or(context.getMethod(), "POST"); + case "path": + case "url_path": + return orEmpty(context.getPath()); + case "query": return ""; + case "scheme": return ""; + case "protocol": return ""; + case "time": return null; + case "referer": return getHeader("referer"); + case "useragent": return getHeader("user-agent"); + + default: + return null; + } + } + + private String getHeader(String key) { + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return ""; + } + return String.join(",", values); + } + + private static String orEmpty(@Nullable String s) { + return s == null ? "" : s; + } + + private static String or(@Nullable String s, String def) { + return s == null ? def : s; + } + + private static final class LazyRequestMap extends AbstractMap { + private static final Set KNOWN_KEYS = ImmutableSet.of( + "headers", "host", "id", "method", "path", "url_path", "query", "scheme", "protocol", + "referer", "useragent", "time"); + private final GrpcCelEnvironment resolver; + + LazyRequestMap(GrpcCelEnvironment resolver) { + this.resolver = resolver; + } + + @Override + public Object get(Object key) { + if (key instanceof String) { + Object val = resolver.getRequestField((String) key); + if (val == null) { + return null; + } + return val; + } + return null; + } + + @Override + public boolean containsKey(Object key) { + boolean contains = key instanceof String && KNOWN_KEYS.contains(key); + return contains; + } + + @Override + public Set keySet() { + return KNOWN_KEYS; + } + + @Override + public int size() { + return KNOWN_KEYS.size(); + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public Set> entrySet() { + throw new UnsupportedOperationException("LazyRequestMap does not support entrySet"); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java new file mode 100644 index 00000000000..2dc6cbfea02 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeaderMatchInput.java @@ -0,0 +1,114 @@ +/* + * 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.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.InvalidProtocolBufferException; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import java.util.Locale; + +/** + * MatchInput for extracting HTTP headers. + */ +final class HeaderMatchInput implements MatchInput { + private static final BaseEncoding BASE64 = BaseEncoding.base64(); + private final String headerName; + private final Metadata.Key binaryKey; + private final Metadata.Key stringKey; + + static final String TYPE_URL = + "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; + + HeaderMatchInput(String headerName) { + this.headerName = checkNotNull(headerName, "headerName"); + if (headerName.isEmpty() || headerName.length() >= 16384) { + throw new IllegalArgumentException( + "Header name length must be in range [1, 16384): " + headerName.length()); + } + if (!headerName.equals(headerName.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Header name must be lowercase: " + headerName); + } + try { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + this.binaryKey = Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER); + this.stringKey = null; + } else { + this.binaryKey = null; + this.stringKey = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid header name: " + headerName, e); + } + } + + @Override + public String apply(MatchContext context) { + if ("te".equals(headerName)) { + return null; + } + if (binaryKey != null) { + Iterable values = context.getMetadata().getAll(binaryKey); + if (values == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (byte[] value : values) { + if (!first) { + sb.append(","); + } + first = false; + sb.append(BASE64.encode(value)); + } + return sb.toString(); + } + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll(stringKey); + if (values == null) { + return null; + } + return String.join(",", values); + } + + @Override + public Class outputType() { + return String.class; + } + + static final class Provider implements MatchInputProvider { + @Override + public HeaderMatchInput getInput(TypedExtensionConfig config) { + try { + HttpRequestHeaderMatchInput proto = config.getTypedConfig() + .unpack(HttpRequestHeaderMatchInput.class); + return new HeaderMatchInput(proto.getHeaderName()); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException( + "Invalid input config: " + config.getTypedConfig().getTypeUrl(), e); + } + } + + @Override + public String typeUrl() { + return TYPE_URL; + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java new file mode 100644 index 00000000000..8c0f4e31392 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HeadersWrapper.java @@ -0,0 +1,172 @@ +/* + * 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.matcher; + +import com.google.common.collect.ImmutableSet; +import com.google.common.io.BaseEncoding; +import com.google.errorprone.annotations.DoNotCall; +import io.grpc.Metadata; +import java.util.AbstractMap; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * A Map view over Metadata and MatchContext for CEL attribute resolution. + * Supports efficient lookup of headers and pseudo-headers without unnecessary copying. + */ +final class HeadersWrapper extends AbstractMap { + private static final ImmutableSet PSEUDO_HEADERS = + ImmutableSet.of(":method", ":authority", ":path"); + private final MatchContext context; + + HeadersWrapper(MatchContext context) { + this.context = context; + } + + @Override + @Nullable + public String get(Object key) { + if (!(key instanceof String)) { + return null; + } + String headerName = ((String) key).toLowerCase(java.util.Locale.ROOT); + // The "te" header is a hop-by-hop header used for protocol signaling (trailers). + // Per gRFC A41, it must be treated as not present to prevent matching logic + // from depending on transport-level semantics. + if (headerName.equals("te")) { + return null; + } + switch (headerName) { + case ":method": return context.getMethod(); + case ":authority": return context.getHost(); + case "host": return context.getHost(); + case ":path": return context.getPath(); + default: return getHeader(headerName); + } + } + + @Nullable + private String getHeader(String headerName) { + try { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable values = context.getMetadata().getAll( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + if (values == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (byte[] value : values) { + if (!first) { + sb.append(","); + } + first = false; + sb.append(BaseEncoding.base64().omitPadding().encode(value)); + } + return sb.toString(); + } + Metadata metadata = context.getMetadata(); + Iterable values = metadata.getAll( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + if (values == null) { + return null; + } + return String.join(",", values); + } catch (IllegalArgumentException e) { + // Catching IllegalArgumentException and returning null matches Envoy's behavior + // (returning an empty optional for invalid header names). + return null; + } + } + + @Override + public boolean containsKey(Object key) { + if (!(key instanceof String)) { + return false; + } + String headerName = ((String) key).toLowerCase(java.util.Locale.ROOT); + if (headerName.equals("te")) { + return false; + } + if (PSEUDO_HEADERS.contains(headerName) || headerName.equals("host")) { + return true; + } + try { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + return context.getMetadata().containsKey( + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER)); + } + return context.getMetadata().containsKey( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + } catch (IllegalArgumentException e) { + return false; + } + } + + private boolean isMetadataKeyResolvable(String headerName) { + try { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER); + } else { + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + } + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + @Override + public Set keySet() { + ImmutableSet.Builder builder = ImmutableSet.builder(); + for (String key : context.getMetadata().keys()) { + String lowerKey = key.toLowerCase(java.util.Locale.ROOT); + // Filter out any keys we provide specialized aliases/values for. + if (!lowerKey.equals("te") + && !lowerKey.equals("host") + && !PSEUDO_HEADERS.contains(lowerKey) + && isMetadataKeyResolvable(lowerKey)) { + builder.add(key); + } + } + builder.addAll(PSEUDO_HEADERS); + builder.add("host"); + return builder.build(); + } + + @Override + public int size() { + int count = 0; + for (String key : context.getMetadata().keys()) { + String lowerKey = key.toLowerCase(java.util.Locale.ROOT); + if (!lowerKey.equals("te") + && !lowerKey.equals("host") + && !PSEUDO_HEADERS.contains(lowerKey) + && isMetadataKeyResolvable(lowerKey)) { + count++; + } + } + return count + PSEUDO_HEADERS.size() + 1; // +1 for "host" alias + } + + @Override + @DoNotCall("Always throws UnsupportedOperationException") + public Set> entrySet() { + throw new UnsupportedOperationException( + "Should not be called to prevent resolving all header values."); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java new file mode 100644 index 00000000000..e497590b547 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/HttpAttributesCelMatchInput.java @@ -0,0 +1,52 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; + +/** + * MatchInput for extracting CEL environment from HTTP attributes. + */ +final class HttpAttributesCelMatchInput implements MatchInput { + static final HttpAttributesCelMatchInput INSTANCE = new HttpAttributesCelMatchInput(); + static final String TYPE_URL = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + private HttpAttributesCelMatchInput() {} + + @Override + public GrpcCelEnvironment apply(MatchContext context) { + return new GrpcCelEnvironment(context); + } + + @Override + public Class outputType() { + return GrpcCelEnvironment.class; + } + + static final class Provider implements MatchInputProvider { + @Override + public HttpAttributesCelMatchInput getInput(TypedExtensionConfig config) { + return INSTANCE; + } + + @Override + public String typeUrl() { + return TYPE_URL; + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java new file mode 100644 index 00000000000..de43b21076a --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchContext.java @@ -0,0 +1,99 @@ +/* + * 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.matcher; + +import com.google.common.base.Preconditions; +import io.grpc.Metadata; + +final class MatchContext { + private final Metadata metadata; + private final String path; + private final String host; + private final String method; + private final String id; + + MatchContext(Metadata metadata, String path, + String host, String method, + String id) { + this.metadata = Preconditions.checkNotNull(metadata, "metadata"); + this.path = path; + this.host = host; + this.method = method; + this.id = id; + } + + Metadata getMetadata() { + return metadata; + } + + String getPath() { + return path; + } + + String getHost() { + return host; + } + + String getMethod() { + return method; + } + + String getId() { + return id; + } + + static Builder newBuilder() { + return new Builder(); + } + + static final class Builder { + private Metadata metadata = new Metadata(); + private String path; + private String host; + private String method; + private String id; + + Builder setMetadata(Metadata metadata) { + this.metadata = metadata; + return this; + } + + Builder setPath(String path) { + this.path = path; + return this; + } + + Builder setHost(String host) { + this.host = host; + return this; + } + + Builder setMethod(String method) { + this.method = method; + return this; + } + + Builder setId(String id) { + this.id = id; + return this; + } + + MatchContext build() { + return new MatchContext(metadata, path, host, method, id); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java new file mode 100644 index 00000000000..54f7e5adc6f --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInput.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021 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.matcher; + +import javax.annotation.Nullable; + +/** + * Interface for extracting values from a match context (e.g. HTTP headers). + */ +interface MatchInput { + /** + * Extracts the value from the context. + * @param context the context (e.g. Metadata, Attributes) + * @return the extracted value, or null if not found. + */ + @Nullable + Object apply(MatchContext context); + + /** + * Returns the type of value extracted by this input. + */ + Class outputType(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java new file mode 100644 index 00000000000..c1c2af5e85b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputProvider.java @@ -0,0 +1,34 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; + +/** + * Provider interface for creating {@link MatchInput} instances. + */ +interface MatchInputProvider { + /** + * Returns the corresponding {@link MatchInput} for the given config. + */ + MatchInput getInput(TypedExtensionConfig config); + + /** + * Returns the type URL supported by this provider. + */ + String typeUrl(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java new file mode 100644 index 00000000000..7b4f64fd97e --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchInputRegistry.java @@ -0,0 +1,61 @@ +/* + * 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.matcher; + +import com.google.common.annotations.VisibleForTesting; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Registry for {@link MatchInputProvider}s. + */ +final class MatchInputRegistry { + private static MatchInputRegistry instance; + + private final Map providers = new HashMap<>(); + + private MatchInputRegistry() {} + + static synchronized MatchInputRegistry getDefaultRegistry() { + if (instance == null) { + instance = newRegistry().register( + new HeaderMatchInput.Provider(), + new HttpAttributesCelMatchInput.Provider() + ); + } + return instance; + } + + @VisibleForTesting + static MatchInputRegistry newRegistry() { + return new MatchInputRegistry(); + } + + @VisibleForTesting + MatchInputRegistry register(MatchInputProvider... inputProviders) { + for (MatchInputProvider provider : inputProviders) { + providers.put(provider.typeUrl(), provider); + } + return this; + } + + @Nullable + MatchInputProvider getProvider(String typeUrl) { + return providers.get(typeUrl); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java new file mode 100644 index 00000000000..004988095e1 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatchResult.java @@ -0,0 +1,66 @@ +/* + * 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.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.github.xds.core.v3.TypedExtensionConfig; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Result of a matching operation. + */ +final class MatchResult { + final List actions; + final boolean matched; + + private MatchResult( + List actions, + boolean matched) { + this.actions = + Collections.unmodifiableList( + new ArrayList<>(checkNotNull(actions, "actions"))); + this.matched = matched; + } + + /** + * Creates a result indicating a successful match with a terminal action. + */ + static MatchResult create(List actions) { + return new MatchResult(actions, true); + } + + /** + * Creates a result indicating a match with a terminal action and no accumulated actions. + */ + static MatchResult create(TypedExtensionConfig action) { + return new MatchResult(Collections.singletonList(action), true); + } + + /** + * Creates a result indicating no terminal match, but potentially with accumulated actions. + */ + static MatchResult noMatch(List actions) { + return new MatchResult(actions, false); + } + + static MatchResult noMatch() { + return new MatchResult(Collections.emptyList(), false); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java new file mode 100644 index 00000000000..b31929e3ce9 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/Matcher.java @@ -0,0 +1,34 @@ +/* + * 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.matcher; + +/** + * Interface that defines a matcher that can match a given value. + */ +interface Matcher { + /** + * Returns true if the value matches the matcher. + */ + boolean match(Object value); + + /** + * Returns the type of value accepted by this matcher. + */ + default Class inputType() { + return Object.class; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java new file mode 100644 index 00000000000..6ee14cca2e2 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherList.java @@ -0,0 +1,87 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import javax.annotation.Nullable; + +final class MatcherList extends UnifiedMatcher { + private final List matchers; + @Nullable + private final OnMatch onNoMatch; + + MatcherList(Matcher.MatcherList proto, @Nullable Matcher.OnMatch onNoMatchProto, + Predicate actionValidator) { + if (proto.getMatchersCount() == 0) { + throw new IllegalArgumentException("MatcherList must contain at least one FieldMatcher"); + } + this.matchers = new ArrayList<>(proto.getMatchersCount()); + for (Matcher.MatcherList.FieldMatcher fm : proto.getMatchersList()) { + matchers.add(new FieldMatcher(fm, actionValidator)); + } + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + MatchResult match(MatchContext context) { + List accumulated = new ArrayList<>(); + for (FieldMatcher matcher : matchers) { + if (matcher.matches(context)) { + MatchResult result = matcher.onMatch.evaluate(context); + accumulated.addAll(result.actions); + + if (result.matched) { + if (!matcher.onMatch.keepMatching) { + return MatchResult.create(accumulated); + } + } + } + } + + if (onNoMatch != null) { + MatchResult noMatchResult = onNoMatch.evaluate(context); + accumulated.addAll(noMatchResult.actions); + if (noMatchResult.matched) { + return MatchResult.create(accumulated); + } + } + return MatchResult.noMatch(accumulated); + } + + private static final class FieldMatcher { + private final PredicateEvaluator predicate; + private final OnMatch onMatch; + + FieldMatcher(Matcher.MatcherList.FieldMatcher proto, + Predicate actionValidator) { + this.predicate = PredicateEvaluator.fromProto(proto.getPredicate()); + this.onMatch = new OnMatch(proto.getOnMatch(), actionValidator); + } + + boolean matches(MatchContext context) { + return predicate.evaluate(context); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java new file mode 100644 index 00000000000..8364f61cf4d --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherProvider.java @@ -0,0 +1,34 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; + +/** + * Provider interface for creating {@link Matcher} instances. + */ +interface MatcherProvider { + /** + * Returns the corresponding {@link Matcher} for the given config. + */ + Matcher getMatcher(TypedExtensionConfig config); + + /** + * Returns the type URL supported by this provider. + */ + String typeUrl(); +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java new file mode 100644 index 00000000000..51712a44b28 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRegistry.java @@ -0,0 +1,60 @@ +/* + * 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.matcher; + +import com.google.common.annotations.VisibleForTesting; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Registry for {@link MatcherProvider}. + */ +final class MatcherRegistry { + private static MatcherRegistry instance; + + private final Map matcherProviders = new HashMap<>(); + + private MatcherRegistry() { + } + + static synchronized MatcherRegistry getDefaultRegistry() { + if (instance == null) { + instance = newRegistry().register( + new CelStateMatcher.Provider()); + } + return instance; + } + + @VisibleForTesting + static MatcherRegistry newRegistry() { + return new MatcherRegistry(); + } + + @VisibleForTesting + MatcherRegistry register(MatcherProvider... providers) { + for (MatcherProvider provider : providers) { + matcherProviders.put(provider.typeUrl(), provider); + } + return this; + } + + @Nullable + MatcherProvider getMatcherProvider(String typeUrl) { + return matcherProviders.get(typeUrl); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java new file mode 100644 index 00000000000..622ad4c4653 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherRunner.java @@ -0,0 +1,42 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import java.util.List; +import javax.annotation.Nullable; + +/** + * Executes a UnifiedMatcher against a request. + */ +final class MatcherRunner { + private MatcherRunner() {} + + /** + * runs the matcher. + */ + @Nullable + static List checkMatch( + UnifiedMatcher matcher, MatchContext context) { + MatchResult result = matcher.match(context); + if (!result.matched || result.actions.isEmpty()) { + return null; + } + return result.actions; + } + +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java new file mode 100644 index 00000000000..0852bf2a465 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/MatcherTree.java @@ -0,0 +1,190 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import javax.annotation.Nullable; + +final class MatcherTree extends UnifiedMatcher { + private static final String TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + private final MatchInput input; + @Nullable + private final Map exactMatchMap; + @Nullable + private final PrefixTrie prefixTrie; + @Nullable + private final OnMatch onNoMatch; + + MatcherTree(Matcher.MatcherTree proto, @Nullable Matcher.OnMatch onNoMatchProto, + Predicate actionValidator) { + if (!proto.hasInput()) { + throw new IllegalArgumentException("MatcherTree must have input"); + } + if (proto.getInput().getTypedConfig().getTypeUrl() + .equals(TYPE_URL_HTTP_ATTRIBUTES_CEL_INPUT)) { + throw new IllegalArgumentException( + "HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + + if (proto.hasCustomMatch()) { + throw new IllegalArgumentException("MatcherTree does not support custom_match"); + } + + this.input = UnifiedMatcher.resolveInput(proto.getInput()); + + if (proto.hasExactMatchMap()) { + Matcher.MatcherTree.MatchMap matchMap = proto.getExactMatchMap(); + if (matchMap.getMapCount() == 0) { + throw new IllegalArgumentException( + "MatcherTree exact_match_map must contain at least one entry"); + } + this.exactMatchMap = new HashMap<>(); + for (Map.Entry entry : + matchMap.getMapMap().entrySet()) { + this.exactMatchMap.put(entry.getKey(), + new OnMatch(entry.getValue(), actionValidator)); + } + this.prefixTrie = null; + } else if (proto.hasPrefixMatchMap()) { + Matcher.MatcherTree.MatchMap matchMap = proto.getPrefixMatchMap(); + if (matchMap.getMapCount() == 0) { + throw new IllegalArgumentException( + "MatcherTree prefix_match_map must contain at least one entry"); + } + this.prefixTrie = new PrefixTrie(); + for (Map.Entry entry : + matchMap.getMapMap().entrySet()) { + this.prefixTrie.insert(entry.getKey(), + new OnMatch(entry.getValue(), actionValidator)); + } + this.exactMatchMap = null; + } else { + throw new IllegalArgumentException( + "MatcherTree must have either exact_match_map or prefix_match_map"); + } + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + MatchResult match(MatchContext context) { + + Object valueObj = input.apply(context); + if (!(valueObj instanceof String)) { + return onNoMatch != null ? onNoMatch.evaluate(context) : MatchResult.noMatch(); + } + String value = (String) valueObj; + if (exactMatchMap != null) { + return matchExact(value, context); + } + return matchPrefix(value, context); + } + + private MatchResult matchExact(String value, MatchContext context) { + OnMatch match = exactMatchMap.get(value); + if (match != null) { + MatchResult result = match.evaluate(context); + + List accumulated = new ArrayList<>(result.actions); + + if (result.matched && !match.keepMatching) { + return MatchResult.create(accumulated); + } + + return MatchResult.noMatch(accumulated); + } + return onNoMatch != null ? onNoMatch.evaluate(context) : MatchResult.noMatch(); + } + + private MatchResult matchPrefix(String value, MatchContext context) { + List matchingPrefixes = prefixTrie.matchPrefixes(value); + + if (matchingPrefixes.isEmpty()) { + return onNoMatch != null ? onNoMatch.evaluate(context) : MatchResult.noMatch(); + } + + List accumulatedActions = new ArrayList<>(); + + for (OnMatch onMatch : matchingPrefixes) { + MatchResult result = onMatch.evaluate(context); + accumulatedActions.addAll(result.actions); + + if (result.matched && !onMatch.keepMatching) { + return MatchResult.create(accumulatedActions); + } + } + + return MatchResult.noMatch(accumulatedActions); + } + + private static final class PrefixTrie { + private final TrieNode root = new TrieNode(); + + void insert(String prefix, OnMatch onMatch) { + TrieNode current = root; + for (int i = 0; i < prefix.length(); i++) { + char c = prefix.charAt(i); + TrieNode child = current.children.get(c); + if (child == null) { + child = new TrieNode(); + current.children.put(c, child); + } + current = child; + } + current.onMatch = onMatch; + } + + List matchPrefixes(String value) { + List matchingPrefixes = new ArrayList<>(); + TrieNode current = root; + if (current.onMatch != null) { + matchingPrefixes.add(current.onMatch); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + current = current.children.get(c); + if (current == null) { + break; + } + if (current.onMatch != null) { + matchingPrefixes.add(current.onMatch); + } + } + + // Evaluate longest matching prefix first + Collections.reverse(matchingPrefixes); + return matchingPrefixes; + } + + private static final class TrieNode { + final Map children = new HashMap<>(); + @Nullable + OnMatch onMatch; + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java new file mode 100644 index 00000000000..9ebe207260b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/OnMatch.java @@ -0,0 +1,55 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import java.util.function.Predicate; +import javax.annotation.Nullable; + +/** + * Handles the action to take upon a match (recurse or return action). + */ +final class OnMatch { + @Nullable private final UnifiedMatcher nestedMatcher; + @Nullable private final TypedExtensionConfig action; + final boolean keepMatching; + + OnMatch(Matcher.OnMatch proto, Predicate actionValidator) { + this.keepMatching = proto.getKeepMatching(); + if (proto.hasMatcher()) { + this.nestedMatcher = UnifiedMatcher.fromProto(proto.getMatcher(), actionValidator); + this.action = null; + } else if (proto.hasAction()) { + this.nestedMatcher = null; + this.action = proto.getAction(); + String typeUrl = this.action.getTypedConfig().getTypeUrl(); + if (!actionValidator.test(typeUrl)) { + throw new IllegalArgumentException("Unsupported action type: " + typeUrl); + } + } else { + throw new IllegalArgumentException("OnMatch must have either matcher or action"); + } + } + + MatchResult evaluate(MatchContext context) { + if (nestedMatcher != null) { + return nestedMatcher.match(context); + } + return MatchResult.create(action); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java new file mode 100644 index 00000000000..a5091b57ad0 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/PredicateEvaluator.java @@ -0,0 +1,174 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher.MatcherList.Predicate; +import io.grpc.xds.internal.MatcherParser; +import io.grpc.xds.internal.Matchers; +import java.util.ArrayList; +import java.util.List; + +abstract class PredicateEvaluator { + abstract boolean evaluate(MatchContext context); + + static PredicateEvaluator fromProto(Predicate proto) { + switch (proto.getMatchTypeCase()) { + case SINGLE_PREDICATE: + return new SinglePredicateEvaluator(proto.getSinglePredicate()); + case OR_MATCHER: + return new OrMatcherEvaluator(proto.getOrMatcher()); + case AND_MATCHER: + return new AndMatcherEvaluator(proto.getAndMatcher()); + case NOT_MATCHER: + return new NotMatcherEvaluator(proto.getNotMatcher()); + case MATCHTYPE_NOT_SET: + default: + throw new IllegalArgumentException( + "Predicate must have one of: single_predicate, or_matcher, and_matcher, not_matcher"); + } + } + + private static final class SinglePredicateEvaluator extends PredicateEvaluator { + private final MatchInput input; + private final Matcher matcher; + + SinglePredicateEvaluator(Predicate.SinglePredicate proto) { + if (!proto.hasInput()) { + throw new IllegalArgumentException("SinglePredicate must have input"); + } + this.input = UnifiedMatcher.resolveInput(proto.getInput()); + + if (proto.hasValueMatch()) { + Matchers.StringMatcher stringMatcher = + MatcherParser.parseStringMatcher(proto.getValueMatch()); + this.matcher = new StringMatcherAdapter(stringMatcher); + } else if (proto.hasCustomMatch()) { + TypedExtensionConfig customConfig = proto.getCustomMatch(); + MatcherProvider provider = MatcherRegistry.getDefaultRegistry() + .getMatcherProvider(customConfig.getTypedConfig().getTypeUrl()); + if (provider == null) { + throw new IllegalArgumentException("Unsupported custom_match matcher: " + + customConfig.getTypedConfig().getTypeUrl()); + } + this.matcher = provider.getMatcher(customConfig); + } else { + throw new IllegalArgumentException( + "SinglePredicate must have either value_match or custom_match"); + } + + if (!input.outputType().equals(matcher.inputType())) { + throw new IllegalArgumentException("Type mismatch: input " + input.outputType().getName() + + " not compatible with matcher " + matcher.inputType().getName()); + } + } + + @Override + boolean evaluate(MatchContext context) { + return matcher.match(input.apply(context)); + } + + + + private static final class StringMatcherAdapter implements Matcher { + private final Matchers.StringMatcher stringMatcher; + + StringMatcherAdapter(Matchers.StringMatcher stringMatcher) { + this.stringMatcher = stringMatcher; + } + + @Override + public boolean match(Object value) { + if (value == null) { + return false; + } + if (!(value instanceof String)) { + throw new IllegalArgumentException( + "StringMatcher expected a String input, but received: " + + value.getClass().getName()); + } + return stringMatcher.matches((String) value); + } + + @Override + public Class inputType() { + return String.class; + } + } + } + + private static final class OrMatcherEvaluator extends PredicateEvaluator { + private final List evaluators; + + OrMatcherEvaluator(Predicate.PredicateList proto) { + if (proto.getPredicateCount() < 2) { + throw new IllegalArgumentException("OrMatcher must have at least 2 predicates"); + } + this.evaluators = new ArrayList<>(proto.getPredicateCount()); + for (Predicate p : proto.getPredicateList()) { + evaluators.add(PredicateEvaluator.fromProto(p)); + } + } + + @Override + boolean evaluate(MatchContext context) { + for (PredicateEvaluator e : evaluators) { + if (e.evaluate(context)) { + return true; + } + } + return false; + } + } + + private static final class AndMatcherEvaluator extends PredicateEvaluator { + private final List evaluators; + + AndMatcherEvaluator(Predicate.PredicateList proto) { + if (proto.getPredicateCount() < 2) { + throw new IllegalArgumentException("AndMatcher must have at least 2 predicates"); + } + this.evaluators = new ArrayList<>(proto.getPredicateCount()); + for (Predicate p : proto.getPredicateList()) { + evaluators.add(PredicateEvaluator.fromProto(p)); + } + } + + @Override + boolean evaluate(MatchContext context) { + for (PredicateEvaluator e : evaluators) { + if (!e.evaluate(context)) { + return false; + } + } + return true; + } + } + + private static final class NotMatcherEvaluator extends PredicateEvaluator { + private final PredicateEvaluator evaluator; + + NotMatcherEvaluator(Predicate proto) { + this.evaluator = PredicateEvaluator.fromProto(proto); + } + + @Override + boolean evaluate(MatchContext context) { + return !evaluator.evaluate(context); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java new file mode 100644 index 00000000000..62abec8c0fa --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/matcher/UnifiedMatcher.java @@ -0,0 +1,120 @@ +/* + * 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.matcher; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import java.util.function.Predicate; +import javax.annotation.Nullable; + +/** + * Represents a compiled xDS Matcher. + */ +abstract class UnifiedMatcher { + + static final int MAX_RECURSION_DEPTH = 16; + + abstract MatchResult match(MatchContext context); + + static MatchInput resolveInput(TypedExtensionConfig config) { + String typeUrl = config.getTypedConfig().getTypeUrl(); + MatchInputProvider provider = MatchInputRegistry.getDefaultRegistry().getProvider(typeUrl); + if (provider == null) { + throw new IllegalArgumentException("Unsupported input type: " + typeUrl); + } + return provider.getInput(config); + } + + /** + * Parses a proto Matcher into a UnifiedMatcher. + * + * @param proto the proto matcher + * @param actionValidator a predicate that returns true if the action type URL is supported + */ + static UnifiedMatcher fromProto(Matcher proto, + Predicate actionValidator) { + checkRecursionDepth(proto, 0); + Matcher.OnMatch onNoMatch = proto.hasOnNoMatch() ? proto.getOnNoMatch() : null; + if (proto.hasMatcherList()) { + return new MatcherList(proto.getMatcherList(), onNoMatch, actionValidator); + } else if (proto.hasMatcherTree()) { + return new MatcherTree(proto.getMatcherTree(), onNoMatch, actionValidator); + } + return new NoOpMatcher(onNoMatch, actionValidator); + } + + /** + * Parses a proto Matcher into a UnifiedMatcher, allowing all actions. + */ + static UnifiedMatcher fromProto(Matcher proto) { + return fromProto(proto, (typeUrl) -> true); + } + + private static void checkRecursionDepth(Matcher proto, int currentDepth) { + if (currentDepth > MAX_RECURSION_DEPTH) { + throw new IllegalArgumentException( + "Matcher tree depth exceeds limit of " + MAX_RECURSION_DEPTH); + } + if (proto.hasMatcherList()) { + for (Matcher.MatcherList.FieldMatcher fm : proto.getMatcherList().getMatchersList()) { + if (fm.hasOnMatch() && fm.getOnMatch().hasMatcher()) { + checkRecursionDepth(fm.getOnMatch().getMatcher(), currentDepth + 1); + } + } + } else if (proto.hasMatcherTree()) { + Matcher.MatcherTree tree = proto.getMatcherTree(); + if (tree.hasExactMatchMap()) { + for (Matcher.OnMatch onMatch : tree.getExactMatchMap().getMapMap().values()) { + if (onMatch.hasMatcher()) { + checkRecursionDepth(onMatch.getMatcher(), currentDepth + 1); + } + } + } else if (tree.hasPrefixMatchMap()) { + for (Matcher.OnMatch onMatch : tree.getPrefixMatchMap().getMapMap().values()) { + if (onMatch.hasMatcher()) { + checkRecursionDepth(onMatch.getMatcher(), currentDepth + 1); + } + } + } + } + if (proto.hasOnNoMatch() && proto.getOnNoMatch().hasMatcher()) { + checkRecursionDepth(proto.getOnNoMatch().getMatcher(), currentDepth + 1); + } + } + + private static final class NoOpMatcher extends UnifiedMatcher { + @Nullable + private final OnMatch onNoMatch; + + NoOpMatcher(@Nullable Matcher.OnMatch onNoMatchProto, + Predicate actionValidator) { + if (onNoMatchProto != null) { + this.onNoMatch = new OnMatch(onNoMatchProto, actionValidator); + } else { + this.onNoMatch = null; + } + } + + @Override + MatchResult match(MatchContext context) { + if (onNoMatch != null) { + return onNoMatch.evaluate(context); + } + return MatchResult.noMatch(); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/security/CommonTlsContextUtil.java b/xds/src/main/java/io/grpc/xds/internal/security/CommonTlsContextUtil.java index 50fa18b64f9..bd8a423e683 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/CommonTlsContextUtil.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/CommonTlsContextUtil.java @@ -28,7 +28,10 @@ public static boolean hasCertProviderInstance(CommonTlsContext commonTlsContext) if (commonTlsContext == null) { return false; } + @SuppressWarnings("deprecation") + boolean hasDeprecatedField = commonTlsContext.hasTlsCertificateCertificateProviderInstance(); return commonTlsContext.hasTlsCertificateProviderInstance() + || hasDeprecatedField || hasValidationProviderInstance(commonTlsContext); } @@ -37,9 +40,19 @@ private static boolean hasValidationProviderInstance(CommonTlsContext commonTlsC .hasCaCertificateProviderInstance()) { return true; } - return commonTlsContext.hasCombinedValidationContext() - && commonTlsContext.getCombinedValidationContext().getDefaultValidationContext() - .hasCaCertificateProviderInstance(); + if (commonTlsContext.hasCombinedValidationContext()) { + CommonTlsContext.CombinedCertificateValidationContext combined = + commonTlsContext.getCombinedValidationContext(); + if (combined.hasDefaultValidationContext() + && combined.getDefaultValidationContext().hasCaCertificateProviderInstance()) { + return true; + } + // Check deprecated field (field 4) in CombinedValidationContext + @SuppressWarnings("deprecation") + boolean hasDeprecatedField = combined.hasValidationContextCertificateProviderInstance(); + return hasDeprecatedField; + } + return false; } /** diff --git a/xds/src/main/java/io/grpc/xds/internal/security/DynamicSslContextProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/DynamicSslContextProvider.java index 6bf66d022ff..e7b27cd644a 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/DynamicSslContextProvider.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/DynamicSslContextProvider.java @@ -30,9 +30,11 @@ import java.io.IOException; import java.security.cert.CertStoreException; import java.security.cert.CertificateException; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.List; import javax.annotation.Nullable; +import javax.net.ssl.X509TrustManager; /** Base class for dynamic {@link SslContextProvider}s. */ @Internal @@ -40,7 +42,8 @@ public abstract class DynamicSslContextProvider extends SslContextProvider { protected final List pendingCallbacks = new ArrayList<>(); @Nullable protected final CertificateValidationContext staticCertificateValidationContext; - @Nullable protected SslContext sslContext; + @Nullable protected AbstractMap.SimpleImmutableEntry + sslContextAndTrustManager; protected DynamicSslContextProvider( BaseTlsContext tlsContext, CertificateValidationContext staticCertValidationContext) { @@ -49,15 +52,17 @@ protected DynamicSslContextProvider( } @Nullable - public SslContext getSslContext() { - return sslContext; + public AbstractMap.SimpleImmutableEntry + getSslContextAndTrustManager() { + return sslContextAndTrustManager; } protected abstract CertificateValidationContext generateCertificateValidationContext(); /** Gets a server or client side SslContextBuilder. */ - protected abstract SslContextBuilder getSslContextBuilder( - CertificateValidationContext certificateValidationContext) + protected abstract AbstractMap.SimpleImmutableEntry + getSslContextBuilderAndTrustManager( + CertificateValidationContext certificateValidationContext) throws CertificateException, IOException, CertStoreException; // this gets called only when requested secrets are ready... @@ -65,7 +70,8 @@ protected final void updateSslContext() { try { CertificateValidationContext localCertValidationContext = generateCertificateValidationContext(); - SslContextBuilder sslContextBuilder = getSslContextBuilder(localCertValidationContext); + AbstractMap.SimpleImmutableEntry sslContextBuilderAndTm = + getSslContextBuilderAndTrustManager(localCertValidationContext); CommonTlsContext commonTlsContext = getCommonTlsContext(); if (commonTlsContext != null && commonTlsContext.getAlpnProtocolsCount() > 0) { List alpnList = commonTlsContext.getAlpnProtocolsList(); @@ -75,16 +81,18 @@ protected final void updateSslContext() { ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, alpnList); - sslContextBuilder.applicationProtocolConfig(apn); + sslContextBuilderAndTm.getKey().applicationProtocolConfig(apn); } List pendingCallbacksCopy; - SslContext sslContextCopy; + AbstractMap.SimpleImmutableEntry + sslContextAndExtendedX09TrustManagerCopy; synchronized (pendingCallbacks) { - sslContext = sslContextBuilder.build(); - sslContextCopy = sslContext; + sslContextAndTrustManager = new AbstractMap.SimpleImmutableEntry<>( + sslContextBuilderAndTm.getKey().build(), sslContextBuilderAndTm.getValue()); + sslContextAndExtendedX09TrustManagerCopy = sslContextAndTrustManager; pendingCallbacksCopy = clonePendingCallbacksAndClear(); } - makePendingCallbacks(sslContextCopy, pendingCallbacksCopy); + makePendingCallbacks(sslContextAndExtendedX09TrustManagerCopy, pendingCallbacksCopy); } catch (Exception e) { onError(Status.fromThrowable(e)); throw new RuntimeException(e); @@ -92,12 +100,13 @@ protected final void updateSslContext() { } protected final void callPerformCallback( - Callback callback, final SslContext sslContextCopy) { + Callback callback, + final AbstractMap.SimpleImmutableEntry sslContextAndTmCopy) { performCallback( new SslContextGetter() { @Override - public SslContext get() { - return sslContextCopy; + public AbstractMap.SimpleImmutableEntry get() { + return sslContextAndTmCopy; } }, callback @@ -108,10 +117,10 @@ public SslContext get() { public final void addCallback(Callback callback) { checkNotNull(callback, "callback"); // if there is a computed sslContext just send it - SslContext sslContextCopy = null; + AbstractMap.SimpleImmutableEntry sslContextCopy = null; synchronized (pendingCallbacks) { - if (sslContext != null) { - sslContextCopy = sslContext; + if (sslContextAndTrustManager != null) { + sslContextCopy = sslContextAndTrustManager; } else { pendingCallbacks.add(callback); } @@ -122,9 +131,11 @@ public final void addCallback(Callback callback) { } private final void makePendingCallbacks( - SslContext sslContextCopy, List pendingCallbacksCopy) { + AbstractMap.SimpleImmutableEntry + sslContextAndExtendedX509TrustManagerCopy, + List pendingCallbacksCopy) { for (Callback callback : pendingCallbacksCopy) { - callPerformCallback(callback, sslContextCopy); + callPerformCallback(callback, sslContextAndExtendedX509TrustManagerCopy); } } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/SecurityProtocolNegotiators.java b/xds/src/main/java/io/grpc/xds/internal/security/SecurityProtocolNegotiators.java index c34fab74032..a93299de11c 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/SecurityProtocolNegotiators.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/SecurityProtocolNegotiators.java @@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; import io.grpc.Attributes; import io.grpc.Grpc; import io.grpc.internal.GrpcUtil; @@ -29,6 +30,10 @@ import io.grpc.netty.InternalProtocolNegotiator.ProtocolNegotiator; import io.grpc.netty.InternalProtocolNegotiators; import io.grpc.netty.ProtocolNegotiationEvent; +import io.grpc.xds.EnvoyServerProtoData; +import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; +import io.grpc.xds.internal.XdsInternalAttributes; +import io.grpc.xds.internal.security.trust.CertificateUtils; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerAdapter; import io.netty.channel.ChannelHandlerContext; @@ -36,12 +41,14 @@ import io.netty.handler.ssl.SslContext; import io.netty.util.AsciiString; import java.security.cert.CertStoreException; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; +import javax.net.ssl.X509TrustManager; /** * Provides client and server side gRPC {@link ProtocolNegotiator}s to provide the SSL @@ -60,14 +67,14 @@ private SecurityProtocolNegotiators() { private static final AsciiString SCHEME = AsciiString.of("http"); public static final Attributes.Key - ATTR_SERVER_SSL_CONTEXT_PROVIDER_SUPPLIER = - Attributes.Key.create("io.grpc.xds.internal.security.server.sslContextProviderSupplier"); + ATTR_SERVER_SSL_CONTEXT_PROVIDER_SUPPLIER = + Attributes.Key.create("io.grpc.xds.internal.security.server.sslContextProviderSupplier"); /** Attribute key for SslContextProviderSupplier (used from client) for a subchannel. */ @Grpc.TransportAttr public static final Attributes.Key ATTR_SSL_CONTEXT_PROVIDER_SUPPLIER = - Attributes.Key.create("io.grpc.xds.internal.security.SslContextProviderSupplier"); + Attributes.Key.create("io.grpc.xds.internal.security.SslContextProviderSupplier"); /** * Returns a {@link InternalProtocolNegotiator.ClientFactory}. @@ -142,7 +149,8 @@ public ChannelHandler newHandler(GrpcHttp2ConnectionHandler grpcHandler) { fallbackProtocolNegotiator, "No TLS config and no fallbackProtocolNegotiator!"); return fallbackProtocolNegotiator.newHandler(grpcHandler); } - return new ClientSecurityHandler(grpcHandler, localSslContextProviderSupplier); + return new ClientSecurityHandler(grpcHandler, localSslContextProviderSupplier, + grpcHandler.getEagAttributes().get(XdsInternalAttributes.ATTR_ADDRESS_NAME)); } @Override @@ -185,10 +193,13 @@ static final class ClientSecurityHandler extends InternalProtocolNegotiators.ProtocolNegotiationHandler { private final GrpcHttp2ConnectionHandler grpcHandler; private final SslContextProviderSupplier sslContextProviderSupplier; + private final String sni; + private final boolean autoSniSanValidationDoesNotApply; ClientSecurityHandler( GrpcHttp2ConnectionHandler grpcHandler, - SslContextProviderSupplier sslContextProviderSupplier) { + SslContextProviderSupplier sslContextProviderSupplier, + String endpointHostname) { super( // superclass (InternalProtocolNegotiators.ProtocolNegotiationHandler) expects 'next' // handler but we don't have a next handler _yet_. So we "disable" superclass's behavior @@ -202,6 +213,26 @@ public void handlerAdded(ChannelHandlerContext ctx) throws Exception { checkNotNull(grpcHandler, "grpcHandler"); this.grpcHandler = grpcHandler; this.sslContextProviderSupplier = sslContextProviderSupplier; + EnvoyServerProtoData.BaseTlsContext tlsContext = sslContextProviderSupplier.getTlsContext(); + UpstreamTlsContext upstreamTlsContext = ((UpstreamTlsContext) tlsContext); + + String sniToUse = upstreamTlsContext.getAutoHostSni() + && !Strings.isNullOrEmpty(endpointHostname) + ? endpointHostname : upstreamTlsContext.getSni(); + if (sniToUse.isEmpty()) { + if (CertificateUtils.useChannelAuthorityIfNoSniApplicable) { + sniToUse = grpcHandler.getAuthority(); + } + autoSniSanValidationDoesNotApply = true; + } else { + autoSniSanValidationDoesNotApply = false; + } + sni = sniToUse; + } + + @VisibleForTesting + String getSni() { + return sni; } @Override @@ -213,7 +244,8 @@ protected void handlerAdded0(final ChannelHandlerContext ctx) { new SslContextProvider.Callback(ctx.executor()) { @Override - public void updateSslContext(SslContext sslContext) { + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContextAndTm) { if (ctx.isRemoved()) { return; } @@ -222,7 +254,9 @@ public void updateSslContext(SslContext sslContext) { "ClientSecurityHandler.updateSslContext authority={0}, ctx.name={1}", new Object[]{grpcHandler.getAuthority(), ctx.name()}); ChannelHandler handler = - InternalProtocolNegotiators.tls(sslContext).newHandler(grpcHandler); + InternalProtocolNegotiators.tls( + sslContextAndTm.getKey(), sni, sslContextAndTm.getValue()) + .newHandler(grpcHandler); // Delegate rest of handshake to TLS handler ctx.pipeline().addAfter(ctx.name(), null, handler); @@ -234,8 +268,8 @@ public void updateSslContext(SslContext sslContext) { public void onException(Throwable throwable) { ctx.fireExceptionCaught(throwable); } - } - ); + }, + autoSniSanValidationDoesNotApply); } @Override @@ -356,9 +390,10 @@ protected void handlerAdded0(final ChannelHandlerContext ctx) { new SslContextProvider.Callback(ctx.executor()) { @Override - public void updateSslContext(SslContext sslContext) { - ChannelHandler handler = - InternalProtocolNegotiators.serverTls(sslContext).newHandler(grpcHandler); + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContextAndTm) { + ChannelHandler handler = InternalProtocolNegotiators.serverTls( + sslContextAndTm.getKey()).newHandler(grpcHandler); // Delegate rest of handshake to TLS handler if (!ctx.isRemoved()) { @@ -372,8 +407,8 @@ public void updateSslContext(SslContext sslContext) { public void onException(Throwable throwable) { ctx.fireExceptionCaught(throwable); } - } - ); + }, + false); } } } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/SslContextProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/SslContextProvider.java index a0c4ed37dfb..a5d14f72dc5 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/SslContextProvider.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/SslContextProvider.java @@ -32,7 +32,9 @@ import java.io.IOException; import java.security.cert.CertStoreException; import java.security.cert.CertificateException; +import java.util.AbstractMap; import java.util.concurrent.Executor; +import javax.net.ssl.X509TrustManager; /** * A SslContextProvider is a "container" or provider of SslContext. This is used by gRPC-xds to @@ -57,7 +59,8 @@ protected Callback(Executor executor) { } /** Informs callee of new/updated SslContext. */ - @VisibleForTesting public abstract void updateSslContext(SslContext sslContext); + @VisibleForTesting public abstract void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContext); /** Informs callee of an exception that was generated. */ @VisibleForTesting protected abstract void onException(Throwable throwable); @@ -119,8 +122,9 @@ protected final void performCallback( @Override public void run() { try { - SslContext sslContext = sslContextGetter.get(); - callback.updateSslContext(sslContext); + AbstractMap.SimpleImmutableEntry sslContextAndTm = + sslContextGetter.get(); + callback.updateSslContextAndExtendedX509TrustManager(sslContextAndTm); } catch (Throwable e) { callback.onException(e); } @@ -130,6 +134,6 @@ public void run() { /** Allows implementations to compute or get SslContext. */ protected interface SslContextGetter { - SslContext get() throws Exception; + AbstractMap.SimpleImmutableEntry get() throws Exception; } } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/SslContextProviderSupplier.java b/xds/src/main/java/io/grpc/xds/internal/security/SslContextProviderSupplier.java index 5f629273179..94fc423c202 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/SslContextProviderSupplier.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/SslContextProviderSupplier.java @@ -25,7 +25,9 @@ import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; import io.grpc.xds.TlsContextManager; import io.netty.handler.ssl.SslContext; +import java.util.AbstractMap; import java.util.Objects; +import javax.net.ssl.X509TrustManager; /** * Enables Client or server side to initialize this object with the received {@link BaseTlsContext} @@ -52,22 +54,24 @@ public BaseTlsContext getTlsContext() { } /** Updates SslContext via the passed callback. */ - public synchronized void updateSslContext(final SslContextProvider.Callback callback) { + public synchronized void updateSslContext( + final SslContextProvider.Callback callback, boolean autoSniSanValidationDoesNotApply) { checkNotNull(callback, "callback"); try { if (!shutdown) { if (sslContextProvider == null) { - sslContextProvider = getSslContextProvider(); + sslContextProvider = getSslContextProvider(autoSniSanValidationDoesNotApply); } } // we want to increment the ref-count so call findOrCreate again... - final SslContextProvider toRelease = getSslContextProvider(); + final SslContextProvider toRelease = getSslContextProvider(autoSniSanValidationDoesNotApply); toRelease.addCallback( new SslContextProvider.Callback(callback.getExecutor()) { @Override - public void updateSslContext(SslContext sslContext) { - callback.updateSslContext(sslContext); + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContextAndTm) { + callback.updateSslContextAndExtendedX509TrustManager(sslContextAndTm); releaseSslContextProvider(toRelease); } @@ -95,10 +99,20 @@ private void releaseSslContextProvider(SslContextProvider toRelease) { } } - private SslContextProvider getSslContextProvider() { - return tlsContext instanceof UpstreamTlsContext - ? tlsContextManager.findOrCreateClientSslContextProvider((UpstreamTlsContext) tlsContext) - : tlsContextManager.findOrCreateServerSslContextProvider((DownstreamTlsContext) tlsContext); + private SslContextProvider getSslContextProvider(boolean autoSniSanValidationDoesNotApply) { + if (tlsContext instanceof UpstreamTlsContext) { + UpstreamTlsContext upstreamTlsContext = (UpstreamTlsContext) tlsContext; + if (autoSniSanValidationDoesNotApply && upstreamTlsContext.getAutoSniSanValidation()) { + upstreamTlsContext = new UpstreamTlsContext( + upstreamTlsContext.getCommonTlsContext(), + upstreamTlsContext.getSni(), + upstreamTlsContext.getAutoHostSni(), + false); + } + return tlsContextManager.findOrCreateClientSslContextProvider(upstreamTlsContext); + } + return tlsContextManager.findOrCreateServerSslContextProvider( + (DownstreamTlsContext) tlsContext); } @VisibleForTesting public boolean isShutdown() { diff --git a/xds/src/main/java/io/grpc/xds/internal/security/TlsContextManagerImpl.java b/xds/src/main/java/io/grpc/xds/internal/security/TlsContextManagerImpl.java index 34a8863c52b..f56524d50b7 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/TlsContextManagerImpl.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/TlsContextManagerImpl.java @@ -71,8 +71,6 @@ public SslContextProvider findOrCreateServerSslContextProvider( public SslContextProvider findOrCreateClientSslContextProvider( UpstreamTlsContext upstreamTlsContext) { checkNotNull(upstreamTlsContext, "upstreamTlsContext"); - CommonTlsContext.Builder builder = upstreamTlsContext.getCommonTlsContext().toBuilder(); - upstreamTlsContext = new UpstreamTlsContext(builder.build()); return mapForClients.get(upstreamTlsContext); } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProvider.java index d7c2267c48f..8984efc9435 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProvider.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProvider.java @@ -26,8 +26,11 @@ import io.netty.handler.ssl.SslContextBuilder; import java.security.cert.CertStoreException; import java.security.cert.X509Certificate; +import java.util.AbstractMap; +import java.util.Arrays; import java.util.Map; import javax.annotation.Nullable; +import javax.net.ssl.X509TrustManager; /** A client SslContext provider using CertificateProviderInstance to fetch secrets. */ final class CertProviderClientSslContextProvider extends CertProviderSslContextProvider { @@ -51,28 +54,34 @@ final class CertProviderClientSslContextProvider extends CertProviderSslContextP } @Override - protected final SslContextBuilder getSslContextBuilder( - CertificateValidationContext certificateValidationContextdationContext) - throws CertStoreException { - SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient(); - // Null rootCertInstance implies hasSystemRootCerts because of the check in - // CertProviderClientSslContextProviderFactory. - if (rootCertInstance != null) { - if (savedSpiffeTrustMap != null) { - sslContextBuilder = sslContextBuilder.trustManager( - new XdsTrustManagerFactory( + protected final AbstractMap.SimpleImmutableEntry + getSslContextBuilderAndTrustManager( + CertificateValidationContext certificateValidationContext) + throws CertStoreException { + UpstreamTlsContext upstreamTlsContext = (UpstreamTlsContext) tlsContext; + XdsTrustManagerFactory trustManagerFactory; + if (savedSpiffeTrustMap != null) { + trustManagerFactory = new XdsTrustManagerFactory( savedSpiffeTrustMap, - certificateValidationContextdationContext)); - } else { - sslContextBuilder = sslContextBuilder.trustManager( - new XdsTrustManagerFactory( - savedTrustedRoots.toArray(new X509Certificate[0]), - certificateValidationContextdationContext)); - } + certificateValidationContext, + upstreamTlsContext.getAutoSniSanValidation()); + } else if (savedTrustedRoots != null) { + trustManagerFactory = new XdsTrustManagerFactory( + savedTrustedRoots.toArray(new X509Certificate[0]), + certificateValidationContext, + upstreamTlsContext.getAutoSniSanValidation()); + } else { + // Should be impossible because of the check in CertProviderClientSslContextProviderFactory + throw new IllegalStateException("There must be trusted roots or a SPIFFE trust map"); } + + SslContextBuilder sslContextBuilder = + GrpcSslContexts.forClient().trustManager(trustManagerFactory); if (isMtls()) { sslContextBuilder.keyManager(savedKey, savedCertChain); } - return sslContextBuilder; + return new AbstractMap.SimpleImmutableEntry<>(sslContextBuilder, + io.grpc.internal.CertificateUtils.getX509ExtendedTrustManager( + Arrays.asList(trustManagerFactory.getTrustManagers()))); } } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProvider.java index ef65bbfb6f9..3712b948142 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProvider.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProvider.java @@ -30,8 +30,10 @@ import java.security.cert.CertStoreException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.AbstractMap; import java.util.Map; import javax.annotation.Nullable; +import javax.net.ssl.X509TrustManager; /** A server SslContext provider using CertificateProviderInstance to fetch secrets. */ final class CertProviderServerSslContextProvider extends CertProviderSslContextProvider { @@ -55,23 +57,25 @@ final class CertProviderServerSslContextProvider extends CertProviderSslContextP } @Override - protected final SslContextBuilder getSslContextBuilder( - CertificateValidationContext certificateValidationContextdationContext) - throws CertStoreException, CertificateException, IOException { + protected final AbstractMap.SimpleImmutableEntry + getSslContextBuilderAndTrustManager( + CertificateValidationContext certificateValidationContextdationContext) + throws CertStoreException, CertificateException, IOException { SslContextBuilder sslContextBuilder = SslContextBuilder.forServer(savedKey, savedCertChain); XdsTrustManagerFactory trustManagerFactory = null; if (isMtls() && savedSpiffeTrustMap != null) { trustManagerFactory = new XdsTrustManagerFactory( savedSpiffeTrustMap, - certificateValidationContextdationContext); + certificateValidationContextdationContext, false); } else if (isMtls()) { trustManagerFactory = new XdsTrustManagerFactory( savedTrustedRoots.toArray(new X509Certificate[0]), - certificateValidationContextdationContext); + certificateValidationContextdationContext, false); } setClientAuthValues(sslContextBuilder, trustManagerFactory); sslContextBuilder = GrpcSslContexts.configure(sslContextBuilder); - return sslContextBuilder; + // TrustManager in the below return value is not used on the server side, so setting it to null + return new AbstractMap.SimpleImmutableEntry<>(sslContextBuilder, null); } } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderSslContextProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderSslContextProvider.java index 801dabeecb7..cb99ca6ad95 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderSslContextProvider.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/CertProviderSslContextProvider.java @@ -24,6 +24,7 @@ import io.grpc.xds.client.Bootstrapper.CertificateProviderInfo; import io.grpc.xds.internal.security.CommonTlsContextUtil; import io.grpc.xds.internal.security.DynamicSslContextProvider; +import java.io.Closeable; import java.security.PrivateKey; import java.security.cert.X509Certificate; import java.util.List; @@ -34,8 +35,8 @@ abstract class CertProviderSslContextProvider extends DynamicSslContextProvider implements CertificateProvider.Watcher { - @Nullable private final CertificateProviderStore.Handle certHandle; - @Nullable private final CertificateProviderStore.Handle rootCertHandle; + @Nullable private final NoExceptionCloseable certHandle; + @Nullable private final NoExceptionCloseable rootCertHandle; @Nullable private final CertificateProviderInstance certInstance; @Nullable protected final CertificateProviderInstance rootCertInstance; @Nullable protected PrivateKey savedKey; @@ -55,24 +56,33 @@ protected CertProviderSslContextProvider( super(tlsContext, staticCertValidationContext); this.certInstance = certInstance; this.rootCertInstance = rootCertInstance; - String certInstanceName = null; - if (certInstance != null && certInstance.isInitialized()) { - certInstanceName = certInstance.getInstanceName(); + this.isUsingSystemRootCerts = rootCertInstance == null + && CommonTlsContextUtil.isUsingSystemRootCerts(tlsContext.getCommonTlsContext()); + boolean createCertInstance = certInstance != null && certInstance.isInitialized(); + boolean createRootCertInstance = rootCertInstance != null && rootCertInstance.isInitialized(); + boolean sharedCertInstance = createCertInstance && createRootCertInstance + && rootCertInstance.getInstanceName().equals(certInstance.getInstanceName()); + if (createCertInstance) { CertificateProviderInfo certProviderInstanceConfig = - getCertProviderConfig(certProviders, certInstanceName); + getCertProviderConfig(certProviders, certInstance.getInstanceName()); + CertificateProvider.Watcher watcher = this; + if (!sharedCertInstance && !isUsingSystemRootCerts) { + watcher = new IgnoreUpdatesWatcher(watcher, /* ignoreRootCertUpdates= */ true); + } + // TODO: Previously we'd hang if certProviderInstanceConfig were null or + // certInstance.isInitialized() == false. Now we'll proceed. Those should be errors, or are + // they impossible and should be assertions? certHandle = certProviderInstanceConfig == null ? null : certificateProviderStore.createOrGetProvider( certInstance.getCertificateName(), certProviderInstanceConfig.pluginName(), certProviderInstanceConfig.config(), - this, - true); + watcher, + true)::close; } else { certHandle = null; } - if (rootCertInstance != null - && rootCertInstance.isInitialized() - && !rootCertInstance.getInstanceName().equals(certInstanceName)) { + if (createRootCertInstance && !sharedCertInstance) { CertificateProviderInfo certProviderInstanceConfig = getCertProviderConfig(certProviders, rootCertInstance.getInstanceName()); rootCertHandle = certProviderInstanceConfig == null ? null @@ -80,13 +90,16 @@ protected CertProviderSslContextProvider( rootCertInstance.getCertificateName(), certProviderInstanceConfig.pluginName(), certProviderInstanceConfig.config(), - this, - true); + new IgnoreUpdatesWatcher(this, /* ignoreRootCertUpdates= */ false), + false)::close; + } else if (rootCertInstance == null + && CommonTlsContextUtil.isUsingSystemRootCerts(tlsContext.getCommonTlsContext())) { + SystemRootCertificateProvider systemRootProvider = new SystemRootCertificateProvider(this); + systemRootProvider.start(); + rootCertHandle = systemRootProvider::close; } else { rootCertHandle = null; } - this.isUsingSystemRootCerts = rootCertInstance == null - && CommonTlsContextUtil.isUsingSystemRootCerts(tlsContext.getCommonTlsContext()); } private static CertificateProviderInfo getCertProviderConfig( @@ -100,7 +113,13 @@ protected static CertificateProviderInstance getCertProviderInstance( if (commonTlsContext.hasTlsCertificateProviderInstance()) { return CommonTlsContextUtil.convert(commonTlsContext.getTlsCertificateProviderInstance()); } - return null; + // Fall back to deprecated field for backward compatibility with Istio + @SuppressWarnings("deprecation") + CertificateProviderInstance deprecatedInstance = + commonTlsContext.hasTlsCertificateCertificateProviderInstance() + ? commonTlsContext.getTlsCertificateCertificateProviderInstance() + : null; + return deprecatedInstance; } @Nullable @@ -150,17 +169,16 @@ public final void updateSpiffeTrustMap(Map> spiffe private void updateSslContextWhenReady() { if (isMtls()) { - if (savedKey != null - && (savedTrustedRoots != null || isUsingSystemRootCerts || savedSpiffeTrustMap != null)) { + if (savedKey != null && (savedTrustedRoots != null || savedSpiffeTrustMap != null)) { updateSslContext(); clearKeysAndCerts(); } - } else if (isClientSideTls()) { + } else if (isRegularTlsAndClientSide()) { if (savedTrustedRoots != null || savedSpiffeTrustMap != null) { updateSslContext(); clearKeysAndCerts(); } - } else if (isServerSideTls()) { + } else if (isRegularTlsAndServerSide()) { if (savedKey != null) { updateSslContext(); clearKeysAndCerts(); @@ -170,8 +188,10 @@ private void updateSslContextWhenReady() { private void clearKeysAndCerts() { savedKey = null; - savedTrustedRoots = null; - savedSpiffeTrustMap = null; + if (!isUsingSystemRootCerts) { + savedTrustedRoots = null; + savedSpiffeTrustMap = null; + } savedCertChain = null; } @@ -179,11 +199,11 @@ protected final boolean isMtls() { return certInstance != null && (rootCertInstance != null || isUsingSystemRootCerts); } - protected final boolean isClientSideTls() { - return rootCertInstance != null && certInstance == null; + protected final boolean isRegularTlsAndClientSide() { + return (rootCertInstance != null || isUsingSystemRootCerts) && certInstance == null; } - protected final boolean isServerSideTls() { + protected final boolean isRegularTlsAndServerSide() { return certInstance != null && rootCertInstance == null; } @@ -201,4 +221,9 @@ public final void close() { rootCertHandle.close(); } } + + interface NoExceptionCloseable extends Closeable { + @Override + void close(); + } } diff --git a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProvider.java index 304124cc7f2..9cb9a867118 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProvider.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProvider.java @@ -116,7 +116,7 @@ void checkAndReloadCertificates() { FileTime currentCertTime = Files.getLastModifiedTime(certFile); FileTime currentKeyTime = Files.getLastModifiedTime(keyFile); if (!currentCertTime.equals(lastModifiedTimeCert) - && !currentKeyTime.equals(lastModifiedTimeKey)) { + || !currentKeyTime.equals(lastModifiedTimeKey)) { byte[] certFileContents = Files.readAllBytes(certFile); byte[] keyFileContents = Files.readAllBytes(keyFile); FileTime currentCertTime2 = Files.getLastModifiedTime(certFile); diff --git a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/IgnoreUpdatesWatcher.java b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/IgnoreUpdatesWatcher.java new file mode 100644 index 00000000000..cd9d88be41b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/IgnoreUpdatesWatcher.java @@ -0,0 +1,68 @@ +/* + * 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.security.certprovider; + +import static java.util.Objects.requireNonNull; + +import com.google.common.annotations.VisibleForTesting; +import io.grpc.Status; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +public final class IgnoreUpdatesWatcher implements CertificateProvider.Watcher { + private final CertificateProvider.Watcher delegate; + private final boolean ignoreRootCertUpdates; + + public IgnoreUpdatesWatcher( + CertificateProvider.Watcher delegate, boolean ignoreRootCertUpdates) { + this.delegate = requireNonNull(delegate, "delegate"); + this.ignoreRootCertUpdates = ignoreRootCertUpdates; + } + + @Override + public void updateCertificate(PrivateKey key, List certChain) { + if (ignoreRootCertUpdates) { + delegate.updateCertificate(key, certChain); + } + } + + @Override + public void updateTrustedRoots(List trustedRoots) { + if (!ignoreRootCertUpdates) { + delegate.updateTrustedRoots(trustedRoots); + } + } + + @Override + public void updateSpiffeTrustMap(Map> spiffeTrustMap) { + if (!ignoreRootCertUpdates) { + delegate.updateSpiffeTrustMap(spiffeTrustMap); + } + } + + @Override + public void onError(Status errorStatus) { + delegate.onError(errorStatus); + } + + @VisibleForTesting + public CertificateProvider.Watcher getDelegate() { + return delegate; + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/security/certprovider/SystemRootCertificateProvider.java b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/SystemRootCertificateProvider.java new file mode 100644 index 00000000000..7c60f714e71 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/security/certprovider/SystemRootCertificateProvider.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020 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.security.certprovider; + +import io.grpc.Status; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * An non-registered provider for CertProviderSslContextProvider to use the same code path for + * system root certs as provider-obtained certs. + */ +final class SystemRootCertificateProvider extends CertificateProvider { + public SystemRootCertificateProvider(CertificateProvider.Watcher watcher) { + super(new DistributorWatcher(), false); + getWatcher().addWatcher(watcher); + } + + @Override + public void start() { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + + List trustManagers = Arrays.asList(trustManagerFactory.getTrustManagers()); + List rootCerts = trustManagers.stream() + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .map(trustManager -> Arrays.asList(trustManager.getAcceptedIssuers())) + .flatMap(Collection::stream) + .collect(Collectors.toList()); + getWatcher().updateTrustedRoots(rootCerts); + } catch (KeyStoreException | NoSuchAlgorithmException ex) { + getWatcher().onError(Status.UNAVAILABLE + .withDescription("Could not load system root certs") + .withCause(ex)); + } + } + + @Override + public void close() { + // Unnecessary because there's no more callbacks, but do it for good measure + for (Watcher watcher : getWatcher().getDownstreamWatchers()) { + getWatcher().removeWatcher(watcher); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/security/trust/CertificateUtils.java b/xds/src/main/java/io/grpc/xds/internal/security/trust/CertificateUtils.java index 86b6dd95c3e..41a3980c123 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/trust/CertificateUtils.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/trust/CertificateUtils.java @@ -16,6 +16,7 @@ package io.grpc.xds.internal.security.trust; +import io.grpc.internal.GrpcUtil; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; @@ -29,6 +30,9 @@ * Contains certificate utility method(s). */ public final class CertificateUtils { + public static boolean useChannelAuthorityIfNoSniApplicable + = GrpcUtil.getFlag("GRPC_USE_CHANNEL_AUTHORITY_IF_NO_SNI_APPLICABLE", false); + /** * Generates X509Certificate array from a file on disk. * diff --git a/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactory.java b/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactory.java index 8cb44117065..664c5dd9362 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactory.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactory.java @@ -58,43 +58,50 @@ public XdsTrustManagerFactory(CertificateValidationContext certificateValidation this( getTrustedCaFromCertContext(certificateValidationContext), certificateValidationContext, + false, false); } public XdsTrustManagerFactory( - X509Certificate[] certs, CertificateValidationContext staticCertificateValidationContext) - throws CertStoreException { - this(certs, staticCertificateValidationContext, true); + X509Certificate[] certs, CertificateValidationContext staticCertificateValidationContext, + boolean autoSniSanValidation) throws CertStoreException { + this(certs, staticCertificateValidationContext, true, autoSniSanValidation); } public XdsTrustManagerFactory(Map> spiffeTrustMap, - CertificateValidationContext staticCertificateValidationContext) throws CertStoreException { - this(spiffeTrustMap, staticCertificateValidationContext, true); + CertificateValidationContext staticCertificateValidationContext, boolean autoSniSanValidation) + throws CertStoreException { + this(spiffeTrustMap, staticCertificateValidationContext, true, autoSniSanValidation); } private XdsTrustManagerFactory( X509Certificate[] certs, CertificateValidationContext certificateValidationContext, - boolean validationContextIsStatic) + boolean validationContextIsStatic, + boolean autoSniSanValidation) throws CertStoreException { if (validationContextIsStatic) { checkArgument( - certificateValidationContext == null || !certificateValidationContext.hasTrustedCa(), + certificateValidationContext == null || !certificateValidationContext.hasTrustedCa() + || certificateValidationContext.hasSystemRootCerts(), "only static certificateValidationContext expected"); } - xdsX509TrustManager = createX509TrustManager(certs, certificateValidationContext); + xdsX509TrustManager = createX509TrustManager( + certs, certificateValidationContext, autoSniSanValidation); } private XdsTrustManagerFactory( Map> spiffeTrustMap, CertificateValidationContext certificateValidationContext, - boolean validationContextIsStatic) + boolean validationContextIsStatic, + boolean autoSniSanValidation) throws CertStoreException { if (validationContextIsStatic) { checkArgument( certificateValidationContext == null || !certificateValidationContext.hasTrustedCa(), "only static certificateValidationContext expected"); - xdsX509TrustManager = createX509TrustManager(spiffeTrustMap, certificateValidationContext); + xdsX509TrustManager = createX509TrustManager( + spiffeTrustMap, certificateValidationContext, autoSniSanValidation); } } @@ -121,21 +128,24 @@ private static X509Certificate[] getTrustedCaFromCertContext( @VisibleForTesting static XdsX509TrustManager createX509TrustManager( - X509Certificate[] certs, CertificateValidationContext certContext) throws CertStoreException { - return new XdsX509TrustManager(certContext, createTrustManager(certs)); + X509Certificate[] certs, CertificateValidationContext certContext, + boolean autoSniSanValidation) + throws CertStoreException { + return new XdsX509TrustManager(certContext, createTrustManager(certs), autoSniSanValidation); } @VisibleForTesting static XdsX509TrustManager createX509TrustManager( Map> spiffeTrustMapFile, - CertificateValidationContext certContext) throws CertStoreException { + CertificateValidationContext certContext, boolean autoSniSanValidation) + throws CertStoreException { checkNotNull(spiffeTrustMapFile, "spiffeTrustMapFile"); Map delegates = new HashMap<>(); for (Map.Entry> entry:spiffeTrustMapFile.entrySet()) { delegates.put(entry.getKey(), createTrustManager( entry.getValue().toArray(new X509Certificate[0]))); } - return new XdsX509TrustManager(certContext, delegates); + return new XdsX509TrustManager(certContext, delegates, autoSniSanValidation); } private static X509ExtendedTrustManager createTrustManager(X509Certificate[] certs) diff --git a/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsX509TrustManager.java b/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsX509TrustManager.java index 1ecfe378d29..01f25dda6c7 100644 --- a/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsX509TrustManager.java +++ b/xds/src/main/java/io/grpc/xds/internal/security/trust/XdsX509TrustManager.java @@ -31,6 +31,7 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; @@ -39,6 +40,8 @@ import java.util.Map; import java.util.Set; import javax.annotation.Nullable; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; @@ -60,21 +63,26 @@ final class XdsX509TrustManager extends X509ExtendedTrustManager implements X509 private final X509ExtendedTrustManager delegate; private final Map spiffeTrustMapDelegates; private final CertificateValidationContext certContext; + private final boolean autoSniSanValidation; XdsX509TrustManager(@Nullable CertificateValidationContext certContext, - X509ExtendedTrustManager delegate) { + X509ExtendedTrustManager delegate, + boolean autoSniSanValidation) { checkNotNull(delegate, "delegate"); this.certContext = certContext; this.delegate = delegate; this.spiffeTrustMapDelegates = null; + this.autoSniSanValidation = autoSniSanValidation; } XdsX509TrustManager(@Nullable CertificateValidationContext certContext, - Map spiffeTrustMapDelegates) { + Map spiffeTrustMapDelegates, + boolean autoSniSanValidation) { checkNotNull(spiffeTrustMapDelegates, "spiffeTrustMapDelegates"); this.spiffeTrustMapDelegates = ImmutableMap.copyOf(spiffeTrustMapDelegates); this.certContext = certContext; this.delegate = null; + this.autoSniSanValidation = autoSniSanValidation; } private static boolean verifyDnsNameInPattern( @@ -147,6 +155,9 @@ private static boolean verifyDnsNameExact( if (Strings.isNullOrEmpty(sanToVerifyExact)) { return false; } + if (sanToVerifyExact.contains("*")) { + return verifyDnsNameWildcard(altNameFromCert, sanToVerifyExact, ignoreCase); + } return ignoreCase ? sanToVerifyExact.equalsIgnoreCase(altNameFromCert) : sanToVerifyExact.equals(altNameFromCert); @@ -203,12 +214,11 @@ private static void verifySubjectAltNameInLeaf( * This is called from various check*Trusted methods. */ @VisibleForTesting - void verifySubjectAltNameInChain(X509Certificate[] peerCertChain) throws CertificateException { + void verifySubjectAltNameInChain(X509Certificate[] peerCertChain, + List verifyList) throws CertificateException { if (certContext == null) { return; } - @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names - List verifyList = certContext.getMatchSubjectAltNamesList(); if (verifyList.isEmpty()) { return; } @@ -220,29 +230,36 @@ void verifySubjectAltNameInChain(X509Certificate[] peerCertChain) throws Certifi } @Override + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { chooseDelegate(chain).checkClientTrusted(chain, authType, socket); - verifySubjectAltNameInChain(chain); + verifySubjectAltNameInChain(chain, certContext != null + ? certContext.getMatchSubjectAltNamesList() : new ArrayList<>()); } @Override + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine sslEngine) throws CertificateException { chooseDelegate(chain).checkClientTrusted(chain, authType, sslEngine); - verifySubjectAltNameInChain(chain); + verifySubjectAltNameInChain(chain, certContext != null + ? certContext.getMatchSubjectAltNamesList() : new ArrayList<>()); } @Override + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { chooseDelegate(chain).checkClientTrusted(chain, authType); - verifySubjectAltNameInChain(chain); + verifySubjectAltNameInChain(chain, certContext != null + ? certContext.getMatchSubjectAltNamesList() : new ArrayList<>()); } @Override public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { + List sniMatchers = null; if (socket instanceof SSLSocket) { SSLSocket sslSocket = (SSLSocket) socket; SSLParameters sslParams = sslSocket.getSSLParameters(); @@ -250,28 +267,60 @@ public void checkServerTrusted(X509Certificate[] chain, String authType, Socket sslParams.setEndpointIdentificationAlgorithm(""); sslSocket.setSSLParameters(sslParams); } + sniMatchers = getAutoSniSanMatchers(sslParams); + } + if (sniMatchers.isEmpty() && certContext != null) { + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names + List sniMatchersTmp = certContext.getMatchSubjectAltNamesList(); + sniMatchers = sniMatchersTmp; } chooseDelegate(chain).checkServerTrusted(chain, authType, socket); - verifySubjectAltNameInChain(chain); + verifySubjectAltNameInChain(chain, sniMatchers); } @Override public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine sslEngine) throws CertificateException { + List sniMatchers = null; SSLParameters sslParams = sslEngine.getSSLParameters(); if (sslParams != null) { sslParams.setEndpointIdentificationAlgorithm(""); sslEngine.setSSLParameters(sslParams); + sniMatchers = getAutoSniSanMatchers(sslParams); + } + if (sniMatchers.isEmpty() && certContext != null) { + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names + List sniMatchersTmp = certContext.getMatchSubjectAltNamesList(); + sniMatchers = sniMatchersTmp; } chooseDelegate(chain).checkServerTrusted(chain, authType, sslEngine); - verifySubjectAltNameInChain(chain); + verifySubjectAltNameInChain(chain, sniMatchers); } @Override + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { chooseDelegate(chain).checkServerTrusted(chain, authType); - verifySubjectAltNameInChain(chain); + verifySubjectAltNameInChain(chain, certContext != null + ? certContext.getMatchSubjectAltNamesList() : new ArrayList<>()); + } + + private List getAutoSniSanMatchers(SSLParameters sslParams) { + List sniNamesToMatch = new ArrayList<>(); + if (autoSniSanValidation) { + List serverNames = sslParams.getServerNames(); + if (serverNames != null) { + for (SNIServerName serverName : serverNames) { + if (serverName instanceof SNIHostName) { + SNIHostName sniHostName = (SNIHostName) serverName; + String hostName = sniHostName.getAsciiName(); + sniNamesToMatch.add(StringMatcher.newBuilder().setExact(hostName).build()); + } + } + } + } + return sniNamesToMatch; } private X509ExtendedTrustManager chooseDelegate(X509Certificate[] chain) @@ -303,4 +352,49 @@ public X509Certificate[] getAcceptedIssuers() { } return delegate.getAcceptedIssuers(); } + + private static boolean verifyDnsNameWildcard( + String altNameFromCert, String sanToVerify, boolean ignoreCase) { + String[] splitPattern = splitAtFirstDelimiter(ignoreCase + ? sanToVerify.toLowerCase(Locale.ROOT) : sanToVerify); + String[] splitDnsName = splitAtFirstDelimiter(ignoreCase + ? altNameFromCert.toLowerCase(Locale.ROOT) : altNameFromCert); + if (splitPattern == null || splitDnsName == null) { + return false; + } + if (splitDnsName[0].startsWith("xn--")) { + return false; + } + if (splitPattern[0].contains("*") + && !splitPattern[1].contains("*") + && !splitPattern[0].startsWith("xn--")) { + return splitDnsName[1].equals(splitPattern[1]) + && labelWildcardMatch(splitDnsName[0], splitPattern[0]); + } + return false; + } + + private static boolean labelWildcardMatch(String dnsLabel, String pattern) { + final char glob = '*'; + // Check the special case of a single * pattern, as it's common. + if (pattern.equals("*")) { + return !dnsLabel.isEmpty(); + } + int globIndex = pattern.indexOf(glob); + if (pattern.indexOf(glob, globIndex + 1) == -1) { + return dnsLabel.length() >= pattern.length() - 1 + && dnsLabel.startsWith(pattern.substring(0, globIndex)) + && dnsLabel.endsWith(pattern.substring(globIndex + 1)); + } + return false; + } + + @Nullable + private static String[] splitAtFirstDelimiter(String s) { + int index = s.indexOf('.'); + if (index == -1) { + return null; + } + return new String[]{s.substring(0, index), s.substring(index + 1)}; + } } diff --git a/xds/src/main/java/io/grpc/xds/orca/OrcaOobUtil.java b/xds/src/main/java/io/grpc/xds/orca/OrcaOobUtil.java index 9ac06d362fc..b37b9bc42e3 100644 --- a/xds/src/main/java/io/grpc/xds/orca/OrcaOobUtil.java +++ b/xds/src/main/java/io/grpc/xds/orca/OrcaOobUtil.java @@ -36,12 +36,16 @@ import io.grpc.ChannelLogger; import io.grpc.ChannelLogger.ChannelLogLevel; import io.grpc.ClientCall; +import io.grpc.ConnectivityState; import io.grpc.ConnectivityStateInfo; import io.grpc.ExperimentalApi; import io.grpc.LoadBalancer; import io.grpc.LoadBalancer.CreateSubchannelArgs; import io.grpc.LoadBalancer.Helper; +import io.grpc.LoadBalancer.PickResult; +import io.grpc.LoadBalancer.PickSubchannelArgs; import io.grpc.LoadBalancer.Subchannel; +import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.LoadBalancer.SubchannelStateListener; import io.grpc.Metadata; import io.grpc.Status; @@ -236,6 +240,30 @@ protected Helper delegate() { return delegate; } + @Override + public void updateBalancingState(ConnectivityState newState, SubchannelPicker newPicker) { + delegate.updateBalancingState(newState, new OrcaOobPicker(newPicker)); + } + + @VisibleForTesting + static final class OrcaOobPicker extends SubchannelPicker { + final SubchannelPicker delegate; + + OrcaOobPicker(SubchannelPicker delegate) { + this.delegate = delegate; + } + + @Override + public PickResult pickSubchannel(PickSubchannelArgs args) { + PickResult result = delegate.pickSubchannel(args); + Subchannel subchannel = result.getSubchannel(); + if (subchannel instanceof SubchannelImpl) { + return result.copyWithSubchannel(((SubchannelImpl) subchannel).delegate()); + } + return result; + } + } + @Override public Subchannel createSubchannel(CreateSubchannelArgs args) { syncContext.throwIfNotInThisSynchronizationContext(); diff --git a/xds/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider b/xds/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider index e1c4d4aa427..04a2d9cf7a8 100644 --- a/xds/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider +++ b/xds/src/main/resources/META-INF/services/io.grpc.LoadBalancerProvider @@ -2,7 +2,6 @@ io.grpc.xds.CdsLoadBalancerProvider io.grpc.xds.PriorityLoadBalancerProvider io.grpc.xds.WeightedTargetLoadBalancerProvider io.grpc.xds.ClusterManagerLoadBalancerProvider -io.grpc.xds.ClusterResolverLoadBalancerProvider io.grpc.xds.ClusterImplLoadBalancerProvider io.grpc.xds.LeastRequestLoadBalancerProvider io.grpc.xds.RingHashLoadBalancerProvider diff --git a/xds/src/test/java/io/grpc/xds/AddressFilterTest.java b/xds/src/test/java/io/grpc/xds/AddressFilterTest.java index 7d92e2ba1ce..36709ab8ee6 100644 --- a/xds/src/test/java/io/grpc/xds/AddressFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/AddressFilterTest.java @@ -58,4 +58,29 @@ public void filterAddresses() { assertThat(filteredAddress0.getAttributes().get(key1)).isEqualTo("value1"); assertThat(filteredAddress1.getAddresses()).containsExactlyElementsIn(eag3.getAddresses()); } + + @Test + public void longerPathChain() { + List addresses = Arrays.asList( + newEag(new InetSocketAddress(8000), Arrays.asList("A", "B", "C")), + newEag(new InetSocketAddress(8001), Arrays.asList("Z", "B", "C")), + newEag(new InetSocketAddress(8002), Arrays.asList("A", "Z", "C")), + newEag(new InetSocketAddress(8003), Arrays.asList("A", "B", "Z"))); + addresses = AddressFilter.filter(addresses, "A"); + assertThat(addresses).hasSize(3); + + addresses = AddressFilter.filter(addresses, "B"); + assertThat(addresses).hasSize(2); + + addresses = AddressFilter.filter(addresses, "C"); + assertThat(addresses).hasSize(1); + assertThat(addresses.get(0).getAddresses()).containsExactly(new InetSocketAddress(8000)); + + addresses = AddressFilter.filter(addresses, "D"); + assertThat(addresses).hasSize(0); + } + + private static EquivalentAddressGroup newEag(InetSocketAddress address, List names) { + return AddressFilter.setPathFilter(new EquivalentAddressGroup(address), names); + } } diff --git a/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java b/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java index 2059022c203..83e46c0d52b 100644 --- a/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java +++ b/xds/src/test/java/io/grpc/xds/CdsLoadBalancer2Test.java @@ -17,7 +17,7 @@ package io.grpc.xds; import static com.google.common.truth.Truth.assertThat; -import static io.grpc.xds.XdsLbPolicies.CLUSTER_RESOLVER_POLICY_NAME; +import static io.grpc.xds.XdsLbPolicies.CLUSTER_IMPL_POLICY_NAME; import static io.grpc.xds.XdsLbPolicies.PRIORITY_POLICY_NAME; import static io.grpc.xds.XdsTestControlPlaneService.ADS_TYPE_URL_CDS; import static io.grpc.xds.XdsTestControlPlaneService.ADS_TYPE_URL_EDS; @@ -61,6 +61,7 @@ import io.grpc.ConnectivityState; import io.grpc.LoadBalancer; import io.grpc.LoadBalancer.Helper; +import io.grpc.LoadBalancer.PickDetailsConsumer; import io.grpc.LoadBalancer.PickResult; import io.grpc.LoadBalancer.PickSubchannelArgs; import io.grpc.LoadBalancer.ResolvedAddresses; @@ -78,11 +79,7 @@ import io.grpc.testing.GrpcCleanupRule; import io.grpc.util.GracefulSwitchLoadBalancerAccessor; import io.grpc.xds.CdsLoadBalancerProvider.CdsConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig.DiscoveryMechanism; -import io.grpc.xds.EnvoyServerProtoData.FailurePercentageEjection; -import io.grpc.xds.EnvoyServerProtoData.SuccessRateEjection; -import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.ClusterImplLoadBalancerProvider.ClusterImplConfig; import io.grpc.xds.client.XdsClient; import io.grpc.xds.internal.security.CommonTlsContextTestsUtil; import java.util.ArrayList; @@ -139,7 +136,6 @@ public class CdsLoadBalancer2Test { .directExecutor() .build()), fakeClock); - private final ServerInfo lrsServerInfo = xdsClient.getBootstrapInfo().servers().get(0); private XdsDependencyManager xdsDepManager; @Mock @@ -151,8 +147,10 @@ public class CdsLoadBalancer2Test { @Before public void setUp() throws Exception { - lbRegistry.register(new FakeLoadBalancerProvider(CLUSTER_RESOLVER_POLICY_NAME)); + lbRegistry.register(new FakeLoadBalancerProvider(PRIORITY_POLICY_NAME)); + lbRegistry.register(new FakeLoadBalancerProvider(CLUSTER_IMPL_POLICY_NAME)); lbRegistry.register(new FakeLoadBalancerProvider("round_robin")); + lbRegistry.register(new FakeLoadBalancerProvider("outlier_detection_experimental")); lbRegistry.register( new FakeLoadBalancerProvider("ring_hash_experimental", new RingHashLoadBalancerProvider())); lbRegistry.register(new FakeLoadBalancerProvider("least_request_experimental", @@ -222,7 +220,7 @@ private void shutdownLoadBalancer() { } @Test - public void discoverTopLevelEdsCluster() { + public void discoverTopLevelCluster() { Cluster cluster = Cluster.newBuilder() .setName(CLUSTER) .setType(Cluster.DiscoveryType.EDS) @@ -250,64 +248,7 @@ public void discoverTopLevelEdsCluster() { verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); assertThat(childBalancers).hasSize(1); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - assertThat(childBalancer.name).isEqualTo(CLUSTER_RESOLVER_POLICY_NAME); - ClusterResolverConfig childLbConfig = (ClusterResolverConfig) childBalancer.config; - assertThat(childLbConfig.discoveryMechanism).isEqualTo( - DiscoveryMechanism.forEds( - CLUSTER, EDS_SERVICE_NAME, lrsServerInfo, 100L, upstreamTlsContext, - Collections.emptyMap(), io.grpc.xds.EnvoyServerProtoData.OutlierDetection.create( - null, null, null, null, SuccessRateEjection.create(null, null, null, null), - FailurePercentageEjection.create(null, null, null, null)))); - assertThat( - GracefulSwitchLoadBalancerAccessor.getChildProvider(childLbConfig.lbConfig).getPolicyName()) - .isEqualTo("wrr_locality_experimental"); - } - - @Test - public void discoverTopLevelLogicalDnsCluster() { - Cluster cluster = Cluster.newBuilder() - .setName(CLUSTER) - .setType(Cluster.DiscoveryType.LOGICAL_DNS) - .setLoadAssignment(ClusterLoadAssignment.newBuilder() - .addEndpoints(LocalityLbEndpoints.newBuilder() - .addLbEndpoints(LbEndpoint.newBuilder() - .setEndpoint(Endpoint.newBuilder() - .setAddress(Address.newBuilder() - .setSocketAddress(SocketAddress.newBuilder() - .setAddress("dns.example.com") - .setPortValue(1111))))))) - .setEdsClusterConfig(Cluster.EdsClusterConfig.newBuilder() - .setServiceName(EDS_SERVICE_NAME) - .setEdsConfig(ConfigSource.newBuilder() - .setAds(AggregatedConfigSource.newBuilder()))) - .setLbPolicy(Cluster.LbPolicy.LEAST_REQUEST) - .setLrsServer(ConfigSource.newBuilder() - .setSelf(SelfConfigSource.getDefaultInstance())) - .setCircuitBreakers(CircuitBreakers.newBuilder() - .addThresholds(CircuitBreakers.Thresholds.newBuilder() - .setPriority(RoutingPriority.DEFAULT) - .setMaxRequests(UInt32Value.newBuilder().setValue(100)))) - .setTransportSocket(TransportSocket.newBuilder() - .setName("envoy.transport_sockets.tls") - .setTypedConfig(Any.pack(UpstreamTlsContext.newBuilder() - .setCommonTlsContext(upstreamTlsContext.getCommonTlsContext()) - .build()))) - .build(); - controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of(CLUSTER, cluster)); - startXdsDepManager(); - - verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); - assertThat(childBalancers).hasSize(1); - FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - assertThat(childBalancer.name).isEqualTo(CLUSTER_RESOLVER_POLICY_NAME); - ClusterResolverConfig childLbConfig = (ClusterResolverConfig) childBalancer.config; - assertThat(childLbConfig.discoveryMechanism).isEqualTo( - DiscoveryMechanism.forLogicalDns( - CLUSTER, "dns.example.com:1111", lrsServerInfo, 100L, upstreamTlsContext, - Collections.emptyMap())); - assertThat( - GracefulSwitchLoadBalancerAccessor.getChildProvider(childLbConfig.lbConfig).getPolicyName()) - .isEqualTo("wrr_locality_experimental"); + assertThat(childBalancer.name).isEqualTo(PRIORITY_POLICY_NAME); } @Test @@ -315,14 +256,17 @@ public void nonAggregateCluster_resourceNotExist_returnErrorPicker() { startXdsDepManager(); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - Status unavailable = Status.UNAVAILABLE.withDescription( - "CDS resource " + CLUSTER + " does not exist nodeID: " + NODE_ID); + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + + " nodeID: " + NODE_ID + + ": NOT_FOUND: Timed out waiting for resource " + CLUSTER + " from xDS server"; + Status unavailable = Status.UNAVAILABLE.withDescription(expectedDescription); assertPickerStatus(pickerCaptor.getValue(), unavailable); assertThat(childBalancers).isEmpty(); } @Test public void nonAggregateCluster_resourceUpdate() { + lbRegistry.register(new PriorityLoadBalancerProvider()); Cluster cluster = EDS_CLUSTER.toBuilder() .setCircuitBreakers(CircuitBreakers.newBuilder() .addThresholds(CircuitBreakers.Thresholds.newBuilder() @@ -335,10 +279,9 @@ public void nonAggregateCluster_resourceUpdate() { verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); assertThat(childBalancers).hasSize(1); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - ClusterResolverConfig childLbConfig = (ClusterResolverConfig) childBalancer.config; - assertThat(childLbConfig.discoveryMechanism).isEqualTo( - DiscoveryMechanism.forEds( - CLUSTER, EDS_SERVICE_NAME, null, 100L, null, Collections.emptyMap(), null)); + ClusterImplConfig childLbConfig = (ClusterImplConfig) childBalancer.config; + assertThat(childLbConfig.cluster).isEqualTo(CLUSTER); + assertThat(childLbConfig.maxConcurrentRequests).isEqualTo(100L); cluster = EDS_CLUSTER.toBuilder() .setCircuitBreakers(CircuitBreakers.newBuilder() @@ -350,30 +293,47 @@ public void nonAggregateCluster_resourceUpdate() { verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); assertThat(childBalancers).hasSize(1); childBalancer = Iterables.getOnlyElement(childBalancers); - childLbConfig = (ClusterResolverConfig) childBalancer.config; - assertThat(childLbConfig.discoveryMechanism).isEqualTo( - DiscoveryMechanism.forEds( - CLUSTER, EDS_SERVICE_NAME, null, 200L, null, Collections.emptyMap(), null)); + childLbConfig = (ClusterImplConfig) childBalancer.config; + assertThat(childLbConfig.maxConcurrentRequests).isEqualTo(200L); + } + + @Test + public void nonAggregateCluster_addsBackendServiceAttributeAndPickDetailsLabel() { + controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of(CLUSTER, EDS_CLUSTER)); + startXdsDepManager(); + + FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); + assertThat(childBalancer.attributes.get(NameResolver.ATTR_BACKEND_SERVICE)).isEqualTo(CLUSTER); + childBalancer.deliverSubchannelState(PickResult.withNoResult(), ConnectivityState.READY); + + verify(helper).updateBalancingState(eq(ConnectivityState.READY), pickerCaptor.capture()); + PickDetailsConsumer detailsConsumer = mock(PickDetailsConsumer.class); + PickResult result = + pickerCaptor.getValue().pickSubchannel(newPickSubchannelArgs(detailsConsumer)); + + assertThat(result.getStatus().isOk()).isTrue(); + verify(detailsConsumer).addOptionalLabel("grpc.lb.backend_service", CLUSTER); } @Test public void nonAggregateCluster_resourceRevoked() { + lbRegistry.register(new PriorityLoadBalancerProvider()); controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of(CLUSTER, EDS_CLUSTER)); startXdsDepManager(); verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); assertThat(childBalancers).hasSize(1); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - ClusterResolverConfig childLbConfig = (ClusterResolverConfig) childBalancer.config; - assertThat(childLbConfig.discoveryMechanism).isEqualTo( - DiscoveryMechanism.forEds( - CLUSTER, EDS_SERVICE_NAME, null, null, null, Collections.emptyMap(), null)); + ClusterImplConfig childLbConfig = (ClusterImplConfig) childBalancer.config; + assertThat(childLbConfig.cluster).isEqualTo(CLUSTER); controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of()); assertThat(childBalancer.shutdown).isTrue(); - Status unavailable = Status.UNAVAILABLE.withDescription( - "CDS resource " + CLUSTER + " does not exist nodeID: " + NODE_ID); + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + + " nodeID: " + NODE_ID + + ": NOT_FOUND: Resource " + CLUSTER + " does not exist"; + Status unavailable = Status.UNAVAILABLE.withDescription(expectedDescription); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); assertPickerStatus(pickerCaptor.getValue(), unavailable); @@ -395,10 +355,7 @@ public void dynamicCluster() { verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); assertThat(childBalancers).hasSize(1); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - ClusterResolverConfig childLbConfig = (ClusterResolverConfig) childBalancer.config; - assertThat(childLbConfig.discoveryMechanism).isEqualTo( - DiscoveryMechanism.forEds( - clusterName, EDS_SERVICE_NAME, null, null, null, Collections.emptyMap(), null)); + assertThat(childBalancer.name).isEqualTo(PRIORITY_POLICY_NAME); assertThat(this.lastXdsConfig.getClusters()).containsKey(clusterName); shutdownLoadBalancer(); @@ -407,7 +364,6 @@ public void dynamicCluster() { @Test public void discoverAggregateCluster_createsPriorityLbPolicy() { - lbRegistry.register(new FakeLoadBalancerProvider(PRIORITY_POLICY_NAME)); CdsLoadBalancerProvider cdsLoadBalancerProvider = new CdsLoadBalancerProvider(lbRegistry); lbRegistry.register(cdsLoadBalancerProvider); loadBalancer = (CdsLoadBalancer2) cdsLoadBalancerProvider.newLoadBalancer(helper); @@ -492,6 +448,73 @@ public void discoverAggregateCluster_createsPriorityLbPolicy() { .isEqualTo("cds_experimental"); } + @Test + public void aggregateCluster_doesNotAddBackendServiceAttributeAndPickDetailsLabelFromRoot() { + String cluster1 = "cluster-01.googleapis.com"; + controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of( + // CLUSTER (aggr.) -> [cluster1 (EDS)] + CLUSTER, Cluster.newBuilder() + .setName(CLUSTER) + .setClusterType(Cluster.CustomClusterType.newBuilder() + .setName("envoy.clusters.aggregate") + .setTypedConfig(Any.pack(ClusterConfig.newBuilder() + .addClusters(cluster1) + .build()))) + .build(), + cluster1, EDS_CLUSTER.toBuilder().setName(cluster1).build())); + startXdsDepManager(); + + FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); + assertThat(childBalancer.attributes.get(NameResolver.ATTR_BACKEND_SERVICE)).isNull(); + childBalancer.deliverSubchannelState(PickResult.withNoResult(), ConnectivityState.READY); + + verify(helper).updateBalancingState(eq(ConnectivityState.READY), pickerCaptor.capture()); + PickDetailsConsumer detailsConsumer = mock(PickDetailsConsumer.class); + PickResult result = + pickerCaptor.getValue().pickSubchannel(newPickSubchannelArgs(detailsConsumer)); + + assertThat(result.getStatus().isOk()).isTrue(); + verify(detailsConsumer, never()).addOptionalLabel(eq("grpc.lb.backend_service"), any()); + } + + @Test + public void aggregateCluster_leafAddsBackendServicePickDetailsLabel() { + lbRegistry.register(new PriorityLoadBalancerProvider()); + CdsLoadBalancerProvider cdsLoadBalancerProvider = new CdsLoadBalancerProvider(lbRegistry); + lbRegistry.register(cdsLoadBalancerProvider); + loadBalancer = (CdsLoadBalancer2) cdsLoadBalancerProvider.newLoadBalancer(helper); + + String cluster1 = "cluster-01.googleapis.com"; + controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of( + // CLUSTER (aggr.) -> [cluster1 (EDS)] + CLUSTER, Cluster.newBuilder() + .setName(CLUSTER) + .setClusterType(Cluster.CustomClusterType.newBuilder() + .setName("envoy.clusters.aggregate") + .setTypedConfig(Any.pack(ClusterConfig.newBuilder() + .addClusters(cluster1) + .build()))) + .build(), + cluster1, EDS_CLUSTER.toBuilder().setName(cluster1).build())); + startXdsDepManager(); + + FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); + ClusterImplConfig clusterImplConfig = (ClusterImplConfig) childBalancer.config; + assertThat(clusterImplConfig.cluster).isEqualTo(cluster1); + assertThat(childBalancer.attributes.get(NameResolver.ATTR_BACKEND_SERVICE)) + .isEqualTo(cluster1); + childBalancer.deliverSubchannelState(PickResult.withNoResult(), ConnectivityState.READY); + + verify(helper).updateBalancingState(eq(ConnectivityState.READY), pickerCaptor.capture()); + PickDetailsConsumer detailsConsumer = mock(PickDetailsConsumer.class); + PickResult result = + pickerCaptor.getValue().pickSubchannel(newPickSubchannelArgs(detailsConsumer)); + + assertThat(result.getStatus().isOk()).isTrue(); + verify(detailsConsumer).addOptionalLabel("grpc.lb.backend_service", cluster1); + verify(detailsConsumer, never()).addOptionalLabel("grpc.lb.backend_service", CLUSTER); + } + @Test // Both priorities will get tried using real priority LB policy. public void discoverAggregateCluster_testChildCdsLbPolicyParsing() { @@ -519,22 +542,22 @@ public void discoverAggregateCluster_testChildCdsLbPolicyParsing() { verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); assertThat(childBalancers).hasSize(2); - ClusterResolverConfig cluster1ResolverConfig = - (ClusterResolverConfig) childBalancers.get(0).config; - assertThat(cluster1ResolverConfig.discoveryMechanism.cluster) + ClusterImplConfig cluster1ImplConfig = + (ClusterImplConfig) childBalancers.get(0).config; + assertThat(cluster1ImplConfig.cluster) .isEqualTo("cluster-01.googleapis.com"); - assertThat(cluster1ResolverConfig.discoveryMechanism.type) - .isEqualTo(DiscoveryMechanism.Type.EDS); - assertThat(cluster1ResolverConfig.discoveryMechanism.edsServiceName) + assertThat(cluster1ImplConfig.edsServiceName) .isEqualTo("backend-service-1.googleapis.com"); - ClusterResolverConfig cluster2ResolverConfig = - (ClusterResolverConfig) childBalancers.get(1).config; - assertThat(cluster2ResolverConfig.discoveryMechanism.cluster) + assertThat(childBalancers.get(0).attributes.get(NameResolver.ATTR_BACKEND_SERVICE)) + .isEqualTo(cluster1); + ClusterImplConfig cluster2ImplConfig = + (ClusterImplConfig) childBalancers.get(1).config; + assertThat(cluster2ImplConfig.cluster) .isEqualTo("cluster-02.googleapis.com"); - assertThat(cluster2ResolverConfig.discoveryMechanism.type) - .isEqualTo(DiscoveryMechanism.Type.EDS); - assertThat(cluster2ResolverConfig.discoveryMechanism.edsServiceName) + assertThat(cluster2ImplConfig.edsServiceName) .isEqualTo("backend-service-1.googleapis.com"); + assertThat(childBalancers.get(1).attributes.get(NameResolver.ATTR_BACKEND_SERVICE)) + .isEqualTo(cluster2); } @Test @@ -583,8 +606,10 @@ public void aggregateCluster_noNonAggregateClusterExits_returnErrorPicker() { verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - Status status = Status.UNAVAILABLE.withDescription( - "CDS resource " + cluster1 + " does not exist nodeID: " + NODE_ID); + String expectedDescription = "Error retrieving CDS resource " + cluster1 + + " nodeID: " + NODE_ID + + ": NOT_FOUND: Timed out waiting for resource " + cluster1 + " from xDS server"; + Status status = Status.UNAVAILABLE.withDescription(expectedDescription); assertPickerStatus(pickerCaptor.getValue(), status); assertThat(childBalancers).isEmpty(); } @@ -642,7 +667,9 @@ public void unknownLbProvider() { startXdsDepManager(); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - PickResult result = pickerCaptor.getValue().pickSubchannel(mock(PickSubchannelArgs.class)); + PickResult result = + pickerCaptor.getValue().pickSubchannel( + newPickSubchannelArgs(mock(PickDetailsConsumer.class))); Status actualStatus = result.getStatus(); assertThat(actualStatus.getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(actualStatus.getDescription()).contains("Invalid LoadBalancingPolicy"); @@ -670,7 +697,9 @@ public void invalidLbConfig() { startXdsDepManager(); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - PickResult result = pickerCaptor.getValue().pickSubchannel(mock(PickSubchannelArgs.class)); + PickResult result = + pickerCaptor.getValue().pickSubchannel( + newPickSubchannelArgs(mock(PickDetailsConsumer.class))); Status actualStatus = result.getStatus(); assertThat(actualStatus.getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(actualStatus.getDescription()).contains("Invalid 'minRingSize'"); @@ -704,12 +733,19 @@ private void startXdsDepManager(final CdsConfig cdsConfig) { } private static void assertPickerStatus(SubchannelPicker picker, Status expectedStatus) { - PickResult result = picker.pickSubchannel(mock(PickSubchannelArgs.class)); + PickResult result = picker.pickSubchannel( + newPickSubchannelArgs(mock(PickDetailsConsumer.class))); Status actualStatus = result.getStatus(); assertThat(actualStatus.getCode()).isEqualTo(expectedStatus.getCode()); assertThat(actualStatus.getDescription()).isEqualTo(expectedStatus.getDescription()); } + private static PickSubchannelArgs newPickSubchannelArgs(PickDetailsConsumer pickDetailsConsumer) { + PickSubchannelArgs args = mock(PickSubchannelArgs.class); + when(args.getPickDetailsConsumer()).thenReturn(pickDetailsConsumer); + return args; + } + private final class FakeLoadBalancerProvider extends LoadBalancerProvider { private final String policyName; private final LoadBalancerProvider configParsingDelegate; @@ -725,7 +761,7 @@ private final class FakeLoadBalancerProvider extends LoadBalancerProvider { @Override public LoadBalancer newLoadBalancer(Helper helper) { - FakeLoadBalancer balancer = new FakeLoadBalancer(policyName); + FakeLoadBalancer balancer = new FakeLoadBalancer(policyName, helper); childBalancers.add(balancer); return balancer; } @@ -757,17 +793,21 @@ public NameResolver.ConfigOrError parseLoadBalancingPolicyConfig( private final class FakeLoadBalancer extends LoadBalancer { private final String name; + private final Helper helper; private Object config; + private Attributes attributes; private Status upstreamError; private boolean shutdown; - FakeLoadBalancer(String name) { + FakeLoadBalancer(String name, Helper helper) { this.name = name; + this.helper = helper; } @Override public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { config = resolvedAddresses.getLoadBalancingPolicyConfig(); + attributes = resolvedAddresses.getAttributes(); return Status.OK; } @@ -781,5 +821,15 @@ public void shutdown() { shutdown = true; childBalancers.remove(this); } + + void deliverSubchannelState(final PickResult result, ConnectivityState state) { + SubchannelPicker picker = new SubchannelPicker() { + @Override + public PickResult pickSubchannel(PickSubchannelArgs args) { + return result; + } + }; + helper.updateBalancingState(state, picker); + } } } diff --git a/xds/src/test/java/io/grpc/xds/ClusterImplLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/ClusterImplLoadBalancerTest.java index c5e3f80f170..e89c0c3b8d5 100644 --- a/xds/src/test/java/io/grpc/xds/ClusterImplLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/ClusterImplLoadBalancerTest.java @@ -19,9 +19,13 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static io.grpc.xds.ClusterImplLoadBalancer.ATTR_SUBCHANNEL_ADDRESS_NAME; import static io.grpc.xds.XdsNameResolver.AUTO_HOST_REWRITE_KEY; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -55,7 +59,6 @@ import io.grpc.Status.Code; import io.grpc.SynchronizationContext; import io.grpc.internal.FakeClock; -import io.grpc.internal.ObjectPool; import io.grpc.internal.PickFirstLoadBalancerProvider; import io.grpc.internal.PickSubchannelArgsImpl; import io.grpc.protobuf.ProtoUtils; @@ -68,6 +71,7 @@ import io.grpc.xds.WeightedTargetLoadBalancerProvider.WeightedPolicySelection; import io.grpc.xds.WeightedTargetLoadBalancerProvider.WeightedTargetConfig; import io.grpc.xds.XdsNameResolverProvider.CallCounterProvider; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.Bootstrapper.ServerInfo; import io.grpc.xds.client.LoadReportClient; import io.grpc.xds.client.LoadStatsManager2; @@ -77,6 +81,7 @@ import io.grpc.xds.client.Stats.ClusterStats; import io.grpc.xds.client.Stats.UpstreamLocalityStats; import io.grpc.xds.client.XdsClient; +import io.grpc.xds.internal.XdsInternalAttributes; import io.grpc.xds.internal.security.CommonTlsContextTestsUtil; import io.grpc.xds.internal.security.SecurityProtocolNegotiators; import io.grpc.xds.internal.security.SslContextProvider; @@ -138,19 +143,6 @@ public void uncaughtException(Thread t, Throwable e) { private final LoadStatsManager2 loadStatsManager = new LoadStatsManager2(fakeClock.getStopwatchSupplier()); private final FakeXdsClient xdsClient = new FakeXdsClient(); - private final ObjectPool xdsClientPool = new ObjectPool() { - @Override - public XdsClient getObject() { - xdsClientRefs++; - return xdsClient; - } - - @Override - public XdsClient returnObject(Object object) { - xdsClientRefs--; - return null; - } - }; private final CallCounterProvider callCounterProvider = new CallCounterProvider() { @Override public AtomicLong getOrCreate(String cluster, @Nullable String edsServiceName) { @@ -191,15 +183,15 @@ public void handleResolvedAddresses_propagateToChildPolicy() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(downstreamBalancers); assertThat(Iterables.getOnlyElement(childBalancer.addresses)).isEqualTo(endpoint); assertThat(childBalancer.config).isSameInstanceAs(weightedTargetConfig); - assertThat(childBalancer.attributes.get(XdsAttributes.XDS_CLIENT_POOL)) - .isSameInstanceAs(xdsClientPool); - assertThat(childBalancer.attributes.get(NameResolver.ATTR_BACKEND_SERVICE)).isEqualTo(CLUSTER); + assertThat(childBalancer.attributes.get(io.grpc.xds.XdsAttributes.XDS_CLIENT)) + .isSameInstanceAs(xdsClient); + assertThat(childBalancer.attributes.get(NameResolver.ATTR_BACKEND_SERVICE)).isNull(); } /** @@ -219,7 +211,7 @@ public void handleResolvedAddresses_childPolicyChanges() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), configWithWeightedTarget); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(downstreamBalancers); @@ -234,7 +226,7 @@ public void handleResolvedAddresses_childPolicyChanges() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( wrrLocalityProvider, wrrLocalityConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); deliverAddressesAndConfig(Collections.singletonList(endpoint), configWithWrrLocality); childBalancer = Iterables.getOnlyElement(downstreamBalancers); assertThat(childBalancer.name).isEqualTo(XdsLbPolicies.WRR_LOCALITY_POLICY_NAME); @@ -260,7 +252,7 @@ public void nameResolutionError_afterChildPolicyInstantiated_propagateToDownstre null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); FakeLoadBalancer childBalancer = Iterables.getOnlyElement(downstreamBalancers); @@ -281,7 +273,7 @@ public void pick_addsOptionalLabels() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); FakeLoadBalancer leafBalancer = Iterables.getOnlyElement(downstreamBalancers); @@ -300,11 +292,11 @@ public void pick_addsOptionalLabels() { // The value will be determined by the parent policy, so can be different than the value used in // makeAddress() for the test. verify(detailsConsumer).addOptionalLabel("grpc.lb.locality", locality.toString()); - verify(detailsConsumer).addOptionalLabel("grpc.lb.backend_service", CLUSTER); + verify(detailsConsumer, never()).addOptionalLabel(eq("grpc.lb.backend_service"), any()); } @Test - public void pick_noResult_addsClusterLabel() { + public void pick_noResult_doesNotAddClusterLabel() { LoadBalancerProvider weightedTargetProvider = new WeightedTargetLoadBalancerProvider(); WeightedTargetConfig weightedTargetConfig = buildWeightedTargetConfig(ImmutableMap.of(locality, 10)); @@ -312,7 +304,7 @@ public void pick_noResult_addsClusterLabel() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); FakeLoadBalancer leafBalancer = Iterables.getOnlyElement(downstreamBalancers); @@ -324,7 +316,7 @@ public void pick_noResult_addsClusterLabel() { TestMethodDescriptors.voidMethod(), new Metadata(), CallOptions.DEFAULT, detailsConsumer); PickResult result = currentPicker.pickSubchannel(pickSubchannelArgs); assertThat(result.getStatus().isOk()).isTrue(); - verify(detailsConsumer).addOptionalLabel("grpc.lb.backend_service", CLUSTER); + verify(detailsConsumer, never()).addOptionalLabel(eq("grpc.lb.backend_service"), any()); } @Test @@ -336,7 +328,8 @@ public void recordLoadStats() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), + BackendMetricPropagation.fromMetricSpecs(Arrays.asList("named_metrics.*"))); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); FakeLoadBalancer leafBalancer = Iterables.getOnlyElement(downstreamBalancers); @@ -379,24 +372,24 @@ public void recordLoadStats() { assertThat(localityStats.totalSuccessfulRequests()).isEqualTo(1L); assertThat(localityStats.totalErrorRequests()).isEqualTo(1L); assertThat(localityStats.totalRequestsInProgress()).isEqualTo(1L); - assertThat(localityStats.loadMetricStatsMap().containsKey("named1")).isTrue(); + assertThat(localityStats.loadMetricStatsMap().containsKey("named_metrics.named1")).isTrue(); assertThat( - localityStats.loadMetricStatsMap().get("named1").numRequestsFinishedWithMetric()).isEqualTo( - 2L); - assertThat(localityStats.loadMetricStatsMap().get("named1").totalMetricValue()).isWithin( - TOLERANCE).of(3.14159 + 2.718); - assertThat(localityStats.loadMetricStatsMap().containsKey("named2")).isTrue(); + localityStats.loadMetricStatsMap().get("named_metrics.named1") + .numRequestsFinishedWithMetric()).isEqualTo(2L); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named1") + .totalMetricValue()).isWithin(TOLERANCE).of(3.14159 + 2.718); + assertThat(localityStats.loadMetricStatsMap().containsKey("named_metrics.named2")).isTrue(); assertThat( - localityStats.loadMetricStatsMap().get("named2").numRequestsFinishedWithMetric()).isEqualTo( - 2L); - assertThat(localityStats.loadMetricStatsMap().get("named2").totalMetricValue()).isWithin( - TOLERANCE).of(-1.618 + 1.414); - assertThat(localityStats.loadMetricStatsMap().containsKey("named3")).isTrue(); + localityStats.loadMetricStatsMap().get("named_metrics.named2") + .numRequestsFinishedWithMetric()).isEqualTo(2L); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named2") + .totalMetricValue()).isWithin(TOLERANCE).of(-1.618 + 1.414); + assertThat(localityStats.loadMetricStatsMap().containsKey("named_metrics.named3")).isTrue(); assertThat( - localityStats.loadMetricStatsMap().get("named3").numRequestsFinishedWithMetric()).isEqualTo( - 1L); - assertThat(localityStats.loadMetricStatsMap().get("named3").totalMetricValue()).isWithin( - TOLERANCE).of(0.009); + localityStats.loadMetricStatsMap().get("named_metrics.named3") + .numRequestsFinishedWithMetric()).isEqualTo(1L); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named3") + .totalMetricValue()).isWithin(TOLERANCE).of(0.009); streamTracer3.streamClosed(Status.OK); subchannel.shutdown(); // stats recorder released @@ -415,6 +408,116 @@ public void recordLoadStats() { assertThat(clusterStats.upstreamLocalityStatsList()).isEmpty(); // no longer reported } + @Test + public void recordLoadStats_orcaLrsPropagationEnabled() { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = true; + BackendMetricPropagation backendMetricPropagation = BackendMetricPropagation.fromMetricSpecs( + Arrays.asList("application_utilization", "cpu_utilization", "named_metrics.named1")); + LoadBalancerProvider weightedTargetProvider = new WeightedTargetLoadBalancerProvider(); + WeightedTargetConfig weightedTargetConfig = + buildWeightedTargetConfig(ImmutableMap.of(locality, 10)); + ClusterImplConfig config = new ClusterImplConfig(CLUSTER, EDS_SERVICE_NAME, LRS_SERVER_INFO, + null, Collections.emptyList(), + GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( + weightedTargetProvider, weightedTargetConfig), + null, Collections.emptyMap(), backendMetricPropagation); + EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); + deliverAddressesAndConfig(Collections.singletonList(endpoint), config); + FakeLoadBalancer leafBalancer = Iterables.getOnlyElement(downstreamBalancers); + Subchannel subchannel = leafBalancer.createSubChannel(); + FakeSubchannel fakeSubchannel = helper.subchannels.poll(); + fakeSubchannel.updateState(ConnectivityStateInfo.forNonError(ConnectivityState.CONNECTING)); + fakeSubchannel.setConnectedEagIndex(0); + fakeSubchannel.updateState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + assertThat(currentState).isEqualTo(ConnectivityState.READY); + PickResult result = currentPicker.pickSubchannel(pickSubchannelArgs); + assertThat(result.getStatus().isOk()).isTrue(); + ClientStreamTracer streamTracer = result.getStreamTracerFactory().newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + Metadata trailersWithOrcaLoadReport = new Metadata(); + trailersWithOrcaLoadReport.put(ORCA_ENDPOINT_LOAD_METRICS_KEY, + OrcaLoadReport.newBuilder() + .setApplicationUtilization(1.414) + .setCpuUtilization(0.5) + .setMemUtilization(0.034) + .putNamedMetrics("named1", 3.14159) + .putNamedMetrics("named2", -1.618).build()); + streamTracer.inboundTrailers(trailersWithOrcaLoadReport); + streamTracer.streamClosed(Status.OK); + ClusterStats clusterStats = + Iterables.getOnlyElement(loadStatsManager.getClusterStatsReports(CLUSTER)); + UpstreamLocalityStats localityStats = + Iterables.getOnlyElement(clusterStats.upstreamLocalityStatsList()); + + assertThat(localityStats.loadMetricStatsMap()).containsKey("application_utilization"); + assertThat(localityStats.loadMetricStatsMap().get("application_utilization").totalMetricValue()) + .isWithin(TOLERANCE).of(1.414); + assertThat(localityStats.loadMetricStatsMap()).containsKey("cpu_utilization"); + assertThat(localityStats.loadMetricStatsMap().get("cpu_utilization").totalMetricValue()) + .isWithin(TOLERANCE).of(0.5); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("mem_utilization"); + assertThat(localityStats.loadMetricStatsMap()).containsKey("named_metrics.named1"); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named1").totalMetricValue()) + .isWithin(TOLERANCE).of(3.14159); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("named_metrics.named2"); + subchannel.shutdown(); + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; + } + + @Test + public void recordLoadStats_orcaLrsPropagationDisabled() { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = false; + BackendMetricPropagation backendMetricPropagation = BackendMetricPropagation.fromMetricSpecs( + Arrays.asList("application_utilization", "cpu_utilization", "named_metrics.named1")); + LoadBalancerProvider weightedTargetProvider = new WeightedTargetLoadBalancerProvider(); + WeightedTargetConfig weightedTargetConfig = + buildWeightedTargetConfig(ImmutableMap.of(locality, 10)); + ClusterImplConfig config = new ClusterImplConfig(CLUSTER, EDS_SERVICE_NAME, LRS_SERVER_INFO, + null, Collections.emptyList(), + GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( + weightedTargetProvider, weightedTargetConfig), + null, Collections.emptyMap(), backendMetricPropagation); + EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); + deliverAddressesAndConfig(Collections.singletonList(endpoint), config); + FakeLoadBalancer leafBalancer = Iterables.getOnlyElement(downstreamBalancers); + Subchannel subchannel = leafBalancer.createSubChannel(); + FakeSubchannel fakeSubchannel = helper.subchannels.poll(); + fakeSubchannel.updateState(ConnectivityStateInfo.forNonError(ConnectivityState.CONNECTING)); + fakeSubchannel.setConnectedEagIndex(0); + fakeSubchannel.updateState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + assertThat(currentState).isEqualTo(ConnectivityState.READY); + PickResult result = currentPicker.pickSubchannel(pickSubchannelArgs); + assertThat(result.getStatus().isOk()).isTrue(); + ClientStreamTracer streamTracer = result.getStreamTracerFactory().newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + Metadata trailersWithOrcaLoadReport = new Metadata(); + trailersWithOrcaLoadReport.put(ORCA_ENDPOINT_LOAD_METRICS_KEY, + OrcaLoadReport.newBuilder() + .setApplicationUtilization(1.414) + .setCpuUtilization(0.5) + .setMemUtilization(0.034) + .putNamedMetrics("named1", 3.14159) + .putNamedMetrics("named2", -1.618).build()); + streamTracer.inboundTrailers(trailersWithOrcaLoadReport); + streamTracer.streamClosed(Status.OK); + ClusterStats clusterStats = + Iterables.getOnlyElement(loadStatsManager.getClusterStatsReports(CLUSTER)); + UpstreamLocalityStats localityStats = + Iterables.getOnlyElement(clusterStats.upstreamLocalityStatsList()); + + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("application_utilization"); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("cpu_utilization"); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("mem_utilization"); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("named_metrics.named1"); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("named_metrics.named2"); + assertThat(localityStats.loadMetricStatsMap().containsKey("named1")).isTrue(); + assertThat(localityStats.loadMetricStatsMap().containsKey("named2")).isTrue(); + subchannel.shutdown(); + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; + } + // Verifies https://github.com/grpc/grpc-java/issues/11434. @Test public void pickFirstLoadReport_onUpdateAddress() { @@ -431,7 +534,7 @@ public void pickFirstLoadReport_onUpdateAddress() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig(pickFirstProvider, pickFirstConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint1 = makeAddress("endpoint-addr1", locality1); EquivalentAddressGroup endpoint2 = makeAddress("endpoint-addr2", locality2); deliverAddressesAndConfig(Arrays.asList(endpoint1, endpoint2), config); @@ -521,7 +624,7 @@ public void dropRpcsWithRespectToLbConfigDropCategories() { null, Collections.singletonList(DropOverload.create("throttle", 500_000)), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); when(mockRandom.nextInt(anyInt())).thenReturn(499_999, 999_999, 1_000_000); @@ -555,13 +658,13 @@ public void dropRpcsWithRespectToLbConfigDropCategories() { Collections.singletonList(DropOverload.create("lb", 1_000_000)), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); loadBalancer.acceptResolvedAddresses( ResolvedAddresses.newBuilder() .setAddresses(Collections.singletonList(endpoint)) .setAttributes( Attributes.newBuilder() - .set(XdsAttributes.XDS_CLIENT_POOL, xdsClientPool) + .set(io.grpc.xds.XdsAttributes.XDS_CLIENT, xdsClient) .build()) .setLoadBalancingPolicyConfig(config) .build()); @@ -604,7 +707,7 @@ private void subtest_maxConcurrentRequests_appliedByLbConfig(boolean enableCircu maxConcurrentRequests, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); assertThat(downstreamBalancers).hasSize(1); // one leaf balancer @@ -651,7 +754,7 @@ private void subtest_maxConcurrentRequests_appliedByLbConfig(boolean enableCircu maxConcurrentRequests, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); result = currentPicker.pickSubchannel(pickSubchannelArgs); @@ -699,7 +802,7 @@ private void subtest_maxConcurrentRequests_appliedWithDefaultValue( null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint = makeAddress("endpoint-addr", locality); deliverAddressesAndConfig(Collections.singletonList(endpoint), config); assertThat(downstreamBalancers).hasSize(1); // one leaf balancer @@ -750,7 +853,7 @@ public void endpointAddressesAttachedWithClusterName() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); // One locality with two endpoints. EquivalentAddressGroup endpoint1 = makeAddress("endpoint-addr1", locality); EquivalentAddressGroup endpoint2 = makeAddress("endpoint-addr2", locality); @@ -766,14 +869,14 @@ public void endpointAddressesAttachedWithClusterName() { .build(); Subchannel subchannel = leafBalancer.helper.createSubchannel(args); for (EquivalentAddressGroup eag : subchannel.getAllAddresses()) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_CLUSTER_NAME)) + assertThat(eag.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_CLUSTER_NAME)) .isEqualTo(CLUSTER); } // An address update should also retain the cluster attribute. subchannel.updateAddresses(leafBalancer.addresses); for (EquivalentAddressGroup eag : subchannel.getAllAddresses()) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_CLUSTER_NAME)) + assertThat(eag.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_CLUSTER_NAME)) .isEqualTo(CLUSTER); } } @@ -790,7 +893,7 @@ public void endpointAddressesAttachedWithClusterName() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint1 = makeAddress("endpoint-addr1", locality, "authority-host-name"); deliverAddressesAndConfig(Arrays.asList(endpoint1), config); @@ -811,10 +914,10 @@ public void endpointAddressesAttachedWithClusterName() { new FixedResultPicker(PickResult.withSubchannel(subchannel))); } }); - assertThat(subchannel.getAttributes().get(XdsAttributes.ATTR_ADDRESS_NAME)).isEqualTo( + assertThat(subchannel.getAttributes().get(ATTR_SUBCHANNEL_ADDRESS_NAME)).isEqualTo( "authority-host-name"); for (EquivalentAddressGroup eag : subchannel.getAllAddresses()) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_ADDRESS_NAME)) + assertThat(eag.getAttributes().get(XdsInternalAttributes.ATTR_ADDRESS_NAME)) .isEqualTo("authority-host-name"); } @@ -841,7 +944,7 @@ public void endpointAddressesAttachedWithClusterName() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); EquivalentAddressGroup endpoint1 = makeAddress("endpoint-addr1", locality, "authority-host-name"); deliverAddressesAndConfig(Arrays.asList(endpoint1), config); @@ -863,9 +966,9 @@ public void endpointAddressesAttachedWithClusterName() { } }); // Sub Channel wrapper args won't have the address name although addresses will. - assertThat(subchannel.getAttributes().get(XdsAttributes.ATTR_ADDRESS_NAME)).isNull(); + assertThat(subchannel.getAttributes().get(ATTR_SUBCHANNEL_ADDRESS_NAME)).isNull(); for (EquivalentAddressGroup eag : subchannel.getAllAddresses()) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_ADDRESS_NAME)) + assertThat(eag.getAttributes().get(XdsInternalAttributes.ATTR_ADDRESS_NAME)) .isEqualTo("authority-host-name"); } @@ -881,7 +984,8 @@ public void endpointAddressesAttachedWithClusterName() { @Test public void endpointAddressesAttachedWithTlsConfig_securityEnabledByDefault() { UpstreamTlsContext upstreamTlsContext = - CommonTlsContextTestsUtil.buildUpstreamTlsContext("google_cloud_private_spiffe", true); + CommonTlsContextTestsUtil.buildUpstreamTlsContext( + "google_cloud_private_spiffe", true); LoadBalancerProvider weightedTargetProvider = new WeightedTargetLoadBalancerProvider(); WeightedTargetConfig weightedTargetConfig = buildWeightedTargetConfig(ImmutableMap.of(locality, 10)); @@ -889,7 +993,7 @@ public void endpointAddressesAttachedWithTlsConfig_securityEnabledByDefault() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - upstreamTlsContext, Collections.emptyMap()); + upstreamTlsContext, Collections.emptyMap(), null); // One locality with two endpoints. EquivalentAddressGroup endpoint1 = makeAddress("endpoint-addr1", locality); EquivalentAddressGroup endpoint2 = makeAddress("endpoint-addr2", locality); @@ -914,7 +1018,7 @@ public void endpointAddressesAttachedWithTlsConfig_securityEnabledByDefault() { null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - null, Collections.emptyMap()); + null, Collections.emptyMap(), null); deliverAddressesAndConfig(Arrays.asList(endpoint1, endpoint2), config); assertThat(Iterables.getOnlyElement(downstreamBalancers)).isSameInstanceAs(leafBalancer); subchannel = leafBalancer.helper.createSubchannel(args); // creates new connections @@ -925,13 +1029,13 @@ public void endpointAddressesAttachedWithTlsConfig_securityEnabledByDefault() { } // Config with a new UpstreamTlsContext. - upstreamTlsContext = - CommonTlsContextTestsUtil.buildUpstreamTlsContext("google_cloud_private_spiffe1", true); + upstreamTlsContext = CommonTlsContextTestsUtil.buildUpstreamTlsContext( + "google_cloud_private_spiffe1", true); config = new ClusterImplConfig(CLUSTER, EDS_SERVICE_NAME, LRS_SERVER_INFO, null, Collections.emptyList(), GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( weightedTargetProvider, weightedTargetConfig), - upstreamTlsContext, Collections.emptyMap()); + upstreamTlsContext, Collections.emptyMap(), null); deliverAddressesAndConfig(Arrays.asList(endpoint1, endpoint2), config); assertThat(Iterables.getOnlyElement(downstreamBalancers)).isSameInstanceAs(leafBalancer); subchannel = leafBalancer.helper.createSubchannel(args); // creates new connections @@ -957,8 +1061,8 @@ private void deliverAddressesAndConfig(List addresses, .setAddresses(addresses) .setAttributes( Attributes.newBuilder() - .set(XdsAttributes.XDS_CLIENT_POOL, xdsClientPool) - .set(XdsAttributes.CALL_COUNTER_PROVIDER, callCounterProvider) + .set(io.grpc.xds.XdsAttributes.XDS_CLIENT, xdsClient) + .set(io.grpc.xds.XdsAttributes.CALL_COUNTER_PROVIDER, callCounterProvider) .build()) .setLoadBalancingPolicyConfig(config) .build()); @@ -1015,11 +1119,11 @@ public String toString() { } Attributes.Builder attributes = Attributes.newBuilder() - .set(XdsAttributes.ATTR_LOCALITY, locality) + .set(io.grpc.xds.XdsAttributes.ATTR_LOCALITY, locality) // Unique but arbitrary string .set(EquivalentAddressGroup.ATTR_LOCALITY_NAME, locality.toString()); if (authorityHostname != null) { - attributes.set(XdsAttributes.ATTR_ADDRESS_NAME, authorityHostname); + attributes.set(XdsInternalAttributes.ATTR_ADDRESS_NAME, authorityHostname); } EquivalentAddressGroup eag = new EquivalentAddressGroup(new FakeSocketAddress(name), attributes.build()); @@ -1241,8 +1345,9 @@ public ClusterDropStats addClusterDropStats( @Override public ClusterLocalityStats addClusterLocalityStats( ServerInfo lrsServerInfo, String clusterName, @Nullable String edsServiceName, - Locality locality) { - return loadStatsManager.getClusterLocalityStats(clusterName, edsServiceName, locality); + Locality locality, BackendMetricPropagation backendMetricPropagation) { + return loadStatsManager.getClusterLocalityStats( + clusterName, edsServiceName, locality, backendMetricPropagation); } @Override diff --git a/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerProviderTest.java b/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerProviderTest.java deleted file mode 100644 index a201ecfaa4b..00000000000 --- a/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerProviderTest.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2020 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.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.grpc.ChannelLogger; -import io.grpc.LoadBalancer; -import io.grpc.LoadBalancer.Helper; -import io.grpc.LoadBalancerProvider; -import io.grpc.LoadBalancerRegistry; -import io.grpc.NameResolver; -import io.grpc.NameResolver.ServiceConfigParser; -import io.grpc.NameResolverRegistry; -import io.grpc.SynchronizationContext; -import io.grpc.internal.FakeClock; -import io.grpc.internal.GrpcUtil; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -/** Tests for {@link ClusterResolverLoadBalancerProvider}. */ -@RunWith(JUnit4.class) -public class ClusterResolverLoadBalancerProviderTest { - - @Test - public void provided() { - LoadBalancerProvider provider = - LoadBalancerRegistry.getDefaultRegistry().getProvider( - XdsLbPolicies.CLUSTER_RESOLVER_POLICY_NAME); - assertThat(provider).isInstanceOf(ClusterResolverLoadBalancerProvider.class); - } - - @Test - public void providesLoadBalancer() { - Helper helper = mock(Helper.class); - - SynchronizationContext syncContext = new SynchronizationContext( - new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - throw new AssertionError(e); - } - }); - FakeClock fakeClock = new FakeClock(); - NameResolverRegistry nsRegistry = new NameResolverRegistry(); - NameResolver.Args args = NameResolver.Args.newBuilder() - .setDefaultPort(8080) - .setProxyDetector(GrpcUtil.NOOP_PROXY_DETECTOR) - .setSynchronizationContext(syncContext) - .setServiceConfigParser(mock(ServiceConfigParser.class)) - .setChannelLogger(mock(ChannelLogger.class)) - .build(); - when(helper.getNameResolverRegistry()).thenReturn(nsRegistry); - when(helper.getNameResolverArgs()).thenReturn(args); - when(helper.getSynchronizationContext()).thenReturn(syncContext); - when(helper.getScheduledExecutorService()).thenReturn(fakeClock.getScheduledExecutorService()); - when(helper.getAuthority()).thenReturn("api.google.com"); - LoadBalancerProvider provider = new ClusterResolverLoadBalancerProvider(); - LoadBalancer loadBalancer = provider.newLoadBalancer(helper); - assertThat(loadBalancer).isInstanceOf(ClusterResolverLoadBalancer.class); - } -} diff --git a/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java index be68018792b..388af1a643e 100644 --- a/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/ClusterResolverLoadBalancerTest.java @@ -36,7 +36,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; -import com.google.common.testing.EqualsTester; import com.google.protobuf.Any; import com.google.protobuf.Duration; import com.google.protobuf.UInt32Value; @@ -61,9 +60,10 @@ import io.grpc.ConnectivityState; import io.grpc.EquivalentAddressGroup; import io.grpc.HttpConnectProxiedSocketAddress; -import io.grpc.InsecureChannelCredentials; +import io.grpc.InternalEquivalentAddressGroup; import io.grpc.LoadBalancer; import io.grpc.LoadBalancer.Helper; +import io.grpc.LoadBalancer.PickDetailsConsumer; import io.grpc.LoadBalancer.PickResult; import io.grpc.LoadBalancer.PickSubchannelArgs; import io.grpc.LoadBalancer.ResolvedAddresses; @@ -83,26 +83,22 @@ import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; import io.grpc.testing.GrpcCleanupRule; -import io.grpc.util.GracefulSwitchLoadBalancer; import io.grpc.util.GracefulSwitchLoadBalancerAccessor; import io.grpc.util.OutlierDetectionLoadBalancer.OutlierDetectionLoadBalancerConfig; import io.grpc.util.OutlierDetectionLoadBalancerProvider; import io.grpc.xds.CdsLoadBalancerProvider.CdsConfig; import io.grpc.xds.ClusterImplLoadBalancerProvider.ClusterImplConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig; -import io.grpc.xds.ClusterResolverLoadBalancerProvider.ClusterResolverConfig.DiscoveryMechanism; import io.grpc.xds.Endpoints.DropOverload; -import io.grpc.xds.EnvoyServerProtoData.FailurePercentageEjection; -import io.grpc.xds.EnvoyServerProtoData.SuccessRateEjection; import io.grpc.xds.EnvoyServerProtoData.UpstreamTlsContext; -import io.grpc.xds.LeastRequestLoadBalancer.LeastRequestConfig; import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig; import io.grpc.xds.PriorityLoadBalancerProvider.PriorityLbConfig.PriorityChildConfig; import io.grpc.xds.RingHashLoadBalancer.RingHashConfig; import io.grpc.xds.WrrLocalityLoadBalancer.WrrLocalityConfig; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.LoadStatsManager2; import io.grpc.xds.client.XdsClient; -import io.grpc.xds.internal.security.CommonTlsContextTestsUtil; +import io.grpc.xds.internal.XdsInternalAttributes; import java.net.InetSocketAddress; import java.net.URI; import java.util.ArrayList; @@ -199,7 +195,6 @@ public void uncaughtException(Thread t, Throwable e) { @Before public void setUp() throws Exception { - lbRegistry.register(new ClusterResolverLoadBalancerProvider(lbRegistry)); lbRegistry.register(new RingHashLoadBalancerProvider()); lbRegistry.register(new WrrLocalityLoadBalancerProvider()); lbRegistry.register(new FakeLoadBalancerProvider(PRIORITY_POLICY_NAME)); @@ -267,16 +262,33 @@ public void tearDown() throws Exception { assertThat(fakeClock.getPendingTasks()).isEmpty(); } + @Test + public void edsClustersWithRingHashEndpointLbPolicy_oppositePickFirstWeightedShuffling() + throws Exception { + boolean original = CdsLoadBalancer2.pickFirstWeightedShuffling; + CdsLoadBalancer2.pickFirstWeightedShuffling = !CdsLoadBalancer2.pickFirstWeightedShuffling; + try { + edsClustersWithRingHashEndpointLbPolicy(); + } finally { + CdsLoadBalancer2.pickFirstWeightedShuffling = original; + } + } + @Test public void edsClustersWithRingHashEndpointLbPolicy() throws Exception { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = true; + List metricSpecs = Arrays.asList("cpu_utilization"); + BackendMetricPropagation backendMetricPropagation = + BackendMetricPropagation.fromMetricSpecs(metricSpecs); Cluster cluster = EDS_CLUSTER.toBuilder() .setLbPolicy(Cluster.LbPolicy.RING_HASH) .setRingHashLbConfig(Cluster.RingHashLbConfig.newBuilder() .setMinimumRingSize(UInt64Value.of(10)) .setMaximumRingSize(UInt64Value.of(100)) .build()) + .addAllLrsReportEndpointMetrics(metricSpecs) .build(); - // One priority with two localities of different weights. ClusterLoadAssignment clusterLoadAssignment = ClusterLoadAssignment.newBuilder() .setClusterName(EDS_SERVICE_NAME) .addEndpoints(LocalityLbEndpoints.newBuilder() @@ -307,16 +319,22 @@ public void edsClustersWithRingHashEndpointLbPolicy() throws Exception { // LOCALITY1 are equally weighted. assertThat(addr1.getAddresses()) .isEqualTo(Arrays.asList(newInetSocketAddress("127.0.0.1", 8080))); - assertThat(addr1.getAttributes().get(XdsAttributes.ATTR_SERVER_WEIGHT)) - .isEqualTo(10); + assertThat(addr1.getAttributes().get(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE)) + .isEqualTo(CLUSTER); + assertThat(addr1.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_SERVER_WEIGHT)) + .isEqualTo(CdsLoadBalancer2.pickFirstWeightedShuffling ? 0x0AAAAAAA /* 1/12 */ : 10); assertThat(addr2.getAddresses()) .isEqualTo(Arrays.asList(newInetSocketAddress("127.0.0.2", 8080))); - assertThat(addr2.getAttributes().get(XdsAttributes.ATTR_SERVER_WEIGHT)) - .isEqualTo(10); + assertThat(addr2.getAttributes().get(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE)) + .isEqualTo(CLUSTER); + assertThat(addr2.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_SERVER_WEIGHT)) + .isEqualTo(CdsLoadBalancer2.pickFirstWeightedShuffling ? 0x0AAAAAAA /* 1/12 */ : 10); assertThat(addr3.getAddresses()) .isEqualTo(Arrays.asList(newInetSocketAddress("127.0.1.1", 8080))); - assertThat(addr3.getAttributes().get(XdsAttributes.ATTR_SERVER_WEIGHT)) - .isEqualTo(50 * 60); + assertThat(addr3.getAttributes().get(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE)) + .isEqualTo(CLUSTER); + assertThat(addr3.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_SERVER_WEIGHT)) + .isEqualTo(CdsLoadBalancer2.pickFirstWeightedShuffling ? 0x6AAAAAAA /* 5/6 */ : 50 * 60); assertThat(childBalancer.name).isEqualTo(PRIORITY_POLICY_NAME); PriorityLbConfig priorityLbConfig = (PriorityLbConfig) childBalancer.config; assertThat(priorityLbConfig.priorities).containsExactly(CLUSTER + "[child1]"); @@ -330,6 +348,8 @@ public void edsClustersWithRingHashEndpointLbPolicy() throws Exception { GracefulSwitchLoadBalancerAccessor.getChildConfig(priorityChildConfig.childConfig); assertClusterImplConfig(clusterImplConfig, CLUSTER, EDS_SERVICE_NAME, null, null, null, Collections.emptyList(), "ring_hash_experimental"); + assertThat(clusterImplConfig.backendMetricPropagation).isEqualTo(backendMetricPropagation); + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; RingHashConfig ringHashConfig = (RingHashConfig) GracefulSwitchLoadBalancerAccessor.getChildConfig(clusterImplConfig.childConfig); assertThat(ringHashConfig.minRingSize).isEqualTo(10L); @@ -382,7 +402,7 @@ public void edsClustersWithLeastRequestEndpointLbPolicy() { assertThat( childBalancer.addresses.get(0).getAttributes() - .get(XdsAttributes.ATTR_LOCALITY_WEIGHT)).isEqualTo(100); + .get(io.grpc.xds.XdsAttributes.ATTR_LOCALITY_WEIGHT)).isEqualTo(100); } @Test @@ -408,7 +428,7 @@ public void edsClustersEndpointHostname_addedToAddressAttribute() { assertThat( childBalancer.addresses.get(0).getAttributes() - .get(XdsAttributes.ATTR_ADDRESS_NAME)).isEqualTo("hostname1"); + .get(XdsInternalAttributes.ATTR_ADDRESS_NAME)).isEqualTo("hostname1"); } @Test @@ -580,12 +600,13 @@ public void onlyEdsClusters_receivedEndpoints() { io.grpc.xds.client.Locality locality2 = io.grpc.xds.client.Locality.create( LOCALITY2.getRegion(), LOCALITY2.getZone(), LOCALITY2.getSubZone()); for (EquivalentAddressGroup eag : childBalancer.addresses) { - io.grpc.xds.client.Locality locality = eag.getAttributes().get(XdsAttributes.ATTR_LOCALITY); + io.grpc.xds.client.Locality locality = + eag.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_LOCALITY); if (locality.equals(locality1)) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_LOCALITY_WEIGHT)) + assertThat(eag.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_LOCALITY_WEIGHT)) .isEqualTo(70); } else if (locality.equals(locality2)) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_LOCALITY_WEIGHT)) + assertThat(eag.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_LOCALITY_WEIGHT)) .isEqualTo(30); } else { throw new AssertionError("Unexpected locality region: " + locality.region()); @@ -677,11 +698,10 @@ public void onlyEdsClusters_resourceNeverExist_returnErrorPicker() { verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - assertPicker( - pickerCaptor.getValue(), - Status.UNAVAILABLE.withDescription( - "CDS resource " + CLUSTER + " does not exist nodeID: node-id"), - null); + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + " nodeID: node-id: " + + "NOT_FOUND: Timed out waiting for resource " + CLUSTER + " from xDS server"; + Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); + assertPicker(pickerCaptor.getValue(), expectedError, null); } @Test @@ -701,8 +721,10 @@ public void cdsMissing_handledDirectly() { assertThat(childBalancers).hasSize(0); // no child LB policy created verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - Status expectedError = Status.UNAVAILABLE.withDescription( - "CDS resource " + CLUSTER + " does not exist nodeID: node-id"); + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + " nodeID: node-id: " + + "NOT_FOUND: Timed out waiting for resource " + CLUSTER + " from xDS server"; + Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); + assertPicker(pickerCaptor.getValue(), expectedError, null); assertPicker(pickerCaptor.getValue(), expectedError, null); } @@ -730,42 +752,42 @@ public void cdsRevoked_handledDirectly() { controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of()); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - Status expectedError = Status.UNAVAILABLE.withDescription( - "CDS resource " + CLUSTER + " does not exist nodeID: node-id"); + String expectedDescription = "Error retrieving CDS resource " + CLUSTER + " nodeID: node-id: " + + "NOT_FOUND: Resource " + CLUSTER + " does not exist"; + Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); assertPicker(pickerCaptor.getValue(), expectedError, null); assertThat(childBalancer.shutdown).isTrue(); } @Test - public void edsMissing_handledByChildPolicy() { + public void edsMissing_failsRpcs() { controlPlaneService.setXdsConfig(ADS_TYPE_URL_EDS, ImmutableMap.of()); startXdsDepManager(); - assertThat(childBalancers).hasSize(1); // child LB policy created - FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - assertThat(childBalancer.upstreamError).isNotNull(); - assertThat(childBalancer.upstreamError.getCode()).isEqualTo(Status.Code.UNAVAILABLE); - assertThat(childBalancer.upstreamError.getDescription()) - .isEqualTo("EDS resource " + EDS_SERVICE_NAME + " does not exist nodeID: node-id"); - assertThat(childBalancer.shutdown).isFalse(); + assertThat(childBalancers).hasSize(0); // Graceful switch handles it, so no child policies yet + verify(helper).updateBalancingState( + eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); + String expectedDescription = "Error retrieving EDS resource " + EDS_SERVICE_NAME + + " nodeID: node-id: " + + "NOT_FOUND: Timed out waiting for resource " + EDS_SERVICE_NAME + " from xDS server"; + Status expectedError = Status.UNAVAILABLE.withDescription(expectedDescription); + assertPicker(pickerCaptor.getValue(), expectedError, null); } @Test - public void logicalDnsLookupFailed_handledByChildPolicy() { + public void logicalDnsLookupFailed_failsRpcs() { controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of( CLUSTER, LOGICAL_DNS_CLUSTER)); startXdsDepManager(new CdsConfig(CLUSTER), /* forwardTime= */ false); FakeNameResolver resolver = assertResolverCreated("/" + DNS_HOST_NAME + ":9000"); assertThat(childBalancers).isEmpty(); - resolver.deliverError(Status.UNAVAILABLE.withDescription("OH NO! Who would have guessed?")); + Status status = Status.UNAVAILABLE.withDescription("OH NO! Who would have guessed?"); + resolver.deliverError(status); - assertThat(childBalancers).hasSize(1); // child LB policy created - FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - assertThat(childBalancer.upstreamError).isNotNull(); - assertThat(childBalancer.upstreamError.getCode()).isEqualTo(Status.Code.UNAVAILABLE); - assertThat(childBalancer.upstreamError.getDescription()) - .isEqualTo("OH NO! Who would have guessed?"); - assertThat(childBalancer.shutdown).isFalse(); + assertThat(childBalancers).hasSize(0); // Graceful switch handles it, so no child policies yet + verify(helper).updateBalancingState( + eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); + assertPicker(pickerCaptor.getValue(), status, null); } @Test @@ -813,7 +835,8 @@ public void handleEdsResource_ignoreLocalitiesWithNoHealthyEndpoints() { io.grpc.xds.client.Locality locality2 = io.grpc.xds.client.Locality.create( LOCALITY2.getRegion(), LOCALITY2.getZone(), LOCALITY2.getSubZone()); for (EquivalentAddressGroup eag : childBalancer.addresses) { - assertThat(eag.getAttributes().get(XdsAttributes.ATTR_LOCALITY)).isEqualTo(locality2); + assertThat(eag.getAttributes().get(io.grpc.xds.XdsAttributes.ATTR_LOCALITY)) + .isEqualTo(locality2); } } @@ -857,19 +880,26 @@ public void handleEdsResource_noHealthyEndpoint() { EDS_SERVICE_NAME, clusterLoadAssignment)); startXdsDepManager(); - verify(helper, never()).updateBalancingState(eq(ConnectivityState.TRANSIENT_FAILURE), any()); - assertThat(childBalancers).hasSize(1); - FakeLoadBalancer childBalancer = Iterables.getOnlyElement(childBalancers); - assertThat(childBalancer.upstreamError).isNotNull(); - assertThat(childBalancer.upstreamError.getCode()).isEqualTo(Status.Code.UNAVAILABLE); - assertThat(childBalancer.upstreamError.getDescription()) - .isEqualTo("No usable endpoint from cluster: " + CLUSTER); + assertThat(childBalancers).hasSize(0); // Graceful switch handles it, so no child policies yet + verify(helper).updateBalancingState( + eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); + Status expectedStatus = Status.UNAVAILABLE + .withDescription("No usable endpoint from cluster: " + CLUSTER); + assertPicker(pickerCaptor.getValue(), expectedStatus, null); } @Test public void onlyLogicalDnsCluster_endpointsResolved() { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = true; + List metricSpecs = Arrays.asList("cpu_utilization"); + BackendMetricPropagation backendMetricPropagation = + BackendMetricPropagation.fromMetricSpecs(metricSpecs); + Cluster logicalDnsClusterWithMetrics = LOGICAL_DNS_CLUSTER.toBuilder() + .addAllLrsReportEndpointMetrics(metricSpecs) + .build(); controlPlaneService.setXdsConfig(ADS_TYPE_URL_CDS, ImmutableMap.of( - CLUSTER, LOGICAL_DNS_CLUSTER)); + CLUSTER, logicalDnsClusterWithMetrics)); startXdsDepManager(new CdsConfig(CLUSTER), /* forwardTime= */ false); FakeNameResolver resolver = assertResolverCreated("/" + DNS_HOST_NAME + ":9000"); assertThat(childBalancers).isEmpty(); @@ -892,12 +922,16 @@ public void onlyLogicalDnsCluster_endpointsResolved() { GracefulSwitchLoadBalancerAccessor.getChildConfig(priorityChildConfig.childConfig); assertClusterImplConfig(clusterImplConfig, CLUSTER, null, null, null, null, Collections.emptyList(), "wrr_locality_experimental"); + assertThat(clusterImplConfig.backendMetricPropagation).isEqualTo(backendMetricPropagation); + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; assertAddressesEqual( Arrays.asList(new EquivalentAddressGroup(Arrays.asList( newInetSocketAddress("127.0.2.1", 9000), newInetSocketAddress("127.0.2.2", 9000)))), childBalancer.addresses); assertThat(childBalancer.addresses.get(0).getAttributes() - .get(XdsAttributes.ATTR_ADDRESS_NAME)).isEqualTo(DNS_HOST_NAME + ":9000"); + .get(InternalEquivalentAddressGroup.ATTR_BACKEND_SERVICE)).isEqualTo(CLUSTER); + assertThat(childBalancer.addresses.get(0).getAttributes() + .get(XdsInternalAttributes.ATTR_ADDRESS_NAME)).isEqualTo(DNS_HOST_NAME + ":9000"); } @Test @@ -990,49 +1024,6 @@ public void outlierDetection_fullConfig() { "wrr_locality_experimental"); } - @Test - public void config_equalsTester() { - ServerInfo lrsServerInfo = - ServerInfo.create("lrs.googleapis.com", InsecureChannelCredentials.create()); - UpstreamTlsContext tlsContext = - CommonTlsContextTestsUtil.buildUpstreamTlsContext("google_cloud_private_spiffe", true); - DiscoveryMechanism edsDiscoveryMechanism1 = - DiscoveryMechanism.forEds(CLUSTER, EDS_SERVICE_NAME, lrsServerInfo, 100L, tlsContext, - Collections.emptyMap(), null); - io.grpc.xds.EnvoyServerProtoData.OutlierDetection outlierDetection = - io.grpc.xds.EnvoyServerProtoData.OutlierDetection.create( - 100L, 100L, 100L, 100, SuccessRateEjection.create(100, 100, 100, 100), - FailurePercentageEjection.create(100, 100, 100, 100)); - DiscoveryMechanism edsDiscoveryMechanismWithOutlierDetection = - DiscoveryMechanism.forEds(CLUSTER, EDS_SERVICE_NAME, lrsServerInfo, 100L, tlsContext, - Collections.emptyMap(), outlierDetection); - Object roundRobin = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - new FakeLoadBalancerProvider("wrr_locality_experimental"), new WrrLocalityConfig( - GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - new FakeLoadBalancerProvider("round_robin"), null))); - Object leastRequest = GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - new FakeLoadBalancerProvider("wrr_locality_experimental"), new WrrLocalityConfig( - GracefulSwitchLoadBalancer.createLoadBalancingPolicyConfig( - new FakeLoadBalancerProvider("least_request_experimental"), - new LeastRequestConfig(3)))); - - new EqualsTester() - .addEqualityGroup( - new ClusterResolverConfig( - edsDiscoveryMechanism1, leastRequest, false), - new ClusterResolverConfig( - edsDiscoveryMechanism1, leastRequest, false)) - .addEqualityGroup(new ClusterResolverConfig( - edsDiscoveryMechanism1, roundRobin, false)) - .addEqualityGroup(new ClusterResolverConfig( - edsDiscoveryMechanism1, leastRequest, true)) - .addEqualityGroup(new ClusterResolverConfig( - edsDiscoveryMechanismWithOutlierDetection, - leastRequest, - false)) - .testEquals(); - } - private void startXdsDepManager() { startXdsDepManager(new CdsConfig(CLUSTER)); } @@ -1053,8 +1044,8 @@ private void startXdsDepManager(final CdsConfig cdsConfig, boolean forwardTime) loadBalancer.acceptResolvedAddresses(ResolvedAddresses.newBuilder() .setAddresses(Collections.emptyList()) .setAttributes(Attributes.newBuilder() - .set(XdsAttributes.XDS_CONFIG, xdsConfig.getValue()) - .set(XdsAttributes.XDS_CLUSTER_SUBSCRIPT_REGISTRY, xdsDepManager) + .set(io.grpc.xds.XdsAttributes.XDS_CONFIG, xdsConfig.getValue()) + .set(io.grpc.xds.XdsAttributes.XDS_CLUSTER_SUBSCRIPT_REGISTRY, xdsDepManager) .build()) .setLoadBalancingPolicyConfig(cdsConfig) .build()); @@ -1074,7 +1065,9 @@ private FakeNameResolver assertResolverCreated(String uriPath) { private static void assertPicker(SubchannelPicker picker, Status expectedStatus, @Nullable Subchannel expectedSubchannel) { - PickResult result = picker.pickSubchannel(mock(PickSubchannelArgs.class)); + PickSubchannelArgs args = mock(PickSubchannelArgs.class); + when(args.getPickDetailsConsumer()).thenReturn(mock(PickDetailsConsumer.class)); + PickResult result = picker.pickSubchannel(args); Status actualStatus = result.getStatus(); assertThat(actualStatus.getCode()).isEqualTo(expectedStatus.getCode()); assertThat(actualStatus.getDescription()).isEqualTo(expectedStatus.getDescription()); @@ -1235,7 +1228,6 @@ private final class FakeLoadBalancer extends LoadBalancer { private final Helper helper; private List addresses; private Object config; - private Status upstreamError; private boolean shutdown; FakeLoadBalancer(String name, Helper helper) { @@ -1252,7 +1244,6 @@ public Status acceptResolvedAddresses(ResolvedAddresses resolvedAddresses) { @Override public void handleNameResolutionError(Status error) { - upstreamError = error; } @Override diff --git a/xds/src/test/java/io/grpc/xds/CsdsServiceTest.java b/xds/src/test/java/io/grpc/xds/CsdsServiceTest.java index 573aef7ca1e..e8bd7461736 100644 --- a/xds/src/test/java/io/grpc/xds/CsdsServiceTest.java +++ b/xds/src/test/java/io/grpc/xds/CsdsServiceTest.java @@ -513,13 +513,8 @@ public List getTargets() { } @Override - public void setBootstrapOverride(Map bootstrap) { - throw new UnsupportedOperationException("Should not be called"); - } - - - @Override - public ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) { + public ObjectPool getOrCreate( + String target, BootstrapInfo bootstrapInfo, MetricRecorder metricRecorder) { throw new UnsupportedOperationException("Should not be called"); } } diff --git a/xds/src/test/java/io/grpc/xds/ExtAuthzConfigParserTest.java b/xds/src/test/java/io/grpc/xds/ExtAuthzConfigParserTest.java new file mode 100644 index 00000000000..fa2718cbe63 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/ExtAuthzConfigParserTest.java @@ -0,0 +1,297 @@ +/* + * 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/ExternalProcessorClientInterceptorTest.java b/xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java new file mode 100644 index 00000000000..9b07cae3477 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/ExternalProcessorClientInterceptorTest.java @@ -0,0 +1,14659 @@ +/* + * 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.io.BaseEncoding; +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.config.core.v3.HeaderValue; +import io.envoyproxy.envoy.config.core.v3.HeaderValueOption; +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.HttpBody; +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.ClientInterceptors; +import io.grpc.Context; +import io.grpc.Deadline; +import io.grpc.ForwardingClientCall.SimpleForwardingClientCall; +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.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.ConfigOrError; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterConfig; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterOverrideConfig; +import io.grpc.xds.Filter; +import io.grpc.xds.XdsNameResolver; +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 ExternalProcessorClientInterceptorTest { + static { + System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "true"); + } + + @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 static final Filter.FilterContext FAKE_CONTEXT = Filter.FilterContext.create( + "test-filter", new io.grpc.MetricRecorder() {}); + private static final CallOptions DEFAULT_CALL_OPTIONS = CallOptions.DEFAULT + .withOption(XdsNameResolver.CLUSTER_SELECTION_KEY, "backend-service-metric"); + private Filter.FilterConfigParseContext 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.FilterConfigParseContext.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 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + 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(FAKE_CONTEXT); + ExternalProcessorClientInterceptor interceptor = (ExternalProcessorClientInterceptor) + filter.buildClientInterceptor(parentConfig, overrideConfig, scheduler); + + assertThat(interceptor.getFilterConfig().getDeferredCloseTimeoutNanos()) + .isEqualTo(TimeUnit.SECONDS.toNanos(10)); + } + + // --- Category 2: 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + } + + + + // --- Category 3: Protocol config propagation --- + + @Test + public void protocolConfig_onHeaders() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + final CountDownLatch sidecarLatch = new CountDownLatch(3); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(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() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .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).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(capturedRequests.size()).isAtLeast(2); + + // 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); + + // Subsequent requests should NOT have protocol_config + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).hasProtocolConfig()).isFalse(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + public void protocolConfig_onBody() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + final CountDownLatch sidecarLatch = new CountDownLatch(2); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(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() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .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).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(capturedRequests.size()).isAtLeast(1); + + // First request should be RequestBody and should have protocol_config + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasRequestBody()).isTrue(); + assertThat(firstReq.hasProtocolConfig()).isTrue(); + assertThat(firstReq.getProtocolConfig().getRequestBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + assertThat(firstReq.getProtocolConfig().getResponseBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + + // Subsequent requests should NOT have protocol_config + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).hasProtocolConfig()).isFalse(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + public void protocolConfig_onResponseHeaders() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + final CountDownLatch sidecarLatch = new CountDownLatch(2); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.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() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .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).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(capturedRequests.size()).isAtLeast(1); + + // First request should be ResponseHeaders and should have protocol_config + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasResponseHeaders()).isTrue(); + assertThat(firstReq.hasProtocolConfig()).isTrue(); + assertThat(firstReq.getProtocolConfig().getRequestBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.NONE); + assertThat(firstReq.getProtocolConfig().getResponseBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + + // Subsequent requests should NOT have protocol_config + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).hasProtocolConfig()).isFalse(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + public void protocolConfig_onResponseBody() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasResponseBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.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() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .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).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(capturedRequests.size()).isAtLeast(1); + + // First request should be ResponseBody and should have protocol_config + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasResponseBody()).isTrue(); + assertThat(firstReq.hasProtocolConfig()).isTrue(); + assertThat(firstReq.getProtocolConfig().getRequestBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.NONE); + assertThat(firstReq.getProtocolConfig().getResponseBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.GRPC); + + // Subsequent requests should NOT have protocol_config + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).hasProtocolConfig()).isFalse(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + public void protocolConfig_onResponseTrailers() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasResponseTrailers()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.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() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .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).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(capturedRequests.size()).isAtLeast(1); + + // First request should be ResponseTrailers and should have protocol_config + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasResponseTrailers()).isTrue(); + assertThat(firstReq.hasProtocolConfig()).isTrue(); + assertThat(firstReq.getProtocolConfig().getRequestBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.NONE); + assertThat(firstReq.getProtocolConfig().getResponseBodyMode()) + .isEqualTo(ProcessingMode.BodySendMode.NONE); + + // Subsequent requests should NOT have protocol_config + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).hasProtocolConfig()).isFalse(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 4: GrpcService Initial Metadata --- + + @Test + public void givenGrpcServiceWithInitialMetadata_whenCallIntercepted_thenSendsMetadata() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .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 = + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 5: Request attributes propagation --- + + @Test + public void requestAttributes_onHeaders() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .addRequestAttributes("request.path") + .addRequestAttributes("request.host") + .build(); + + final CountDownLatch sidecarLatch = new CountDownLatch(2); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + capturedRequests.add(request); + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.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()); + + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequests.size()).isAtLeast(2); + + // First request should be RequestHeaders and should have attributes + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasRequestHeaders()).isTrue(); + assertThat(firstReq.getAttributesCount()).isGreaterThan(0); + com.google.protobuf.Struct pathAttr = firstReq.getAttributesMap().get("request.path"); + assertThat(pathAttr.getFieldsOrThrow("").getStringValue()) + .isEqualTo("/test.TestService/SayHello"); + com.google.protobuf.Struct hostAttr = firstReq.getAttributesMap().get("request.host"); + assertThat(hostAttr.getFieldsOrThrow("").getStringValue()) + .isEqualTo(dataPlaneChannel.authority()); + + // Subsequent requests should NOT have attributes + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).getAttributesCount()).isEqualTo(0); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + public void requestAttributes_onBody() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .addRequestAttributes("request.path") + .addRequestAttributes("request.host") + .build(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + capturedRequests.add(request); + if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.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()); + + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequests.size()).isAtLeast(1); + + // First request should be RequestBody and should have attributes + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasRequestBody()).isTrue(); + assertThat(firstReq.getAttributesCount()).isGreaterThan(0); + com.google.protobuf.Struct pathAttr = firstReq.getAttributesMap().get("request.path"); + assertThat(pathAttr.getFieldsOrThrow("").getStringValue()) + .isEqualTo("/test.TestService/SayHello"); + com.google.protobuf.Struct hostAttr = firstReq.getAttributesMap().get("request.host"); + assertThat(hostAttr.getFieldsOrThrow("").getStringValue()) + .isEqualTo(dataPlaneChannel.authority()); + + // Subsequent requests should NOT have attributes + for (int i = 1; i < capturedRequests.size(); i++) { + assertThat(capturedRequests.get(i).getAttributesCount()).isEqualTo(0); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + public void requestAttributes_notSent() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = dataPlaneServerName; + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .addRequestAttributes("request.path") + .addRequestAttributes("request.host") + .build(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + capturedRequests.add(request); + if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(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()); + + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedRequests.size()).isAtLeast(1); + + // First request should be ResponseHeaders, and should NOT have attributes + ProcessingRequest firstReq = capturedRequests.get(0); + assertThat(firstReq.hasResponseHeaders()).isTrue(); + assertThat(firstReq.getAttributesCount()).isEqualTo(0); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 6: Request Header Processing --- + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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") + // Note: givenRequestHeaderModeSend_whenExtProcTerminates_thenCallIsActivated tests the case + // when the ext-proc stream terminates while waiting for call activation. + 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; + + final CountDownLatch appFinishedLatch = new CountDownLatch(1); + + // 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()) { + try { + appFinishedLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + Metadata headers = new Metadata(); + proxyCall.start(new ClientCall.Listener() {}, headers); + + // Send message and half-close to trigger unary call while the call is buffered + // (since ext-proc is waiting) + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Release the ext-proc response now that all app events are buffered + appFinishedLatch.countDown(); + + // 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 givenHeaderModeSend_whenCallHasBinaryHeaders_thenBinaryHeadersForwarded() + 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 AtomicReference capturedRequest = new AtomicReference<>(); + final CountDownLatch extProcLatch = new CountDownLatch(1); + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase 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()) { + capturedRequest.set(request); + } + new Thread(() -> { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + extProcLatch.countDown(); + }).start(); + } + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + Metadata headers = new Metadata(); + headers.put( + Metadata.Key.of("x-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER), + new byte[] {4, 5, 6}); + proxyCall.start(new ClientCall.Listener() {}, headers); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(extProcLatch.await(5, TimeUnit.SECONDS)).isTrue(); + ProcessingRequest req = capturedRequest.get(); + assertThat(req).isNotNull(); + assertThat(req.hasRequestHeaders()).isTrue(); + + // Find x-bin-key-bin header in HeaderMap + io.envoyproxy.envoy.config.core.v3.HeaderValue foundHeader = null; + for (io.envoyproxy.envoy.config.core.v3.HeaderValue hv + : req.getRequestHeaders().getHeaders().getHeadersList()) { + if (hv.getKey().equals("x-bin-key-bin")) { + foundHeader = hv; + break; + } + } + assertThat(foundHeader).isNotNull(); + assertThat(foundHeader.getRawValue()).isEqualTo(ByteString.copyFromUtf8("BAUG")); + + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + } + + @Test + @SuppressWarnings("unchecked") + public void + givenRequestHeaderModeSkip_whenBodyProcessingEnabled_thenFirstRequestToExtProcIsRequestBody() + 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; + + final CountDownLatch extProcLatch = new CountDownLatch(1); + final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + ExternalProcessorGrpc.ExternalProcessorImplBase 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) { + capturedRequests.add(request); + if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder().build()) + .build()); + extProcLatch.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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test-message"); + proxyCall.halfClose(); + + assertThat(extProcLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // The requests sent during the request flow should be body requests, + // and no request header message should be sent. + assertThat(capturedRequests).hasSize(2); + assertThat(capturedRequests.get(0).hasRequestHeaders()).isFalse(); + assertThat(capturedRequests.get(0).hasRequestBody()).isTrue(); + assertThat(capturedRequests.get(0).getRequestBody().getBody().toStringUtf8()) + .isEqualTo("test-message"); + assertThat(capturedRequests.get(1).hasRequestHeaders()).isFalse(); + assertThat(capturedRequests.get(1).hasRequestBody()).isTrue(); + assertThat(capturedRequests.get(1).getRequestBody().getEndOfStreamWithoutMessage()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 7: 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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) { + 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) + .setEndOfStreamWithoutMessage(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()); + } + } + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 givenRequestBodyModeGrpc_whenExtProcRespondsEmpty_thenEmptyMsgDelivered() + throws Exception { + String uniqueExtProcServerName = + "extProc-emptyMsg-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-emptyMsg-" + 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) + .setResponseTrailerMode(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) { + 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) + .setEndOfStreamWithoutMessage(true) + .build()) + .build()) + .build()); + } else { + bodyResponse.setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.EMPTY) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + final AtomicReference clientStatus = new AtomicReference<>(); + final CountDownLatch clientCloseLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + clientStatus.set(status); + clientCloseLatch.countDown(); + } + }, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("Original"); + proxyCall.halfClose(); + + boolean dataPlaneOk = dataPlaneLatch.await(5, TimeUnit.SECONDS); + boolean clientClosedOk = clientCloseLatch.await(5, TimeUnit.SECONDS); + + assertThat(dataPlaneOk).isTrue(); + assertThat(receivedBody.get()).isEqualTo(""); + assertThat(clientClosedOk).isTrue(); + assertThat(clientStatus.get().isOk()).isTrue(); + + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 givenRequestBodyModeNone_whenSendMessageCalled_thenMessageSentDirectlyToDataPlane() + throws Exception { + String uniqueExtProcServerName = "extProc-noneBody-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-noneBody-" + 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.NONE).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final AtomicInteger extProcBodyCount = 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.hasRequestBody()) { + extProcBodyCount.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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("Hello World"); + proxyCall.halfClose(); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedBody.get()).isEqualTo("Hello World"); + assertThat(extProcBodyCount.get()).isEqualTo(0); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + + // --- Category 8: Response Header Mutation --- + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenResponseHeaderModeSkip_responseHeadersSentDirectlyUpstream() + throws Exception { + String uniqueExtProcServerName = + "extProc-skip-headers-" + InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = + "dataPlane-skip-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() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP).build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + Metadata.Key customKey = + Metadata.Key.of("custom-response-header", Metadata.ASCII_STRING_MARSHALLER); + + // External Processor Server + final java.util.concurrent.atomic.AtomicBoolean responseHeadersReceived = + new java.util.concurrent.atomic.AtomicBoolean(false); + final java.util.concurrent.CountDownLatch requestHeadersLatch = + new java.util.concurrent.CountDownLatch(1); + final CountDownLatch extProcCompletedLatch = 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()); + requestHeadersLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseHeadersReceived.set(true); + } + responseObserver.onNext(response.build()); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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) { + Metadata responseHeaders = new Metadata(); + responseHeaders.put(customKey, "response-value"); + call.sendHeaders(responseHeaders); + 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); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override public void onHeaders(Metadata headers) { + receivedHeaders.set(headers); + headersLatch.countDown(); + } + + @Override public void onClose(Status status, Metadata trailers) { + appCloseLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + proxyCall.request(1); + proxyCall.halfClose(); + + // Wait for request headers to be processed + assertThat(requestHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify application receives original response headers directly + assertThat(headersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedHeaders.get().get(customKey)).isEqualTo("response-value"); + + // Wait for the call to close naturally and the ext_proc stream to complete + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(responseHeadersReceived.get()).isFalse(); + + channelManager.close(); + } + + + // --- Category 9: 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.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; + + // 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()) + .build()) + .build()) + .build()) + .build()) + .build()); + } else if (request.hasResponseTrailers()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.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()); + fakeClock.forwardTime(1, TimeUnit.SECONDS); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).executor(scheduler).build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // 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 = DEFAULT_CALL_OPTIONS.withExecutor(scheduler); + ClientCall proxyCall = + interceptCall(interceptor, 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.SEND) + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .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")) + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarBodyLatch.countDown(); + } else if (request.hasResponseTrailers()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder().build()) + .build()); + } + } + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // 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 = DEFAULT_CALL_OPTIONS.withExecutor(scheduler); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + } + + + // --- Category 10: Response Trailers --- + + @Test + public void + givenResponseTrailerModeSend_whenCallCloses_thenTrailersAndStatusPropagated() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference capturedRequest = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + private boolean responseCompleted; + + private void completeResponse() { + if (!responseCompleted) { + responseCompleted = true; + responseObserver.onCompleted(); + } + } + + @Override + public void onNext(ProcessingRequest request) { + if (request.hasResponseTrailers()) { + capturedRequest.set(request); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders(HeaderValueOption.newBuilder() + .setHeader(HeaderValue.newBuilder() + .setKey("x-extproc-trailer") + .setValue("mutated") + .build()) + .build()) + .build()) + .build()) + .build()); + sidecarLatch.countDown(); + completeResponse(); + } 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() { + completeResponse(); + } + }; + } + }; + + 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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // Data Plane Server returning specific status and trailer + 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-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER), + "original"); + super.close( + Status.INVALID_ARGUMENT.withDescription("Custom DataPlane Error"), + 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 appCloseLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + final AtomicReference capturedTrailers = new AtomicReference<>(); + + ClientCall proxyCall = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + capturedTrailers.set(trailers); + 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(); + + // Verify status was propagated correctly + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(capturedStatus.get().getDescription()).isEqualTo("Custom DataPlane Error"); + + // Verify trailers contain both dataplane trailer and mutated extproc trailer + Metadata finalTrailers = capturedTrailers.get(); + assertThat(finalTrailers.get( + Metadata.Key.of("x-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("original"); + assertThat(finalTrailers.get( + Metadata.Key.of("x-extproc-trailer", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("mutated"); + + channelManager.close(); + } + + @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() { + private boolean responseCompleted; + + private void completeResponse() { + if (!responseCompleted) { + responseCompleted = true; + responseObserver.onCompleted(); + } + } + + @Override + public void onNext(ProcessingRequest request) { + if (request.hasResponseTrailers()) { + capturedRequest.set(request); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + completeResponse(); + } 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() { + completeResponse(); + } + }; + } + }; + 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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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); + final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + private boolean responseCompleted; + + private void completeResponse() { + if (!responseCompleted) { + responseCompleted = true; + responseObserver.onCompleted(); + } + } + + @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(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + completeResponse(); + extProcCompletedLatch.countDown(); + } + }; + } + }; + 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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER), + "original"); + super.close( + Status.INVALID_ARGUMENT.withDescription("Custom DataPlane Error"), + 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 appCloseLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + final AtomicReference capturedTrailers = new AtomicReference<>(); + + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + capturedTrailers.set(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 for the ext_proc stream to complete + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarTrailerCount.get()).isEqualTo(0); + + // Verify status was propagated correctly + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(capturedStatus.get().getDescription()).isEqualTo("Custom DataPlane Error"); + + // Verify trailers contain dataplane trailer + Metadata finalTrailers = capturedTrailers.get(); + assertThat(finalTrailers.get( + Metadata.Key.of("x-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("original"); + + channelManager.close(); + } + + @Test + public void givenResponseTrailerModeSkip_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); + final CountDownLatch extProcCompletedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl; + extProcImpl = new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + return new StreamObserver() { + private boolean responseCompleted; + + private void completeResponse() { + if (!responseCompleted) { + responseCompleted = true; + responseObserver.onCompleted(); + } + } + + @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(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + completeResponse(); + extProcCompletedLatch.countDown(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build() + .start()); + + // SKIP mode for trailers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER), + "original"); + super.close( + Status.INVALID_ARGUMENT.withDescription("Custom DataPlane Error"), + 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 appCloseLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + final AtomicReference capturedTrailers = new AtomicReference<>(); + + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + capturedTrailers.set(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 for the ext_proc stream to complete + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarTrailerCount.get()).isEqualTo(0); + + // Verify status was propagated correctly + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(capturedStatus.get().getDescription()).isEqualTo("Custom DataPlane Error"); + + // Verify trailers contain dataplane trailer + Metadata finalTrailers = capturedTrailers.get(); + assertThat(finalTrailers.get( + Metadata.Key.of("x-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("original"); + + channelManager.close(); + } + + // --- Category 11: Trailers-only response handling --- + + @Test + public void + givenResponseHeaderModeSend_whenTrailersOnlyReceived_thenResponseHeadersSentToExtProc() + 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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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(); + } + + @Test + public void + givenResponseHeaderModeDefault_whenTrailersOnlyReceived_thenResponseHeadersSentToExtProc() + 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 set response header mode to DEFAULT + 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.DEFAULT) + .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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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(); + } + + @Test + public void + givenResponseHeaderModeSkip_whenTrailersOnlyReceived_thenResponseHeadersNotSentToExtProc() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final AtomicInteger sidecarTrailerCount = new AtomicInteger(0); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final CountDownLatch extProcCompletedLatch = 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.hasResponseHeaders()) { + sidecarTrailerCount.incrementAndGet(); + } else 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(); + extProcCompletedLatch.countDown(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(Executors.newSingleThreadExecutor()) + .build() + .start()); + + // SKIP mode for headers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + MutableHandlerRegistry uniqueDataPlaneRegistry = new MutableHandlerRegistry(); + uniqueDataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + Metadata trailers = new Metadata(); + trailers.put( + Metadata.Key.of("x-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER), + "original"); + responseObserver.onError( + Status.INVALID_ARGUMENT + .withDescription("Custom DataPlane Error") + .asRuntimeException(trailers)); + })).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); + final AtomicReference capturedStatus = new AtomicReference<>(); + final AtomicReference capturedTrailers = new AtomicReference<>(); + + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + capturedTrailers.set(trailers); + appCloseLatch.countDown(); + } + }, new Metadata()); + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(10, TimeUnit.SECONDS)).isTrue(); + // Wait for the ext_proc stream to complete + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(sidecarTrailerCount.get()).isEqualTo(0); + + // Verify status was propagated correctly + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT); + assertThat(capturedStatus.get().getDescription()).isEqualTo("Custom DataPlane Error"); + + // Verify trailers contain dataplane trailer + Metadata finalTrailers = capturedTrailers.get(); + assertThat(finalTrailers.get( + Metadata.Key.of("x-dataplane-trailer", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("original"); + + channelManager.close(); + } + + + + // --- Category 12: Half-Close handling --- + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 deferredHalfClose_whenExtProcRespondsWithEosWithoutMessage_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 + 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.hasRequestBody()) { + if (request.getRequestBody().getEndOfStreamWithoutMessage()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated1")) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated2")) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStreamWithoutMessage(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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List serverReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(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) { + serverReceivedMessages.add(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Ack"); + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.halfClose(); + + assertThat(dataPlaneHalfClosedLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThat(serverReceivedMessages).containsExactly("mutated1", "mutated2"); + + 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 + 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.hasRequestBody()) { + if (request.getRequestBody().getEndOfStreamWithoutMessage()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated1")) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated2")) + .setEndOfStream(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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List serverReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(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) { + serverReceivedMessages.add(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Ack"); + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.halfClose(); + + assertThat(dataPlaneHalfClosedLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThat(serverReceivedMessages).containsExactly("mutated1", "mutated2"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void + extProcRespondsWithEosWithoutMsg_whenAppNotHalfClosed_thenSuperHalfClose_moreDiscarded() + 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; + + final List extProcRequests = + new java.util.concurrent.CopyOnWriteArrayList<>(); + // 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) { + extProcRequests.add(request); + if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated1")) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated2")) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStreamWithoutMessage(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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List serverReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(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) { + serverReceivedMessages.add(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Ack"); + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("req1"); + + assertThat(dataPlaneHalfClosedLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThat(serverReceivedMessages).containsExactly("mutated1", "mutated2"); + + // Client app continues to send messages after super half close propagated. + proxyCall.sendMessage("req2"); + + // Assert that these messages are discarded and not propagated to either + // the ext_proc or the dataplane. + assertThat(serverReceivedMessages).containsExactly("mutated1", "mutated2"); + + List requestBodies = new java.util.ArrayList<>(); + for (ProcessingRequest req : extProcRequests) { + if (req.hasRequestBody()) { + requestBodies.add(req.getRequestBody()); + } + } + assertThat(requestBodies).hasSize(1); + assertThat(requestBodies.get(0).getBody().toStringUtf8()).isEqualTo("req1"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void + givenExtProcRespondsWithEos_whenAppHasNotHalfClosed_thenSuperHalfClose_moreDiscarded() + 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; + + final List extProcRequests = + new java.util.concurrent.CopyOnWriteArrayList<>(); + // 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) { + extProcRequests.add(request); + if (request.hasRequestBody()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated1")) + .build()) + .build()) + .build()) + .build()) + .build()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("mutated2")) + .setEndOfStream(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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List serverReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + MutableHandlerRegistry uniqueRegistry = new MutableHandlerRegistry(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueRegistry) + .directExecutor() + .build().start()); + uniqueRegistry.addService(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) { + serverReceivedMessages.add(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Ack"); + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("req1"); + + assertThat(dataPlaneHalfClosedLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThat(serverReceivedMessages).containsExactly("mutated1", "mutated2"); + + // Client app continues to send messages after super half close propagated. + proxyCall.sendMessage("req2"); + + // Assert that these messages are discarded and not propagated to either + // the ext_proc or the dataplane. + assertThat(serverReceivedMessages).containsExactly("mutated1", "mutated2"); + + List requestBodies = new java.util.ArrayList<>(); + for (ProcessingRequest req : extProcRequests) { + if (req.hasRequestBody()) { + requestBodies.add(req.getRequestBody()); + } + } + assertThat(requestBodies).hasSize(1); + assertThat(requestBodies.get(0).getBody().toStringUtf8()).isEqualTo("req1"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + + + // --- Category 13: 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; + + final List extProcRequests = + new java.util.concurrent.CopyOnWriteArrayList<>(); + // 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) { + extProcRequests.add(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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 CountDownLatch readyLatch = new CountDownLatch(1); + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onReady() { + readyLatch.countDown(); + } + }, new Metadata()); + + // Wait for activation (sidecar needs to respond to headers) + assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isTrue(); + + // Sidecar busy + sidecarReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + + assertThat(extProcRequests).isNotEmpty(); + for (ProcessingRequest request : extProcRequests) { + assertThat(request.getObservabilityMode()).isTrue(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenObservabilityMode_whenUpstreamBusy_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; + + final List extProcRequests = + new java.util.concurrent.CopyOnWriteArrayList<>(); + // 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) { + extProcRequests.add(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()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + final AtomicBoolean upstreamReady = new AtomicBoolean(true); + 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< + ReqT, RespT>(next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + return upstreamReady.get(); + } + }; + } + }) + .build()); + + final CountDownLatch readyLatch = new CountDownLatch(1); + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onReady() { + readyLatch.countDown(); + } + }, new Metadata()); + + // Wait for activation (sidecar needs to respond to headers) + assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isTrue(); + + // Upstream busy + upstreamReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + + assertThat(extProcRequests).isNotEmpty(); + for (ProcessingRequest request : extProcRequests) { + assertThat(request.getObservabilityMode()).isTrue(); + } + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenNormalMode_whenUpstreamBusy_thenIsReadyReturnsTrue() + 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(false) + .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()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + })) + .build()); + + final AtomicBoolean upstreamReady = new AtomicBoolean(false); + 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< + ReqT, RespT>(next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + return upstreamReady.get(); + } + }; + } + }) + .build()); + + final CountDownLatch readyLatch = new CountDownLatch(1); + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onReady() { + readyLatch.countDown(); + } + }, new Metadata()); + + // Wait for activation (sidecar needs to respond to headers) + assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // Since sidecar is ready, proxyCall.isReady() should return true, + // ignoring that upstream is busy + assertThat(proxyCall.isReady()).isTrue(); + + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 + public void givenExtProcStreamCompleted_whenIsReadyCalled_thenDelegatesToSuper() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + // 1. Configure ProcessingMode to only send request headers (skip the rest) + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + final CountDownLatch sidecarResponseLatch = new CountDownLatch(1); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + responseObserver.onCompleted(); + sidecarResponseLatch.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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + // 2. Custom ClientInterceptor to mock downstream call readiness + final AtomicBoolean downstreamReady = new AtomicBoolean(true); + final AtomicInteger downstreamIsReadyCallCount = new AtomicInteger(0); + ClientInterceptor downstreamMockInterceptor = new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new SimpleForwardingClientCall(next.newCall(method, callOptions)) { + @Override + public boolean isReady() { + downstreamIsReadyCallCount.incrementAndGet(); + return downstreamReady.get(); + } + }; + } + }; + + MutableHandlerRegistry dataPlaneRegistry = new MutableHandlerRegistry(); + dataPlaneRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + // Do not respond immediately to keep the data plane call active + })).build()); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(dataPlaneRegistry) + .directExecutor() + .build().start()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .intercept(downstreamMockInterceptor) + .directExecutor() + .build()); + + ClientCall proxyCall = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + proxyCall.request(1); + + // 3. Wait for the sidecar response to complete the external processor stream + assertThat(sidecarResponseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // 4. Assert that proxyCall.isReady() delegates directly to the downstream call + downstreamReady.set(true); + assertThat(proxyCall.isReady()).isTrue(); + assertThat(downstreamIsReadyCallCount.get()).isEqualTo(1); + + downstreamReady.set(false); + assertThat(proxyCall.isReady()).isFalse(); + assertThat(downstreamIsReadyCallCount.get()).isEqualTo(2); + + proxyCall.cancel("cleanup", null); + channelManager.close(); + } + + @Test + public void givenDataPlaneCallIdle_whenIsReadyCalled_thenReturnsFalse() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(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) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .directExecutor() + .build()); + + ClientCall proxyCall = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + // Call isReady() before calling start() + assertThat(proxyCall.isReady()).isFalse(); + + channelManager.close(); + } + + // --- Category 14: Ext-proc request draining --- + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 + public void givenDrainingStream_whenObserverIsNull_thenSendMessageDoesNotQueue() + 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()) + .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 = + 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() + .setRequestDrain(true) + .build()); + drainLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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()); + + @SuppressWarnings("unchecked") + ClientCall proxyCall = (ClientCall) (ClientCall) + interceptor.interceptCall( + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(drainLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Force the observer to null via reflection to test the false branch + java.lang.reflect.Field field = proxyCall.getClass() + .getDeclaredField("extProcClientCallRequestObserver"); + field.setAccessible(true); + Object originalObserver = field.get(proxyCall); + field.set(proxyCall, null); + + // Call sendMessage; it should return safely and be ignored without throwing NPE + proxyCall.sendMessage(new java.io.ByteArrayInputStream( + "test".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + + // Restore the observer so that cancel() can clean up resources properly + field.set(proxyCall, originalObserver); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @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) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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 + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + + // Request messages from server while stream is draining (and sidecar not ready) + proxyCall.request(1); + + // 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(); + + // 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(); + } + + @Test + @SuppressWarnings("unchecked") + public void + drainingStartsBeforeRequestHeaders_whenAppSendsAndHalfCloses_thenBufferedAndDelivered() + 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) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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 + final CountDownLatch sidecarFinishLatch = new CountDownLatch(1); + final CountDownLatch drainCompletedLatch = new CountDownLatch(1); + final AtomicInteger extProcReceivedBodyCount = 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()) { + new Thread(() -> { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .setRequestDrain(true) + .build()); + try { + if (sidecarFinishLatch.await(5, TimeUnit.SECONDS)) { + responseObserver.onCompleted(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } else if (request.hasRequestBody()) { + extProcReceivedBodyCount.incrementAndGet(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + drainCompletedLatch.countDown(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List dataPlaneReceivedMessages = + new java.util.concurrent.CopyOnWriteArrayList<>(); + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(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) { + dataPlaneReceivedMessages.add(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Direct Response"); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + } + }; + } + })) + .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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Wait for drain to be processed + assertThat(drainCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isFalse(); + + // Send message and half-close concurrently during drain state + final CountDownLatch appActionLatch = new CountDownLatch(1); + new Thread(() -> { + proxyCall.sendMessage("App Message During Drain"); + proxyCall.halfClose(); + appActionLatch.countDown(); + }).start(); + + assertThat(appActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Assert that it was NOT received by extProc + assertThat(extProcReceivedBodyCount.get()).isEqualTo(0); + + // Now let sidecar complete + sidecarFinishLatch.countDown(); + + // Request response from data plane + proxyCall.request(1); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify the exact messages and their delivery order at data plane server + assertThat(dataPlaneReceivedMessages).containsExactly( + "App Message During Drain" + ).inOrder(); + + assertThat(appLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessage.get()).isEqualTo("Direct Response"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void drainingStartsAfterRequestHeaders_whenAppSendsAndHalfCloses_thenBufferedAndDelivered() + 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) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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 + final CountDownLatch sidecarFinishLatch = new CountDownLatch(1); + final CountDownLatch drainCompletedLatch = new CountDownLatch(1); + final CountDownLatch extProcReceivedBodyLatch = new CountDownLatch(1); + final CountDownLatch mutatedBodyDeliveredLatch = 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.hasRequestBody()) { + extProcReceivedBodyLatch.countDown(); + new Thread(() -> { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("Mutated Message 1")) + .build()) + .build()) + .build()) + .build()) + .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() { + drainCompletedLatch.countDown(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final List dataPlaneReceivedMessages = + new java.util.concurrent.CopyOnWriteArrayList<>(); + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(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) { + dataPlaneReceivedMessages.add(value); + if (value.equals("Mutated Message 1")) { + mutatedBodyDeliveredLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + responseObserver.onNext("Direct Response"); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + } + }; + } + })) + .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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_CLIENT_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + proxyCall.request(1); + + // Send original message 1 + proxyCall.sendMessage("Original Message 1"); + + // Wait until ext-proc receives it and mutated body is delivered downstream + assertThat(extProcReceivedBodyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(mutatedBodyDeliveredLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify proxyCall is in draining (i.e. isReady is false) + assertThat(proxyCall.isReady()).isFalse(); + + // Send message and half-close concurrently during drain state + final CountDownLatch appActionLatch = new CountDownLatch(1); + new Thread(() -> { + proxyCall.sendMessage("App Message During Drain"); + proxyCall.halfClose(); + appActionLatch.countDown(); + }).start(); + + assertThat(appActionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify the message during drain has NOT been delivered to the data plane server yet + assertThat(dataPlaneReceivedMessages).containsExactly("Mutated Message 1"); + + // Now let sidecar complete + sidecarFinishLatch.countDown(); + + // Wait for the control stream drain to be fully completed + assertThat(drainCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Request response from data plane + proxyCall.request(1); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify the exact messages and their delivery order at data plane server + assertThat(dataPlaneReceivedMessages).containsExactly( + "Mutated Message 1", "App Message During Drain" + ).inOrder(); + + assertThat(appLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appReceivedMessage.get()).isEqualTo("Direct Response"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void drainingStartsBeforeResponseHeaders_whenUpstreamResponds_thenBufferedAndDelivered() + 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) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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 + final CountDownLatch sidecarFinishLatch = new CountDownLatch(1); + final CountDownLatch drainCompletedLatch = 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() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .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() { + drainCompletedLatch.countDown(); + } + }; + } + }; + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + final CountDownLatch dataPlaneCallStartedLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + dataPlaneCallStartedLatch.countDown(); + return new StreamObserver() { + @Override + public void onNext(String value) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final List appReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + final AtomicReference appReceivedHeaders = new AtomicReference<>(); + final AtomicReference appReceivedStatus = new AtomicReference<>(); + final AtomicReference appReceivedTrailers = new AtomicReference<>(); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + appReceivedHeaders.set(headers); + } + + @Override + public void onMessage(String message) { + appReceivedMessages.add(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appReceivedStatus.set(status); + appReceivedTrailers.set(trailers); + appCloseLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Request messages from server + proxyCall.request(10); + + // Wait for drain to be processed and sidecar's client stream to finish + assertThat(drainCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isFalse(); + + // Verify the data plane call has started + assertThat(dataPlaneCallStartedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + StreamObserver dataPlaneResponseObserver = dataPlaneResponseObserverRef.get(); + assertThat(dataPlaneResponseObserver).isNotNull(); + + // Upstream server sends response headers, response message, and closes call during drain + dataPlaneResponseObserver.onNext("Response Message During Drain"); + dataPlaneResponseObserver.onCompleted(); + + // Verify app listener has NOT received headers, messages, or close yet because the drain + // is active + assertThat(appReceivedHeaders.get()).isNull(); + assertThat(appReceivedMessages).isEmpty(); + assertThat(appReceivedStatus.get()).isNull(); + + // Now let sidecar complete the drain + sidecarFinishLatch.countDown(); + + // Wait for the call to close on application side + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify the delivery order: response headers, upstream response, and close status/trailers + assertThat(appReceivedHeaders.get()).isNotNull(); + assertThat(appReceivedMessages).containsExactly("Response Message During Drain"); + assertThat(appReceivedStatus.get().isOk()).isTrue(); + assertThat(appReceivedTrailers.get()).isNotNull(); + + } + + @Test + @SuppressWarnings("unchecked") + public void drainingStartsAfterResponseHeaders_whenUpstreamResponds_thenBufferedAndDelivered() + 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) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch reqHeadersLatch = new CountDownLatch(1); + final CountDownLatch respHeadersLatch = new CountDownLatch(1); + final CountDownLatch m2ReceivedLatch = new CountDownLatch(1); + final CountDownLatch respBody1Latch = new CountDownLatch(1); + final CountDownLatch respBody2Latch = new CountDownLatch(1); + final CountDownLatch m3SentLatch = new CountDownLatch(1); + final CountDownLatch sidecarFinishLatch = new CountDownLatch(1); + final CountDownLatch drainCompletedLatch = new CountDownLatch(1); + + // External Processor Server + ExternalProcessorGrpc.ExternalProcessorImplBase 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()); + reqHeadersLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + respHeadersLatch.countDown(); + } else if (request.hasResponseBody()) { + String msgStr = request.getResponseBody().getBody().toStringUtf8(); + if ("Original Message 1".equals(msgStr)) { + new Thread(() -> { + try { + // Wait until M2 is received by sidecar so both M1 and M2 are in flight + if (m2ReceivedLatch.await(5, TimeUnit.SECONDS)) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("Mutated Message 1")) + .build()) + .build()) + .build()) + .build()) + .setRequestDrain(true) + .build()); + respBody1Latch.countDown(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } else if ("Original Message 2".equals(msgStr)) { + m2ReceivedLatch.countDown(); + new Thread(() -> { + try { + // Wait until M3 is sent by upstream concurrently during drain + if (m3SentLatch.await(5, TimeUnit.SECONDS)) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(ByteString.copyFromUtf8("Mutated Message 2")) + .build()) + .build()) + .build()) + .build()) + .build()); + respBody2Latch.countDown(); + } + 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() { + drainCompletedLatch.countDown(); + } + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(extProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(extProcServerName).directExecutor().build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final AtomicReference> dataPlaneResponseObserverRef = + new AtomicReference<>(); + final CountDownLatch dataPlaneCallStartedLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + dataPlaneResponseObserverRef.set(responseObserver); + dataPlaneCallStartedLatch.countDown(); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + })) + .build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final List appReceivedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + final AtomicReference appReceivedHeaders = new AtomicReference<>(); + final AtomicReference appReceivedStatus = new AtomicReference<>(); + final AtomicReference appReceivedTrailers = new AtomicReference<>(); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + final CountDownLatch mutatedMsg1ReceivedLatch = new CountDownLatch(1); + final CountDownLatch mutatedMsg2ReceivedLatch = new CountDownLatch(1); + + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + appReceivedHeaders.set(headers); + } + + @Override + public void onMessage(String message) { + appReceivedMessages.add(message); + if ("Mutated Message 1".equals(message)) { + mutatedMsg1ReceivedLatch.countDown(); + } else if ("Mutated Message 2".equals(message)) { + mutatedMsg2ReceivedLatch.countDown(); + } + } + + @Override + public void onClose(Status status, Metadata trailers) { + appReceivedStatus.set(status); + appReceivedTrailers.set(trailers); + appCloseLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_BIDI_STREAMING, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Request messages from server + proxyCall.request(10); + + // Wait for the data plane call to start + assertThat(dataPlaneCallStartedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + StreamObserver dataPlaneResponseObserver = dataPlaneResponseObserverRef.get(); + assertThat(dataPlaneResponseObserver).isNotNull(); + + // 1. Upstream sends M1 (which triggers response headers and M1 body to sidecar) + dataPlaneResponseObserver.onNext("Original Message 1"); + + // Wait for sidecar to receive and respond to response headers + assertThat(respHeadersLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // 2. Upstream sends M2 (which triggers M2 body to sidecar) + dataPlaneResponseObserver.onNext("Original Message 2"); + + // Wait for app to receive Mutated Message 1 (meaning M1's response with request_drain=true + // has been processed) + assertThat(mutatedMsg1ReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify the stream is currently in DRAINING state and app has only received Mutated + // Message 1 so far + assertThat(appReceivedMessages).containsExactly("Mutated Message 1"); + + // 3. Upstream concurrently sends M3 and completes the call during draining + dataPlaneResponseObserver.onNext("Original Message 3"); + dataPlaneResponseObserver.onCompleted(); + + // Verify that M3 and close are not delivered to application yet because drain is still active + assertThat(appReceivedMessages).containsExactly("Mutated Message 1"); + assertThat(appReceivedStatus.get()).isNull(); + + // 4. Signal sidecar to send Mutated Message 2 + m3SentLatch.countDown(); + + // Wait for sidecar to finish sending M2 and app to receive it + assertThat(respBody2Latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(mutatedMsg2ReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify that Mutated Message 2 is delivered immediately to app upon arrival (even before + // M3 is released) + assertThat(appReceivedMessages).containsExactly("Mutated Message 1", "Mutated Message 2"); + + // 5. Complete sidecar stream to finish the drain + sidecarFinishLatch.countDown(); + + // Wait for the call to close on application side + assertThat(appCloseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify delivery order: mutated messages first, then bypass messages, and finally + // status/trailers + assertThat(appReceivedHeaders.get()).isNotNull(); + assertThat(appReceivedMessages).containsExactly( + "Mutated Message 1", "Mutated Message 2", "Original Message 3" + ); + assertThat(appReceivedStatus.get().isOk()).isTrue(); + assertThat(appReceivedTrailers.get()).isNotNull(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 15: 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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) { + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + 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 void request(int numMessages) { + dataPlaneRequestCount.addAndGet(numMessages); + super.request(numMessages); + } + }; + } + }) + .build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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. + assertThat(dataPlaneRequestCount.get()).isEqualTo(5); + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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); + + // Verify requests are buffered and not sent to data plane + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + // 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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); + assertThat(dataPlaneRequestCount.get()).isEqualTo(0); + + // Sidecar becomes ready + sidecarReady.set(true); + sidecarListenerRef.get().onReady(); + + // Verify buffered request drained + assertThat(dataPlaneRequestCount.get()).isEqualTo(10); + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + 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()); + + final CountDownLatch readyLatch = new CountDownLatch(1); + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onReady() { + readyLatch.countDown(); + } + }, new Metadata()); + + // Wait for sidecar stream completion + assertThat(readyLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.request(7); + + // Verify request forwarded immediately + assertThat(dataPlaneRequestCount.get()).isEqualTo(7); + // proxyCall.isReady() should remain true as sidecar is gone + assertThat(proxyCall.isReady()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 16: Error Handling & Security --- + + @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(FAKE_CONTEXT, 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.interceptForward( + channel, + Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); + + ClientCall call = + interceptedChannel.newCall( + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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("FutureReturnValueIgnored") + public void + givenStreamingCall_whenImmediateResponseReceivedDuringRequestStreaming_thenTerminatesCleanly() + 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<>(); + final AtomicInteger extProcRequestCount = new AtomicInteger(0); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasRequestBody()) { + int count = extProcRequestCount.incrementAndGet(); + if (count == 1) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder().build()) + .build()) + .build()); + } else if (count == 2) { + 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(FAKE_CONTEXT, channelManager); + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .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_BIDI_STREAMING, (call, headers) -> { + call.sendHeaders(new Metadata()); + call.request(100); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) { + call.sendMessage("server-response-" + message); + } + + @Override + public void onHalfClose() { + 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.interceptForward( + channel, + Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); + + ClientCall call = + interceptedChannel.newCall( + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.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:" + message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + appEvents.add("CLOSE:" + status.getCode()); + appTrailers.set(trailers); + finishLatch.countDown(); + } + }, new Metadata()); + + call.request(100); + + // 1. Send Message 1 (should succeed and be allowed) + call.sendMessage("msg1"); + + // 2. Send Message 2 (should trigger the delay and then ImmediateResponse on ext_proc) + call.sendMessage("msg2"); + + // 3. Concurrent write of Message 3 (while ext_proc is sleeping) + try { + call.sendMessage("msg3"); + } catch (IllegalStateException e) { + appEvents.add("WRITE_FAILED"); + } + + call.halfClose(); + + assertThat(finishLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(appEvents).contains("CLOSE:UNAUTHENTICATED"); + assertThat(appEvents).doesNotContain("WRITE_FAILED"); + assertThat(appTrailers.get().get(immediateKey)).isEqualTo("true"); + assertThat(extProcCompletedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + sidecarResponseExecutor.shutdown(); + channelManager.close(); + } + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Verify application receives INTERNAL due to sidecar failure + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + final CountDownLatch headersReceivedLatch = new CountDownLatch(1); + final CountDownLatch resumeAsyncThreadLatch = new CountDownLatch(1); + + ServerInterceptor dataPlaneInterceptor = new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + headersReceivedLatch.countDown(); + try { + resumeAsyncThreadLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return next.startCall(call, headers); + } + }; + + dataPlaneServiceRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall( + (request, responseObserver) -> { + responseObserver.onNext("Hello " + request); + responseObserver.onCompleted(); + dataPlaneLatch.countDown(); + })) + .build(), + dataPlaneInterceptor)); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + final AtomicReference statusRef = new AtomicReference<>(); + final CountDownLatch closedLatch = new CountDownLatch(1); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + statusRef.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + // Trigger unary call. request(1) starts it. + proxyCall.request(1); + + // Wait for the async sidecar thread to enter activateCall() and block inside interceptCall + assertThat(headersReceivedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Now, while the async thread is blocked (and passThroughMode is still false), + // send a message and half-close. + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Unblock the async thread + resumeAsyncThreadLatch.countDown(); + + // Verify data plane call reached (failed open) + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify client call completes successfully + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(statusRef.get().isOk()).isTrue(); + + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, 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); + } + 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); + } + 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); + } + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, 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 INTERNAL 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.INTERNAL); + 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() + .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; + + // 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify application receives INTERNAL with correct description + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + 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(); + } + } + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, 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 17: Immediate Response Handling --- + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 givenImmediateResponseAndObservabilityTrue_whenReceived_thenImmediateResponseIgnored() + 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 sends ImmediateResponse + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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); + final AtomicReference closedStatus = new AtomicReference<>(); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // In observability mode, the call should NOT be cancelled by the immediate response. + // It should proceed normally to the data plane and finish successfully (Status.OK). + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().isOk()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void givenImmediateResponseDisabled_whenReceivedBeforeActivation_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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + + 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 givenImmediateResponseDisabled_whenReceivedAfterActivation_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 request headers first (activating the call) + // and then schedules an immediate response (which is 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()) { + // 1. Send request headers response to activate the call + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + + // 2. Schedule the immediate response to be sent after 2 seconds + @SuppressWarnings("unused") + java.util.concurrent.ScheduledFuture unused = + fakeClock.getScheduledExecutorService().schedule(() -> { + 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()); + }, 2, TimeUnit.SECONDS); + } + } + + @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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + DEFAULT_CALL_OPTIONS.withExecutor(fakeClock.getScheduledExecutorService()); + ClientCall proxyCall = + interceptCall(interceptor, 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 UNIMPLEMENTED because data plane connection succeeded + // but the method was not registered, and it failed before the ext-proc stream failed + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.UNIMPLEMENTED); + + 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 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + } + + // --- Category 18: Resource Management --- + + @Test + public void givenFilter_whenClosed_thenCachedChannelManagerIsClosed() throws Exception { + CachedChannelManager mockChannelManager = Mockito.mock(CachedChannelManager.class); + + ExternalProcessorFilter filter = new ExternalProcessorFilter(FAKE_CONTEXT, mockChannelManager); + + filter.close(); + + Mockito.verify(mockChannelManager).close(); + } + + // --- Category 19: 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 20: 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; + + final List extProcRequests = + new java.util.concurrent.CopyOnWriteArrayList<>(); + // 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) { + extProcRequests.add(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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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(); + + assertThat(extProcRequests).isNotEmpty(); + for (ProcessingRequest request : extProcRequests) { + assertThat(request.getObservabilityMode()).isFalse(); + } + + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, 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 21: 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) + .setEndOfStreamWithoutMessage( + request.getRequestBody() + .getEndOfStreamWithoutMessage()) + .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()) + .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(); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, sidecarRealScheduler, FAKE_CONTEXT); + + 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( + interceptCall(interceptor, + METHOD_CLIENT_STREAMING, + DEFAULT_CALL_OPTIONS.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) + .setEndOfStreamWithoutMessage( + request.getRequestBody() + .getEndOfStreamWithoutMessage()) + .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()) + .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(); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, bidiRealScheduler, FAKE_CONTEXT); + + 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( + interceptCall(interceptor, + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.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 22: Header Forwarding --- + + @Test + public void + givenAllowedHeaders_whenRequestHeadersForwarded_thenOnlyAllowedAreSent() + throws Exception { + String uniqueExtProcServerName = 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()); + } + } + + @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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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()); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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 + givenAllowedHeaders_whenResponseHeadersForwarded_thenOnlyAllowedAreSent() + throws Exception { + String uniqueExtProcServerName = 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()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + capturedHeaders.set(request.getResponseHeaders()); + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(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()); + + // Config with forward_rules: allowed_headers = ["x-allowed-*", "content-type"] + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.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) { + Metadata responseHeaders = new Metadata(); + responseHeaders.put( + Metadata.Key.of("x-allowed-response", Metadata.ASCII_STRING_MARSHALLER), "v1"); + responseHeaders.put( + Metadata.Key.of("x-disallowed-response", Metadata.ASCII_STRING_MARSHALLER), "v2"); + responseHeaders.put( + Metadata.Key.of("content-type", Metadata.ASCII_STRING_MARSHALLER), + "application/grpc"); + + call.sendHeaders(responseHeaders); + return next.startCall(call, headers); + } + })); + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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(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-response"); + assertThat(headerNames).contains("content-type"); + assertThat(headerNames).doesNotContain("x-disallowed-response"); + + 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()); + } + } + + @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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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()); + } + } + + @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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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 23: 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.url_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()); + } + } + + @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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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.url_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()); + } + } + + @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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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"); + headers.put( + Metadata.Key.of("x-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER), new byte[]{1, 2}); + + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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"); + assertThat(attributes.get("request.useragent").getFieldsOrThrow("").getStringValue()) + .isEqualTo("custom-ua"); + assertThat(attributes.get("request.id").getFieldsOrThrow("").getStringValue()) + .isEqualTo("req-123"); + + com.google.protobuf.Struct headersStruct = attributes.get("request.headers"); + assertThat(headersStruct.getFieldsOrThrow("x-bin-key-bin").getStringValue()) + .isEqualTo("AQI"); + + channelManager.close(); + } + + + + // --- Category 24: 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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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 INTERNAL status + // due to stream failure triggered by protocol error + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(appStatus.get().getDescription()).contains("External processor stream failed"); + + channelManager.close(); + } + + @Test + public void givenUnexpectedResponseHeaders_whenHeadersArriveBeforeServerHeaders_thenFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference extProcError = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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 ResponseHeaders response instead of RequestHeaders response + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @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()); + + // Configure processing mode to SEND both request and response headers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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 INTERNAL status due to protocol error + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(appStatus.get().getDescription()).contains("External processor stream failed"); + + // The data plane call should have the local cause set to the protocol violation + assertThat(appStatus.get().getCause()).isNotNull(); + assertThat(appStatus.get().getCause().getMessage()) + .contains("Protocol error: received response out of order"); + + channelManager.close(); + } + + @Test + public void givenUnexpectedResponseTrailers_whenTrailersArriveBeforeServerTrailers_thenFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference extProcError = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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 ResponseTrailers response instead of RequestHeaders + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseTrailers(TrailersResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @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()); + + // Configure processing mode to SEND both request headers and response trailers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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 INTERNAL status due to protocol error + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(appStatus.get().getDescription()).contains("External processor stream failed"); + + // The data plane call should have the local cause set to the protocol violation + assertThat(appStatus.get().getCause()).isNotNull(); + assertThat(appStatus.get().getCause().getMessage()) + .contains("Protocol error: received response out of order"); + + channelManager.close(); + } + + @Test + public void givenOutOfOrderRespResponses_whenResponseBodyArrivesBeforeResponseHeaders_thenFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarLatch = new CountDownLatch(1); + final AtomicReference extProcError = new AtomicReference<>(); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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()) { + // Send valid RequestHeaders response first + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } else if (request.hasResponseHeaders()) { + // Violate order: send ResponseBody response instead of ResponseHeaders response + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseBody(BodyResponse.newBuilder().build()) + .build()); + sidecarLatch.countDown(); + responseObserver.onCompleted(); + } + } + + @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()); + + // Configure processing mode to SEND request headers, response headers, response body, + // and response trailers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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; + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(Executors.newSingleThreadExecutor()) + .build()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + // The data plane server responds to trigger response headers on the client + responseObserver.onNext("Hello"); + })).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 = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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 INTERNAL status due to protocol error + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(appStatus.get().getDescription()).contains("External processor stream failed"); + + // The data plane call should have the local cause set to the protocol violation + assertThat(appStatus.get().getCause()).isNotNull(); + assertThat(appStatus.get().getCause().getMessage()) + .contains("Protocol error: received response_body before headers response."); + + channelManager.close(); + } + + @Test + public void givenValidOrder_whenResponsesArriveInOrder_thenSucceeds() throws Exception { + String uniqueExtProcServerName = 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()); + + // Configure processing mode to SEND request headers, but SKIP response headers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + .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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 callLatch = new CountDownLatch(1); + final AtomicReference capturedStatus = new AtomicReference<>(); + + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callLatch.countDown(); + } + }, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify that headers are processed correctly and the ordering check passes + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + // Verify that the call completes successfully + assertThat(callLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().isOk()).isTrue(); + + channelManager.close(); + } + + @Test + public void givenBidiStreamInterleavedEvents_whenExtProcRespondsOutOfLockstep_thenSucceeds() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + final CountDownLatch sidecarRequestBodyLatch = new CountDownLatch(1); + final CountDownLatch sidecarResponseHeadersLatch = new CountDownLatch(1); + final CountDownLatch allDoneLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + final StreamObserver responseObserver) { + ((ServerCallStreamObserver) responseObserver).request(100); + final AtomicReference> observerRef = + new AtomicReference<>(responseObserver); + return new StreamObserver() { + private ProcessingRequest savedRequestBody; + + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestBody()) { + if (request.getRequestBody().getEndOfStream() + || request.getRequestBody().getEndOfStreamWithoutMessage()) { + // This is the half-close request! + observerRef.get().onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setEndOfStream(true) + .build()) + .build()) + .build()) + .build()) + .build()); + } else { + savedRequestBody = request; + sidecarRequestBodyLatch.countDown(); + } + } else if (request.hasResponseHeaders()) { + // When RESPONSE_HEADERS is received, we respond to it first! + // This is out-of-lockstep because REQUEST_BODY response is still outstanding. + observerRef.get().onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarResponseHeadersLatch.countDown(); + + // Now send response to REQUEST_BODY with streamed response containing the body + if (savedRequestBody != null) { + observerRef.get().onNext(ProcessingResponse.newBuilder() + .setRequestBody(BodyResponse.newBuilder() + .setResponse(CommonResponse.newBuilder() + .setBodyMutation(BodyMutation.newBuilder() + .setStreamedResponse(StreamedBodyResponse.newBuilder() + .setBody(savedRequestBody.getRequestBody().getBody()) + .build()) + .build()) + .build()) + .build()) + .build()); + } + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + observerRef.get().onCompleted(); + } + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(scheduler) + .build().start()); + + MutableHandlerRegistry uniqueBidiRegistry = new MutableHandlerRegistry(); + uniqueBidiRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_BIDI_STREAMING, ServerCalls.asyncBidiStreamingCall( + new ServerCalls.BidiStreamingMethod() { + @Override + public StreamObserver invoke(StreamObserver responseObserver) { + // Send headers immediately by sending a message when stream starts + responseObserver.onNext("Welcome"); + return new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + })) + .build()); + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueDataPlaneServerName) + .fallbackHandlerRegistry(uniqueBidiRegistry) + .executor(scheduler) + .build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + // SKIP so data plane call starts immediately + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SKIP) + // GRPC body mode to trigger REQUEST_BODY + .setRequestBodyMode(ProcessingMode.BodySendMode.GRPC) + // SEND to trigger RESPONSE_HEADERS + .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(scheduler) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .executor(scheduler) + .build()); + + ClientCall clientCall = interceptCall(interceptor, + METHOD_BIDI_STREAMING, + DEFAULT_CALL_OPTIONS.withExecutor(scheduler), + dataPlaneChannel); + + StreamObserver bidiRequestObserver = ClientCalls.asyncBidiStreamingCall( + clientCall, + new StreamObserver() { + @Override + public void onNext(String value) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + allDoneLatch.countDown(); + } + }); + + // Send client message to trigger REQUEST_BODY to ext_proc + bidiRequestObserver.onNext("ClientMsg"); + + // Wait for ext_proc to process out-of-lockstep events + while (sidecarRequestBodyLatch.getCount() > 0 || sidecarResponseHeadersLatch.getCount() > 0) { + if (fakeClock.numPendingTasks() == 0) { + break; + } + fakeClock.runDueTasks(); + } + assertThat(sidecarRequestBodyLatch.getCount()).isEqualTo(0); + assertThat(sidecarResponseHeadersLatch.getCount()).isEqualTo(0); + + // Complete the bidi stream + bidiRequestObserver.onCompleted(); + while (allDoneLatch.getCount() > 0) { + if (fakeClock.numPendingTasks() == 0) { + break; + } + fakeClock.runDueTasks(); + } + assertThat(allDoneLatch.getCount()).isEqualTo(0); + + // Clean up by cancelling the call explicitly + clientCall.cancel("Test finished", null); + + channelManager.close(); + } + + // --- Category 25: 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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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()); + }); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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 = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.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(); + } + + @Test + public void givenExtProcCall_whenExecutionSucceeds_thenAll4MetricsAreRecorded() throws Exception { + final String uniqueExtProcServerName = "ext-proc-server-metrics-" + java.util.UUID.randomUUID(); + final CountDownLatch sidecarRequestHeadersLatch = new CountDownLatch(1); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + // In-process mock server for External Processor + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarRequestHeadersLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(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()); + + // Enable request headers and response headers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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()); + }); + + // Mock MetricRecorder to assert records + io.grpc.MetricRecorder mockMetricRecorder = Mockito.mock(io.grpc.MetricRecorder.class); + Filter.FilterContext customContext = Filter.FilterContext.create( + "envoy.ext_proc", + mockMetricRecorder); + + ScheduledExecutorService realScheduler = Executors.newSingleThreadScheduledExecutor(); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, realScheduler, customContext); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + new Thread(() -> { + try { + if (dataPlaneLatch.await(10, TimeUnit.SECONDS)) { + responseObserver.onNext("Hello"); + responseObserver.onCompleted(); + } + } catch (InterruptedException e) { + responseObserver.onError(e); + } + }).start(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .overrideAuthority("xds:///target-service-metric") + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()) + .withOption(XdsNameResolver.CLUSTER_SELECTION_KEY, "backend-service-metric"), + 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(); + + // 1. Wait for mock Ext Proc to receive and process client request headers + assertThat(sidecarRequestHeadersLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + // 2. Release the data plane server to respond back to the client call + dataPlaneLatch.countDown(); + + // 3. Assert that all stages complete in sequence deterministically + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + // Clean up and close the Ext Proc stream to release in-process server/channel resources cleanly + proxyCall.cancel("Cleanup", null); + + // Verify that the 4 duration metrics were recorded with proper labels! + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.clientHeadersDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric"))); + + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.clientHalfCloseDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric"))); + + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.serverHeadersDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric"))); + + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.serverTrailersDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric"))); + + channelManager.close(); + realScheduler.shutdown(); + } + + @Test + public void givenExtProcCall_whenExecutionFails_thenAll4MetricsAreRecorded() throws Exception { + final String uniqueExtProcServerName = + "ext-proc-server-metrics-fail-" + java.util.UUID.randomUUID(); + final CountDownLatch sidecarRequestHeadersLatch = new CountDownLatch(1); + final CountDownLatch sidecarLatch = new CountDownLatch(1); + + // In-process mock server for External Processor + ExternalProcessorGrpc.ExternalProcessorImplBase 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.hasRequestHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + sidecarRequestHeadersLatch.countDown(); + } else if (request.hasResponseHeaders()) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setResponseHeaders(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()); + + // Enable request headers and response headers + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .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()); + }); + + // Mock MetricRecorder to assert records + io.grpc.MetricRecorder mockMetricRecorder = Mockito.mock(io.grpc.MetricRecorder.class); + Filter.FilterContext customContext = Filter.FilterContext.create( + "envoy.ext_proc", + mockMetricRecorder); + + ScheduledExecutorService realScheduler = Executors.newSingleThreadScheduledExecutor(); + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, realScheduler, customContext); + + final CountDownLatch dataPlaneLatch = new CountDownLatch(1); + dataPlaneServiceRegistry.addService(ServerInterceptors.intercept( + ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + new Thread(() -> { + try { + if (dataPlaneLatch.await(10, TimeUnit.SECONDS)) { + responseObserver.onError( + Status.UNAUTHENTICATED + .withDescription("authentication failed") + .asRuntimeException()); + } + } catch (InterruptedException e) { + responseObserver.onError(e); + } + }).start(); + })).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(dataPlaneServerName) + .overrideAuthority("xds:///target-service-metric-fail") + .executor(Executors.newSingleThreadExecutor()) + .build()); + + final AtomicReference appStatus = new AtomicReference<>(); + final CountDownLatch appCloseLatch = new CountDownLatch(1); + ClientCall proxyCall = + interceptCall(interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()) + .withOption(XdsNameResolver.CLUSTER_SELECTION_KEY, "backend-service-metric-fail"), + 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(); + + // 1. Wait for mock Ext Proc to receive and process client request headers + assertThat(sidecarRequestHeadersLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + // 2. Release the data plane server to respond back with error + dataPlaneLatch.countDown(); + + // 3. Assert that all stages complete + assertThat(sidecarLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(appCloseLatch.await(10, TimeUnit.SECONDS)).isTrue(); + + assertThat(appStatus.get().getCode()).isEqualTo(Status.Code.UNAUTHENTICATED); + + // Clean up and close the Ext Proc stream + proxyCall.cancel("Cleanup", null); + + // Verify that the 4 duration metrics were recorded with proper labels! + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.clientHeadersDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric-fail")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric-fail"))); + + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.clientHalfCloseDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric-fail")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric-fail"))); + + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.serverHeadersDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric-fail")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric-fail"))); + + Mockito.verify(mockMetricRecorder, Mockito.times(1)).recordDoubleHistogram( + Mockito.eq(ExternalProcessorClientInterceptor.serverTrailersDuration), + Mockito.anyDouble(), + Mockito.eq(com.google.common.collect.ImmutableList.of("xds:///target-service-metric-fail")), + Mockito.eq(com.google.common.collect.ImmutableList.of("backend-service-metric-fail"))); + + channelManager.close(); + realScheduler.shutdown(); + } + + // --- Category 26: Call activation with failure mode allow on and off --- + @Test + public void + givenRequestHeaderModeSend_Fma_true_whenExtProcTerminates_thenCallIsActivated() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch streamActiveLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + streamActiveLatch.countDown(); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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) -> { + responseObserver.onNext("response"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .directExecutor() + .build()); + + ClientCall clientCall = interceptCall( + interceptor, METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + final CountDownLatch callCompletedLatch = new CountDownLatch(1); + final AtomicReference closedStatus = new AtomicReference<>(); + clientCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + callCompletedLatch.countDown(); + } + }, new Metadata()); + clientCall.request(1); + + boolean active = streamActiveLatch.await(5, TimeUnit.SECONDS); + assertThat(active).isTrue(); + + clientCall.sendMessage("app-msg"); + clientCall.halfClose(); + + responseObserverRef.get().onError(new RuntimeException("Stream failure during start")); + + boolean completed = callCompletedLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + // Verify call completed successfully due to FMA true (fail-open) + assertThat(closedStatus.get().isOk()).isTrue(); + + channelManager.close(); + } + + @Test + public void + givenRequestHeaderModeSend_Fma_false_whenExtProcTerminates_thenCallIsClosed() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(false) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch streamActiveLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + streamActiveLatch.countDown(); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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) -> { + responseObserver.onNext("response"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .directExecutor() + .build()); + + ClientCall clientCall = interceptCall( + interceptor, METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + final CountDownLatch callCompletedLatch = new CountDownLatch(1); + final AtomicReference closedStatus = new AtomicReference<>(); + clientCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + callCompletedLatch.countDown(); + } + }, new Metadata()); + clientCall.request(1); + + boolean active = streamActiveLatch.await(5, TimeUnit.SECONDS); + assertThat(active).isTrue(); + + // Terminate stream with error + responseObserverRef.get().onError(new RuntimeException("Stream failure during start")); + + boolean completed = callCompletedLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + // Verify call closed with INTERNAL status due to FMA false + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + channelManager.close(); + } + + @Test + public void givenFailureModeAllowTrue_whenExtProcStreamFailsAfterRequestBodySent_thenCallFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(true) + .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; + + final AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch streamActiveLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + streamActiveLatch.countDown(); + 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(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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) -> { + responseObserver.onNext("response"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .directExecutor() + .build()); + + ClientCall clientCall = interceptCall( + interceptor, METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch callCompletedLatch = new CountDownLatch(1); + clientCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + callCompletedLatch.countDown(); + } + }, new Metadata()); + clientCall.request(1); + + boolean active = streamActiveLatch.await(5, TimeUnit.SECONDS); + assertThat(active).isTrue(); + + clientCall.sendMessage("app-msg"); + clientCall.halfClose(); + + // Now abruptly fail the stream + responseObserverRef.get() + .onError(new RuntimeException("Stream failure after sending body/EOS")); + + // Verify that the call failed with INTERNAL status instead of succeeding. + boolean completed = callCompletedLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + channelManager.close(); + } + + @Test + public void givenFailureModeAllowTrue_whenExtProcStreamFailsAfterResponseBodySent_thenCallFails() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(true) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setRequestBodyMode(ProcessingMode.BodySendMode.NONE) + .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 AtomicReference> responseObserverRef = + new AtomicReference<>(); + final CountDownLatch streamActiveLatch = new CountDownLatch(1); + final CountDownLatch streamFailedLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + responseObserverRef.set(responseObserver); + streamActiveLatch.countDown(); + 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()) { + // Fail the stream once we see response body message + responseObserver.onError( + Status.INTERNAL.withDescription("Stream failure after response body") + .asRuntimeException()); + streamFailedLatch.countDown(); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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) -> { + responseObserver.onNext("response-body-msg"); + responseObserver.onCompleted(); + })).build()); + + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName) + .directExecutor() + .build()); + + ClientCall clientCall = interceptCall( + interceptor, METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()), + dataPlaneChannel); + + final AtomicReference closedStatus = new AtomicReference<>(); + final CountDownLatch callCompletedLatch = new CountDownLatch(1); + clientCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + callCompletedLatch.countDown(); + } + }, new Metadata()); + clientCall.request(1); + + boolean active = streamActiveLatch.await(5, TimeUnit.SECONDS); + assertThat(active).isTrue(); + + // Since request body mode is NONE, this sendMessage is NOT sent to ext_proc + clientCall.sendMessage("app-msg"); + clientCall.halfClose(); + + // Verify call completed and failed with INTERNAL status + boolean completed = callCompletedLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + assertThat(closedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(closedStatus.get().getDescription()).contains("External processor stream failed"); + + channelManager.close(); + } + + @Test + public void givenObservabilityTrue_whenExtProcStreamFails_thenCallContinues() + throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setFailureModeAllow(false) + .setObservabilityMode(true) + .build(); + ConfigOrError configOrError = + provider.parseFilterConfig(Any.pack(proto), filterContext); + assertThat(configOrError.errorDetail).isNull(); + ExternalProcessorFilterConfig filterConfig = configOrError.config; + + final CountDownLatch streamActiveLatch = new CountDownLatch(1); + + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + streamActiveLatch.countDown(); + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + if (request.hasRequestHeaders()) { + // Fail the stream immediately on receiving headers + responseObserver.onError( + Status.INTERNAL.withDescription("Simulated sidecar failure") + .asRuntimeException()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .directExecutor() + .build().start()); + + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueExtProcServerName) + .directExecutor() + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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); + final AtomicReference closedStatus = new AtomicReference<>(); + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closedStatus.set(status); + closedLatch.countDown(); + } + }; + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(appListener, new Metadata()); + + proxyCall.request(1); + proxyCall.sendMessage("test"); + proxyCall.halfClose(); + + // Verify stream failed + assertThat(streamActiveLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + // Verify data plane call still succeeded (observability mode ignores ext_proc failure) + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(closedStatus.get().isOk()).isTrue(); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + // --- Category 27: Request-Scoped Context Propagation --- + + @Test + public void clientInterceptor_contextPropagatedToStartCall() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) { + responseObserver.onNext(ProcessingResponse.newBuilder() + .setRequestHeaders(HeadersResponse.newBuilder().build()) + .build()); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + ExecutorService extProcServerExecutor = Executors.newSingleThreadExecutor(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(extProcServerExecutor) + .build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExecutorService extProcChannelExecutor = Executors.newSingleThreadExecutor(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(extProcChannelExecutor) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + final Context.Key testKey = Context.key("test-key"); + Context testContext = Context.current().withValue(testKey, "test-value"); + final AtomicReference contextValueAtDownstreamStart = new AtomicReference<>(); + final CountDownLatch downstreamStartLatch = new CountDownLatch(1); + + ClientInterceptor assertInterceptor = new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new SimpleForwardingClientCall(next.newCall(method, callOptions)) { + @Override + public void start(ClientCall.Listener responseListener, Metadata headers) { + contextValueAtDownstreamStart.set(testKey.get()); + super.start(responseListener, headers); + downstreamStartLatch.countDown(); + } + }; + } + }; + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("response-msg"); + responseObserver.onCompleted(); + })).build()); + + ExecutorService dataPlaneChannelExecutor = Executors.newSingleThreadExecutor(); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .intercept(assertInterceptor) + .executor(dataPlaneChannelExecutor) + .build()); + + final AtomicReference> proxyCallRef = new AtomicReference<>(); + ExecutorService callExecutor = Executors.newSingleThreadExecutor(); + try { + testContext.run(() -> { + ClientCall proxyCall = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(callExecutor), + dataPlaneChannel); + proxyCallRef.set(proxyCall); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + }); + + ClientCall proxyCall = proxyCallRef.get(); + + proxyCall.request(1); + proxyCall.sendMessage("hello"); + proxyCall.halfClose(); + + assertThat(downstreamStartLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(contextValueAtDownstreamStart.get()).isEqualTo("test-value"); + + proxyCall.cancel("cleanup", null); + } finally { + channelManager.close(); + shutdownAndAwaitTermination(extProcServerExecutor); + shutdownAndAwaitTermination(extProcChannelExecutor); + shutdownAndAwaitTermination(dataPlaneChannelExecutor); + shutdownAndAwaitTermination(callExecutor); + } + } + + @Test + public void clientInterceptor_contextPropagatedToListenerCallbacks() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + 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()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + }; + + ExecutorService extProcServerExecutor = Executors.newSingleThreadExecutor(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(extProcServerExecutor) + .build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .setResponseHeaderMode(ProcessingMode.HeaderSendMode.SEND) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExecutorService extProcChannelExecutor = Executors.newSingleThreadExecutor(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .executor(extProcChannelExecutor) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + dataPlaneServiceRegistry.addService(ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, ServerCalls.asyncUnaryCall((request, responseObserver) -> { + responseObserver.onNext("response-msg"); + responseObserver.onCompleted(); + })).build()); + + ExecutorService dataPlaneChannelExecutor = Executors.newSingleThreadExecutor(); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(dataPlaneChannelExecutor) + .build()); + + final Context.Key testKey = Context.key("test-key"); + Context testContext = Context.current().withValue(testKey, "test-value"); + + final AtomicReference onHeadersContext = new AtomicReference<>(); + final AtomicReference onMessageContext = new AtomicReference<>(); + final AtomicReference onCloseContext = new AtomicReference<>(); + final AtomicReference onReadyContext = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + ClientCall.Listener appListener = new ClientCall.Listener() { + @Override + public void onHeaders(Metadata headers) { + onHeadersContext.set(testKey.get()); + } + + @Override + public void onMessage(String message) { + onMessageContext.set(testKey.get()); + } + + @Override + public void onClose(Status status, Metadata trailers) { + onCloseContext.set(testKey.get()); + latch.countDown(); + } + + @Override + public void onReady() { + onReadyContext.set(testKey.get()); + } + }; + + final AtomicReference> proxyCallRef = new AtomicReference<>(); + ExecutorService callExecutor = Executors.newSingleThreadExecutor(); + try { + testContext.run(() -> { + ClientCall proxyCall = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(callExecutor), + dataPlaneChannel); + proxyCallRef.set(proxyCall); + proxyCall.start(appListener, new Metadata()); + }); + + ClientCall proxyCall = proxyCallRef.get(); + + proxyCall.request(1); + proxyCall.sendMessage("hello"); + proxyCall.halfClose(); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(onHeadersContext.get()).isEqualTo("test-value"); + assertThat(onMessageContext.get()).isEqualTo("test-value"); + assertThat(onCloseContext.get()).isEqualTo("test-value"); + assertThat(onReadyContext.get()).isEqualTo("test-value"); + + proxyCall.cancel("cleanup", null); + } finally { + channelManager.close(); + shutdownAndAwaitTermination(extProcServerExecutor); + shutdownAndAwaitTermination(extProcChannelExecutor); + shutdownAndAwaitTermination(dataPlaneChannelExecutor); + shutdownAndAwaitTermination(callExecutor); + } + } + + @Test + public void clientInterceptor_contextPropagatedToExtProcStub() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + public StreamObserver process( + StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ProcessingRequest request) {} + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() {} + }; + } + }; + + ExecutorService extProcServerExecutor = Executors.newSingleThreadExecutor(); + grpcCleanup.register(InProcessServerBuilder.forName(uniqueExtProcServerName) + .addService(extProcImpl) + .executor(extProcServerExecutor) + .build().start()); + + ExternalProcessor proto = createBaseProto(uniqueExtProcServerName).build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + final Context.Key testKey = Context.key("test-key"); + Context testContext = Context.current().withValue(testKey, "test-value"); + final AtomicReference contextAtExtProcCall = new AtomicReference<>(); + final CountDownLatch extProcCallLatch = new CountDownLatch(1); + + ExecutorService extProcChannelExecutor = Executors.newSingleThreadExecutor(); + CachedChannelManager channelManager = new CachedChannelManager(config -> { + return grpcCleanup.register(InProcessChannelBuilder.forName(uniqueExtProcServerName) + .intercept(new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + if (method.equals(ExternalProcessorGrpc.getProcessMethod())) { + contextAtExtProcCall.set(testKey.get()); + extProcCallLatch.countDown(); + } + return next.newCall(method, callOptions); + } + }) + .executor(extProcChannelExecutor) + .build()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ExecutorService dataPlaneChannelExecutor = Executors.newSingleThreadExecutor(); + ManagedChannel dataPlaneChannel = grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName) + .executor(dataPlaneChannelExecutor) + .build()); + + final AtomicReference> proxyCallRef = new AtomicReference<>(); + ExecutorService callExecutor = Executors.newSingleThreadExecutor(); + try { + testContext.run(() -> { + ClientCall proxyCall = interceptCall( + interceptor, + METHOD_SAY_HELLO, + DEFAULT_CALL_OPTIONS.withExecutor(callExecutor), + dataPlaneChannel); + proxyCallRef.set(proxyCall); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + }); + + ClientCall proxyCall = proxyCallRef.get(); + + assertThat(extProcCallLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(contextAtExtProcCall.get()).isEqualTo("test-value"); + + proxyCall.cancel("cleanup", null); + } finally { + channelManager.close(); + shutdownAndAwaitTermination(extProcServerExecutor); + shutdownAndAwaitTermination(extProcChannelExecutor); + shutdownAndAwaitTermination(dataPlaneChannelExecutor); + shutdownAndAwaitTermination(callExecutor); + } + } + + // --- Category 28: Header Option Value Spec Compliance and Validation --- + + @Test + @SuppressWarnings("unchecked") + public void serialization_specCompliance() 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()) + .setProcessingMode(ProcessingMode.newBuilder() + .setRequestHeaderMode(ProcessingMode.HeaderSendMode.SEND).build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + final CountDownLatch requestSentLatch = new CountDownLatch(1); + final AtomicReference capturedRequest = new AtomicReference<>(); + ExternalProcessorGrpc.ExternalProcessorImplBase extProcImpl = + new ExternalProcessorGrpc.ExternalProcessorImplBase() { + @Override + 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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(dataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("custom-ascii", Metadata.ASCII_STRING_MARSHALLER), "hello-world"); + headers.put( + Metadata.Key.of("custom-bin", Metadata.BINARY_BYTE_MARSHALLER), + new byte[]{0x00, 0x01, 0x02}); + + proxyCall.start(new ClientCall.Listener() {}, headers); + + assertThat(requestSentLatch.await(5, TimeUnit.SECONDS)).isTrue(); + ProcessingRequest req = capturedRequest.get(); + assertThat(req.hasRequestHeaders()).isTrue(); + + // Find our headers in the captured request + io.envoyproxy.envoy.config.core.v3.HeaderMap headerMap = req.getRequestHeaders().getHeaders(); + io.envoyproxy.envoy.config.core.v3.HeaderValue customAsciiProto = null; + io.envoyproxy.envoy.config.core.v3.HeaderValue customBinProto = null; + for (io.envoyproxy.envoy.config.core.v3.HeaderValue hv : headerMap.getHeadersList()) { + if (hv.getKey().equals("custom-ascii")) { + customAsciiProto = hv; + } else if (hv.getKey().equals("custom-bin")) { + customBinProto = hv; + } + } + + assertThat(customAsciiProto).isNotNull(); + // ASCII: value is not set, raw_value is set to the ASCII string bytes + assertThat(customAsciiProto.getValue()).isEmpty(); + assertThat(customAsciiProto.getRawValue().toStringUtf8()).isEqualTo("hello-world"); + + assertThat(customBinProto).isNotNull(); + // Binary: value is not set, raw_value is set to base64-encoded bytes + assertThat(customBinProto.getValue()).isEmpty(); + String expectedBase64 = BaseEncoding.base64().encode(new byte[]{0x00, 0x01, 0x02}); + assertThat(customBinProto.getRawValue().toStringUtf8()).isEqualTo(expectedBase64); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_preferRawValue() 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(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-ascii") + .setValue("legacy-val") + .setRawValue(ByteString.copyFromUtf8("raw-val")) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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(); + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + capturedHeaders.set(headers); + dataPlaneLatch.countDown(); + return next.startCall(call, headers); + } + })); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + Metadata headersApplied = capturedHeaders.get(); + // It should have chosen raw_value ("raw-val") and ignored value ("legacy-val") + assertThat( + headersApplied.get( + Metadata.Key.of("custom-ascii", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("raw-val"); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_binaryHeader_validBase64() 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(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-bin") + .setRawValue( + ByteString.copyFromUtf8("YmFy")) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + 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(); + })) + .build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + capturedHeaders.set(headers); + dataPlaneLatch.countDown(); + return next.startCall(call, headers); + } + })); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + proxyCall.start(new ClientCall.Listener() {}, new Metadata()); + + assertThat(dataPlaneLatch.await(5, TimeUnit.SECONDS)).isTrue(); + Metadata headersApplied = capturedHeaders.get(); + // It should have base64 decoded "YmFy" to "bar" + byte[] binValue = + headersApplied.get(Metadata.Key.of("custom-bin", Metadata.BINARY_BYTE_MARSHALLER)); + assertThat(binValue).isEqualTo(new byte[]{'b', 'a', 'r'}); + + proxyCall.cancel("Cleanup", null); + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_binaryHeader_invalidBase64_noError_fails() 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(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-bin") + .setRawValue( + ByteString.copyFromUtf8("invalid_base64!")) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + final AtomicReference capturedStatus = new AtomicReference<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getCause()).isInstanceOf(IllegalArgumentException.class); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_binaryHeader_invalidBase64_failsCall() 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()) + .setMutationRules( + io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules + .newBuilder() + .setDisallowIsError(com.google.protobuf.BoolValue.of(true)) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-bin") + .setRawValue( + ByteString.copyFromUtf8("invalid_base64!")) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + final AtomicReference capturedStatus = new AtomicReference<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getCause()).isInstanceOf(IllegalArgumentException.class); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_asciiHeader_invalidChars_noError_fails() 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(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-ascii") + .setRawValue( + ByteString.copyFromUtf8( + "value_with_newline\n")) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + final AtomicReference capturedStatus = new AtomicReference<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getCause()).isInstanceOf(IllegalArgumentException.class); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_asciiHeader_invalidCharacters_failsCall() 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()) + .setMutationRules( + io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules + .newBuilder() + .setDisallowIsError(com.google.protobuf.BoolValue.of(true)) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-ascii") + .setRawValue( + ByteString.copyFromUtf8( + "value_with_newline\n")) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + final AtomicReference capturedStatus = new AtomicReference<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getCause()).isInstanceOf(IllegalArgumentException.class); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_headerValue_tooLong_noError_fails() 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(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + String longValue = new String(new char[16385]).replace('\0', 'v'); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-ascii") + .setRawValue(ByteString.copyFromUtf8(longValue)) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + final AtomicReference capturedStatus = new AtomicReference<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getCause()).isInstanceOf(IllegalArgumentException.class); + + channelManager.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void deserialization_headerValue_tooLong_failsCall() throws Exception { + String uniqueExtProcServerName = InProcessServerBuilder.generateName(); + String uniqueDataPlaneServerName = InProcessServerBuilder.generateName(); + // Enable disallowIsError = true in mutation rules + 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()) + .setMutationRules( + io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules + .newBuilder() + .setDisallowIsError(com.google.protobuf.BoolValue.of(false)) + .build()) + .build(); + ExternalProcessorFilterConfig filterConfig = + provider.parseFilterConfig(Any.pack(proto), filterContext).config; + + // Create a value that is 16385 characters long (exceeding 16384 limit) + String longValue = new String(new char[16385]).replace('\0', 'v'); + + ExternalProcessorGrpc.ExternalProcessorImplBase 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() + .setHeaderMutation(HeaderMutation.newBuilder() + .addSetHeaders( + io.envoyproxy.envoy.config.core.v3.HeaderValueOption + .newBuilder() + .setHeader( + io.envoyproxy.envoy.config.core.v3.HeaderValue + .newBuilder() + .setKey("custom-ascii") + .setRawValue(ByteString.copyFromUtf8(longValue)) + .build()) + .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()); + }); + + ExternalProcessorClientInterceptor interceptor = new ExternalProcessorClientInterceptor( + filterConfig, channelManager, scheduler, FAKE_CONTEXT); + + ManagedChannel dataPlaneChannel = + grpcCleanup.register( + InProcessChannelBuilder.forName(uniqueDataPlaneServerName).directExecutor().build()); + + CallOptions callOptions = DEFAULT_CALL_OPTIONS.withExecutor(MoreExecutors.directExecutor()); + ClientCall proxyCall = + interceptCall(interceptor, METHOD_SAY_HELLO, callOptions, dataPlaneChannel); + + final AtomicReference capturedStatus = new AtomicReference<>(); + final CountDownLatch callClosedLatch = new CountDownLatch(1); + proxyCall.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + capturedStatus.set(status); + callClosedLatch.countDown(); + } + }, new Metadata()); + + assertThat(callClosedLatch.await(5, TimeUnit.SECONDS)).isTrue(); + // The call should fail unconditionally due to IllegalArgumentException + assertThat(capturedStatus.get().getCode()).isEqualTo(Status.Code.INTERNAL); + assertThat(capturedStatus.get().getCause()).isInstanceOf(IllegalArgumentException.class); + + channelManager.close(); + } + + private static ClientCall interceptCall( + ExternalProcessorClientInterceptor interceptor, + MethodDescriptor method, + CallOptions callOptions, + Channel next) { + Channel intercepted = ClientInterceptors.interceptForward( + next, + Arrays.asList(new XdsNameResolver.RawMessageClientInterceptor(), interceptor)); + return intercepted.newCall(method, callOptions); + } + + private void shutdownAndAwaitTermination(ExecutorService executor) { + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} 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..74853f1edd0 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/ExternalProcessorFilterTest.java @@ -0,0 +1,344 @@ +/* + * 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.protobuf.Any; +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.ProcessingMode; +import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; +import io.grpc.NameResolverRegistry; +import io.grpc.testing.GrpcCleanupRule; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterConfig; +import io.grpc.xds.ExternalProcessorFilter.ExternalProcessorFilterOverrideConfig; +import io.grpc.xds.client.Bootstrapper; +import io.grpc.xds.client.EnvoyProtoData.Node; +import java.net.SocketAddress; +import java.net.URI; +import java.util.Collection; +import java.util.Collections; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link ExternalProcessorFilter} configuration parsing and provider. + */ +@RunWith(JUnit4.class) +public class ExternalProcessorFilterTest { + static { + System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "true"); + } + + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + private String extProcServerName; + private ExternalProcessorFilter.Provider provider; + private Filter.FilterConfigParseContext filterContext; + private Bootstrapper.BootstrapInfo bootstrapInfo; + private Bootstrapper.ServerInfo serverInfo; + + @Before + public void setUp() throws Exception { + NameResolverRegistry.getDefaultRegistry().register(new InProcessNameResolverProvider()); + extProcServerName = "test-ext-proc-server"; + 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.FilterConfigParseContext.builder() + .bootstrapInfo(bootstrapInfo) + .serverInfo(serverInfo) + .build(); + } + + 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(); + } + } + + 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: Filter Provider registration based on flag --- + @Test + public void provider_registeredInFilterRegistry_basedOnFlag() { + // Test with flag true + System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "true"); + try { + FilterRegistry.reset(); + FilterRegistry registry = FilterRegistry.getDefaultRegistry(); + assertThat(registry.get(ExternalProcessorFilter.TYPE_URL)).isNotNull(); + assertThat(registry.get(ExternalProcessorFilter.TYPE_URL).isClientFilter()).isTrue(); + } 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.reset(); + FilterRegistry registry = FilterRegistry.getDefaultRegistry(); + assertThat(registry.get(ExternalProcessorFilter.TYPE_URL)).isNotNull(); + assertThat(registry.get(ExternalProcessorFilter.TYPE_URL).isClientFilter()).isFalse(); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT"); + } + + // Restore default for other tests + System.setProperty("GRPC_EXPERIMENTAL_XDS_EXT_PROC_ON_CLIENT", "true"); + FilterRegistry.reset(); + FilterRegistry.getDefaultRegistry(); + } + + // --- Category 2: 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 givenUnsupportedRequestBodyMode_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 givenUnsupportedResponseBodyMode_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseBodyMode(ProcessingMode.BodySendMode.BUFFERED) // Unsupported + .build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("Invalid response_body_mode"); + } + + @Test + public void givenNonGoogleGrpcService_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 givenInvalidGrpcService_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = ExternalProcessor.newBuilder() + .setGrpcService(GrpcService.newBuilder() + .setGoogleGrpc(GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("in-process:///" + extProcServerName) + // Invalid: no channel credentials plugin + .build()) + .build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("Error parsing GrpcService config"); + assertThat(result.errorDetail).contains("No valid supported channel_credentials found"); + } + + @Test + public void givenInvalidMutationRules_whenParsed_thenReturnsError() throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setMutationRules( + io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules.newBuilder() + .setAllowExpression(io.envoyproxy.envoy.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex("allow-[") // Invalid regex pattern + .build()) + .build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains("Error parsing HeaderMutationRules"); + assertThat(result.errorDetail).contains("Invalid regex pattern for allow_expression"); + } + + @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 givenNonPositiveDeferredCloseTimeout_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 givenResponseBodyModeGrpcWithResponseTrailerModeNotSend_whenParsed_thenReturnsError() + throws Exception { + ExternalProcessor proto = createBaseProto(extProcServerName) + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfig(Any.pack(proto), filterContext); + + assertThat(result.errorDetail).contains( + "response_trailer_mode must be SEND if response_body_mode is GRPC"); + } + + @Test + public void givenOverrideConfigWithBodyGrpcAndTrailerNotSend_whenParsed_thenError() + throws Exception { + ExtProcPerRoute perRoute = ExtProcPerRoute.newBuilder() + .setOverrides(ExtProcOverrides.newBuilder() + .setProcessingMode(ProcessingMode.newBuilder() + .setResponseBodyMode(ProcessingMode.BodySendMode.GRPC) + .setResponseTrailerMode(ProcessingMode.HeaderSendMode.SKIP) + .build()) + .build()) + .build(); + + ConfigOrError result = + provider.parseFilterConfigOverride(Any.pack(perRoute), filterContext); + + assertThat(result.errorDetail).contains( + "response_trailer_mode must be SEND if response_body_mode is GRPC"); + } + + @Test + public void givenInvalidProto_whenParseFilterConfig_thenReturnsError() throws Exception { + com.google.protobuf.Struct structMessage = com.google.protobuf.Struct.newBuilder().build(); + ConfigOrError result = + provider.parseFilterConfig(Any.pack(structMessage), filterContext); + + assertThat(result.errorDetail).contains("Invalid proto:"); + } + + @Test + public void givenInvalidProto_whenParseFilterConfigOverride_thenReturnsError() throws Exception { + com.google.protobuf.Struct structMessage = com.google.protobuf.Struct.newBuilder().build(); + ConfigOrError result = + provider.parseFilterConfigOverride(Any.pack(structMessage), filterContext); + + assertThat(result.errorDetail).contains("Invalid proto:"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/FailingClientInterceptor.java b/xds/src/test/java/io/grpc/xds/FailingClientInterceptor.java new file mode 100644 index 00000000000..c8b32f376ee --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/FailingClientInterceptor.java @@ -0,0 +1,50 @@ +/* + * 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; + +import static java.util.Objects.requireNonNull; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.NoopClientCall; +import io.grpc.Status; + +/** + * An interceptor that fails all RPCs with the provided status. + */ +final class FailingClientInterceptor implements ClientInterceptor { + private final Status status; + + public FailingClientInterceptor(Status status) { + this.status = requireNonNull(status, "status"); + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new NoopClientCall() { + @Override + public void start(Listener responseListener, Metadata headers) { + responseListener.onClose(status, new Metadata()); + } + }; + } +} diff --git a/xds/src/test/java/io/grpc/xds/FakeControlPlaneXdsIntegrationTest.java b/xds/src/test/java/io/grpc/xds/FakeControlPlaneXdsIntegrationTest.java index c8f2b8932ef..a273c6f3ebf 100644 --- a/xds/src/test/java/io/grpc/xds/FakeControlPlaneXdsIntegrationTest.java +++ b/xds/src/test/java/io/grpc/xds/FakeControlPlaneXdsIntegrationTest.java @@ -50,8 +50,10 @@ import io.grpc.ClientCall; import io.grpc.ClientInterceptor; import io.grpc.ClientStreamTracer; +import io.grpc.FlagResetRule; import io.grpc.ForwardingClientCall.SimpleForwardingClientCall; import io.grpc.ForwardingClientCallListener; +import io.grpc.InternalFeatureFlags; import io.grpc.LoadBalancerRegistry; import io.grpc.ManagedChannel; import io.grpc.Metadata; @@ -60,10 +62,14 @@ import io.grpc.testing.protobuf.SimpleResponse; import io.grpc.testing.protobuf.SimpleServiceGrpc; import java.net.InetSocketAddress; +import java.util.Arrays; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; /** * Xds integration tests using a local control plane, implemented in {@link @@ -85,13 +91,28 @@ * 3) Construct EDS config w/ test server address from 2). Set CDS and EDS Config at the Control * Plane. Then start the test xDS client (requires EDS to do xDS name resolution). */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class FakeControlPlaneXdsIntegrationTest { @Rule(order = 0) public ControlPlaneRule controlPlane = new ControlPlaneRule(); @Rule(order = 1) public DataPlaneRule dataPlane = new DataPlaneRule(controlPlane); + @Rule(order = 2) + public final FlagResetRule flagResetRule = new FlagResetRule(); + + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + + @Before + public void setupRfc3986UrisFeatureFlag() throws Exception { + flagResetRule.setFlagForTest( + InternalFeatureFlags::setRfc3986UrisEnabled, enableRfc3986UrisParam); + } @Test public void pingPong() throws Exception { diff --git a/xds/src/test/java/io/grpc/xds/FaultFilterTest.java b/xds/src/test/java/io/grpc/xds/FaultFilterTest.java index 8f0a33951b0..9033d1e636e 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.FilterConfigParseContext getFilterContext() { + return Filter.FilterConfigParseContext.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 1d6c97d81e6..11745c01fc2 100644 --- a/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java +++ b/xds/src/test/java/io/grpc/xds/GcpAuthenticationFilterTest.java @@ -65,10 +65,12 @@ 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; -import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -112,8 +114,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 +128,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,14 +139,14 @@ 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"); } @Test - public void testClientInterceptor_success() throws IOException, ResourceInvalidException { + public void testClientInterceptor_success() throws ResourceInvalidException { XdsConfig.XdsClusterConfig clusterConfig = new XdsConfig.XdsClusterConfig( CLUSTER_NAME, cdsUpdate, @@ -173,7 +175,7 @@ public void testClientInterceptor_success() throws IOException, ResourceInvalidE @Test public void testClientInterceptor_createsAndReusesCachedCredentials() - throws IOException, ResourceInvalidException { + throws ResourceInvalidException { XdsConfig.XdsClusterConfig clusterConfig = new XdsConfig.XdsClusterConfig( CLUSTER_NAME, cdsUpdate, @@ -320,8 +322,7 @@ public void testClientInterceptor_statusOrError() throws Exception { } @Test - public void testClientInterceptor_notAudienceWrapper() - throws IOException, ResourceInvalidException { + public void testClientInterceptor_notAudienceWrapper() throws ResourceInvalidException { XdsConfig.XdsClusterConfig clusterConfig = new XdsConfig.XdsClusterConfig( CLUSTER_NAME, getCdsUpdateWithIncorrectAudienceWrapper(), @@ -349,7 +350,7 @@ public void testClientInterceptor_notAudienceWrapper() } @Test - public void testLruCacheAcrossInterceptors() throws IOException, ResourceInvalidException { + public void testLruCacheAcrossInterceptors() throws ResourceInvalidException { XdsConfig.XdsClusterConfig clusterConfig = new XdsConfig.XdsClusterConfig( CLUSTER_NAME, cdsUpdate, new EndpointConfig(StatusOr.fromValue(edsUpdate))); XdsConfig defaultXdsConfig = new XdsConfig.XdsConfigBuilder() @@ -383,7 +384,7 @@ public void testLruCacheAcrossInterceptors() throws IOException, ResourceInvalid } @Test - public void testLruCacheEvictionOnResize() throws IOException, ResourceInvalidException { + public void testLruCacheEvictionOnResize() throws ResourceInvalidException { XdsConfig.XdsClusterConfig clusterConfig = new XdsConfig.XdsClusterConfig( CLUSTER_NAME, cdsUpdate, new EndpointConfig(StatusOr.fromValue(edsUpdate))); XdsConfig defaultXdsConfig = new XdsConfig.XdsConfigBuilder() @@ -468,7 +469,9 @@ private static LdsUpdate getLdsUpdate() { private static RdsUpdate getRdsUpdate() { RouteConfiguration routeConfiguration = buildRouteConfiguration("my-server", RDS_NAME, CLUSTER_NAME); - XdsResourceType.Args args = new XdsResourceType.Args(null, "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) { @@ -489,35 +492,40 @@ private static EdsUpdate getEdsUpdate() { private static CdsUpdate getCdsUpdate() { ImmutableMap.Builder parsedMetadata = ImmutableMap.builder(); parsedMetadata.put("FILTER_INSTANCE_NAME", new AudienceWrapper("TEST_AUDIENCE")); - try { - CdsUpdate.Builder cdsUpdate = CdsUpdate.forEds( - CLUSTER_NAME, EDS_NAME, null, null, null, null, false) - .lbPolicyConfig(getWrrLbConfigAsMap()); - return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); - } catch (IOException ex) { - return null; - } + CdsUpdate.Builder cdsUpdate = CdsUpdate.forEds( + CLUSTER_NAME, EDS_NAME, null, null, null, null, false, null) + .lbPolicyConfigJsonForTesting(getWrrLbConfigAsMap()); + return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); } private static CdsUpdate getCdsUpdate2() { ImmutableMap.Builder parsedMetadata = ImmutableMap.builder(); parsedMetadata.put("FILTER_INSTANCE_NAME", new AudienceWrapper("NEW_TEST_AUDIENCE")); - try { - CdsUpdate.Builder cdsUpdate = CdsUpdate.forEds( - CLUSTER_NAME, EDS_NAME, null, null, null, null, false) - .lbPolicyConfig(getWrrLbConfigAsMap()); - return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); - } catch (IOException ex) { - return null; - } + CdsUpdate.Builder cdsUpdate = CdsUpdate.forEds( + CLUSTER_NAME, EDS_NAME, null, null, null, null, false, null) + .lbPolicyConfigJsonForTesting(getWrrLbConfigAsMap()); + return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); } - private static CdsUpdate getCdsUpdateWithIncorrectAudienceWrapper() throws IOException { + private static CdsUpdate getCdsUpdateWithIncorrectAudienceWrapper() { ImmutableMap.Builder parsedMetadata = ImmutableMap.builder(); parsedMetadata.put("FILTER_INSTANCE_NAME", "TEST_AUDIENCE"); CdsUpdate.Builder cdsUpdate = CdsUpdate.forEds( - CLUSTER_NAME, EDS_NAME, null, null, null, null, false) - .lbPolicyConfig(getWrrLbConfigAsMap()); + CLUSTER_NAME, EDS_NAME, null, null, null, null, false, null) + .lbPolicyConfigJsonForTesting(getWrrLbConfigAsMap()); return cdsUpdate.parsedMetadata(parsedMetadata.build()).build(); } + + private static Filter.FilterConfigParseContext getFilterContext() { + return Filter.FilterConfigParseContext.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/GrpcBootstrapperImplTest.java b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java index 3f93cc6f191..d4ee4159bc2 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java @@ -28,6 +28,8 @@ import io.grpc.TlsChannelCredentials; import io.grpc.internal.GrpcUtil; import io.grpc.internal.GrpcUtil.GrpcBuildVersion; +import io.grpc.xds.client.AllowedGrpcServices; +import io.grpc.xds.client.AllowedGrpcServices.AllowedGrpcService; import io.grpc.xds.client.Bootstrapper; import io.grpc.xds.client.Bootstrapper.AuthorityInfo; import io.grpc.xds.client.Bootstrapper.BootstrapInfo; @@ -84,6 +86,72 @@ public void restoreEnvironment() { CommonBootstrapperTestUtils.setEnableXdsFallback(originalExperimentalXdsFallbackFlag); } + @Test + public void parseBootstrap_emptyServers_throws() { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " ]\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + XdsInitializationException e = Assert.assertThrows(XdsInitializationException.class, + bootstrapper::bootstrap); + assertThat(e).hasMessageThat().isEqualTo("Invalid bootstrap: 'xds_servers' is empty"); + } + + @Test + public void parseBootstrap_allowedGrpcServices() throws XdsInitializationException { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}]\n" + + " }\n" + + " ],\n" + + " \"allowed_grpc_services\": {\n" + + " \"dns:///foo.com:443\": {\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}],\n" + + " \"call_creds\": [{\"type\": \"access_token\"}]\n" + + " }\n" + + " }\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + GrpcBootstrapImplConfig customConfig = + (GrpcBootstrapImplConfig) info.implSpecificObject().get(); + AllowedGrpcServices allowed = customConfig.allowedGrpcServices(); + assertThat(allowed).isNotNull(); + assertThat(allowed.services()).containsKey("dns:///foo.com:443"); + AllowedGrpcService service = allowed.services().get("dns:///foo.com:443"); + assertThat(service.configuredChannelCredentials().channelCredentials()) + .isInstanceOf(InsecureChannelCredentials.class); + assertThat(service.callCredentials().isPresent()).isFalse(); + } + + @Test + public void parseBootstrap_allowedGrpcServices_invalidChannelCreds() { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}]\n" + + " }\n" + + " ],\n" + + " \"allowed_grpc_services\": {\n" + + " \"dns:///foo.com:443\": {\n" + + " \"channel_creds\": []\n" + + " }\n" + + " }\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + XdsInitializationException e = assertThrows(XdsInitializationException.class, + bootstrapper::bootstrap); + assertThat(e).hasMessageThat() + .isEqualTo("Invalid bootstrap: server dns:///foo.com:443 'channel_creds' required"); + } + @Test public void parseBootstrap_singleXdsServer() throws XdsInitializationException { String rawData = "{\n" @@ -352,7 +420,14 @@ public void parseBootstrap_serverWithoutServerUri() throws XdsInitializationExce public void parseBootstrap_certProviderInstances() throws XdsInitializationException { String rawData = "{\n" - + " \"xds_servers\": [],\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ]\n" + + " }\n" + + " ],\n" + " \"certificate_providers\": {\n" + " \"gcp_id\": {\n" + " \"plugin_name\": \"meshca\",\n" @@ -389,7 +464,6 @@ public void parseBootstrap_certProviderInstances() throws XdsInitializationExcep bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); BootstrapInfo info = bootstrapper.bootstrap(); - assertThat(info.servers()).isEmpty(); assertThat(info.node()).isEqualTo(getNodeBuilder().build()); Map certProviders = info.certProviders(); assertThat(certProviders).isNotNull(); @@ -556,7 +630,14 @@ public void parseBootstrap_missingPluginName() { @Test public void parseBootstrap_grpcServerResourceId() throws XdsInitializationException { String rawData = "{\n" - + " \"xds_servers\": [],\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ]\n" + + " }\n" + + " ],\n" + " \"server_listener_resource_name_template\": \"grpc/serverx=%s\"\n" + "}"; @@ -697,6 +778,52 @@ public void serverFeatures_ignoresUnknownValues() throws XdsInitializationExcept assertThat(serverInfo.isTrustedXdsServer()).isTrue(); } + @Test + public void serverFeature_failOnDataErrors() throws XdsInitializationException { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"server_features\": [\"fail_on_data_errors\"]\n" + + " }\n" + + " ]\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + ServerInfo serverInfo = Iterables.getOnlyElement(info.servers()); + assertThat(serverInfo.target()).isEqualTo(SERVER_URI); + assertThat(serverInfo.implSpecificConfig()).isInstanceOf(InsecureChannelCredentials.class); + assertThat(serverInfo.failOnDataErrors()).isTrue(); + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + @Test + public void serverFeature_failOnDataErrors_requiresEnvVar() throws XdsInitializationException { + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"server_features\": [\"fail_on_data_errors\"]\n" + + " }\n" + + " ]\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + ServerInfo serverInfo = Iterables.getOnlyElement(info.servers()); + // Should be false when env var is not enabled + assertThat(serverInfo.failOnDataErrors()).isFalse(); + } + @Test public void notFound() { bootstrapper.bootstrapPathFromEnvVar = null; @@ -779,6 +906,12 @@ public void fallbackToConfigFromSysProp() throws XdsInitializationException { public void parseClientDefaultListenerResourceNameTemplate() throws Exception { String rawData = "{\n" + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ]\n" + + " }\n" + " ]\n" + "}"; bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); @@ -788,6 +921,12 @@ public void parseClientDefaultListenerResourceNameTemplate() throws Exception { rawData = "{\n" + " \"client_default_listener_resource_name_template\": \"xdstp://a.com/faketype/%s\",\n" + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ]\n" + + " }\n" + " ]\n" + "}"; bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); diff --git a/xds/src/test/java/io/grpc/xds/GrpcServiceConfigParserTest.java b/xds/src/test/java/io/grpc/xds/GrpcServiceConfigParserTest.java new file mode 100644 index 00000000000..7dd4bb7cb67 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/GrpcServiceConfigParserTest.java @@ -0,0 +1,787 @@ +/* + * 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.assertThrows; + +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import com.google.protobuf.Duration; +import io.envoyproxy.envoy.config.core.v3.GrpcService; +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.extensions.grpc_service.channel_credentials.insecure.v3.InsecureCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3.LocalCredentials; +import io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3.XdsCredentials; +import io.grpc.Attributes; +import io.grpc.CallCredentials; +import io.grpc.CompositeCallCredentials; +import io.grpc.CompositeChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.SecurityLevel; +import io.grpc.Status; +import io.grpc.alts.GoogleDefaultChannelCredentials; +import io.grpc.xds.client.AllowedGrpcServices; +import io.grpc.xds.client.AllowedGrpcServices.AllowedGrpcService; +import io.grpc.xds.client.Bootstrapper.BootstrapInfo; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.ConfiguredChannelCredentials; +import io.grpc.xds.client.EnvoyProtoData.Node; +import io.grpc.xds.internal.grpcservice.GrpcServiceConfig; +import io.grpc.xds.internal.grpcservice.GrpcServiceParseException; +import java.io.InputStream; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mockito; + +@RunWith(JUnit4.class) +public class GrpcServiceConfigParserTest { + + private static final String CALL_CREDENTIALS_CLASS_NAME = + "io.grpc.xds.GrpcServiceConfigParser$SecurityAwareAccessTokenCredentials"; + + private static BootstrapInfo dummyBootstrapInfo() { + return dummyBootstrapInfo(Optional.empty()); + } + + private static BootstrapInfo dummyBootstrapInfo(Optional implSpecificObject) { + return BootstrapInfo.builder() + .servers(Collections + .singletonList(ServerInfo.create("test_target", Collections.emptyMap()))) + .node(Node.newBuilder().build()).implSpecificObject(implSpecificObject).build(); + } + + private static ServerInfo dummyServerInfo() { + return dummyServerInfo(true); + } + + private static ServerInfo dummyServerInfo(boolean isTrusted) { + return ServerInfo.create("test_target", Collections.emptyMap(), false, isTrusted, false, + false); + } + + private static GrpcServiceConfig parse( + GrpcService grpcServiceProto, BootstrapInfo bootstrapInfo, + ServerInfo serverInfo) + throws GrpcServiceParseException { + return GrpcServiceConfigParser.parse(grpcServiceProto, bootstrapInfo, serverInfo); + } + + private static GrpcServiceConfig.GoogleGrpcConfig parseGoogleGrpcConfig( + GrpcService.GoogleGrpc googleGrpcProto, BootstrapInfo bootstrapInfo, + ServerInfo serverInfo) + throws GrpcServiceParseException { + return GrpcServiceConfigParser.parseGoogleGrpcConfig( + googleGrpcProto, bootstrapInfo, serverInfo); + } + + @Test + public void parse_success() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + Duration timeout = Duration.newBuilder().setSeconds(10).build(); + GrpcService grpcService = + GrpcService.newBuilder().setGoogleGrpc(googleGrpc).setTimeout(timeout).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + // Assert target URI + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + + // Assert channel credentials + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(InsecureChannelCredentials.class); + GrpcServiceConfigParser.ProtoChannelCredsConfig credsConfig = + (GrpcServiceConfigParser.ProtoChannelCredsConfig) + config.googleGrpc().configuredChannelCredentials().channelCredsConfig(); + assertThat(credsConfig.configProto()).isEqualTo(insecureCreds); + + // Assert call credentials + assertThat(config.googleGrpc().callCredentials().isPresent()).isTrue(); + assertThat(config.googleGrpc().callCredentials().get().getClass().getName()) + .isEqualTo(CALL_CREDENTIALS_CLASS_NAME); + + // Assert timeout + assertThat(config.timeout().isPresent()).isTrue(); + assertThat(config.timeout().get()).isEqualTo(java.time.Duration.ofSeconds(10)); + } + + @Test + public void parse_minimalSuccess_defaults() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + assertThat(config.googleGrpc().target()).isEqualTo("test_uri"); + assertThat(config.timeout().isPresent()).isFalse(); + } + + @Test + public void parse_withInitialMetadata() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + io.envoyproxy.envoy.config.core.v3.HeaderValue asciiHeader = + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("test_key").setValue("test_value").build(); + io.envoyproxy.envoy.config.core.v3.HeaderValue binaryHeader = + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("test_key-bin") + .setRawValue(ByteString.copyFromUtf8("test_value_binary")) + .build(); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc) + .addInitialMetadata(asciiHeader).addInitialMetadata(binaryHeader).build(); + + GrpcServiceConfig config = parse(grpcService, dummyBootstrapInfo(), dummyServerInfo()); + + // Assert initial metadata + assertThat(config.initialMetadata()).hasSize(2); + assertThat(config.initialMetadata().get(0).key()).isEqualTo("test_key"); + assertThat(config.initialMetadata().get(0).value().get()).isEqualTo("test_value"); + assertThat(config.initialMetadata().get(1).key()).isEqualTo("test_key-bin"); + assertThat(config.initialMetadata().get(1).rawValue().get()) + .isEqualTo(ByteString.copyFromUtf8("test_value_binary")); + } + + @Test + public void parse_disallowedInitialMetadata() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + io.envoyproxy.envoy.config.core.v3.HeaderValue disallowedHeader = + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("host").setValue("test_value").build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc) + .addInitialMetadata(disallowedHeader).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, dummyBootstrapInfo(), dummyServerInfo())); + assertThat(exception).hasMessageThat().contains("Invalid initial metadata header: host"); + } + + @Test + public void parse_invalidInitialMetadataValue() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + io.envoyproxy.envoy.config.core.v3.HeaderValue invalidHeader = + io.envoyproxy.envoy.config.core.v3.HeaderValue.newBuilder() + .setKey("custom-header").setValue("invalid_value\n").build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc) + .addInitialMetadata(invalidHeader).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, dummyBootstrapInfo(), dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("Invalid initial metadata header: custom-header"); + assertThat(exception).hasCauseThat().isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_missingGoogleGrpc() { + GrpcService grpcService = GrpcService.newBuilder().build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .startsWith("Unsupported: GrpcService must have GoogleGrpc, got: "); + } + + @Test + public void parse_emptyCallCredentials() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + assertThat(config.googleGrpc().callCredentials().isPresent()).isFalse(); + } + + @Test + public void parse_emptyChannelCredentials() { + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .isEqualTo("No valid supported channel_credentials found"); + } + + @Test + public void parse_googleDefaultCredentials() throws GrpcServiceParseException { + Any googleDefaultCreds = Any.pack(GoogleDefaultCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(googleDefaultCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(CompositeChannelCredentials.class); + GrpcServiceConfigParser.ProtoChannelCredsConfig credsConfig = + (GrpcServiceConfigParser.ProtoChannelCredsConfig) + config.googleGrpc().configuredChannelCredentials().channelCredsConfig(); + assertThat(credsConfig.configProto()).isEqualTo(googleDefaultCreds); + } + + @Test + public void parse_localCredentials() throws GrpcServiceParseException { + Any localCreds = Any.pack(LocalCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(localCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("LocalCredentials are not supported in grpc-java"); + } + + @Test + public void parse_xdsCredentials_withInsecureFallback() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + XdsCredentials xdsCreds = + XdsCredentials.newBuilder().setFallbackCredentials(insecureCreds).build(); + Any xdsCredsAny = Any.pack(xdsCreds); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(xdsCredsAny).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = GrpcServiceConfigParser.parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isNotNull(); + GrpcServiceConfigParser.ProtoChannelCredsConfig credsConfig = + (GrpcServiceConfigParser.ProtoChannelCredsConfig) + config.googleGrpc().configuredChannelCredentials().channelCredsConfig(); + assertThat(credsConfig.configProto()).isEqualTo(xdsCredsAny); + } + + @Test + public void parse_tlsCredentials_notSupported() { + Any tlsCreds = Any + .pack(io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3.TlsCredentials + .getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(tlsCreds).addCallCredentialsPlugin(accessTokenCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("TlsCredentials input stream construction pending"); + } + + @Test + public void parse_invalidChannelCredentialsProto() { + // Pack a Duration proto, but try to unpack it as GoogleDefaultCredentials + Any invalidCreds = Any.pack(Duration.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(invalidCreds).addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat().contains("No valid supported channel_credentials found"); + } + + @Test + public void parse_ignoredUnsupportedCallCredentialsProto() throws GrpcServiceParseException { + // Pack a Duration proto, but try to unpack it as AccessTokenCredentials + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any invalidCallCredentials = Any.pack(Duration.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + assertThat(config.googleGrpc().callCredentials().isPresent()).isFalse(); + } + + @Test + public void parse_invalidAccessTokenCallCredentialsProto() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any invalidCallCredentials = Any.pack(AccessTokenCredentials.newBuilder().setToken("").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCredentials) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("Missing or empty access token in call credentials"); + } + + @Test + public void parse_multipleCallCredentials() throws GrpcServiceParseException { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds1 = + Any.pack(AccessTokenCredentials.newBuilder().setToken("token1").build()); + Any accessTokenCreds2 = + Any.pack(AccessTokenCredentials.newBuilder().setToken("token2").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(accessTokenCreds1) + .addCallCredentialsPlugin(accessTokenCreds2).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + assertThat(config.googleGrpc().callCredentials().isPresent()).isTrue(); + assertThat(config.googleGrpc().callCredentials().get()) + .isInstanceOf(CompositeCallCredentials.class); + } + + @Test + public void parse_untrustedControlPlane_withoutOverride() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + BootstrapInfo untrustedBootstrapInfo = dummyBootstrapInfo(Optional.empty()); + ServerInfo untrustedServerInfo = + dummyServerInfo(false); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse( + grpcService, untrustedBootstrapInfo, untrustedServerInfo)); + assertThat(exception).hasMessageThat() + .contains("Untrusted xDS server & URI not found in allowed_grpc_services"); + } + + @Test + public void parse_untrustedControlPlane_withOverride() throws GrpcServiceParseException { + // The proto credentials (insecure) should be ignored in favor of the override (google default) + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + ConfiguredChannelCredentials overrideChannelCreds = ConfiguredChannelCredentials.create( + GoogleDefaultChannelCredentials.create(), + new GrpcServiceConfigParser.ProtoChannelCredsConfig( + GrpcServiceConfigParser.GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL, + Any.pack(GoogleDefaultCredentials.getDefaultInstance()))); + AllowedGrpcService override = AllowedGrpcService.builder() + .configuredChannelCredentials(overrideChannelCreds).build(); + AllowedGrpcServices servicesMap = + AllowedGrpcServices.create( + ImmutableMap.of("test_uri", override)); + + BootstrapInfo untrustedBootstrapInfo = + dummyBootstrapInfo(Optional.of(GrpcBootstrapImplConfig.create(servicesMap))); + ServerInfo untrustedServerInfo = + dummyServerInfo(false); + + GrpcServiceConfig config = + parse(grpcService, untrustedBootstrapInfo, untrustedServerInfo); + + // Assert channel credentials are the override, not the proto's insecure creds + assertThat(config.googleGrpc().configuredChannelCredentials().channelCredentials()) + .isInstanceOf(CompositeChannelCredentials.class); + } + + @Test + public void parse_invalidTimeout() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + + // Negative timeout + Duration timeout = Duration.newBuilder().setSeconds(-10).build(); + GrpcService grpcService = GrpcService.newBuilder() + .setGoogleGrpc(googleGrpc).setTimeout(timeout).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("Timeout must be strictly positive"); + + // Zero timeout + timeout = Duration.newBuilder().setSeconds(0).setNanos(0).build(); + GrpcService grpcServiceZero = GrpcService.newBuilder() + .setGoogleGrpc(googleGrpc).setTimeout(timeout).build(); + + exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcServiceZero, + dummyBootstrapInfo(), + dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("Timeout must be strictly positive"); + } + + @Test + public void parseGoogleGrpcConfig_unsupportedScheme() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("unknown://test") + .addChannelCredentialsPlugin(insecureCreds).build(); + + BootstrapInfo bootstrapInfo = dummyBootstrapInfo(); + ServerInfo serverInfo = dummyServerInfo(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parseGoogleGrpcConfig( + googleGrpc, bootstrapInfo, serverInfo)); + assertThat(exception).hasMessageThat() + .contains("Target URI scheme is not resolvable"); + } + + + @Test + public void parse_invalidDuration() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + + Duration timeout = Duration.newBuilder().setSeconds(10).setNanos(1_000_000_000).build(); + GrpcService grpcService = GrpcService.newBuilder() + .setGoogleGrpc(googleGrpc).setTimeout(timeout).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, dummyBootstrapInfo(), dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("Timeout must be strictly positive and valid"); + } + + @Test + public void parse_invalidChannelCredsProto() { + Any invalidCreds = Any.newBuilder() + .setTypeUrl(GrpcServiceConfigParser.XDS_CREDENTIALS_TYPE_URL) + .setValue(ByteString.copyFrom(new byte[]{1, 2, 3})).build(); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(invalidCreds).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, dummyBootstrapInfo(), dummyServerInfo())); + assertThat(exception).hasMessageThat().contains("Failed to parse channel credentials"); + } + + @Test + public void parse_unsupportedXdsFallbackCreds() { + Any unsupportedFallback = Any.pack(Duration.getDefaultInstance()); + XdsCredentials xds = + XdsCredentials.newBuilder().setFallbackCredentials(unsupportedFallback).build(); + Any xdsCredsAny = Any.newBuilder() + .setTypeUrl(GrpcServiceConfigParser.XDS_CREDENTIALS_TYPE_URL) + .setValue(xds.toByteString()).build(); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(xdsCredsAny).build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, dummyBootstrapInfo(), dummyServerInfo())); + assertThat(exception).hasMessageThat() + .contains("Unsupported fallback credentials type for XdsCredentials"); + } + + @Test + public void parse_invalidCallCredsProto() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + // We just create an Any representing AccessTokenCredentials but with invalid bytes + Any invalidCallCreds = Any.newBuilder() + .setTypeUrl(Any.pack(AccessTokenCredentials.getDefaultInstance()).getTypeUrl()) + .setValue(ByteString.copyFrom(new byte[]{1, 2, 3})).build(); + + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).addCallCredentialsPlugin(invalidCallCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parse(grpcService, dummyBootstrapInfo(), dummyServerInfo())); + assertThat(exception).hasMessageThat().contains("Failed to parse access token credentials"); + } + + @Test + public void parseGoogleGrpcConfig_malformedUriThrows() { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri(":::::") + .addChannelCredentialsPlugin(insecureCreds).build(); + + BootstrapInfo bootstrapInfo = dummyBootstrapInfo(); + ServerInfo serverInfo = dummyServerInfo(); + + GrpcServiceParseException exception = assertThrows(GrpcServiceParseException.class, + () -> parseGoogleGrpcConfig(googleGrpc, bootstrapInfo, serverInfo)); + assertThat(exception).hasMessageThat().contains("Target URI scheme is not resolvable"); + } + + @Test + public void parseGoogleGrpcConfig_untrustedWithCallCredentialsOverride() throws Exception { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder().setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds).build(); + + ConfiguredChannelCredentials overrideChannelCreds = + ConfiguredChannelCredentials.create(GoogleDefaultChannelCredentials.create(), + new GrpcServiceConfigParser.ProtoChannelCredsConfig( + GrpcServiceConfigParser.GOOGLE_DEFAULT_CREDENTIALS_TYPE_URL, + Any.pack(GoogleDefaultCredentials.getDefaultInstance()))); + + CallCredentials fakeCallCreds = Mockito.mock(CallCredentials.class); + AllowedGrpcService override = AllowedGrpcService.builder() + .configuredChannelCredentials(overrideChannelCreds).callCredentials(fakeCallCreds).build(); + + AllowedGrpcServices servicesMap = + AllowedGrpcServices + .create(ImmutableMap.of("test_uri", override)); + + BootstrapInfo untrustedBootstrapInfo = + dummyBootstrapInfo(Optional.of(GrpcBootstrapImplConfig.create(servicesMap))); + ServerInfo untrustedServerInfo = dummyServerInfo(false); + + GrpcServiceConfig.GoogleGrpcConfig config = + parseGoogleGrpcConfig(googleGrpc, untrustedBootstrapInfo, untrustedServerInfo); + + assertThat(config.callCredentials().isPresent()).isTrue(); + assertThat(config.callCredentials().get()).isSameInstanceAs(fakeCallCreds); + } + + @Test + public void protoChannelCredsConfig_equalsAndHashCode() { + Any insecureCreds1 = Any.pack(InsecureCredentials.getDefaultInstance()); + Any insecureCreds2 = Any.pack(InsecureCredentials.getDefaultInstance()); + Any localCreds = Any.pack(LocalCredentials.getDefaultInstance()); + + GrpcServiceConfigParser.ProtoChannelCredsConfig config1 = + new GrpcServiceConfigParser.ProtoChannelCredsConfig("type1", insecureCreds1); + GrpcServiceConfigParser.ProtoChannelCredsConfig config1Equivalent = + new GrpcServiceConfigParser.ProtoChannelCredsConfig("type1", insecureCreds2); + GrpcServiceConfigParser.ProtoChannelCredsConfig configDifferentType = + new GrpcServiceConfigParser.ProtoChannelCredsConfig("type2", insecureCreds1); + GrpcServiceConfigParser.ProtoChannelCredsConfig configDifferentProto = + new GrpcServiceConfigParser.ProtoChannelCredsConfig("type1", localCreds); + + assertThat(config1.type()).isEqualTo("type1"); + assertThat(config1.equals(config1)).isTrue(); + assertThat(config1.equals(null)).isFalse(); + assertThat(config1.equals(new Object())).isFalse(); + assertThat(config1.equals(config1Equivalent)).isTrue(); + assertThat(config1.hashCode()).isEqualTo(config1Equivalent.hashCode()); + assertThat(config1.equals(configDifferentType)).isFalse(); + assertThat(config1.equals(configDifferentProto)).isFalse(); + } + + static class RecordingMetadataApplier extends CallCredentials.MetadataApplier { + boolean applied = false; + boolean failed = false; + Metadata appliedHeaders = null; + + @Override + public void apply(Metadata headers) { + applied = true; + appliedHeaders = headers; + } + + @Override + public void fail(Status status) { + failed = true; + } + } + + static class FakeRequestInfo extends CallCredentials.RequestInfo { + private final SecurityLevel securityLevel; + private final MethodDescriptor methodDescriptor; + + FakeRequestInfo(SecurityLevel securityLevel) { + this.securityLevel = securityLevel; + this.methodDescriptor = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test_service/test_method") + .setRequestMarshaller(new NoopMarshaller()) + .setResponseMarshaller(new NoopMarshaller()) + .build(); + } + + private static class NoopMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(T value) { + return null; + } + + @Override + public T parse(InputStream stream) { + return null; + } + } + + @Override + public MethodDescriptor getMethodDescriptor() { + return methodDescriptor; + } + + @Override + public SecurityLevel getSecurityLevel() { + return securityLevel; + } + + @Override + public String getAuthority() { + return "dummy-authority"; + } + + @Override + public Attributes getTransportAttrs() { + return Attributes.EMPTY; + } + } + + + @Test + public void securityAwareCredentials_secureConnection_appliesToken() throws Exception { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds) + .addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + CallCredentials creds = config.googleGrpc().callCredentials().get(); + RecordingMetadataApplier applier = new RecordingMetadataApplier(); + CountDownLatch latch = new CountDownLatch(1); + + creds.applyRequestMetadata( + new FakeRequestInfo(SecurityLevel.PRIVACY_AND_INTEGRITY), + Runnable::run, // Use direct executor to avoid async issues in test + new CallCredentials.MetadataApplier() { + @Override + public void apply(Metadata headers) { + applier.apply(headers); + latch.countDown(); + } + + @Override + public void fail(Status status) { + applier.fail(status); + latch.countDown(); + } + }); + + latch.await(5, TimeUnit.SECONDS); + assertThat(applier.applied).isTrue(); + assertThat(applier.appliedHeaders.get( + Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER))) + .isEqualTo("Bearer test_token"); + } + + @Test + public void securityAwareCredentials_insecureConnection_appliesEmptyMetadata() throws Exception { + Any insecureCreds = Any.pack(InsecureCredentials.getDefaultInstance()); + Any accessTokenCreds = + Any.pack(AccessTokenCredentials.newBuilder().setToken("test_token").build()); + GrpcService.GoogleGrpc googleGrpc = GrpcService.GoogleGrpc.newBuilder() + .setTargetUri("test_uri") + .addChannelCredentialsPlugin(insecureCreds) + .addCallCredentialsPlugin(accessTokenCreds) + .build(); + GrpcService grpcService = GrpcService.newBuilder().setGoogleGrpc(googleGrpc).build(); + + GrpcServiceConfig config = parse(grpcService, + dummyBootstrapInfo(), + dummyServerInfo()); + + CallCredentials creds = config.googleGrpc().callCredentials().get(); + RecordingMetadataApplier applier = new RecordingMetadataApplier(); + + creds.applyRequestMetadata( + new FakeRequestInfo(SecurityLevel.NONE), + Runnable::run, + applier); + + assertThat(applier.applied).isTrue(); + assertThat(applier.appliedHeaders.get( + Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER))) + .isNull(); + } + + +} diff --git a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java index 3650e5d5bb9..dc9590aed51 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java @@ -18,12 +18,12 @@ import static com.google.common.truth.Truth.assertThat; import static io.envoyproxy.envoy.config.route.v3.RouteAction.ClusterSpecifierCase.CLUSTER_SPECIFIER_PLUGIN; -import static io.grpc.xds.XdsClusterResource.TRANSPORT_SOCKET_NAME_HTTP11_PROXY; import static io.grpc.xds.XdsEndpointResource.GRPC_EXPERIMENTAL_XDS_DUALSTACK_ENDPOINTS; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import com.github.udpa.udpa.type.v1.TypedStruct; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; @@ -116,19 +116,19 @@ import io.grpc.InsecureChannelCredentials; import io.grpc.LoadBalancerRegistry; import io.grpc.Status.Code; -import io.grpc.internal.JsonUtil; -import io.grpc.internal.ServiceConfigUtil; -import io.grpc.internal.ServiceConfigUtil.LbConfig; import io.grpc.lookup.v1.GrpcKeyBuilder; import io.grpc.lookup.v1.GrpcKeyBuilder.Name; import io.grpc.lookup.v1.NameMatcher; import io.grpc.lookup.v1.RouteLookupClusterSpecifier; import io.grpc.lookup.v1.RouteLookupConfig; +import io.grpc.util.GracefulSwitchLoadBalancer; import io.grpc.xds.ClusterSpecifierPlugin.NamedPluginConfig; import io.grpc.xds.ClusterSpecifierPlugin.PluginConfig; import io.grpc.xds.Endpoints.LbEndpoint; import io.grpc.xds.Endpoints.LocalityLbEndpoints; import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.Filter.FilterConfigParseContext; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.GcpAuthenticationFilter.AudienceMetadataParser.AudienceWrapper; import io.grpc.xds.MetadataRegistry.MetadataValueParser; import io.grpc.xds.RouteLookupServiceClusterSpecifierPlugin.RlsPluginConfig; @@ -138,9 +138,10 @@ import io.grpc.xds.VirtualHost.Route.RouteAction.HashPolicy; import io.grpc.xds.VirtualHost.Route.RouteMatch; import io.grpc.xds.VirtualHost.Route.RouteMatch.PathMatcher; -import io.grpc.xds.WeightedRoundRobinLoadBalancer.WeightedRoundRobinLoadBalancerConfig; import io.grpc.xds.XdsClusterResource.CdsUpdate; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.LoadStatsManager2; import io.grpc.xds.client.XdsClient; import io.grpc.xds.client.XdsResourceType; import io.grpc.xds.client.XdsResourceType.ResourceInvalidException; @@ -565,7 +566,7 @@ public void parseRouteAction_withCluster_flagDisabled_autoHostRewriteNotEnabled( assertThat(struct.getErrorDetail()).isNull(); assertThat(struct.getStruct().cluster()).isEqualTo("cluster-foo"); assertThat(struct.getStruct().weightedClusters()).isNull(); - assertThat(struct.getStruct().autoHostRewrite()).isFalse(); + assertThat(struct.getStruct().autoHostRewrite()).isTrue(); } @Test @@ -653,7 +654,7 @@ public void parseRouteAction_withWeightedCluster_flagDisabled_autoHostRewriteDis assertThat(struct.getStruct().weightedClusters()).containsExactly( ClusterWeight.create("cluster-foo", 30, ImmutableMap.of()), ClusterWeight.create("cluster-bar", 70, ImmutableMap.of())); - assertThat(struct.getStruct().autoHostRewrite()).isFalse(); + assertThat(struct.getStruct().autoHostRewrite()).isTrue(); } @Test @@ -1035,7 +1036,7 @@ public void parseRouteAction_clusterSpecifier_flagDisabled_autoHostRewriteDisabl ImmutableMap.of("lookupService", "rls-cbt.googleapis.com"))), ImmutableSet.of(), getXdsResourceTypeArgs(true)); assertThat(struct.getStruct()).isNotNull(); - assertThat(struct.getStruct().autoHostRewrite()).isFalse(); + assertThat(struct.getStruct().autoHostRewrite()).isTrue(); } @Test @@ -1046,7 +1047,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); } @@ -1252,7 +1255,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 { @@ -1286,17 +1290,19 @@ public boolean isClientFilter() { } @Override - public TestFilter newInstance(String name) { + public TestFilter newInstance(FilterContext context) { return new TestFilter(); } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig(Message rawProtoMessage, + FilterConfigParseContext context) { return ConfigOrError.fromConfig(new SimpleFilterConfig(rawProtoMessage)); } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage, + FilterConfigParseContext context) { return ConfigOrError.fromConfig(new SimpleFilterConfig(rawProtoMessage)); } } @@ -1316,7 +1322,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() @@ -1327,7 +1333,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); } @@ -1353,7 +1359,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); } @@ -1365,7 +1371,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 " @@ -1382,7 +1389,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); } @@ -1396,7 +1404,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); } @@ -1423,7 +1432,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); } @@ -1450,7 +1460,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"); @@ -1479,7 +1490,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); } @@ -1506,7 +1518,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"); @@ -1531,7 +1544,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"); @@ -1552,7 +1566,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"); @@ -1571,7 +1586,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"); @@ -1581,7 +1598,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"); @@ -2219,8 +2238,9 @@ public void parseCluster_ringHashLbPolicy_defaultLbConfig() throws ResourceInval CdsUpdate update = XdsClusterResource.processCluster( cluster, null, LRS_SERVER_INFO, LoadBalancerRegistry.getDefaultRegistry()); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(update.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("ring_hash_experimental"); + assertThat(update.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(Arrays.asList( + ImmutableMap.of("ring_hash_experimental", ImmutableMap.of()))).getConfig()); } @Test @@ -2241,11 +2261,11 @@ public void parseCluster_leastRequestLbPolicy_defaultLbConfig() throws ResourceI CdsUpdate update = XdsClusterResource.processCluster( cluster, null, LRS_SERVER_INFO, LoadBalancerRegistry.getDefaultRegistry()); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(update.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("least_request_experimental"); + assertThat(update.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(Arrays.asList( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + Arrays.asList(ImmutableMap.of("least_request_experimental", + ImmutableMap.of())))))).getConfig()); } @Test @@ -2292,20 +2312,16 @@ public void parseCluster_WrrLbPolicy_defaultLbConfig() throws ResourceInvalidExc CdsUpdate update = XdsClusterResource.processCluster( cluster, null, LRS_SERVER_INFO, LoadBalancerRegistry.getDefaultRegistry()); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(update.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("weighted_round_robin"); - WeightedRoundRobinLoadBalancerConfig result = (WeightedRoundRobinLoadBalancerConfig) - new WeightedRoundRobinLoadBalancerProvider().parseLoadBalancingPolicyConfig( - childConfigs.get(0).getRawConfigValue()).getConfig(); - assertThat(result.blackoutPeriodNanos).isEqualTo(17_000_000_000L); - assertThat(result.enableOobLoadReport).isTrue(); - assertThat(result.oobReportingPeriodNanos).isEqualTo(10_000_000_000L); - assertThat(result.weightUpdatePeriodNanos).isEqualTo(1_000_000_000L); - assertThat(result.weightExpirationPeriodNanos).isEqualTo(180_000_000_000L); - assertThat(result.errorUtilizationPenalty).isEqualTo(1.75F); + assertThat(update.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(Arrays.asList( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + Arrays.asList(ImmutableMap.of("weighted_round_robin", ImmutableMap.of( + "blackoutPeriod", "17s", + "enableOobLoadReport", true, + "oobReportingPeriod", "10s", + "weightUpdatePeriod", "1s", + "errorUtilizationPenalty", 1.75 + ))))))).getConfig()); } @Test @@ -2444,7 +2460,6 @@ public Object parse(Any value) { @Test public void processCluster_parsesAudienceMetadata() throws Exception { - FilterRegistry.isEnabledGcpAuthnFilter = true; MetadataRegistry.getInstance(); Audience audience = Audience.newBuilder() @@ -2488,14 +2503,11 @@ public void processCluster_parsesAudienceMetadata() throws Exception { "FILTER_METADATA", ImmutableMap.of( "key1", "value1", "key2", 42.0)); - try { - assertThat(update.parsedMetadata().get("FILTER_METADATA")) - .isEqualTo(expectedParsedMetadata.get("FILTER_METADATA")); - assertThat(update.parsedMetadata().get("AUDIENCE_METADATA")) - .isInstanceOf(AudienceWrapper.class); - } finally { - FilterRegistry.isEnabledGcpAuthnFilter = false; - } + + assertThat(update.parsedMetadata().get("FILTER_METADATA")) + .isEqualTo(expectedParsedMetadata.get("FILTER_METADATA")); + assertThat(update.parsedMetadata().get("AUDIENCE_METADATA")) + .isInstanceOf(AudienceWrapper.class); } @Test @@ -2616,7 +2628,7 @@ public void parseNonAggregateCluster_withHttp11ProxyTransportSocket() throws Exc .build(); TransportSocket transportSocket = TransportSocket.newBuilder() - .setName(TRANSPORT_SOCKET_NAME_HTTP11_PROXY) + .setName("envoy.transport_sockets.http_11_proxy") .setTypedConfig(Any.pack(http11ProxyUpstreamTransport)) .build(); @@ -2640,6 +2652,43 @@ public void parseNonAggregateCluster_withHttp11ProxyTransportSocket() throws Exc assertThat(result.isHttp11ProxyAvailable()).isTrue(); } + @Test + public void processCluster_parsesOrcaLrsPropagationMetrics() throws ResourceInvalidException { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = true; + + ImmutableList metricSpecs = ImmutableList.of( + "cpu_utilization", + "named_metrics.foo", + "unknown_metric_spec" + ); + Cluster cluster = Cluster.newBuilder() + .setName("cluster-orca.googleapis.com") + .setType(DiscoveryType.EDS) + .setEdsClusterConfig( + EdsClusterConfig.newBuilder() + .setEdsConfig( + ConfigSource.newBuilder().setAds(AggregatedConfigSource.getDefaultInstance())) + .setServiceName("service-orca.googleapis.com")) + .setLbPolicy(LbPolicy.ROUND_ROBIN) + .addAllLrsReportEndpointMetrics(metricSpecs) + .build(); + + CdsUpdate update = XdsClusterResource.processCluster( + cluster, null, LRS_SERVER_INFO, LoadBalancerRegistry.getDefaultRegistry()); + + BackendMetricPropagation propagationConfig = update.backendMetricPropagation(); + assertThat(propagationConfig).isNotNull(); + assertThat(propagationConfig.propagateCpuUtilization).isTrue(); + assertThat(propagationConfig.propagateMemUtilization).isFalse(); + assertThat(propagationConfig.shouldPropagateNamedMetric("foo")).isTrue(); + assertThat(propagationConfig.shouldPropagateNamedMetric("bar")).isFalse(); + assertThat(propagationConfig.shouldPropagateNamedMetric("unknown_metric_spec")) + .isFalse(); + + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; + } + @Test public void parseServerSideListener_invalidTrafficDirection() throws ResourceInvalidException { Listener listener = @@ -3099,6 +3148,18 @@ public void validateCommonTlsContext_tlsNewCertificateProviderInstance() .validateCommonTlsContext(commonTlsContext, ImmutableSet.of("name1", "name2"), true); } + @Test + @SuppressWarnings("deprecation") + public void validateCommonTlsContext_tlsDeprecatedCertificateProviderInstance() + throws ResourceInvalidException { + CommonTlsContext commonTlsContext = CommonTlsContext.newBuilder() + .setTlsCertificateCertificateProviderInstance( + CommonTlsContext.CertificateProviderInstance.newBuilder().setInstanceName("name1")) + .build(); + XdsClusterResource + .validateCommonTlsContext(commonTlsContext, ImmutableSet.of("name1", "name2"), true); + } + @Test public void validateCommonTlsContext_tlsCertificateProviderInstance() throws ResourceInvalidException { @@ -3183,6 +3244,24 @@ public void validateCommonTlsContext_combinedValidationContextSystemRootCerts() .validateCommonTlsContext(commonTlsContext, ImmutableSet.of(), false); } + @Test + @SuppressWarnings("deprecation") + public void validateCommonTlsContext_combinedValidationContextDeprecatedCertProvider() + throws ResourceInvalidException { + CommonTlsContext commonTlsContext = CommonTlsContext.newBuilder() + .setTlsCertificateProviderInstance( + CertificateProviderPluginInstance.newBuilder().setInstanceName("cert1")) + .setCombinedValidationContext( + CommonTlsContext.CombinedCertificateValidationContext.newBuilder() + .setValidationContextCertificateProviderInstance( + CommonTlsContext.CertificateProviderInstance.newBuilder() + .setInstanceName("root1")) + .build()) + .build(); + XdsClusterResource + .validateCommonTlsContext(commonTlsContext, ImmutableSet.of("cert1", "root1"), true); + } + @Test public void validateCommonTlsContext_validationContextSystemRootCerts_envVarNotSet_throws() { XdsClusterResource.enableSystemRootCerts = false; @@ -3549,7 +3628,7 @@ private static Filter buildHttpConnectionManagerFilter(HttpFilter... httpFilters private XdsResourceType.Args getXdsResourceTypeArgs(boolean isTrustedServer) { return new XdsResourceType.Args( - ServerInfo.create("http://td", "", false, isTrustedServer, 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/GrpcXdsClientImplTestBase.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java index 9ff19c6d1b0..4918c2af7a4 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java @@ -18,13 +18,16 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static io.grpc.StatusMatcher.statusHasCode; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -40,6 +43,7 @@ import com.google.protobuf.Duration; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Message; +import com.google.protobuf.StringValue; import com.google.protobuf.UInt32Value; import com.google.protobuf.util.Durations; import io.envoyproxy.envoy.config.cluster.v3.OutlierDetection; @@ -56,17 +60,17 @@ import io.grpc.Server; import io.grpc.Status; import io.grpc.Status.Code; +import io.grpc.StatusOr; +import io.grpc.StatusOrMatcher; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.internal.BackoffPolicy; import io.grpc.internal.FakeClock; import io.grpc.internal.FakeClock.ScheduledTask; import io.grpc.internal.FakeClock.TaskFilter; -import io.grpc.internal.JsonUtil; -import io.grpc.internal.ServiceConfigUtil; -import io.grpc.internal.ServiceConfigUtil.LbConfig; import io.grpc.internal.TimeProvider; import io.grpc.testing.GrpcCleanupRule; +import io.grpc.util.GracefulSwitchLoadBalancer; import io.grpc.xds.Endpoints.DropOverload; import io.grpc.xds.Endpoints.LbEndpoint; import io.grpc.xds.Endpoints.LocalityLbEndpoints; @@ -108,6 +112,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Queue; import java.util.concurrent.BlockingDeque; import java.util.concurrent.CountDownLatch; @@ -126,7 +131,6 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import org.mockito.ArgumentCaptor; -import org.mockito.ArgumentMatchers; import org.mockito.Captor; import org.mockito.InOrder; import org.mockito.Mock; @@ -251,15 +255,13 @@ public long currentTimeNanos() { private Message lbEndpointZeroWeight; private Any testClusterLoadAssignment; @Captor - private ArgumentCaptor ldsUpdateCaptor; + private ArgumentCaptor> ldsUpdateCaptor; @Captor - private ArgumentCaptor rdsUpdateCaptor; + private ArgumentCaptor> rdsUpdateCaptor; @Captor - private ArgumentCaptor cdsUpdateCaptor; + private ArgumentCaptor> cdsUpdateCaptor; @Captor - private ArgumentCaptor edsUpdateCaptor; - @Captor - private ArgumentCaptor errorCaptor; + private ArgumentCaptor> edsUpdateCaptor; @Mock private BackoffPolicy.Provider backoffPolicyProvider; @@ -278,6 +280,8 @@ public long currentTimeNanos() { @Mock private ResourceWatcher edsResourceWatcher; @Mock + private ResourceWatcher stringResourceWatcher; + @Mock private XdsClientMetricReporter xdsClientMetricReporter; @Mock private ServerConnectionCallback serverConnectionCallback; @@ -362,7 +366,7 @@ public void setUp() throws IOException { cleanupRule.register(InProcessChannelBuilder.forName(serverName).directExecutor().build()); xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, ignoreResourceDeletion(), - true, false); + true, false, false); BootstrapInfo bootstrapInfo = Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(xdsServerInfo)) @@ -595,11 +599,10 @@ private void verifyGoldenClusterRoundRobin(CdsUpdate cdsUpdate) { assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); assertThat(cdsUpdate.edsServiceName()).isNull(); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); assertThat(cdsUpdate.lrsServerInfo()).isNull(); assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); assertThat(cdsUpdate.upstreamTlsContext()).isNull(); @@ -667,6 +670,58 @@ private void verifyServerConnection(int times, boolean isConnected, String xdsSe eq(xdsServer)); } + @Test + public void doParse_returnsSuccessfully() { + XdsStringResource resourceType = new XdsStringResource(); + xdsClient.watchXdsResource( + resourceType, "resource1", stringResourceWatcher, MoreExecutors.directExecutor()); + DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); + + Any resource = Any.pack(StringValue.newBuilder().setValue("resource1").build()); + call.sendResponse(resourceType, resource, VERSION_1, "0000"); + verify(stringResourceWatcher).onResourceChanged(argThat(StatusOrMatcher.hasValue( + (StringUpdate arg) -> new StringUpdate("resource1").equals(arg)))); + } + + @Test + public void doParse_throwsResourceInvalidException_resourceInvalid() { + XdsStringResource resourceType = new XdsStringResource() { + @Override + protected StringUpdate doParse(Args args, Message unpackedMessage) + throws ResourceInvalidException { + throw new ResourceInvalidException("some bad input"); + } + }; + xdsClient.watchXdsResource( + resourceType, "resource1", stringResourceWatcher, MoreExecutors.directExecutor()); + DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); + + Any resource = Any.pack(StringValue.newBuilder().setValue("resource1").build()); + call.sendResponse(resourceType, resource, VERSION_1, "0000"); + verify(stringResourceWatcher).onResourceChanged(argThat(StatusOrMatcher.hasStatus( + statusHasCode(Status.Code.UNAVAILABLE) + .andDescriptionContains("validation error: some bad input")))); + } + + @Test + public void doParse_throwsError_resourceInvalid() throws Exception { + XdsStringResource resourceType = new XdsStringResource() { + @Override + protected StringUpdate doParse(Args args, Message unpackedMessage) { + throw new AssertionError("something bad happened"); + } + }; + xdsClient.watchXdsResource( + resourceType, "resource1", stringResourceWatcher, MoreExecutors.directExecutor()); + DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); + + Any resource = Any.pack(StringValue.newBuilder().setValue("resource1").build()); + call.sendResponse(resourceType, resource, VERSION_1, "0000"); + verify(stringResourceWatcher).onResourceChanged(argThat(StatusOrMatcher.hasStatus( + statusHasCode(Status.Code.UNAVAILABLE) + .andDescriptionContains("unexpected error: AssertionError: something bad happened")))); + } + @Test public void ldsResourceNotFound() { DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, @@ -683,7 +738,10 @@ public void ldsResourceNotFound() { verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); // Server failed to return subscribed resource within expected time window. fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(ldsResourceWatcher).onResourceDoesNotExist(LDS_RESOURCE); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + assertThat(statusOrUpdate.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataDoesNotExist(LDS, LDS_RESOURCE); // Check metric data. @@ -696,11 +754,11 @@ public void ldsResourceUpdated_withXdstpResourceName_withUnknownAuthority() { "xdstp://unknown.example.com/envoy.config.listener.v3.Listener/listener1"; xdsClient.watchXdsResource(XdsListenerResource.getInstance(), ldsResourceName, ldsResourceWatcher); - verify(ldsResourceWatcher).onError(errorCaptor.capture()); - Status error = errorCaptor.getValue(); - assertThat(error.getCode()).isEqualTo(Code.INVALID_ARGUMENT); - assertThat(error.getDescription()).isEqualTo( - "Wrong configuration: xds server does not exist for resource " + ldsResourceName); + verify(ldsResourceWatcher).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.INVALID_ARGUMENT + && statusOr.getStatus().getDescription().equals( + "Wrong configuration: xds server does not exist for resource " + ldsResourceName))); assertThat(resourceDiscoveryCalls.poll()).isNull(); xdsClient.cancelXdsResourceWatch(XdsListenerResource.getInstance(), ldsResourceName, ldsResourceWatcher); @@ -713,13 +771,20 @@ public void ldsResource_onError_cachedForNewWatcher() { ldsResourceWatcher); DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); call.sendCompleted(); - verify(ldsResourceWatcher).onError(errorCaptor.capture()); - Status initialError = errorCaptor.getValue(); + @SuppressWarnings("unchecked") + ArgumentCaptor> errorCaptor = + ArgumentCaptor.forClass(StatusOr.class); + verify(ldsResourceWatcher, timeout(1000)).onResourceChanged(errorCaptor.capture()); + StatusOr initialError = errorCaptor.getValue(); + assertThat(initialError.hasValue()).isFalse(); + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher2); - ArgumentCaptor secondErrorCaptor = ArgumentCaptor.forClass(Status.class); - verify(ldsResourceWatcher2).onError(secondErrorCaptor.capture()); - Status cachedError = secondErrorCaptor.getValue(); + @SuppressWarnings("unchecked") + ArgumentCaptor> secondErrorCaptor = + ArgumentCaptor.forClass(StatusOr.class); + verify(ldsResourceWatcher2, timeout(1000)).onResourceChanged(secondErrorCaptor.capture()); + StatusOr cachedError = secondErrorCaptor.getValue(); assertThat(cachedError).isEqualTo(initialError); assertThat(resourceDiscoveryCalls.poll()).isNull(); @@ -760,7 +825,7 @@ public void ldsResponseErrorHandling_someResourcesFailedUnpack() { verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); // The response is NACKed with the same error message. call.verifyRequestNack(LDS, LDS_RESOURCE, "", "0000", NODE, errors); - verify(ldsResourceWatcher).onChanged(any(LdsUpdate.class)); + verify(ldsResourceWatcher).onResourceChanged(any()); } /** @@ -841,6 +906,52 @@ public void ldsResponseErrorHandling_subscribedResourceInvalid() { verifySubscribedResourcesMetadataSizes(3, 0, 0, 0); } + @Test + public void ldsResponseErrorHandling_subscribedResourceInvalid_withDataErrorHandlingEnabled() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "A", ldsResourceWatcher); + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "B", ldsResourceWatcher); + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "C", ldsResourceWatcher); + DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); + assertThat(call).isNotNull(); + verifyResourceMetadataRequested(LDS, "A"); + verifyResourceMetadataRequested(LDS, "B"); + verifyResourceMetadataRequested(LDS, "C"); + ImmutableMap resourcesV1 = ImmutableMap.of( + "A", Any.pack(mf.buildListenerWithApiListenerForRds("A", "A.1")), + "B", Any.pack(mf.buildListenerWithApiListenerForRds("B", "B.1")), + "C", Any.pack(mf.buildListenerWithApiListenerForRds("C", "C.1"))); + call.sendResponse(LDS, resourcesV1.values().asList(), VERSION_1, "0000"); + verify(ldsResourceWatcher, times(3)).onResourceChanged(any()); + ImmutableMap resourcesV2 = ImmutableMap.of( + "A", Any.pack(mf.buildListenerWithApiListenerForRds("A", "A.2")), + "B", Any.pack(mf.buildListenerWithApiListenerInvalid("B"))); + call.sendResponse(LDS, resourcesV2.values().asList(), VERSION_2, "0001"); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + verify(ldsResourceWatcher, times(2)).onAmbientError(statusCaptor.capture()); + List receivedStatuses = statusCaptor.getAllValues(); + assertThat(receivedStatuses).hasSize(2); + + assertThat( + receivedStatuses.stream().anyMatch( + status -> status.getCode() == Status.Code.UNAVAILABLE + && status.getDescription().contains("LDS response Listener 'B' validation error"))) + .isTrue(); + assertThat( + receivedStatuses.stream().anyMatch( + status -> status.getCode() == Status.Code.NOT_FOUND + && status.getDescription().contains("Resource C deleted from server"))) + .isTrue(); + List errorsV2 = ImmutableList.of("LDS response Listener 'B' validation error: "); + verifyResourceMetadataAcked(LDS, "A", resourcesV2.get("A"), VERSION_2, TIME_INCREMENT * 2); + verifyResourceMetadataNacked(LDS, "B", resourcesV1.get("B"), VERSION_1, TIME_INCREMENT, + VERSION_2, TIME_INCREMENT * 2, errorsV2, true); + verifyResourceMetadataAcked(LDS, "C", resourcesV1.get("C"), VERSION_1, TIME_INCREMENT); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + @Test public void ldsResponseErrorHandling_subscribedResourceInvalid_withRdsSubscription() { List subscribedResourceNames = ImmutableList.of("A", "B", "C"); @@ -928,8 +1039,10 @@ public void ldsResourceFound_containsVirtualHosts() { // Client sends an ACK LDS request. call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -943,8 +1056,10 @@ public void wrappedLdsResource() { // Client sends an ACK LDS request. call.sendResponse(LDS, mf.buildWrappedResource(testListenerVhosts), VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -962,8 +1077,10 @@ public void wrappedLdsResource_preferWrappedName() { call.sendResponse(LDS, mf.buildWrappedResourceWithName(innerResource, LDS_RESOURCE), VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, innerResource, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -977,8 +1094,10 @@ public void ldsResourceFound_containsRdsName() { // Client sends an ACK LDS request. call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerRds(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerRds(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerRds, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -996,8 +1115,10 @@ public void cachedLdsResource_data() { ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, watcher); - verify(watcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerRds(ldsUpdateCaptor.getValue()); + verify(watcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerRds(statusOrUpdate.getValue()); call.verifyNoMoreRequest(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerRds, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -1009,11 +1130,17 @@ public void cachedLdsResource_absent() { DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(ldsResourceWatcher).onResourceDoesNotExist(LDS_RESOURCE); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + assertThat(statusOrUpdate.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); // Add another watcher. ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, watcher); - verify(watcher).onResourceDoesNotExist(LDS_RESOURCE); + verify(watcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate1 = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate1.hasValue()).isFalse(); + assertThat(statusOrUpdate1.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); call.verifyNoMoreRequest(); verifyResourceMetadataDoesNotExist(LDS, LDS_RESOURCE); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -1028,15 +1155,19 @@ public void ldsResourceUpdated() { // Initial LDS response. call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); // Updated LDS response. call.sendResponse(LDS, testListenerRds, VERSION_2, "0001"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); - verify(ldsResourceWatcher, times(2)).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerRds(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerRds(statusOrUpdate.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerRds, VERSION_2, TIME_INCREMENT * 2); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); assertThat(channelForCustomAuthority).isNull(); @@ -1052,8 +1183,10 @@ public void cancelResourceWatcherNotRemoveUrlSubscribers() { // Initial LDS response. call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), @@ -1066,8 +1199,10 @@ public void cancelResourceWatcherNotRemoveUrlSubscribers() { mf.buildRouteConfiguration("new", mf.buildOpaqueVirtualHosts(VHOST_SIZE)))); call.sendResponse(LDS, testListenerVhosts2, VERSION_2, "0001"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts2, VERSION_2, TIME_INCREMENT * 2); } @@ -1085,8 +1220,10 @@ public void ldsResourceUpdated_withXdstpResourceName() { mf.buildRouteConfiguration("do not care", mf.buildOpaqueVirtualHosts(VHOST_SIZE)))); call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, ldsResourceName, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyResourceMetadataAcked( LDS, ldsResourceName, testListenerVhosts, VERSION_1, TIME_INCREMENT); } @@ -1103,8 +1240,10 @@ public void ldsResourceUpdated_withXdstpResourceName_withEmptyAuthority() { mf.buildRouteConfiguration("do not care", mf.buildOpaqueVirtualHosts(VHOST_SIZE)))); call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, ldsResourceName, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyResourceMetadataAcked( LDS, ldsResourceName, testListenerVhosts, VERSION_1, TIME_INCREMENT); } @@ -1172,8 +1311,12 @@ public void rdsResourceUpdated_withXdstpResourceName_unknownAuthority() { "xdstp://unknown.example.com/envoy.config.route.v3.RouteConfiguration/route1"; xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), rdsResourceName, rdsResourceWatcher); - verify(rdsResourceWatcher).onError(errorCaptor.capture()); - Status error = errorCaptor.getValue(); + @SuppressWarnings("unchecked") + ArgumentCaptor> rdsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr capturedUpdate = rdsUpdateCaptor.getValue(); + assertThat(capturedUpdate.hasValue()).isFalse(); + Status error = capturedUpdate.getStatus(); assertThat(error.getCode()).isEqualTo(Code.INVALID_ARGUMENT); assertThat(error.getDescription()).isEqualTo( "Wrong configuration: xds server does not exist for resource " + rdsResourceName); @@ -1207,8 +1350,12 @@ public void cdsResourceUpdated_withXdstpResourceName_unknownAuthority() { String cdsResourceName = "xdstp://unknown.example.com/envoy.config.cluster.v3.Cluster/cluster1"; xdsClient.watchXdsResource(XdsClusterResource.getInstance(), cdsResourceName, cdsResourceWatcher); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - Status error = errorCaptor.getValue(); + @SuppressWarnings("unchecked") + ArgumentCaptor> cdsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr capturedUpdate = cdsUpdateCaptor.getValue(); + assertThat(capturedUpdate.hasValue()).isFalse(); + Status error = capturedUpdate.getStatus(); assertThat(error.getCode()).isEqualTo(Code.INVALID_ARGUMENT); assertThat(error.getDescription()).isEqualTo( "Wrong configuration: xds server does not exist for resource " + cdsResourceName); @@ -1247,8 +1394,12 @@ public void edsResourceUpdated_withXdstpResourceName_unknownAuthority() { "xdstp://unknown.example.com/envoy.config.endpoint.v3.ClusterLoadAssignment/cluster1"; xdsClient.watchXdsResource(XdsEndpointResource.getInstance(), edsResourceName, edsResourceWatcher); - verify(edsResourceWatcher).onError(errorCaptor.capture()); - Status error = errorCaptor.getValue(); + @SuppressWarnings("unchecked") + ArgumentCaptor> edsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(edsResourceWatcher).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr capturedUpdate = edsUpdateCaptor.getValue(); + assertThat(capturedUpdate.hasValue()).isFalse(); + Status error = capturedUpdate.getStatus(); assertThat(error.getCode()).isEqualTo(Code.INVALID_ARGUMENT); assertThat(error.getDescription()).isEqualTo( "Wrong configuration: xds server does not exist for resource " + edsResourceName); @@ -1297,11 +1448,13 @@ public void ldsResourceUpdate_withFaultInjection() { // Client sends an ACK LDS request. call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, listener, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); - LdsUpdate ldsUpdate = ldsUpdateCaptor.getValue(); + LdsUpdate ldsUpdate = statusOrUpdate.getValue(); assertThat(ldsUpdate.httpConnectionManager().virtualHosts()).hasSize(2); assertThat(ldsUpdate.httpConnectionManager().httpFilterConfigs().get(0).name) .isEqualTo("envoy.fault"); @@ -1326,6 +1479,7 @@ public void ldsResourceUpdate_withFaultInjection() { @Test public void ldsResourceDeleted() { Assume.assumeFalse(ignoreResourceDeletion()); + InOrder inOrder = inOrder(ldsResourceWatcher); DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher); @@ -1334,15 +1488,20 @@ public void ldsResourceDeleted() { // Initial LDS response. call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); // Empty LDS response deletes the listener. call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); - verify(ldsResourceWatcher).onResourceDoesNotExist(LDS_RESOURCE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate1 = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate1.hasValue()).isFalse(); + assertThat(statusOrUpdate1.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); verifyResourceMetadataDoesNotExist(LDS, LDS_RESOURCE); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); } @@ -1362,8 +1521,8 @@ public void ldsResourceDeleted_ignoreResourceDeletion() { // Initial LDS response. call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue().getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); @@ -1371,23 +1530,195 @@ public void ldsResourceDeleted_ignoreResourceDeletion() { call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); // The resource is still ACKED at VERSION_1 (no changes). + verify(ldsResourceWatcher).onAmbientError( + argThat(status -> status.getCode() == Status.Code.NOT_FOUND)); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); - // onResourceDoesNotExist not called - verify(ldsResourceWatcher, never()).onResourceDoesNotExist(LDS_RESOURCE); // Next update is correct, and contains the listener again. - call.sendResponse(LDS, testListenerVhosts, VERSION_3, "0003"); + Any updatedListener = Any.pack(mf.buildListenerWithApiListener(LDS_RESOURCE, + mf.buildRouteConfiguration("do not care", mf.buildOpaqueVirtualHosts(VHOST_SIZE + 1)))); + call.sendResponse(LDS, updatedListener, VERSION_3, "0003"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_3, "0003", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + assertThat(ldsUpdateCaptor.getValue().getValue().httpConnectionManager().virtualHosts()) + .hasSize(VHOST_SIZE + 1); // LDS is now ACKEd at VERSION_3. - verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_3, - TIME_INCREMENT * 3); + verifyResourceMetadataAcked(LDS, LDS_RESOURCE, updatedListener, VERSION_3, TIME_INCREMENT * 3); verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); verifyNoMoreInteractions(ldsResourceWatcher); } + /** + * When fail_on_data_errors server feature is on, xDS client should delete the cached listener + * and fail RPCs when LDS resource is deleted. + */ + @Test + public void ldsResourceDeleted_failOnDataErrors_true() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + verifyResourceMetadataRequested(LDS, LDS_RESOURCE); + + // Initial LDS response. + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + // Empty LDS response deletes the listener and fails RPCs. + call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate1 = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate1.hasValue()).isFalse(); + assertThat(statusOrUpdate1.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); + verifyResourceMetadataDoesNotExist(LDS, LDS_RESOURCE); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * When the fail_on_data_errors server feature is not present, the default behavior + * is to treat a resource deletion as an ambient error and preserve the cached resource. + */ + @Test + public void ldsResourceDeleted_failOnDataErrors_false() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, false); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + verifyResourceMetadataRequested(LDS, LDS_RESOURCE); + + // Initial LDS response. + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + // Empty LDS response deletes the listener and fails RPCs. + call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + inOrder.verify(ldsResourceWatcher).onAmbientError(statusCaptor.capture()); + Status receivedStatus = statusCaptor.getValue(); + assertThat(receivedStatus.getCode()).isEqualTo(Status.Code.NOT_FOUND); + assertThat(receivedStatus.getDescription()).contains( + "Resource " + LDS_RESOURCE + " deleted from server"); + inOrder.verify(ldsResourceWatcher, never()).onResourceChanged(any()); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * Tests that fail_on_data_errors feature is ignored if the env var is not enabled, + * and the old behavior (dropping the resource) is used. + */ + @Test + public void ldsResourceDeleted_failOnDataErrorsIgnoredWithoutEnvVar() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + assertThat(ldsUpdateCaptor.getValue().hasValue()).isTrue(); + call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); + + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + assertThat(statusOrUpdate.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); + } + @Test @SuppressWarnings("unchecked") public void multipleLdsWatchers() { @@ -1405,9 +1736,12 @@ public void multipleLdsWatchers() { verifySubscribedResourcesMetadataSizes(2, 0, 0, 0); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(ldsResourceWatcher).onResourceDoesNotExist(LDS_RESOURCE); - verify(watcher1).onResourceDoesNotExist(ldsResourceTwo); - verify(watcher2).onResourceDoesNotExist(ldsResourceTwo); + verify(ldsResourceWatcher).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() && statusOr.getStatus().getDescription().contains(LDS_RESOURCE))); + verify(watcher1).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() && statusOr.getStatus().getDescription().contains(ldsResourceTwo))); + verify(watcher2).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() && statusOr.getStatus().getDescription().contains(ldsResourceTwo))); verifyResourceMetadataDoesNotExist(LDS, LDS_RESOURCE); verifyResourceMetadataDoesNotExist(LDS, ldsResourceTwo); verifySubscribedResourcesMetadataSizes(2, 0, 0, 0); @@ -1415,16 +1749,22 @@ public void multipleLdsWatchers() { Any listenerTwo = Any.pack(mf.buildListenerWithApiListenerForRds(ldsResourceTwo, RDS_RESOURCE)); call.sendResponse(LDS, ImmutableList.of(testListenerVhosts, listenerTwo), VERSION_1, "0000"); // ResourceWatcher called with listenerVhosts. - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); // watcher1 called with listenerTwo. - verify(watcher1).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerRds(ldsUpdateCaptor.getValue()); - assertThat(ldsUpdateCaptor.getValue().httpConnectionManager().virtualHosts()).isNull(); + verify(watcher1, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerRds(statusOrUpdate.getValue()); + assertThat(statusOrUpdate.getValue().httpConnectionManager().virtualHosts()).isNull(); // watcher2 called with listenerTwo. - verify(watcher2).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerRds(ldsUpdateCaptor.getValue()); - assertThat(ldsUpdateCaptor.getValue().httpConnectionManager().virtualHosts()).isNull(); + verify(watcher2, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerRds(statusOrUpdate.getValue()); + assertThat(statusOrUpdate.getValue().httpConnectionManager().virtualHosts()).isNull(); // Metadata of both listeners is stored. verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifyResourceMetadataAcked(LDS, ldsResourceTwo, listenerTwo, VERSION_1, TIME_INCREMENT); @@ -1446,7 +1786,8 @@ public void rdsResourceNotFound() { verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); // Server failed to return subscribed resource within expected time window. fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(rdsResourceWatcher).onResourceDoesNotExist(RDS_RESOURCE); + verify(rdsResourceWatcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(RDS_RESOURCE))); assertThat(fakeClock.getPendingTasks(RDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataDoesNotExist(RDS, RDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); @@ -1487,7 +1828,7 @@ public void rdsResponseErrorHandling_someResourcesFailedUnpack() { verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); // The response is NACKed with the same error message. call.verifyRequestNack(RDS, RDS_RESOURCE, "", "0000", NODE, errors); - verify(rdsResourceWatcher).onChanged(any(RdsUpdate.class)); + verify(rdsResourceWatcher).onResourceChanged(any()); } @Test @@ -1495,6 +1836,7 @@ public void rdsResponseErrorHandling_nackWeightedSumZero() { DiscoveryRpcCall call = startResourceWatcher(XdsRouteConfigureResource.getInstance(), RDS_RESOURCE, rdsResourceWatcher); verifyResourceMetadataRequested(RDS, RDS_RESOURCE); + String expectedErrorDetail = "Sum of cluster weights should be above 0"; io.envoyproxy.envoy.config.route.v3.RouteAction routeAction = io.envoyproxy.envoy.config.route.v3.RouteAction.newBuilder() @@ -1528,11 +1870,14 @@ public void rdsResponseErrorHandling_nackWeightedSumZero() { "RDS response RouteConfiguration \'route-configuration.googleapis.com\' validation error: " + "RouteConfiguration contains invalid virtual host: Virtual host [do not care] " + "contains invalid route : Route [route-blade] contains invalid RouteAction: " - + "Sum of cluster weights should be above 0."); + + expectedErrorDetail); verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); // The response is NACKed with the same error message. call.verifyRequestNack(RDS, RDS_RESOURCE, "", "0000", NODE, errors); - verify(rdsResourceWatcher, never()).onChanged(any(RdsUpdate.class)); + verify(rdsResourceWatcher).onResourceChanged(argThat( + statusOr -> !statusOr.hasValue() && statusOr.getStatus().getDescription() + .contains(expectedErrorDetail))); + verify(rdsResourceWatcher, never()).onResourceChanged(argThat(StatusOr::hasValue)); } /** @@ -1614,8 +1959,10 @@ public void rdsResourceFound() { // Client sends an ACK RDS request. call.verifyRequest(RDS, RDS_RESOURCE, VERSION_1, "0000", NODE); - verify(rdsResourceWatcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenRouteConfig(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(RDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); @@ -1629,8 +1976,10 @@ public void wrappedRdsResource() { // Client sends an ACK RDS request. call.verifyRequest(RDS, RDS_RESOURCE, VERSION_1, "0000", NODE); - verify(rdsResourceWatcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenRouteConfig(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(RDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); @@ -1648,8 +1997,10 @@ public void cachedRdsResource_data() { ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_RESOURCE, watcher); - verify(watcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + verify(watcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenRouteConfig(statusOrUpdate.getValue()); call.verifyNoMoreRequest(); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); @@ -1661,11 +2012,15 @@ public void cachedRdsResource_absent() { DiscoveryRpcCall call = startResourceWatcher(XdsRouteConfigureResource.getInstance(), RDS_RESOURCE, rdsResourceWatcher); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(rdsResourceWatcher).onResourceDoesNotExist(RDS_RESOURCE); + verify(rdsResourceWatcher).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() && statusOr.getStatus().getDescription().contains(RDS_RESOURCE) + && statusOr.getStatus().getDescription().contains(RDS_RESOURCE))); // Add another watcher. ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_RESOURCE, watcher); - verify(watcher).onResourceDoesNotExist(RDS_RESOURCE); + verify(watcher).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() && statusOr.getStatus().getDescription().contains(RDS_RESOURCE) + && statusOr.getStatus().getDescription().contains(RDS_RESOURCE))); call.verifyNoMoreRequest(); verifyResourceMetadataDoesNotExist(RDS, RDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 0, 1, 0); @@ -1680,8 +2035,10 @@ public void rdsResourceUpdated() { // Initial RDS response. call.sendResponse(RDS, testRouteConfig, VERSION_1, "0000"); call.verifyRequest(RDS, RDS_RESOURCE, VERSION_1, "0000", NODE); - verify(rdsResourceWatcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenRouteConfig(statusOrUpdate.getValue()); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT); // Updated RDS response. @@ -1691,8 +2048,10 @@ public void rdsResourceUpdated() { // Client sends an ACK RDS request. call.verifyRequest(RDS, RDS_RESOURCE, VERSION_2, "0001", NODE); - verify(rdsResourceWatcher, times(2)).onChanged(rdsUpdateCaptor.capture()); - assertThat(rdsUpdateCaptor.getValue().virtualHosts).hasSize(4); + verify(rdsResourceWatcher, times(2)).onResourceChanged(rdsUpdateCaptor.capture()); + statusOrUpdate = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + assertThat(statusOrUpdate.getValue().virtualHosts).hasSize(4); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, routeConfigUpdated, VERSION_2, TIME_INCREMENT * 2); } @@ -1739,15 +2098,19 @@ public void rdsResourceDeletedByLdsApiListener() { DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); call.sendResponse(LDS, testListenerRds, VERSION_1, "0000"); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerRds(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerRds(statusOrUpdate.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerRds, VERSION_1, TIME_INCREMENT); verifyResourceMetadataRequested(RDS, RDS_RESOURCE); verifySubscribedResourcesMetadataSizes(1, 0, 1, 0); call.sendResponse(RDS, testRouteConfig, VERSION_1, "0000"); - verify(rdsResourceWatcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr statusOrUpdate1 = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenRouteConfig(statusOrUpdate1.getValue()); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerRds, VERSION_1, TIME_INCREMENT); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT * 2); verifySubscribedResourcesMetadataSizes(1, 0, 1, 0); @@ -1757,8 +2120,10 @@ public void rdsResourceDeletedByLdsApiListener() { // Note that this must work the same despite the ignore_resource_deletion feature is on. // This happens because the Listener is getting replaced, and not deleted. call.sendResponse(LDS, testListenerVhosts, VERSION_2, "0001"); - verify(ldsResourceWatcher, times(2)).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + verify(ldsResourceWatcher, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); verifyNoMoreInteractions(rdsResourceWatcher); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT * 2); verifyResourceMetadataAcked( @@ -1790,11 +2155,13 @@ public void rdsResourcesDeletedByLdsTcpListener() { // referencing RDS_RESOURCE. DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); call.sendResponse(LDS, packedListener, VERSION_1, "0000"); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); - assertThat(ldsUpdateCaptor.getValue().listener().filterChains()).hasSize(1); + assertThat(statusOrUpdate.getValue().listener().filterChains()).hasSize(1); FilterChain parsedFilterChain = Iterables.getOnlyElement( - ldsUpdateCaptor.getValue().listener().filterChains()); + statusOrUpdate.getValue().listener().filterChains()); assertThat(parsedFilterChain.httpConnectionManager().rdsName()).isEqualTo(RDS_RESOURCE); verifyResourceMetadataAcked(LDS, LISTENER_RESOURCE, packedListener, VERSION_1, TIME_INCREMENT); verifyResourceMetadataRequested(RDS, RDS_RESOURCE); @@ -1802,8 +2169,10 @@ public void rdsResourcesDeletedByLdsTcpListener() { // Simulates receiving the requested RDS resource. call.sendResponse(RDS, testRouteConfig, VERSION_1, "0000"); - verify(rdsResourceWatcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr statusOrUpdate1 = rdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenRouteConfig(statusOrUpdate1.getValue()); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT * 2); // Simulates receiving an updated version of the requested LDS resource as a TCP listener @@ -1821,12 +2190,15 @@ public void rdsResourcesDeletedByLdsTcpListener() { packedListener = Any.pack(mf.buildListenerWithFilterChain(LISTENER_RESOURCE, 7000, "0.0.0.0", filterChain)); call.sendResponse(LDS, packedListener, VERSION_2, "0001"); - verify(ldsResourceWatcher, times(2)).onChanged(ldsUpdateCaptor.capture()); - assertThat(ldsUpdateCaptor.getValue().listener().filterChains()).hasSize(1); + verify(ldsResourceWatcher, times(2)).onResourceChanged(ldsUpdateCaptor.capture()); + statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + assertThat(statusOrUpdate.getValue().listener().filterChains()).hasSize(1); parsedFilterChain = Iterables.getOnlyElement( - ldsUpdateCaptor.getValue().listener().filterChains()); + statusOrUpdate.getValue().listener().filterChains()); assertThat(parsedFilterChain.httpConnectionManager().virtualHosts()).hasSize(VHOST_SIZE); - verify(rdsResourceWatcher, never()).onResourceDoesNotExist(RDS_RESOURCE); + verify(rdsResourceWatcher, never()).onResourceChanged(argThat(statusOr -> + !statusOr.hasValue() && statusOr.getStatus().getDescription().equals(RDS_RESOURCE))); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT * 2); verifyResourceMetadataAcked( LDS, LISTENER_RESOURCE, packedListener, VERSION_2, TIME_INCREMENT * 3); @@ -1851,16 +2223,25 @@ public void multipleRdsWatchers() { verifySubscribedResourcesMetadataSizes(0, 0, 2, 0); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(rdsResourceWatcher).onResourceDoesNotExist(RDS_RESOURCE); - verify(watcher1).onResourceDoesNotExist(rdsResourceTwo); - verify(watcher2).onResourceDoesNotExist(rdsResourceTwo); + verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.NOT_FOUND)); + verify(watcher1).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.NOT_FOUND)); + verify(watcher2).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.NOT_FOUND)); verifyResourceMetadataDoesNotExist(RDS, RDS_RESOURCE); verifyResourceMetadataDoesNotExist(RDS, rdsResourceTwo); verifySubscribedResourcesMetadataSizes(0, 0, 2, 0); call.sendResponse(RDS, testRouteConfig, VERSION_1, "0000"); - verify(rdsResourceWatcher).onChanged(rdsUpdateCaptor.capture()); - verifyGoldenRouteConfig(rdsUpdateCaptor.getValue()); + ArgumentCaptor> rdsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(rdsResourceWatcher, times(2)).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr capturedUpdate1 = rdsUpdateCaptor.getAllValues().get(1); + assertThat(capturedUpdate1.hasValue()).isTrue(); + verifyGoldenRouteConfig(capturedUpdate1.getValue()); verifyNoMoreInteractions(watcher1, watcher2); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT); verifyResourceMetadataDoesNotExist(RDS, rdsResourceTwo); @@ -1869,13 +2250,22 @@ public void multipleRdsWatchers() { Any routeConfigTwo = Any.pack(mf.buildRouteConfiguration(rdsResourceTwo, mf.buildOpaqueVirtualHosts(4))); call.sendResponse(RDS, routeConfigTwo, VERSION_2, "0002"); - verify(watcher1).onChanged(rdsUpdateCaptor.capture()); - assertThat(rdsUpdateCaptor.getValue().virtualHosts).hasSize(4); - verify(watcher2).onChanged(rdsUpdateCaptor.capture()); - assertThat(rdsUpdateCaptor.getValue().virtualHosts).hasSize(4); + ArgumentCaptor> watcher1Captor = + ArgumentCaptor.forClass(StatusOr.class); + verify(watcher1, times(2)).onResourceChanged(watcher1Captor.capture()); + StatusOr capturedUpdate2 = watcher1Captor.getAllValues().get(1); + assertThat(capturedUpdate2.hasValue()).isTrue(); + assertThat(capturedUpdate2.getValue().virtualHosts).hasSize(4); + ArgumentCaptor> watcher2Captor = + ArgumentCaptor.forClass(StatusOr.class); + verify(watcher2, times(2)).onResourceChanged(watcher2Captor.capture()); + StatusOr capturedUpdate3 = watcher2Captor.getAllValues().get(1); + assertThat(capturedUpdate3.hasValue()).isTrue(); + assertThat(capturedUpdate3.getValue().virtualHosts).hasSize(4); verifyNoMoreInteractions(rdsResourceWatcher); verifyResourceMetadataAcked(RDS, RDS_RESOURCE, testRouteConfig, VERSION_1, TIME_INCREMENT); - verifyResourceMetadataAcked(RDS, rdsResourceTwo, routeConfigTwo, VERSION_2, TIME_INCREMENT * 2); + verifyResourceMetadataAcked(RDS, rdsResourceTwo, routeConfigTwo, VERSION_2, + TIME_INCREMENT * 2); verifySubscribedResourcesMetadataSizes(0, 0, 2, 0); } @@ -1898,7 +2288,8 @@ public void cdsResourceNotFound() { verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); // Server failed to return subscribed resource within expected time window. fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(cdsResourceWatcher).onResourceDoesNotExist(CDS_RESOURCE); + verify(cdsResourceWatcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); assertThat(fakeClock.getPendingTasks(CDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataDoesNotExist(CDS, CDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); @@ -1940,7 +2331,7 @@ public void cdsResponseErrorHandling_someResourcesFailedUnpack() { verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); // The response is NACKed with the same error message. call.verifyRequestNack(CDS, CDS_RESOURCE, "", "0000", NODE, errors); - verify(cdsResourceWatcher).onChanged(any(CdsUpdate.class)); + verify(cdsResourceWatcher).onResourceChanged(any()); } /** @@ -2130,8 +2521,10 @@ public void cdsResourceFound() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - verifyGoldenClusterRoundRobin(cdsUpdateCaptor.getValue()); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(CDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, TIME_INCREMENT); @@ -2146,8 +2539,10 @@ public void wrappedCdsResource() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - verifyGoldenClusterRoundRobin(cdsUpdateCaptor.getValue()); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); assertThat(fakeClock.getPendingTasks(CDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, TIME_INCREMENT); @@ -2167,17 +2562,18 @@ public void cdsResourceFound_leastRequestLbPolicy() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); assertThat(cdsUpdate.edsServiceName()).isNull(); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("least_request_experimental"); - assertThat(childConfigs.get(0).getRawConfigValue().get("choiceCount")).isEqualTo(3); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("least_request_experimental", ImmutableMap.of( + "choiceCount", 3.0))))))).getConfig()); assertThat(cdsUpdate.lrsServerInfo()).isNull(); assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); assertThat(cdsUpdate.upstreamTlsContext()).isNull(); @@ -2199,17 +2595,17 @@ public void cdsResourceFound_ringHashLbPolicy() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); assertThat(cdsUpdate.edsServiceName()).isNull(); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("ring_hash_experimental"); - assertThat(JsonUtil.getNumberAsLong(lbConfig.getRawConfigValue(), "minRingSize")).isEqualTo( - 10L); - assertThat(JsonUtil.getNumberAsLong(lbConfig.getRawConfigValue(), "maxRingSize")).isEqualTo( - 100L); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("ring_hash_experimental", ImmutableMap.of( + "minRingSize", 10.0, "maxRingSize", 100.0)))).getConfig()); assertThat(cdsUpdate.lrsServerInfo()).isNull(); assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); assertThat(cdsUpdate.upstreamTlsContext()).isNull(); @@ -2230,15 +2626,16 @@ public void cdsResponseWithAggregateCluster() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.AGGREGATE); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); assertThat(cdsUpdate.prioritizedClusterNames()).containsExactlyElementsIn(candidates).inOrder(); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, clusterAggregate, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); @@ -2257,8 +2654,8 @@ public void cdsResponseWithEmptyAggregateCluster() { String errorMsg = "CDS response Cluster 'cluster.googleapis.com' validation error: " + "Cluster cluster.googleapis.com: aggregate ClusterConfig.clusters must not be empty"; call.verifyRequestNack(CDS, CDS_RESOURCE, "", "0000", NODE, ImmutableList.of(errorMsg)); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + verifyStatusWithNodeId(cdsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, errorMsg); } @Test @@ -2272,16 +2669,17 @@ public void cdsResponseWithCircuitBreakers() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); assertThat(cdsUpdate.edsServiceName()).isNull(); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); assertThat(cdsUpdate.lrsServerInfo()).isNull(); assertThat(cdsUpdate.maxConcurrentRequests()).isEqualTo(200L); assertThat(cdsUpdate.upstreamTlsContext()).isNull(); @@ -2315,8 +2713,10 @@ public void cdsResponseWithUpstreamTlsContext() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); verify(cdsResourceWatcher, times(1)) - .onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + .onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); CertificateProviderPluginInstance certificateProviderInstance = cdsUpdate.upstreamTlsContext().getCommonTlsContext().getCombinedValidationContext() .getDefaultValidationContext().getCaCertificateProviderInstance(); @@ -2350,8 +2750,10 @@ public void cdsResponseWithNewUpstreamTlsContext() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher, times(1)).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher, times(1)).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); CertificateProviderPluginInstance certificateProviderInstance = cdsUpdate.upstreamTlsContext().getCommonTlsContext().getValidationContext() .getCaCertificateProviderInstance(); @@ -2383,8 +2785,8 @@ public void cdsResponseErrorHandling_badUpstreamTlsContext() { + "ca_certificate_provider_instance or system_root_certs is required in " + "upstream-tls-context"; call.verifyRequestNack(CDS, CDS_RESOURCE, "", "0000", NODE, ImmutableList.of(errorMsg)); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + verifyStatusWithNodeId(cdsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, errorMsg); } /** @@ -2425,8 +2827,10 @@ public void cdsResponseWithOutlierDetection() { // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher, times(1)).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher, times(1)).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); // The outlier detection config in CdsUpdate should match what we get from xDS. EnvoyServerProtoData.OutlierDetection outlierDetection = cdsUpdate.outlierDetection(); @@ -2486,8 +2890,8 @@ public void cdsResponseWithInvalidOutlierDetectionNacks() { + "io.grpc.xds.client.XdsResourceType$ResourceInvalidException: outlier_detection " + "max_ejection_percent is > 100"; call.verifyRequestNack(CDS, CDS_RESOURCE, "", "0000", NODE, ImmutableList.of(errorMsg)); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + verifyStatusWithNodeId(cdsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, errorMsg); } @Test(expected = ResourceInvalidException.class) @@ -2581,8 +2985,8 @@ public void cdsResponseErrorHandling_badTransportSocketName() { String errorMsg = "CDS response Cluster 'cluster.googleapis.com' validation error: " + "transport-socket with name envoy.transport_sockets.bad not supported."; call.verifyRequestNack(CDS, CDS_RESOURCE, "", "0000", NODE, ImmutableList.of(errorMsg)); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + verifyStatusWithNodeId(cdsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, errorMsg); } @Test @@ -2624,8 +3028,10 @@ public void cachedCdsResource_data() { ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsClusterResource.getInstance(), CDS_RESOURCE, watcher); - verify(watcher).onChanged(cdsUpdateCaptor.capture()); - verifyGoldenClusterRoundRobin(cdsUpdateCaptor.getValue()); + verify(watcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); call.verifyNoMoreRequest(); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, TIME_INCREMENT); @@ -2639,10 +3045,12 @@ public void cachedCdsResource_absent() { DiscoveryRpcCall call = startResourceWatcher(XdsClusterResource.getInstance(), CDS_RESOURCE, cdsResourceWatcher); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(cdsResourceWatcher).onResourceDoesNotExist(CDS_RESOURCE); + verify(cdsResourceWatcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsClusterResource.getInstance(), CDS_RESOURCE, watcher); - verify(watcher).onResourceDoesNotExist(CDS_RESOURCE); + verify(watcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); call.verifyNoMoreRequest(); verifyResourceMetadataDoesNotExist(CDS, CDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); @@ -2662,16 +3070,17 @@ public void cdsResourceUpdated() { null, null, false, null, null)); call.sendResponse(CDS, clusterDns, VERSION_1, "0000"); call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.LOGICAL_DNS); assertThat(cdsUpdate.dnsHostName()).isEqualTo(dnsHostAddr + ":" + dnsHostPort); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); assertThat(cdsUpdate.lrsServerInfo()).isNull(); assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); assertThat(cdsUpdate.upstreamTlsContext()).isNull(); @@ -2685,16 +3094,17 @@ public void cdsResourceUpdated() { )); call.sendResponse(CDS, clusterEds, VERSION_2, "0001"); call.verifyRequest(CDS, CDS_RESOURCE, VERSION_2, "0001", NODE); - verify(cdsResourceWatcher, times(2)).onChanged(cdsUpdateCaptor.capture()); - cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher, times(2)).onResourceChanged(cdsUpdateCaptor.capture()); + statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); assertThat(cdsUpdate.edsServiceName()).isEqualTo(edsService); - lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); + assertThat(cdsUpdate.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); assertThat(cdsUpdate.lrsServerInfo()).isEqualTo(xdsServerInfo); assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); assertThat(cdsUpdate.upstreamTlsContext()).isNull(); @@ -2727,27 +3137,27 @@ public void cdsResourceUpdatedWithDuplicate() { // Configure with round robin, the update should be sent to the watcher. call.sendResponse(CDS, roundRobinConfig, VERSION_2, "0001"); - verify(cdsResourceWatcher, times(1)).onChanged(isA(CdsUpdate.class)); + verify(cdsResourceWatcher, times(1)).onResourceChanged(argThat(StatusOr::hasValue)); // Second update is identical, watcher should not get an additional update. call.sendResponse(CDS, roundRobinConfig, VERSION_2, "0002"); - verify(cdsResourceWatcher, times(1)).onChanged(isA(CdsUpdate.class)); + verify(cdsResourceWatcher, times(1)).onResourceChanged(any()); // Now we switch to ring hash so the watcher should be notified. call.sendResponse(CDS, ringHashConfig, VERSION_2, "0003"); - verify(cdsResourceWatcher, times(2)).onChanged(isA(CdsUpdate.class)); + verify(cdsResourceWatcher, times(2)).onResourceChanged(argThat(StatusOr::hasValue)); // Second update to ring hash should not result in watcher being notified. call.sendResponse(CDS, ringHashConfig, VERSION_2, "0004"); - verify(cdsResourceWatcher, times(2)).onChanged(isA(CdsUpdate.class)); + verify(cdsResourceWatcher, times(2)).onResourceChanged(any()); // Now we switch to least request so the watcher should be notified. call.sendResponse(CDS, leastRequestConfig, VERSION_2, "0005"); - verify(cdsResourceWatcher, times(3)).onChanged(isA(CdsUpdate.class)); + verify(cdsResourceWatcher, times(3)).onResourceChanged(argThat(StatusOr::hasValue)); // Second update to least request should not result in watcher being notified. call.sendResponse(CDS, leastRequestConfig, VERSION_2, "0006"); - verify(cdsResourceWatcher, times(3)).onChanged(isA(CdsUpdate.class)); + verify(cdsResourceWatcher, times(3)).onResourceChanged(any()); } @Test @@ -2761,8 +3171,10 @@ public void cdsResourceDeleted() { // Initial CDS response. call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - verifyGoldenClusterRoundRobin(cdsUpdateCaptor.getValue()); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); @@ -2770,7 +3182,8 @@ public void cdsResourceDeleted() { // Empty CDS response deletes the cluster. call.sendResponse(CDS, Collections.emptyList(), VERSION_2, "0001"); call.verifyRequest(CDS, CDS_RESOURCE, VERSION_2, "0001", NODE); - verify(cdsResourceWatcher).onResourceDoesNotExist(CDS_RESOURCE); + verify(cdsResourceWatcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); verifyResourceMetadataDoesNotExist(CDS, CDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); } @@ -2790,8 +3203,10 @@ public void cdsResourceDeleted_ignoreResourceDeletion() { // Initial CDS response. call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - verifyGoldenClusterRoundRobin(cdsUpdateCaptor.getValue()); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); @@ -2805,19 +3220,244 @@ public void cdsResourceDeleted_ignoreResourceDeletion() { TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); // onResourceDoesNotExist must not be called. - verify(ldsResourceWatcher, never()).onResourceDoesNotExist(CDS_RESOURCE); + verify(ldsResourceWatcher, never()).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); // Next update is correct, and contains the cluster again. call.sendResponse(CDS, testClusterRoundRobin, VERSION_3, "0003"); call.verifyRequest(CDS, CDS_RESOURCE, VERSION_3, "0003", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - verifyGoldenClusterRoundRobin(cdsUpdateCaptor.getValue()); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_3, TIME_INCREMENT * 3); verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); verifyNoMoreInteractions(ldsResourceWatcher); } + /** + * When fail_on_data_errors server feature is on, xDS client should delete the cached cluster + * and fail RPCs when CDS resource is deleted. + */ + @Test + public void cdsResourceDeleted_failOnDataErrors_true() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + DiscoveryRpcCall call = startResourceWatcher(XdsClusterResource.getInstance(), CDS_RESOURCE, + cdsResourceWatcher); + verifyResourceMetadataRequested(CDS, CDS_RESOURCE); + + // Initial CDS response. + call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, + TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + // Empty CDS response deletes the cluster and fails RPCs. + call.sendResponse(CDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_2, "0001", NODE); + verify(cdsResourceWatcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); + verifyResourceMetadataDoesNotExist(CDS, CDS_RESOURCE); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * When fail_on_data_errors server feature is on, xDS client should delete the cached cluster + * and fail RPCs when CDS resource is deleted. + */ + @Test + public void cdsResourceDeleted_failOnDataErrors_false() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + // Set failOnDataErrors to false for this test case. + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, false); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(cdsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsClusterResource.getInstance(), CDS_RESOURCE, + cdsResourceWatcher); + verifyResourceMetadataRequested(CDS, CDS_RESOURCE); + + // Initial CDS response. + call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, + TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + // Empty CDS response should trigger an ambient error. + call.sendResponse(CDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_2, "0001", NODE); + + // Verify that onAmbientError() is called. + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + inOrder.verify(cdsResourceWatcher).onAmbientError(statusCaptor.capture()); + Status receivedStatus = statusCaptor.getValue(); + assertThat(receivedStatus.getCode()).isEqualTo(Status.Code.NOT_FOUND); + assertThat(receivedStatus.getDescription()).contains( + "Resource " + CDS_RESOURCE + " deleted from server"); + + // Verify that onResourceChanged() is NOT called again. + inOrder.verify(cdsResourceWatcher, never()).onResourceChanged(any()); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * Tests that a NACKed LDS resource update drops the cached resource when fail_on_data_errors + * is enabled. + */ + @Test + public void ldsResourceNacked_withFailOnDataErrors_dropsResource() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr initialUpdate = ldsUpdateCaptor.getValue(); + assertThat(initialUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(initialUpdate.getValue()); + Message invalidListener = mf.buildListenerWithApiListenerInvalid(LDS_RESOURCE); + call.sendResponse(LDS, Collections.singletonList(Any.pack(invalidListener)), VERSION_2, "0001"); + String expectedError = "LDS response Listener '" + LDS_RESOURCE + "' validation error"; + call.verifyRequestNack(LDS, LDS_RESOURCE, VERSION_1, "0001", NODE, + Collections.singletonList(expectedError)); + + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr finalUpdate = ldsUpdateCaptor.getValue(); + assertThat(finalUpdate.hasValue()).isFalse(); + assertThat(finalUpdate.getStatus().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(finalUpdate.getStatus().getDescription()).contains(expectedError); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * Tests that a NACKed LDS resource update is treated as an ambient error when + * fail_on_data_errors is disabled. + */ + @Test + public void ldsResourceNacked_withFailOnDataErrorsDisabled_isAmbientError() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, false); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(any()); + Message invalidListener = mf.buildListenerWithApiListenerInvalid(LDS_RESOURCE); + call.sendResponse(LDS, Collections.singletonList(Any.pack(invalidListener)), VERSION_2, "0001"); + + String expectedError = "LDS response Listener '" + LDS_RESOURCE + "' validation error"; + call.verifyRequestNack(LDS, LDS_RESOURCE, VERSION_1, "0001", NODE, + Collections.singletonList(expectedError)); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + inOrder.verify(ldsResourceWatcher).onAmbientError(statusCaptor.capture()); + Status receivedStatus = statusCaptor.getValue(); + assertThat(receivedStatus.getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(receivedStatus.getDescription()).contains(expectedError); + inOrder.verify(ldsResourceWatcher, never()).onResourceChanged(any()); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + @Test @SuppressWarnings("unchecked") public void multipleCdsWatchers() { @@ -2834,9 +3474,12 @@ public void multipleCdsWatchers() { verifySubscribedResourcesMetadataSizes(0, 2, 0, 0); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(cdsResourceWatcher).onResourceDoesNotExist(CDS_RESOURCE); - verify(watcher1).onResourceDoesNotExist(cdsResourceTwo); - verify(watcher2).onResourceDoesNotExist(cdsResourceTwo); + verify(cdsResourceWatcher).onResourceChanged(argThat(statusOr -> + statusOr.getStatus().getDescription().contains(CDS_RESOURCE))); + verify(watcher1).onResourceChanged(argThat(statusOr -> + statusOr.getStatus().getDescription().contains(cdsResourceTwo))); + verify(watcher2).onResourceChanged(argThat(statusOr -> + statusOr.getStatus().getDescription().contains(cdsResourceTwo))); verifyResourceMetadataDoesNotExist(CDS, CDS_RESOURCE); verifyResourceMetadataDoesNotExist(CDS, cdsResourceTwo); verifySubscribedResourcesMetadataSizes(0, 2, 0, 0); @@ -2850,45 +3493,51 @@ public void multipleCdsWatchers() { Any.pack(mf.buildEdsCluster(cdsResourceTwo, edsService, "round_robin", null, null, true, null, "envoy.transport_sockets.tls", null, null))); call.sendResponse(CDS, clusters, VERSION_1, "0000"); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); - assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); - assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.LOGICAL_DNS); - assertThat(cdsUpdate.dnsHostName()).isEqualTo(dnsHostAddr + ":" + dnsHostPort); - LbConfig lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - List childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); - assertThat(cdsUpdate.lrsServerInfo()).isNull(); - assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); - assertThat(cdsUpdate.upstreamTlsContext()).isNull(); - verify(watcher1).onChanged(cdsUpdateCaptor.capture()); - cdsUpdate = cdsUpdateCaptor.getValue(); - assertThat(cdsUpdate.clusterName()).isEqualTo(cdsResourceTwo); - assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); - assertThat(cdsUpdate.edsServiceName()).isEqualTo(edsService); - lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); - assertThat(cdsUpdate.lrsServerInfo()).isEqualTo(xdsServerInfo); - assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); - assertThat(cdsUpdate.upstreamTlsContext()).isNull(); - verify(watcher2).onChanged(cdsUpdateCaptor.capture()); - cdsUpdate = cdsUpdateCaptor.getValue(); - assertThat(cdsUpdate.clusterName()).isEqualTo(cdsResourceTwo); - assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); - assertThat(cdsUpdate.edsServiceName()).isEqualTo(edsService); - lbConfig = ServiceConfigUtil.unwrapLoadBalancingConfig(cdsUpdate.lbPolicyConfig()); - assertThat(lbConfig.getPolicyName()).isEqualTo("wrr_locality_experimental"); - childConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList( - JsonUtil.getListOfObjects(lbConfig.getRawConfigValue(), "childPolicy")); - assertThat(childConfigs.get(0).getPolicyName()).isEqualTo("round_robin"); - assertThat(cdsUpdate.lrsServerInfo()).isEqualTo(xdsServerInfo); - assertThat(cdsUpdate.maxConcurrentRequests()).isNull(); - assertThat(cdsUpdate.upstreamTlsContext()).isNull(); + ArgumentCaptor> cdsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(cdsResourceWatcher, times(2)).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr capturedUpdate1 = cdsUpdateCaptor.getAllValues().get(1); + assertThat(capturedUpdate1.hasValue()).isTrue(); + CdsUpdate cdsUpdate1 = capturedUpdate1.getValue(); + assertThat(cdsUpdate1.clusterName()).isEqualTo(CDS_RESOURCE); + assertThat(cdsUpdate1.clusterType()).isEqualTo(ClusterType.LOGICAL_DNS); + assertThat(cdsUpdate1.dnsHostName()).isEqualTo(dnsHostAddr + ":" + dnsHostPort); + assertThat(cdsUpdate1.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); + assertThat(cdsUpdate1.lrsServerInfo()).isNull(); + assertThat(cdsUpdate1.maxConcurrentRequests()).isNull(); + assertThat(cdsUpdate1.upstreamTlsContext()).isNull(); + ArgumentCaptor> watcher1Captor = ArgumentCaptor.forClass(StatusOr.class); + verify(watcher1, times(2)).onResourceChanged(watcher1Captor.capture()); + StatusOr capturedUpdate2 = watcher1Captor.getAllValues().get(1); + assertThat(capturedUpdate2.hasValue()).isTrue(); + CdsUpdate cdsUpdate2 = capturedUpdate2.getValue(); + assertThat(cdsUpdate2.clusterName()).isEqualTo(cdsResourceTwo); + assertThat(cdsUpdate2.clusterType()).isEqualTo(ClusterType.EDS); + assertThat(cdsUpdate2.edsServiceName()).isEqualTo(edsService); + assertThat(cdsUpdate2.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); + assertThat(cdsUpdate2.lrsServerInfo()).isEqualTo(xdsServerInfo); + assertThat(cdsUpdate2.maxConcurrentRequests()).isNull(); + assertThat(cdsUpdate2.upstreamTlsContext()).isNull(); + ArgumentCaptor> watcher2Captor = ArgumentCaptor.forClass(StatusOr.class); + verify(watcher2, times(2)).onResourceChanged(watcher2Captor.capture()); + StatusOr capturedUpdate3 = watcher2Captor.getAllValues().get(1); + assertThat(capturedUpdate3.hasValue()).isTrue(); + CdsUpdate cdsUpdate3 = capturedUpdate3.getValue(); + assertThat(cdsUpdate3.clusterName()).isEqualTo(cdsResourceTwo); + assertThat(cdsUpdate3.clusterType()).isEqualTo(ClusterType.EDS); + assertThat(cdsUpdate3.edsServiceName()).isEqualTo(edsService); + assertThat(cdsUpdate3.lbPolicyConfig()).isEqualTo( + GracefulSwitchLoadBalancer.parseLoadBalancingPolicyConfig(ImmutableList.of( + ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))))).getConfig()); + assertThat(cdsUpdate3.lrsServerInfo()).isEqualTo(xdsServerInfo); + assertThat(cdsUpdate3.maxConcurrentRequests()).isNull(); + assertThat(cdsUpdate3.upstreamTlsContext()).isNull(); // Metadata of both clusters is stored. verifyResourceMetadataAcked(CDS, CDS_RESOURCE, clusters.get(0), VERSION_1, TIME_INCREMENT); verifyResourceMetadataAcked(CDS, cdsResourceTwo, clusters.get(1), VERSION_1, TIME_INCREMENT); @@ -2912,7 +3561,8 @@ public void edsResourceNotFound() { verifySubscribedResourcesMetadataSizes(0, 0, 0, 1); // Server failed to return subscribed resource within expected time window. fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(edsResourceWatcher).onResourceDoesNotExist(EDS_RESOURCE); + verify(edsResourceWatcher).onResourceChanged(argThat(statusOr -> + statusOr.getStatus().getDescription().contains(EDS_RESOURCE))); assertThat(fakeClock.getPendingTasks(EDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataDoesNotExist(EDS, EDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 0, 0, 1); @@ -2936,7 +3586,7 @@ public void edsCleanupNonceAfterUnsubscription() { call.sendResponse(EDS, resourcesV1.values().asList(), VERSION_1, "0000"); // {A.1} -> ACK, version 1 call.verifyRequest(EDS, "A.1", VERSION_1, "0000", NODE); - verify(edsResourceWatcher, times(1)).onChanged(any()); + verify(edsResourceWatcher, times(1)).onResourceChanged(any()); // trigger an EDS resource unsubscription. xdsClient.cancelXdsResourceWatch(XdsEndpointResource.getInstance(), "A.1", edsResourceWatcher); @@ -2987,8 +3637,10 @@ public void edsResponseErrorHandling_someResourcesFailedUnpack() { verifySubscribedResourcesMetadataSizes(0, 0, 0, 1); // The response is NACKed with the same error message. call.verifyRequestNack(EDS, EDS_RESOURCE, "", "0000", NODE, errors); - verify(edsResourceWatcher).onChanged(edsUpdateCaptor.capture()); - EdsUpdate edsUpdate = edsUpdateCaptor.getValue(); + verify(edsResourceWatcher).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + EdsUpdate edsUpdate = statusOrUpdate.getValue(); assertThat(edsUpdate.clusterName).isEqualTo(EDS_RESOURCE); } @@ -3072,8 +3724,10 @@ public void edsResourceFound() { // Client sent an ACK EDS request. call.verifyRequest(EDS, EDS_RESOURCE, VERSION_1, "0000", NODE); - verify(edsResourceWatcher).onChanged(edsUpdateCaptor.capture()); - validateGoldenClusterLoadAssignment(edsUpdateCaptor.getValue()); + verify(edsResourceWatcher).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + validateGoldenClusterLoadAssignment(statusOrUpdate.getValue()); verifyResourceMetadataAcked(EDS, EDS_RESOURCE, testClusterLoadAssignment, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 0, 0, 1); @@ -3087,8 +3741,10 @@ public void wrappedEdsResourceFound() { // Client sent an ACK EDS request. call.verifyRequest(EDS, EDS_RESOURCE, VERSION_1, "0000", NODE); - verify(edsResourceWatcher).onChanged(edsUpdateCaptor.capture()); - validateGoldenClusterLoadAssignment(edsUpdateCaptor.getValue()); + verify(edsResourceWatcher).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + validateGoldenClusterLoadAssignment(statusOrUpdate.getValue()); verifyResourceMetadataAcked(EDS, EDS_RESOURCE, testClusterLoadAssignment, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(0, 0, 0, 1); @@ -3106,8 +3762,10 @@ public void cachedEdsResource_data() { // Add another watcher. ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsEndpointResource.getInstance(), EDS_RESOURCE, watcher); - verify(watcher).onChanged(edsUpdateCaptor.capture()); - validateGoldenClusterLoadAssignment(edsUpdateCaptor.getValue()); + verify(watcher).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + validateGoldenClusterLoadAssignment(statusOrUpdate.getValue()); call.verifyNoMoreRequest(); verifyResourceMetadataAcked(EDS, EDS_RESOURCE, testClusterLoadAssignment, VERSION_1, TIME_INCREMENT); @@ -3120,10 +3778,12 @@ public void cachedEdsResource_absent() { DiscoveryRpcCall call = startResourceWatcher(XdsEndpointResource.getInstance(), EDS_RESOURCE, edsResourceWatcher); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(edsResourceWatcher).onResourceDoesNotExist(EDS_RESOURCE); + verify(edsResourceWatcher).onResourceChanged(argThat(statusOr -> + statusOr.getStatus().getDescription().contains(EDS_RESOURCE))); ResourceWatcher watcher = mock(ResourceWatcher.class); xdsClient.watchXdsResource(XdsEndpointResource.getInstance(), EDS_RESOURCE, watcher); - verify(watcher).onResourceDoesNotExist(EDS_RESOURCE); + verify(watcher).onResourceChanged(argThat(statusOr -> + statusOr.getStatus().getDescription().contains(EDS_RESOURCE))); call.verifyNoMoreRequest(); verifyResourceMetadataDoesNotExist(EDS, EDS_RESOURCE); verifySubscribedResourcesMetadataSizes(0, 0, 0, 1); @@ -3154,7 +3814,7 @@ public void flowControlAbsent() throws Exception { fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); assertThat(fakeWatchClock.getPendingTasks().size()).isEqualTo(2); CyclicBarrier barrier = new CyclicBarrier(2); - doAnswer(blockUpdate(barrier)).when(cdsResourceWatcher).onChanged(any(CdsUpdate.class)); + doAnswer(blockUpdate(barrier)).when(cdsResourceWatcher).onResourceChanged(any()); CountDownLatch latch = new CountDownLatch(1); new Thread(() -> { @@ -3176,23 +3836,23 @@ public void flowControlAbsent() throws Exception { verifyResourceMetadataAcked( CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, TIME_INCREMENT); barrier.await(); - verify(cdsResourceWatcher, atLeastOnce()).onChanged(any()); + verify(cdsResourceWatcher, atLeastOnce()).onResourceChanged(any()); String errorMsg = "CDS response Cluster 'cluster.googleapis.com2' validation error: " + "Cluster cluster.googleapis.com2: unspecified cluster discovery type"; call.verifyRequestNack(CDS, Arrays.asList(CDS_RESOURCE, anotherCdsResource), VERSION_1, "0001", NODE, Arrays.asList(errorMsg)); barrier.await(); latch.await(10, TimeUnit.SECONDS); - verify(cdsResourceWatcher, times(2)).onChanged(any()); - verify(anotherWatcher).onResourceDoesNotExist(eq(anotherCdsResource)); - verify(anotherWatcher).onError(any()); + verify(cdsResourceWatcher, times(2)).onResourceChanged(any()); + verify(anotherWatcher, times(2)).onResourceChanged( + argThat(statusOr -> statusOr.getStatus().getDescription().contains(anotherCdsResource))); } @Test public void resourceTimerIsTransientError_schedulesExtendedTimeout() { BootstrapperImpl.xdsDataErrorHandlingEnabled = true; ServerInfo serverInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, - false, true, true); + false, true, true, false); BootstrapInfo bootstrapInfo = Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(serverInfo)) @@ -3237,7 +3897,7 @@ public void resourceTimerIsTransientError_schedulesExtendedTimeout() { public void resourceTimerIsTransientError_callsOnErrorUnavailable() { BootstrapperImpl.xdsDataErrorHandlingEnabled = true; xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, ignoreResourceDeletion(), - true, true); + true, true, false); BootstrapInfo bootstrapInfo = Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(xdsServerInfo)) @@ -3281,9 +3941,11 @@ public void resourceTimerIsTransientError_callsOnErrorUnavailable() { call.verifyRequest(CDS, ImmutableList.of(timeoutResource), "", "", NODE); fakeClock.forwardTime(XdsClientImpl.EXTENDED_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); fakeClock.runDueTasks(); - ArgumentCaptor errorCaptor = ArgumentCaptor.forClass(Status.class); - verify(timeoutWatcher).onError(errorCaptor.capture()); - Status error = errorCaptor.getValue(); + @SuppressWarnings("unchecked") + ArgumentCaptor> statusOrCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(timeoutWatcher).onResourceChanged(statusOrCaptor.capture()); + StatusOr statusOr = statusOrCaptor.getValue(); + Status error = statusOr.getStatus(); assertThat(error.getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(error.getDescription()).isEqualTo( "Timed out waiting for resource " + timeoutResource + " from xDS server"); @@ -3326,7 +3988,7 @@ public void simpleFlowControl() throws Exception { assertThat(call.isReady()).isFalse(); CyclicBarrier barrier = new CyclicBarrier(2); - doAnswer(blockUpdate(barrier)).when(edsResourceWatcher).onChanged(any(EdsUpdate.class)); + doAnswer(blockUpdate(barrier)).when(edsResourceWatcher).onResourceChanged(any()); CountDownLatch latch = new CountDownLatch(1); new Thread(() -> { @@ -3341,12 +4003,14 @@ public void simpleFlowControl() throws Exception { verifyResourceMetadataAcked(EDS, EDS_RESOURCE, testClusterLoadAssignment, VERSION_1, TIME_INCREMENT); barrier.await(); - verify(edsResourceWatcher, atLeastOnce()).onChanged(edsUpdateCaptor.capture()); - EdsUpdate edsUpdate = edsUpdateCaptor.getAllValues().get(0); + verify(edsResourceWatcher, atLeastOnce()).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getAllValues().get(0); + assertThat(statusOrUpdate.hasValue()).isTrue(); + EdsUpdate edsUpdate = statusOrUpdate.getValue(); validateGoldenClusterLoadAssignment(edsUpdate); barrier.await(); latch.await(10, TimeUnit.SECONDS); - verify(edsResourceWatcher, times(2)).onChanged(any()); + verify(edsResourceWatcher, times(2)).onResourceChanged(any()); verifyResourceMetadataAcked(EDS, EDS_RESOURCE, updatedClusterLoadAssignment, VERSION_2, TIME_INCREMENT * 2); } @@ -3358,7 +4022,7 @@ public void flowControlUnknownType() { call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); call.sendResponse(EDS, testClusterLoadAssignment, VERSION_1, "0000"); call.verifyRequest(EDS, EDS_RESOURCE, VERSION_1, "0000", NODE); - verify(edsResourceWatcher).onChanged(any()); + verify(edsResourceWatcher).onResourceChanged(any()); } @Test @@ -3370,8 +4034,10 @@ public void edsResourceUpdated() { // Initial EDS response. call.sendResponse(EDS, testClusterLoadAssignment, VERSION_1, "0000"); call.verifyRequest(EDS, EDS_RESOURCE, VERSION_1, "0000", NODE); - verify(edsResourceWatcher).onChanged(edsUpdateCaptor.capture()); - EdsUpdate edsUpdate = edsUpdateCaptor.getValue(); + verify(edsResourceWatcher).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + EdsUpdate edsUpdate = statusOrUpdate.getValue(); validateGoldenClusterLoadAssignment(edsUpdate); verifyResourceMetadataAcked(EDS, EDS_RESOURCE, testClusterLoadAssignment, VERSION_1, TIME_INCREMENT); @@ -3383,8 +4049,10 @@ public void edsResourceUpdated() { ImmutableList.of())); call.sendResponse(EDS, updatedClusterLoadAssignment, VERSION_2, "0001"); - verify(edsResourceWatcher, times(2)).onChanged(edsUpdateCaptor.capture()); - edsUpdate = edsUpdateCaptor.getValue(); + verify(edsResourceWatcher, times(2)).onResourceChanged(edsUpdateCaptor.capture()); + statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + edsUpdate = statusOrUpdate.getValue(); assertThat(edsUpdate.clusterName).isEqualTo(EDS_RESOURCE); assertThat(edsUpdate.dropPolicies).isEmpty(); assertThat(edsUpdate.localityLbEndpointsMap) @@ -3422,8 +4090,12 @@ public void edsDuplicateLocalityInTheSamePriority() { + "locality:Locality{region=region2, zone=zone2, subZone=subzone2} for priority:1"; call.verifyRequestNack(EDS, EDS_RESOURCE, "", "0001", NODE, ImmutableList.of( errorMsg)); - verify(edsResourceWatcher).onError(errorCaptor.capture()); - assertThat(errorCaptor.getValue().getDescription()).contains(errorMsg); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(StatusOr.class); + verify(edsResourceWatcher).onResourceChanged(captor.capture()); + StatusOr statusOrUpdate = captor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + assertThat(statusOrUpdate.getStatus().getDescription()).contains(errorMsg); } @Test @@ -3450,12 +4122,13 @@ public void edsResourceDeletedByCds() { Any.pack(mf.buildEdsCluster(CDS_RESOURCE, EDS_RESOURCE, "round_robin", null, null, false, null, "envoy.transport_sockets.tls", null, null))); call.sendResponse(CDS, clusters, VERSION_1, "0000"); - verify(cdsWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + ArgumentCaptor> cdsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(cdsWatcher, times(1)).onResourceChanged(cdsUpdateCaptor.capture()); + CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue().getValue(); assertThat(cdsUpdate.edsServiceName()).isEqualTo(null); assertThat(cdsUpdate.lrsServerInfo()).isEqualTo(xdsServerInfo); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher, times(1)).onResourceChanged(cdsUpdateCaptor.capture()); + cdsUpdate = cdsUpdateCaptor.getValue().getValue(); assertThat(cdsUpdate.edsServiceName()).isEqualTo(EDS_RESOURCE); assertThat(cdsUpdate.lrsServerInfo()).isNull(); verifyResourceMetadataAcked(CDS, resource, clusters.get(0), VERSION_1, TIME_INCREMENT); @@ -3479,10 +4152,11 @@ public void edsResourceDeletedByCds() { "endpoint-host-name"), 1, 0)), ImmutableList.of(mf.buildDropOverload("lb", 100))))); call.sendResponse(EDS, clusterLoadAssignments, VERSION_1, "0000"); - verify(edsWatcher).onChanged(edsUpdateCaptor.capture()); - assertThat(edsUpdateCaptor.getValue().clusterName).isEqualTo(resource); - verify(edsResourceWatcher).onChanged(edsUpdateCaptor.capture()); - assertThat(edsUpdateCaptor.getValue().clusterName).isEqualTo(EDS_RESOURCE); + ArgumentCaptor> edsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + verify(edsWatcher, times(1)).onResourceChanged(edsUpdateCaptor.capture()); + assertThat(edsUpdateCaptor.getValue().getValue().clusterName).isEqualTo(resource); + verify(edsResourceWatcher, times(1)).onResourceChanged(edsUpdateCaptor.capture()); + assertThat(edsUpdateCaptor.getValue().getValue().clusterName).isEqualTo(EDS_RESOURCE); verifyResourceMetadataAcked( EDS, EDS_RESOURCE, clusterLoadAssignments.get(0), VERSION_1, TIME_INCREMENT * 2); @@ -3500,12 +4174,8 @@ public void edsResourceDeletedByCds() { "envoy.transport_sockets.tls", null, null ))); call.sendResponse(CDS, clusters, VERSION_2, "0001"); - verify(cdsResourceWatcher, times(2)).onChanged(cdsUpdateCaptor.capture()); - assertThat(cdsUpdateCaptor.getValue().edsServiceName()).isNull(); - // Note that the endpoint must be deleted even if the ignore_resource_deletion feature. - // This happens because the cluster CDS_RESOURCE is getting replaced, and not deleted. - verify(edsResourceWatcher, never()).onResourceDoesNotExist(EDS_RESOURCE); - verify(edsResourceWatcher, never()).onResourceDoesNotExist(resource); + verify(cdsResourceWatcher, times(2)).onResourceChanged(cdsUpdateCaptor.capture()); + assertThat(cdsUpdateCaptor.getValue().getValue().edsServiceName()).isNull(); verifyNoMoreInteractions(cdsWatcher, edsWatcher); verifyResourceMetadataAcked( EDS, EDS_RESOURCE, clusterLoadAssignments.get(0), VERSION_1, TIME_INCREMENT * 2); @@ -3531,16 +4201,24 @@ public void multipleEdsWatchers() { verifySubscribedResourcesMetadataSizes(0, 0, 0, 2); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(edsResourceWatcher).onResourceDoesNotExist(EDS_RESOURCE); - verify(watcher1).onResourceDoesNotExist(edsResourceTwo); - verify(watcher2).onResourceDoesNotExist(edsResourceTwo); + verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getDescription().contains(EDS_RESOURCE))); + verify(watcher1).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getDescription().contains(edsResourceTwo))); + verify(watcher2).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getDescription().contains(edsResourceTwo))); verifyResourceMetadataDoesNotExist(EDS, EDS_RESOURCE); verifyResourceMetadataDoesNotExist(EDS, edsResourceTwo); verifySubscribedResourcesMetadataSizes(0, 0, 0, 2); call.sendResponse(EDS, testClusterLoadAssignment, VERSION_1, "0000"); - verify(edsResourceWatcher).onChanged(edsUpdateCaptor.capture()); - EdsUpdate edsUpdate = edsUpdateCaptor.getValue(); + verify(edsResourceWatcher, times(2)).onResourceChanged(edsUpdateCaptor.capture()); + StatusOr statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + EdsUpdate edsUpdate = statusOrUpdate.getValue(); validateGoldenClusterLoadAssignment(edsUpdate); verifyNoMoreInteractions(watcher1, watcher2); verifyResourceMetadataAcked( @@ -3557,8 +4235,10 @@ public void multipleEdsWatchers() { ImmutableList.of())); call.sendResponse(EDS, clusterLoadAssignmentTwo, VERSION_2, "0001"); - verify(watcher1).onChanged(edsUpdateCaptor.capture()); - edsUpdate = edsUpdateCaptor.getValue(); + verify(watcher1, times(2)).onResourceChanged(edsUpdateCaptor.capture()); + statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + edsUpdate = statusOrUpdate.getValue(); assertThat(edsUpdate.clusterName).isEqualTo(edsResourceTwo); assertThat(edsUpdate.dropPolicies).isEmpty(); assertThat(edsUpdate.localityLbEndpointsMap) @@ -3569,8 +4249,10 @@ public void multipleEdsWatchers() { LbEndpoint.create("172.44.2.2", 8000, 3, true, "endpoint-host-name", ImmutableMap.of())), 2, 0, ImmutableMap.of())); - verify(watcher2).onChanged(edsUpdateCaptor.capture()); - edsUpdate = edsUpdateCaptor.getValue(); + verify(watcher2, times(2)).onResourceChanged(edsUpdateCaptor.capture()); + statusOrUpdate = edsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + edsUpdate = statusOrUpdate.getValue(); assertThat(edsUpdate.clusterName).isEqualTo(edsResourceTwo); assertThat(edsUpdate.dropPolicies).isEmpty(); assertThat(edsUpdate.localityLbEndpointsMap) @@ -3601,10 +4283,13 @@ public void useIndependentRpcContext() { // The inbound RPC finishes and closes its context. The outbound RPC's control plane RPC // should not be impacted. cancellableContext.close(); - verify(ldsResourceWatcher, never()).onError(any(Status.class)); + verify(ldsResourceWatcher, never()).onAmbientError(any(Status.class)); + verify(ldsResourceWatcher, never()).onResourceChanged(argThat( + statusOr -> !statusOr.hasValue() + )); call.sendResponse(LDS, testListenerRds, VERSION_1, "0000"); - verify(ldsResourceWatcher).onChanged(any(LdsUpdate.class)); + verify(ldsResourceWatcher).onResourceChanged(any()); } finally { cancellableContext.detach(prevContext); } @@ -3624,12 +4309,15 @@ public void streamClosedWithNoResponse() { // Check metric data. callback_ReportServerConnection(); verifyServerConnection(1, false, xdsServerInfo.target()); - verify(ldsResourceWatcher, Mockito.timeout(1000).times(1)) - .onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, + verify(ldsResourceWatcher, Mockito.timeout(1000)).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr ldsStatusOr = ldsUpdateCaptor.getValue(); + assertThat(ldsStatusOr.hasValue()).isFalse(); + verifyStatusWithNodeId(ldsStatusOr.getStatus(), Code.UNAVAILABLE, "ADS stream closed with OK before receiving a response"); - verify(rdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, + verify(rdsResourceWatcher, Mockito.timeout(1000)).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr rdsStatusOr = rdsUpdateCaptor.getValue(); + assertThat(rdsStatusOr.hasValue()).isFalse(); + verifyStatusWithNodeId(rdsStatusOr.getStatus(), Code.UNAVAILABLE, "ADS stream closed with OK before receiving a response"); } @@ -3658,17 +4346,21 @@ public void streamClosedAfterSendingResponses() { // Check metric data. callback_ReportServerConnection(); verifyServerConnection(3, true, xdsServerInfo.target()); - verify(ldsResourceWatcher, never()).onError(errorCaptor.capture()); - verify(rdsResourceWatcher, never()).onError(errorCaptor.capture()); + verify(ldsResourceWatcher, never()).onAmbientError(any(Status.class)); + verify(rdsResourceWatcher, never()).onAmbientError(any(Status.class)); + verify(ldsResourceWatcher, times(1)).onResourceChanged(any()); + verify(rdsResourceWatcher, times(1)).onResourceChanged(any()); } @Test public void streamClosedAndRetryWithBackoff() { - InOrder inOrder = Mockito.inOrder(backoffPolicyProvider, backoffPolicy1, backoffPolicy2); + InOrder inOrder = inOrder(backoffPolicyProvider, backoffPolicy1, backoffPolicy2); + InOrder ldsWatcherInOrder = inOrder(ldsResourceWatcher); + InOrder rdsWatcherInOrder = inOrder(rdsResourceWatcher); + InOrder cdsWatcherInOrder = inOrder(cdsResourceWatcher); + InOrder edsWatcherInOrder = inOrder(edsResourceWatcher); + when(backoffPolicyProvider.get()).thenReturn(backoffPolicy1, backoffPolicy2, backoffPolicy2); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher); - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(1, true, xdsServerInfo.target()); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_RESOURCE, rdsResourceWatcher); xdsClient.watchXdsResource(XdsClusterResource.getInstance(), CDS_RESOURCE, cdsResourceWatcher); @@ -3679,25 +4371,25 @@ public void streamClosedAndRetryWithBackoff() { call.verifyRequest(CDS, CDS_RESOURCE, "", "", NODE); call.verifyRequest(EDS, EDS_RESOURCE, "", "", NODE); - // Management server closes the RPC stream with an error. + // Management server closes the RPC stream with an error. No response received yet. fakeClock.forwardNanos(1000L); // Make sure retry isn't based on stopwatch 0 call.sendError(Status.UNKNOWN.asException()); - verify(ldsResourceWatcher, Mockito.timeout(1000).times(1)) - .onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNKNOWN, ""); - verify(rdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNKNOWN, ""); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNKNOWN, ""); - verify(edsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNKNOWN, ""); + ldsWatcherInOrder.verify(ldsResourceWatcher, timeout(1000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); + rdsWatcherInOrder.verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); + cdsWatcherInOrder.verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); + edsWatcherInOrder.verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(1, false, xdsServerInfo.target()); + verifyServerFailureCount(1, 1, xdsServerInfo.target()); // Retry after backoff. - inOrder.verify(backoffPolicyProvider).get(); inOrder.verify(backoffPolicy1).nextBackoffNanos(); ScheduledTask retryTask = Iterables.getOnlyElement(fakeClock.getPendingTasks(RPC_RETRY_TASK_FILTER)); @@ -3709,25 +4401,23 @@ public void streamClosedAndRetryWithBackoff() { call.verifyRequest(CDS, CDS_RESOURCE, "", "", NODE); call.verifyRequest(EDS, EDS_RESOURCE, "", "", NODE); - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(2, false, xdsServerInfo.target()); - - // Management server becomes unreachable. + // Management server becomes unreachable. No response received on this stream either. String errorMsg = "my fault"; call.sendError(Status.UNAVAILABLE.withDescription(errorMsg).asException()); - verify(ldsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); - verify(rdsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); - verify(cdsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); - verify(edsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + ldsWatcherInOrder.verify(ldsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + rdsWatcherInOrder.verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + cdsWatcherInOrder.verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + edsWatcherInOrder.verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(3, false, xdsServerInfo.target()); + verifyServerFailureCount(2, 1, xdsServerInfo.target()); // Retry after backoff. inOrder.verify(backoffPolicy1).nextBackoffNanos(); @@ -3746,36 +4436,25 @@ public void streamClosedAndRetryWithBackoff() { mf.buildRouteConfiguration("do not care", mf.buildOpaqueVirtualHosts(2))))); call.sendResponse(LDS, listeners, "63", "3242"); call.verifyRequest(LDS, LDS_RESOURCE, "63", "3242", NODE); - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(2, true, xdsServerInfo.target()); + ldsWatcherInOrder.verify(ldsResourceWatcher).onResourceChanged( + argThat(statusOr -> statusOr.hasValue())); List routeConfigs = ImmutableList.of( Any.pack(mf.buildRouteConfiguration(RDS_RESOURCE, mf.buildOpaqueVirtualHosts(2)))); call.sendResponse(RDS, routeConfigs, "5", "6764"); call.verifyRequest(RDS, RDS_RESOURCE, "5", "6764", NODE); + rdsWatcherInOrder.verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> statusOr.hasValue())); - call.sendError(Status.DEADLINE_EXCEEDED.asException()); - fakeClock.forwardNanos(100L); - call = resourceDiscoveryCalls.poll(); + // Stream fails AFTER a response. Error is suppressed and no watcher notification occurs. call.sendError(Status.DEADLINE_EXCEEDED.asException()); - // Already received LDS and RDS, so they only error twice. - verify(ldsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verify(rdsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verify(cdsResourceWatcher, times(3)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.DEADLINE_EXCEEDED, ""); - verify(edsResourceWatcher, times(3)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.DEADLINE_EXCEEDED, ""); - - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(2, true, xdsServerInfo.target()); - verifyServerConnection(4, false, xdsServerInfo.target()); + // Failure count does NOT increase. + verifyServerFailureCount(2, 1, xdsServerInfo.target()); // Reset backoff sequence and retry after backoff. inOrder.verify(backoffPolicyProvider).get(); - inOrder.verify(backoffPolicy2, times(2)).nextBackoffNanos(); + inOrder.verify(backoffPolicy2).nextBackoffNanos(); retryTask = Iterables.getOnlyElement(fakeClock.getPendingTasks(RPC_RETRY_TASK_FILTER)); fakeClock.forwardNanos(retryTask.getDelay(TimeUnit.NANOSECONDS)); @@ -3785,23 +4464,21 @@ public void streamClosedAndRetryWithBackoff() { call.verifyRequest(CDS, CDS_RESOURCE, "", "", NODE); call.verifyRequest(EDS, EDS_RESOURCE, "", "", NODE); - // Check metric data, should be in error since haven't gotten a response. - callback_ReportServerConnection(); - verifyServerConnection(2, true, xdsServerInfo.target()); - verifyServerConnection(5, false, xdsServerInfo.target()); - - // Management server becomes unreachable again. + // Management server becomes unreachable again. This is on a new stream, so error propagates. call.sendError(Status.UNAVAILABLE.asException()); - verify(ldsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verify(rdsResourceWatcher, times(2)).onError(errorCaptor.capture()); - verify(cdsResourceWatcher, times(4)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, ""); - verify(edsResourceWatcher, times(4)).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, ""); - - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(6, false, xdsServerInfo.target()); + ldsWatcherInOrder.verify(ldsResourceWatcher).onAmbientError( + argThat(status -> status.getCode() == Code.UNAVAILABLE)); + rdsWatcherInOrder.verify(rdsResourceWatcher).onAmbientError( + argThat(status -> status.getCode() == Code.UNAVAILABLE)); + cdsWatcherInOrder.verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + edsWatcherInOrder.verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + + // Failure count is now 3. + verifyServerFailureCount(3, 1, xdsServerInfo.target()); // Retry after backoff. inOrder.verify(backoffPolicy2).nextBackoffNanos(); @@ -3815,16 +4492,41 @@ public void streamClosedAndRetryWithBackoff() { call.verifyRequest(CDS, CDS_RESOURCE, "", "", NODE); call.verifyRequest(EDS, EDS_RESOURCE, "", "", NODE); - // Check metric data. - callback_ReportServerConnection(); - verifyServerConnection(7, false, xdsServerInfo.target()); - - // Send a response so CPC is considered working + // Send a response so CPC is considered working and close gracefully. call.sendResponse(LDS, listeners, "63", "3242"); - callback_ReportServerConnection(); - verifyServerConnection(3, true, xdsServerInfo.target()); + call.sendCompleted(); + + // Final failure count is still 3. + verifyServerFailureCount(3, 1, xdsServerInfo.target()); + } + + @Test + public void newWatcher_receivesCachedDataAndAmbientError() throws Exception { + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call1 = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + call1.sendResponse(LDS, testListenerRds, VERSION_1, "0000"); + inOrder.verify(ldsResourceWatcher, timeout(5000)) + .onResourceChanged(argThat(statusOr -> statusOr.hasValue())); - inOrder.verifyNoMoreInteractions(); + call1.sendError(Status.DEADLINE_EXCEEDED.asException()); + ScheduledTask retryTask = + Iterables.getOnlyElement(fakeClock.getPendingTasks(RPC_RETRY_TASK_FILTER)); + fakeClock.forwardNanos(retryTask.getDelay(TimeUnit.NANOSECONDS)); + DiscoveryRpcCall call2 = resourceDiscoveryCalls.poll(); + Status propagatedError = Status.UNAVAILABLE.withDescription("real failure"); + call2.sendError(propagatedError.asException()); + inOrder.verify(ldsResourceWatcher, timeout(5000)).onAmbientError( + argThat(status -> status.getCode() == Code.UNAVAILABLE)); + @SuppressWarnings("unchecked") + ResourceWatcher ldsResourceWatcher2 = mock(ResourceWatcher.class); + xdsClient.watchXdsResource( + XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher2); + + verify(ldsResourceWatcher2, timeout(5000)).onResourceChanged( + argThat(statusOr -> statusOr.hasValue())); + verify(ldsResourceWatcher2, timeout(5000)).onAmbientError( + argThat(status -> status.getCode() == Code.UNAVAILABLE)); } @Test @@ -3839,10 +4541,10 @@ public void streamClosedAndRetryRaceWithAddRemoveWatchers() { verifyServerConnection(1, true, xdsServerInfo.target()); call.sendError(Status.UNAVAILABLE.asException()); verify(ldsResourceWatcher, Mockito.timeout(1000).times(1)) - .onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, ""); - verify(rdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, ""); + .onResourceChanged(ldsUpdateCaptor.capture()); + verifyStatusWithNodeId(ldsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, ""); + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + verifyStatusWithNodeId(rdsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, ""); ScheduledTask retryTask = Iterables.getOnlyElement(fakeClock.getPendingTasks(RPC_RETRY_TASK_FILTER)); assertThat(retryTask.getDelay(TimeUnit.NANOSECONDS)).isEqualTo(10L); @@ -3915,22 +4617,29 @@ public void streamClosedAndRetryRestartsResourceInitialFetchTimerForUnresolvedRe call.sendError(Status.UNAVAILABLE.asException()); assertThat(cdsResourceTimeout.isCancelled()).isTrue(); assertThat(edsResourceTimeout.isCancelled()).isTrue(); - verify(ldsResourceWatcher, never()).onError(errorCaptor.capture()); - verify(rdsResourceWatcher, never()).onError(errorCaptor.capture()); - verify(cdsResourceWatcher, never()).onError(errorCaptor.capture()); - verify(edsResourceWatcher, never()).onError(errorCaptor.capture()); - // Check metric data. + + // With the reverted logic, the first error is suppressed because a response was received. + // We verify that no error callbacks are invoked at this point. + verify(ldsResourceWatcher, never()).onAmbientError(any(Status.class)); + verify(rdsResourceWatcher, never()).onAmbientError(any(Status.class)); + + // The metric report for a failed server connection is also suppressed. callback_ReportServerConnection(); verifyServerConnection(4, true, xdsServerInfo.target()); - verify(cdsResourceWatcher, never()).onError(errorCaptor.capture()); // We had a response fakeClock.forwardTime(5, TimeUnit.SECONDS); DiscoveryRpcCall call2 = resourceDiscoveryCalls.poll(); call2.sendError(Status.UNAVAILABLE.asException()); - verify(cdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, ""); - verify(edsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, ""); + + // Now, verify the watchers are notified as expected. + verify(ldsResourceWatcher).onAmbientError(any(Status.class)); + verify(rdsResourceWatcher).onAmbientError(any(Status.class)); + verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); fakeClock.forwardTime(5, TimeUnit.SECONDS); DiscoveryRpcCall call3 = resourceDiscoveryCalls.poll(); @@ -3988,8 +4697,10 @@ public void serverSideListenerFound() { call.sendResponse(LDS, listeners, "0", "0000"); // Client sends an ACK LDS request. call.verifyRequest(LDS, Collections.singletonList(LISTENER_RESOURCE), "0", "0000", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - EnvoyServerProtoData.Listener parsedListener = ldsUpdateCaptor.getValue().listener(); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + EnvoyServerProtoData.Listener parsedListener = statusOrUpdate.getValue().listener(); assertThat(parsedListener.name()).isEqualTo(LISTENER_RESOURCE); assertThat(parsedListener.address()).isEqualTo("0.0.0.0:7000"); assertThat(parsedListener.defaultFilterChain()).isNull(); @@ -4026,7 +4737,8 @@ public void serverSideListenerNotFound() { verifyNoInteractions(ldsResourceWatcher); fakeClock.forwardTime(XdsClientImpl.INITIAL_RESOURCE_FETCH_TIMEOUT_SEC, TimeUnit.SECONDS); - verify(ldsResourceWatcher).onResourceDoesNotExist(LISTENER_RESOURCE); + verify(ldsResourceWatcher).onResourceChanged(argThat( + statusOr -> statusOr.getStatus().getDescription().contains(LISTENER_RESOURCE))); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); } @@ -4052,8 +4764,10 @@ public void serverSideListenerResponseErrorHandling_badDownstreamTlsContext() { + "0.0.0.0:7000\' validation error: " + "common-tls-context is required in downstream-tls-context"; call.verifyRequestNack(LDS, LISTENER_RESOURCE, "", "0000", NODE, ImmutableList.of(errorMsg)); - verify(ldsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + verifyStatusWithNodeId(statusOrUpdate.getStatus(), Code.UNAVAILABLE, errorMsg); } @Test @@ -4079,8 +4793,8 @@ public void serverSideListenerResponseErrorHandling_badTransportSocketName() { + "transport-socket with name envoy.transport_sockets.bad1 not supported."; call.verifyRequestNack(LDS, LISTENER_RESOURCE, "", "0000", NODE, ImmutableList.of( errorMsg)); - verify(ldsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, errorMsg); + verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + verifyStatusWithNodeId(ldsUpdateCaptor.getValue().getStatus(), Code.UNAVAILABLE, errorMsg); } @Test @@ -4110,16 +4824,17 @@ public void sendingToStoppedServer() throws Exception { .build() .start()); fakeClock.forwardTime(5, TimeUnit.SECONDS); - verify(ldsResourceWatcher, never()).onResourceDoesNotExist(LDS_RESOURCE); + verify(ldsResourceWatcher, never()).onResourceChanged(argThat( + statusOr -> statusOr.getStatus().getDescription().contains(LDS_RESOURCE))); fakeClock.forwardTime(20, TimeUnit.SECONDS); // Trigger rpcRetryTimer DiscoveryRpcCall call = resourceDiscoveryCalls.poll(3, TimeUnit.SECONDS); // Check metric data. callback_ReportServerConnection(); - verifyServerConnection(2, false, xdsServerInfo.target()); if (call == null) { // The first rpcRetry may have happened before the channel was ready fakeClock.forwardTime(50, TimeUnit.SECONDS); call = resourceDiscoveryCalls.poll(3, TimeUnit.SECONDS); } + verifyServerConnection(2, false, xdsServerInfo.target()); // Check metric data. callback_ReportServerConnection(); @@ -4131,8 +4846,12 @@ public void sendingToStoppedServer() throws Exception { // Send a response and do verifications call.sendResponse(LDS, mf.buildWrappedResource(testListenerVhosts), VERSION_1, "0001"); call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0001", NODE); - verify(ldsResourceWatcher).onChanged(ldsUpdateCaptor.capture()); - verifyGoldenListenerVhosts(ldsUpdateCaptor.getValue()); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(StatusOr.class); + verify(ldsResourceWatcher, timeout(1000).atLeast(2)).onResourceChanged(captor.capture()); + StatusOr lastValue = captor.getAllValues().get(captor.getAllValues().size() - 1); + assertThat(lastValue.hasValue()).isTrue(); + verifyGoldenListenerVhosts(lastValue.getValue()); assertThat(fakeClock.getPendingTasks(LDS_RESOURCE_FETCH_TIMEOUT_TASK_FILTER)).isEmpty(); verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); verifySubscribedResourcesMetadataSizes(1, 1, 0, 0); @@ -4153,8 +4872,8 @@ public void sendToBadUrl() throws Exception { client.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher); fakeClock.forwardTime(20, TimeUnit.SECONDS); verify(ldsResourceWatcher, Mockito.timeout(5000).atLeastOnce()) - .onError(errorCaptor.capture()); - assertThat(errorCaptor.getValue().getDescription()).contains(garbageUri); + .onResourceChanged(ldsUpdateCaptor.capture()); + assertThat(ldsUpdateCaptor.getValue().getStatus().getDescription()).contains(garbageUri); client.shutdown(); } @@ -4169,8 +4888,10 @@ public void circuitBreakingConversionOf32bitIntTo64bitLongForMaxRequestNegativeV // Client sent an ACK CDS request. call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); - verify(cdsResourceWatcher).onChanged(cdsUpdateCaptor.capture()); - CdsUpdate cdsUpdate = cdsUpdateCaptor.getValue(); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + CdsUpdate cdsUpdate = statusOrUpdate.getValue(); assertThat(cdsUpdate.clusterName()).isEqualTo(CDS_RESOURCE); assertThat(cdsUpdate.clusterType()).isEqualTo(ClusterType.EDS); @@ -4185,7 +4906,10 @@ public void sendToNonexistentServer() throws Exception { // file. Assume localhost doesn't speak HTTP/2 on the finger port XdsClientImpl client = createXdsClient("localhost:79"); client.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher); - verify(ldsResourceWatcher, Mockito.timeout(5000).times(1)).onError(ArgumentMatchers.any()); + verify(ldsResourceWatcher, Mockito.timeout(5000)).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + assertThat(statusOrUpdate.getStatus().getCode()).isEqualTo(Status.Code.UNAVAILABLE); assertThat(fakeClock.numPendingTasks()).isEqualTo(1); //retry assertThat(fakeClock.getPendingTasks().iterator().next().toString().contains("RpcRetryTask")) .isTrue(); @@ -4251,19 +4975,27 @@ public void serverFailureMetricReport() { DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); // Management server closes the RPC stream before sending any response. call.sendCompleted(); - verify(ldsResourceWatcher, Mockito.timeout(1000).times(1)) - .onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, + verify(ldsResourceWatcher, Mockito.timeout(1000)).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr ldsStatusOr = ldsUpdateCaptor.getValue(); + assertThat(ldsStatusOr.hasValue()).isFalse(); + verifyStatusWithNodeId(ldsStatusOr.getStatus(), Code.UNAVAILABLE, "ADS stream closed with OK before receiving a response"); - verify(rdsResourceWatcher).onError(errorCaptor.capture()); - verifyStatusWithNodeId(errorCaptor.getValue(), Code.UNAVAILABLE, + verify(rdsResourceWatcher).onResourceChanged(rdsUpdateCaptor.capture()); + StatusOr rdsStatusOr = rdsUpdateCaptor.getValue(); + assertThat(rdsStatusOr.hasValue()).isFalse(); + verifyStatusWithNodeId(rdsStatusOr.getStatus(), Code.UNAVAILABLE, "ADS stream closed with OK before receiving a response"); verifyServerFailureCount(1, 1, xdsServerInfo.target()); } @Test public void serverFailureMetricReport_forRetryAndBackoff() { - InOrder inOrder = Mockito.inOrder(backoffPolicyProvider, backoffPolicy1, backoffPolicy2); + InOrder inOrder = inOrder(backoffPolicyProvider, backoffPolicy1, backoffPolicy2); + InOrder ldsWatcherInOrder = inOrder(ldsResourceWatcher); + InOrder rdsWatcherInOrder = inOrder(rdsResourceWatcher); + InOrder cdsWatcherInOrder = inOrder(cdsResourceWatcher); + InOrder edsWatcherInOrder = inOrder(edsResourceWatcher); + when(backoffPolicyProvider.get()).thenReturn(backoffPolicy1, backoffPolicy2, backoffPolicy2); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), LDS_RESOURCE, ldsResourceWatcher); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_RESOURCE, rdsResourceWatcher); @@ -4273,6 +5005,18 @@ public void serverFailureMetricReport_forRetryAndBackoff() { // Management server closes the RPC stream with an error. call.sendError(Status.UNKNOWN.asException()); + ldsWatcherInOrder.verify(ldsResourceWatcher, timeout(1000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); + rdsWatcherInOrder.verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); + cdsWatcherInOrder.verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); + edsWatcherInOrder.verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNKNOWN)); verifyServerFailureCount(1, 1, xdsServerInfo.target()); // Retry after backoff. @@ -4287,6 +5031,18 @@ public void serverFailureMetricReport_forRetryAndBackoff() { // Management server becomes unreachable. String errorMsg = "my fault"; call.sendError(Status.UNAVAILABLE.withDescription(errorMsg).asException()); + ldsWatcherInOrder.verify(ldsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + rdsWatcherInOrder.verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + cdsWatcherInOrder.verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + edsWatcherInOrder.verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); verifyServerFailureCount(2, 1, xdsServerInfo.target()); // Retry after backoff. @@ -4299,13 +5055,18 @@ public void serverFailureMetricReport_forRetryAndBackoff() { List resources = ImmutableList.of(FAILING_ANY, testListenerRds, FAILING_ANY); call.sendResponse(LDS, resources, "63", "3242"); + ldsWatcherInOrder.verify(ldsResourceWatcher).onResourceChanged( + argThat(statusOr -> statusOr.hasValue())); List routeConfigs = ImmutableList.of(FAILING_ANY, testRouteConfig, FAILING_ANY); call.sendResponse(RDS, routeConfigs, "5", "6764"); + rdsWatcherInOrder.verify(rdsResourceWatcher).onResourceChanged( + argThat(statusOr -> statusOr.hasValue())); + // Stream fails AFTER a response. Error is suppressed and no watcher notification occurs. call.sendError(Status.DEADLINE_EXCEEDED.asException()); - // Server Failure metric will not be reported, as stream is closed with an error after receiving - // a response + + // Failure count does NOT increase because the error was suppressed. It is still 2. verifyServerFailureCount(2, 1, xdsServerInfo.target()); // Reset backoff sequence and retry after backoff. @@ -4317,8 +5078,20 @@ public void serverFailureMetricReport_forRetryAndBackoff() { fakeClock.forwardNanos(20L); call = resourceDiscoveryCalls.poll(); - // Management server becomes unreachable again. + // Management server becomes unreachable again. This is on a new stream, so error propagates. call.sendError(Status.UNAVAILABLE.asException()); + ldsWatcherInOrder.verify(ldsResourceWatcher).onAmbientError( + argThat(status -> status.getCode() == Code.UNAVAILABLE)); + rdsWatcherInOrder.verify(rdsResourceWatcher).onAmbientError( + argThat(status -> status.getCode() == Code.UNAVAILABLE)); + cdsWatcherInOrder.verify(cdsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + edsWatcherInOrder.verify(edsResourceWatcher).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Code.UNAVAILABLE)); + + // Server failure count is now 3. verifyServerFailureCount(3, 1, xdsServerInfo.target()); // Retry after backoff. @@ -4332,12 +5105,11 @@ public void serverFailureMetricReport_forRetryAndBackoff() { List clusters = ImmutableList.of(FAILING_ANY, testClusterRoundRobin); call.sendResponse(CDS, clusters, VERSION_1, "0000"); call.sendCompleted(); - // Server Failure metric will not be reported once again, as stream is closed after receiving a - // response + + // Final failure count is still 3 as the stream closed gracefully. verifyServerFailureCount(3, 1, xdsServerInfo.target()); } - private XdsClientImpl createXdsClient(String serverUri) { BootstrapInfo bootstrapInfo = buildBootStrap(serverUri); return new XdsClientImpl( @@ -4355,7 +5127,7 @@ private XdsClientImpl createXdsClient(String serverUri) { private BootstrapInfo buildBootStrap(String serverUri) { ServerInfo xdsServerInfo = ServerInfo.create(serverUri, CHANNEL_CREDENTIALS, - ignoreResourceDeletion(), true, false); + ignoreResourceDeletion(), true, false, false); return Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(xdsServerInfo)) @@ -4591,4 +5363,70 @@ protected abstract Message buildHttpConnectionManagerFilter( protected abstract Message buildTerminalFilter(); } + + private static class XdsStringResource extends XdsResourceType { + @Override + @SuppressWarnings("unchecked") + protected Class unpackedClassName() { + return StringValue.class; + } + + @Override + public String typeName() { + return "EMPTY"; + } + + @Override + public String typeUrl() { + return "type.googleapis.com/google.protobuf.StringValue"; + } + + @Override + public boolean shouldRetrieveResourceKeysForArgs() { + return false; + } + + @Override + protected boolean isFullStateOfTheWorld() { + return false; + } + + @Override + @Nullable + protected String extractResourceName(Message unpackedResource) { + if (!(unpackedResource instanceof StringValue)) { + return null; + } + return ((StringValue) unpackedResource).getValue(); + } + + @Override + protected StringUpdate doParse(Args args, Message unpackedMessage) + throws ResourceInvalidException { + return new StringUpdate(((StringValue) unpackedMessage).getValue()); + } + } + + private static final class StringUpdate implements ResourceUpdate { + @SuppressWarnings("UnusedVariable") + public final String value; + + public StringUpdate(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof StringUpdate)) { + return false; + } + StringUpdate that = (StringUpdate) o; + return Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } } diff --git a/xds/src/test/java/io/grpc/xds/LeastRequestLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/LeastRequestLoadBalancerTest.java index 6fb6507fa4e..302faed95a4 100644 --- a/xds/src/test/java/io/grpc/xds/LeastRequestLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/LeastRequestLoadBalancerTest.java @@ -22,6 +22,7 @@ import static io.grpc.ConnectivityState.READY; import static io.grpc.ConnectivityState.SHUTDOWN; import static io.grpc.ConnectivityState.TRANSIENT_FAILURE; +import static io.grpc.LoadBalancerMatchers.pickerReturns; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -50,6 +51,7 @@ import io.grpc.EquivalentAddressGroup; import io.grpc.LoadBalancer; import io.grpc.LoadBalancer.CreateSubchannelArgs; +import io.grpc.LoadBalancer.FixedResultPicker; import io.grpc.LoadBalancer.Helper; import io.grpc.LoadBalancer.PickResult; import io.grpc.LoadBalancer.PickSubchannelArgs; @@ -62,7 +64,6 @@ import io.grpc.internal.PickFirstLoadBalancerProvider; import io.grpc.util.AbstractTestHelper; import io.grpc.util.MultiChildLoadBalancer.ChildLbState; -import io.grpc.xds.LeastRequestLoadBalancer.EmptyPicker; import io.grpc.xds.LeastRequestLoadBalancer.LeastRequestConfig; import io.grpc.xds.LeastRequestLoadBalancer.LeastRequestLbState; import io.grpc.xds.LeastRequestLoadBalancer.ReadyPicker; @@ -238,7 +239,8 @@ public void pickAfterStateChange() throws Exception { ChildLbState childLbState = loadBalancer.getChildLbStates().iterator().next(); Subchannel subchannel = getSubchannel(servers.get(0)); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), isA(EmptyPicker.class)); + inOrder.verify(helper) + .updateBalancingState(eq(CONNECTING), pickerReturns(PickResult.withNoResult())); assertThat(childLbState.getCurrentState()).isEqualTo(CONNECTING); deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(READY)); @@ -251,7 +253,8 @@ public void pickAfterStateChange() throws Exception { assertThat(childLbState.getCurrentState()).isEqualTo(TRANSIENT_FAILURE); assertThat(childLbState.getCurrentPicker().toString()).contains(error.toString()); refreshInvokedAndUpdateBS(inOrder, CONNECTING); - assertThat(pickerCaptor.getValue()).isInstanceOf(EmptyPicker.class); + assertThat(pickerCaptor.getValue().pickSubchannel(mockArgs)) + .isEqualTo(PickResult.withNoResult()); deliverSubchannelState(subchannel, ConnectivityStateInfo.forNonError(IDLE)); inOrder.verify(helper).refreshNameResolution(); @@ -302,7 +305,8 @@ public void ignoreShutdownSubchannelStateChange() { ResolvedAddresses.newBuilder().setAddresses(servers).setAttributes(Attributes.EMPTY) .build()); assertThat(addressesAcceptanceStatus.isOk()).isTrue(); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), isA(EmptyPicker.class)); + inOrder.verify(helper) + .updateBalancingState(eq(CONNECTING), pickerReturns(PickResult.withNoResult())); List savedSubchannels = new ArrayList<>(subchannels.values()); loadBalancer.shutdown(); @@ -324,7 +328,8 @@ public void stayTransientFailureUntilReady() { .build()); assertThat(addressesAcceptanceStatus.isOk()).isTrue(); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), isA(EmptyPicker.class)); + inOrder.verify(helper) + .updateBalancingState(eq(CONNECTING), pickerReturns(PickResult.withNoResult())); // Simulate state transitions for each subchannel individually. List children = new ArrayList<>(loadBalancer.getChildLbStates()); @@ -384,7 +389,8 @@ public void refreshNameResolutionWhenSubchannelConnectionBroken() { assertThat(addressesAcceptanceStatus.isOk()).isTrue(); verify(helper, times(3)).createSubchannel(any(CreateSubchannelArgs.class)); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), isA(EmptyPicker.class)); + inOrder.verify(helper) + .updateBalancingState(eq(CONNECTING), pickerReturns(PickResult.withNoResult())); // Simulate state transitions for each subchannel individually. for (Subchannel sc : subchannels.values()) { @@ -399,7 +405,8 @@ public void refreshNameResolutionWhenSubchannelConnectionBroken() { deliverSubchannelState(sc, ConnectivityStateInfo.forNonError(IDLE)); inOrder.verify(helper).refreshNameResolution(); verify(sc, times(2)).requestConnection(); - inOrder.verify(helper).updateBalancingState(eq(CONNECTING), isA(EmptyPicker.class)); + inOrder.verify(helper) + .updateBalancingState(eq(CONNECTING), pickerReturns(PickResult.withNoResult())); } AbstractTestHelper.verifyNoMoreMeaningfulInteractions(helper); @@ -469,14 +476,6 @@ public void pickerLeastRequest() throws Exception { assertEquals(0, ((LeastRequestLbState) childLbStates.get(0)).getActiveRequests()); } - @Test - public void pickerEmptyList() throws Exception { - SubchannelPicker picker = new EmptyPicker(); - - assertNull(picker.pickSubchannel(mockArgs).getSubchannel()); - assertEquals(Status.OK, picker.pickSubchannel(mockArgs).getStatus()); - } - @Test public void nameResolutionErrorWithNoChannels() throws Exception { Status error = Status.NOT_FOUND.withDescription("nameResolutionError"); @@ -554,7 +553,8 @@ public void subchannelStateIsolation() throws Exception { Iterator pickers = pickerCaptor.getAllValues().iterator(); // The picker is incrementally updated as subchannels become READY assertEquals(CONNECTING, stateIterator.next()); - assertThat(pickers.next()).isInstanceOf(EmptyPicker.class); + assertThat(pickers.next().pickSubchannel(mockArgs)) + .isEqualTo(PickResult.withNoResult()); assertEquals(READY, stateIterator.next()); assertThat(getList(pickers.next())).containsExactly(sc1); assertEquals(READY, stateIterator.next()); @@ -585,8 +585,8 @@ public void readyPicker_emptyList() { @Test public void internalPickerComparisons() { - EmptyPicker empty1 = new EmptyPicker(); - EmptyPicker empty2 = new EmptyPicker(); + FixedResultPicker empty1 = new FixedResultPicker(PickResult.withNoResult()); + FixedResultPicker empty2 = new FixedResultPicker(PickResult.withNoResult()); loadBalancer.acceptResolvedAddresses( ResolvedAddresses.newBuilder().setAddresses(servers).setAttributes(affinity).build()); diff --git a/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java b/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java index e09066461c4..446cb89d82e 100644 --- a/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java +++ b/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java @@ -101,6 +101,22 @@ public class LoadBalancerConfigFactoryTest { .build())) .build()) .build(); + + private static final Policy WRR_POLICY_WITH_METRICS = Policy.newBuilder() + .setTypedExtensionConfig(TypedExtensionConfig.newBuilder() + .setName("backend") + .setTypedConfig( + Any.pack(ClientSideWeightedRoundRobin.newBuilder() + .setBlackoutPeriod(Duration.newBuilder().setSeconds(287).build()) + .setEnableOobLoadReport( + BoolValue.newBuilder().setValue(true).build()) + .setErrorUtilizationPenalty( + FloatValue.newBuilder().setValue(1.75F).build()) + .addMetricNamesForComputingUtilization("foo") + .addMetricNamesForComputingUtilization("bar") + .build())) + .build()) + .build(); private static final String CUSTOM_POLICY_NAME = "myorg.MyCustomLeastRequestPolicy"; private static final String CUSTOM_POLICY_FIELD_KEY = "choiceCount"; private static final double CUSTOM_POLICY_FIELD_VALUE = 2; @@ -129,7 +145,16 @@ public class LoadBalancerConfigFactoryTest { ImmutableMap.of("childPolicy", ImmutableList.of( ImmutableMap.of("weighted_round_robin", ImmutableMap.of("blackoutPeriod","287s", "enableOobLoadReport", true, - "errorUtilizationPenalty", 1.75F ))))); + "errorUtilizationPenalty", 1.75 ))))); + + private static final LbConfig VALID_WRR_CONFIG_WITH_METRICS = + new LbConfig("wrr_locality_experimental", + ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("weighted_round_robin", + ImmutableMap.of("blackoutPeriod", "287s", "enableOobLoadReport", true, + "errorUtilizationPenalty", 1.75, + LoadBalancerConfigFactory.METRIC_NAMES_FOR_COMPUTING_UTILIZATION, + ImmutableList.of("foo", "bar")))))); private static final LbConfig VALID_RING_HASH_CONFIG = new LbConfig("ring_hash_experimental", ImmutableMap.of("minRingSize", (double) RING_HASH_MIN_RING_SIZE, "maxRingSize", (double) RING_HASH_MAX_RING_SIZE)); @@ -165,6 +190,13 @@ public void weightedRoundRobin() throws ResourceInvalidException { assertThat(newLbConfig(cluster, true)).isEqualTo(VALID_WRR_CONFIG); } + @Test + public void weightedRoundRobin_withMetrics() throws ResourceInvalidException { + Cluster cluster = newCluster(buildWrrPolicy(WRR_POLICY_WITH_METRICS)); + + assertThat(newLbConfig(cluster, true)).isEqualTo(VALID_WRR_CONFIG_WITH_METRICS); + } + @Test public void weightedRoundRobin_invalid() throws ResourceInvalidException { Cluster cluster = newCluster(buildWrrPolicy(Policy.newBuilder() diff --git a/xds/src/test/java/io/grpc/xds/LoadReportClientTest.java b/xds/src/test/java/io/grpc/xds/LoadReportClientTest.java index 9bdf86132b6..80eb5cc1f47 100644 --- a/xds/src/test/java/io/grpc/xds/LoadReportClientTest.java +++ b/xds/src/test/java/io/grpc/xds/LoadReportClientTest.java @@ -51,6 +51,7 @@ import io.grpc.internal.FakeClock; import io.grpc.stub.StreamObserver; import io.grpc.testing.GrpcCleanupRule; +import io.grpc.xds.client.BackendMetricPropagation; import io.grpc.xds.client.EnvoyProtoData; import io.grpc.xds.client.LoadReportClient; import io.grpc.xds.client.LoadStatsManager2; @@ -58,6 +59,7 @@ import io.grpc.xds.client.LoadStatsManager2.ClusterLocalityStats; import io.grpc.xds.client.Locality; import java.util.ArrayDeque; +import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -91,6 +93,8 @@ public class LoadReportClientTest { private static final String EDS_SERVICE_NAME2 = "backend-service-bar.googleapis.com"; private static final Locality LOCALITY1 = Locality.create("region1", "zone1", "subZone1"); private static final Locality LOCALITY2 = Locality.create("region2", "zone2", "subZone2"); + private static final BackendMetricPropagation PROPAGATE_ALL = + BackendMetricPropagation.fromMetricSpecs(Arrays.asList("named_metrics.*")); private static final FakeClock.TaskFilter LOAD_REPORTING_TASK_FILTER = new FakeClock.TaskFilter() { @Override @@ -205,7 +209,8 @@ private void addFakeStatsData() { dropStats2.recordDroppedRequest("throttle"); } ClusterLocalityStats localityStats1 = - loadStatsManager.getClusterLocalityStats(CLUSTER1, EDS_SERVICE_NAME1, LOCALITY1); + loadStatsManager.getClusterLocalityStats( + CLUSTER1, EDS_SERVICE_NAME1, LOCALITY1, PROPAGATE_ALL); for (int i = 0; i < 31; i++) { localityStats1.recordCallStarted(); } @@ -213,7 +218,8 @@ private void addFakeStatsData() { localityStats1.recordBackendLoadMetricStats(ImmutableMap.of("named1", 1.618)); localityStats1.recordBackendLoadMetricStats(ImmutableMap.of("named1", -2.718)); ClusterLocalityStats localityStats2 = - loadStatsManager.getClusterLocalityStats(CLUSTER2, EDS_SERVICE_NAME2, LOCALITY2); + loadStatsManager.getClusterLocalityStats( + CLUSTER2, EDS_SERVICE_NAME2, LOCALITY2, PROPAGATE_ALL); for (int i = 0; i < 45; i++) { localityStats2.recordCallStarted(); } @@ -263,7 +269,7 @@ public void periodicLoadReporting() { assertThat(localityStats.getLoadMetricStatsCount()).isEqualTo(1); EndpointLoadMetricStats loadMetricStats = Iterables.getOnlyElement( localityStats.getLoadMetricStatsList()); - assertThat(loadMetricStats.getMetricName()).isEqualTo("named1"); + assertThat(loadMetricStats.getMetricName()).isEqualTo("named_metrics.named1"); assertThat(loadMetricStats.getNumRequestsFinishedWithMetric()).isEqualTo(3L); assertThat(loadMetricStats.getTotalMetricValue()).isEqualTo(3.14159 + 1.618 - 2.718); @@ -353,7 +359,7 @@ public void periodicLoadReporting() { assertThat(localityStats2.getLoadMetricStatsCount()).isEqualTo(1); EndpointLoadMetricStats loadMetricStats2 = Iterables.getOnlyElement( localityStats2.getLoadMetricStatsList()); - assertThat(loadMetricStats2.getMetricName()).isEqualTo("named2"); + assertThat(loadMetricStats2.getMetricName()).isEqualTo("named_metrics.named2"); assertThat(loadMetricStats2.getNumRequestsFinishedWithMetric()).isEqualTo(1L); assertThat(loadMetricStats2.getTotalMetricValue()).isEqualTo(1.414); @@ -530,7 +536,7 @@ public void lrsStreamClosedAndRetried() { assertThat(localityStats.getLoadMetricStatsCount()).isEqualTo(1); EndpointLoadMetricStats loadMetricStats = Iterables.getOnlyElement( localityStats.getLoadMetricStatsList()); - assertThat(loadMetricStats.getMetricName()).isEqualTo("named1"); + assertThat(loadMetricStats.getMetricName()).isEqualTo("named_metrics.named1"); assertThat(loadMetricStats.getNumRequestsFinishedWithMetric()).isEqualTo(3L); assertThat(loadMetricStats.getTotalMetricValue()).isEqualTo(3.14159 + 1.618 - 2.718); diff --git a/xds/src/test/java/io/grpc/xds/MetadataLoadBalancerProvider.java b/xds/src/test/java/io/grpc/xds/MetadataLoadBalancerProvider.java index 7b56f59451c..0499bafdb23 100644 --- a/xds/src/test/java/io/grpc/xds/MetadataLoadBalancerProvider.java +++ b/xds/src/test/java/io/grpc/xds/MetadataLoadBalancerProvider.java @@ -107,6 +107,7 @@ protected LoadBalancer delegate() { return delegateLb; } + @Deprecated @Override public void handleResolvedAddresses(ResolvedAddresses resolvedAddresses) { MetadataLoadBalancerConfig config diff --git a/xds/src/test/java/io/grpc/xds/PriorityLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/PriorityLoadBalancerTest.java index beb568be9ce..988bc720e45 100644 --- a/xds/src/test/java/io/grpc/xds/PriorityLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/PriorityLoadBalancerTest.java @@ -149,6 +149,106 @@ public void tearDown() { @Test public void acceptResolvedAddresses() { + boolean originalFlagVal = PriorityLoadBalancer.enablePriorityLbChildPolicyCache; + PriorityLoadBalancer.enablePriorityLbChildPolicyCache = true; + try { + SocketAddress socketAddress = new InetSocketAddress(8080); + EquivalentAddressGroup eag = new EquivalentAddressGroup(socketAddress); + eag = AddressFilter.setPathFilter(eag, ImmutableList.of("p1")); + List addresses = ImmutableList.of(eag); + Attributes attributes = + Attributes.newBuilder().set(Attributes.Key.create("fakeKey"), "fakeValue").build(); + Object fooConfig0 = new Object(); + PriorityChildConfig priorityChildConfig0 = + new PriorityChildConfig(newChildConfig(fooLbProvider, fooConfig0), true); + Object barConfig0 = new Object(); + PriorityChildConfig priorityChildConfig1 = + new PriorityChildConfig(newChildConfig(barLbProvider, barConfig0), true); + Object fooConfig1 = new Object(); + PriorityChildConfig priorityChildConfig2 = + new PriorityChildConfig(newChildConfig(fooLbProvider, fooConfig1), true); + PriorityLbConfig priorityLbConfig = + new PriorityLbConfig( + ImmutableMap.of("p0", priorityChildConfig0, "p1", priorityChildConfig1, + "p2", priorityChildConfig2), + ImmutableList.of("p0", "p1", "p2")); + Status status = priorityLb.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(addresses) + .setAttributes(attributes) + .setLoadBalancingPolicyConfig(priorityLbConfig) + .build()); + assertThat(status.getCode()).isEqualTo(Status.Code.OK); + assertThat(fooBalancers).hasSize(1); + assertThat(barBalancers).isEmpty(); + LoadBalancer fooBalancer0 = Iterables.getOnlyElement(fooBalancers); + verify(fooBalancer0).acceptResolvedAddresses(resolvedAddressesCaptor.capture()); + ResolvedAddresses addressesReceived = resolvedAddressesCaptor.getValue(); + assertThat(addressesReceived.getAddresses()).isEmpty(); + assertThat(addressesReceived.getAttributes()).isEqualTo(attributes); + assertThat(addressesReceived.getLoadBalancingPolicyConfig()).isEqualTo(fooConfig0); + + // Fail over to p1. + fakeClock.forwardTime(10, TimeUnit.SECONDS); + assertThat(fooBalancers).hasSize(1); + assertThat(barBalancers).hasSize(1); + LoadBalancer barBalancer0 = Iterables.getOnlyElement(barBalancers); + verify(barBalancer0).acceptResolvedAddresses(resolvedAddressesCaptor.capture()); + addressesReceived = resolvedAddressesCaptor.getValue(); + assertThat(Iterables.getOnlyElement(addressesReceived.getAddresses()).getAddresses()) + .containsExactly(socketAddress); + assertThat(addressesReceived.getAttributes()).isEqualTo(attributes); + assertThat(addressesReceived.getLoadBalancingPolicyConfig()).isEqualTo(barConfig0); + + // Fail over to p2. + fakeClock.forwardTime(10, TimeUnit.SECONDS); + assertThat(fooBalancers).hasSize(2); + assertThat(barBalancers).hasSize(1); + LoadBalancer fooBalancer1 = Iterables.getLast(fooBalancers); + verify(fooBalancer1).acceptResolvedAddresses(resolvedAddressesCaptor.capture()); + addressesReceived = resolvedAddressesCaptor.getValue(); + assertThat(addressesReceived.getAddresses()).isEmpty(); + assertThat(addressesReceived.getAttributes()).isEqualTo(attributes); + assertThat(addressesReceived.getLoadBalancingPolicyConfig()).isEqualTo(fooConfig1); + + // New update: p0 and p2 deleted; p1 config changed. + SocketAddress newSocketAddress = new InetSocketAddress(8081); + EquivalentAddressGroup newEag = new EquivalentAddressGroup(newSocketAddress); + newEag = AddressFilter.setPathFilter(newEag, ImmutableList.of("p1")); + List newAddresses = ImmutableList.of(newEag); + Object newBarConfig = new Object(); + PriorityLbConfig newPriorityLbConfig = + new PriorityLbConfig( + ImmutableMap.of("p1", + new PriorityChildConfig(newChildConfig(barLbProvider, newBarConfig), true)), + ImmutableList.of("p1")); + status = priorityLb.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(newAddresses) + .setLoadBalancingPolicyConfig(newPriorityLbConfig) + .build()); + assertThat(status.getCode()).isEqualTo(Status.Code.OK); + assertThat(fooBalancers).hasSize(2); + assertThat(barBalancers).hasSize(1); + verify(barBalancer0, times(2)).acceptResolvedAddresses(resolvedAddressesCaptor.capture()); + addressesReceived = resolvedAddressesCaptor.getValue(); + assertThat(Iterables.getOnlyElement(addressesReceived.getAddresses()).getAddresses()) + .containsExactly(newSocketAddress); + assertThat(addressesReceived.getAttributes()).isEqualTo(Attributes.EMPTY); + assertThat(addressesReceived.getLoadBalancingPolicyConfig()).isEqualTo(newBarConfig); + verify(fooBalancer0, never()).shutdown(); + verify(fooBalancer1, never()).shutdown(); + fakeClock.forwardTime(15, TimeUnit.MINUTES); + verify(fooBalancer0).shutdown(); + verify(fooBalancer1).shutdown(); + verify(barBalancer0, never()).shutdown(); + } finally { + PriorityLoadBalancer.enablePriorityLbChildPolicyCache = originalFlagVal; + } + } + + @Test + public void acceptResolvedAddresses_cacheDisabled() { SocketAddress socketAddress = new InetSocketAddress(8080); EquivalentAddressGroup eag = new EquivalentAddressGroup(socketAddress); eag = AddressFilter.setPathFilter(eag, ImmutableList.of("p1")); @@ -233,9 +333,6 @@ public void acceptResolvedAddresses() { .containsExactly(newSocketAddress); assertThat(addressesReceived.getAttributes()).isEqualTo(Attributes.EMPTY); assertThat(addressesReceived.getLoadBalancingPolicyConfig()).isEqualTo(newBarConfig); - verify(fooBalancer0, never()).shutdown(); - verify(fooBalancer1, never()).shutdown(); - fakeClock.forwardTime(15, TimeUnit.MINUTES); verify(fooBalancer0).shutdown(); verify(fooBalancer1).shutdown(); verify(barBalancer0, never()).shutdown(); @@ -297,41 +394,47 @@ public void acceptResolvedAddresses_propagatesChildFailures() { @Test public void handleNameResolutionError() { - Object fooConfig0 = new Object(); - PriorityChildConfig priorityChildConfig0 = - new PriorityChildConfig(newChildConfig(fooLbProvider, fooConfig0), true); - Object fooConfig1 = new Object(); - PriorityChildConfig priorityChildConfig1 = - new PriorityChildConfig(newChildConfig(fooLbProvider, fooConfig1), true); - - PriorityLbConfig priorityLbConfig = - new PriorityLbConfig(ImmutableMap.of("p0", priorityChildConfig0), ImmutableList.of("p0")); - priorityLb.acceptResolvedAddresses( - ResolvedAddresses.newBuilder() - .setAddresses(ImmutableList.of()) - .setLoadBalancingPolicyConfig(priorityLbConfig) - .build()); - LoadBalancer fooLb0 = Iterables.getOnlyElement(fooBalancers); - Status status = Status.DATA_LOSS.withDescription("fake error"); - priorityLb.handleNameResolutionError(status); - verify(fooLb0).handleNameResolutionError(status); - - priorityLbConfig = - new PriorityLbConfig(ImmutableMap.of("p1", priorityChildConfig1), ImmutableList.of("p1")); - priorityLb.acceptResolvedAddresses( - ResolvedAddresses.newBuilder() - .setAddresses(ImmutableList.of()) - .setLoadBalancingPolicyConfig(priorityLbConfig) - .build()); - assertThat(fooBalancers).hasSize(2); - LoadBalancer fooLb1 = Iterables.getLast(fooBalancers); - status = Status.UNAVAILABLE.withDescription("fake error"); - priorityLb.handleNameResolutionError(status); - // fooLb0 is deactivated but not yet deleted. However, because it is delisted by the latest - // address update, name resolution error will not be propagated to it. - verify(fooLb0, never()).shutdown(); - verify(fooLb0, never()).handleNameResolutionError(status); - verify(fooLb1).handleNameResolutionError(status); + boolean originalFlagVal = PriorityLoadBalancer.enablePriorityLbChildPolicyCache; + PriorityLoadBalancer.enablePriorityLbChildPolicyCache = true; + try { + Object fooConfig0 = new Object(); + PriorityChildConfig priorityChildConfig0 = + new PriorityChildConfig(newChildConfig(fooLbProvider, fooConfig0), true); + Object fooConfig1 = new Object(); + PriorityChildConfig priorityChildConfig1 = + new PriorityChildConfig(newChildConfig(fooLbProvider, fooConfig1), true); + + PriorityLbConfig priorityLbConfig = + new PriorityLbConfig(ImmutableMap.of("p0", priorityChildConfig0), ImmutableList.of("p0")); + priorityLb.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.of()) + .setLoadBalancingPolicyConfig(priorityLbConfig) + .build()); + LoadBalancer fooLb0 = Iterables.getOnlyElement(fooBalancers); + Status status = Status.DATA_LOSS.withDescription("fake error"); + priorityLb.handleNameResolutionError(status); + verify(fooLb0).handleNameResolutionError(status); + + priorityLbConfig = + new PriorityLbConfig(ImmutableMap.of("p1", priorityChildConfig1), ImmutableList.of("p1")); + priorityLb.acceptResolvedAddresses( + ResolvedAddresses.newBuilder() + .setAddresses(ImmutableList.of()) + .setLoadBalancingPolicyConfig(priorityLbConfig) + .build()); + assertThat(fooBalancers).hasSize(2); + LoadBalancer fooLb1 = Iterables.getLast(fooBalancers); + status = Status.UNAVAILABLE.withDescription("fake error"); + priorityLb.handleNameResolutionError(status); + // fooLb0 is deactivated but not yet deleted. However, because it is delisted by the latest + // address update, name resolution error will not be propagated to it. + verify(fooLb0, never()).shutdown(); + verify(fooLb0, never()).handleNameResolutionError(status); + verify(fooLb1).handleNameResolutionError(status); + } finally { + PriorityLoadBalancer.enablePriorityLbChildPolicyCache = originalFlagVal; + } } @Test diff --git a/xds/src/test/java/io/grpc/xds/RbacFilterTest.java b/xds/src/test/java/io/grpc/xds/RbacFilterTest.java index 334e159dd1d..4680f570327 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( @@ -261,7 +264,8 @@ public void testAuthorizationInterceptor() { OrMatcher.create(AlwaysTrueMatcher.INSTANCE)); AuthConfig authconfig = AuthConfig.create(Collections.singletonList(policyMatcher), GrpcAuthorizationEngine.Action.ALLOW); - FILTER_PROVIDER.newInstance(name).buildServerInterceptor(RbacConfig.create(authconfig), null) + FILTER_PROVIDER.newInstance(Filter.FilterContext.create(name, new io.grpc.MetricRecorder() {})) + .buildServerInterceptor(RbacConfig.create(authconfig), null) .interceptCall(mockServerCall, new Metadata(), mockHandler); verify(mockHandler, never()).startCall(eq(mockServerCall), any(Metadata.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(Status.class); @@ -270,10 +274,11 @@ public void testAuthorizationInterceptor() { assertThat(captor.getValue().getDescription()).isEqualTo("Access Denied"); verify(mockServerCall).getAttributes(); verifyNoMoreInteractions(mockServerCall); - + authconfig = AuthConfig.create(Collections.singletonList(policyMatcher), GrpcAuthorizationEngine.Action.DENY); - FILTER_PROVIDER.newInstance(name).buildServerInterceptor(RbacConfig.create(authconfig), null) + FILTER_PROVIDER.newInstance(Filter.FilterContext.create(name, new io.grpc.MetricRecorder() {})) + .buildServerInterceptor(RbacConfig.create(authconfig), null) .interceptCall(mockServerCall, new Metadata(), mockHandler); verify(mockHandler).startCall(eq(mockServerCall), any(Metadata.class)); } @@ -299,7 +304,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,10 +326,13 @@ 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); + FILTER_PROVIDER.newInstance( + Filter.FilterContext.create(name, new io.grpc.MetricRecorder() {})) + .buildServerInterceptor(original, override); assertThat(interceptor).isNull(); policyMatcher = PolicyMatcher.create("policy-matcher-override", @@ -334,7 +342,8 @@ public void overrideConfig() { GrpcAuthorizationEngine.Action.ALLOW); override = RbacConfig.create(authconfig); - FILTER_PROVIDER.newInstance(name).buildServerInterceptor(original, override) + FILTER_PROVIDER.newInstance(Filter.FilterContext.create(name, new io.grpc.MetricRecorder() {})) + .buildServerInterceptor(original, override) .interceptCall(mockServerCall, new Metadata(), mockHandler); verify(mockHandler).startCall(eq(mockServerCall), any(Metadata.class)); verify(mockServerCall).getAttributes(); @@ -346,22 +355,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 +403,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 +471,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.FilterConfigParseContext getFilterContext() { + return Filter.FilterConfigParseContext.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/RingHashLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/RingHashLoadBalancerTest.java index d65cf96c00d..b515ed81158 100644 --- a/xds/src/test/java/io/grpc/xds/RingHashLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/RingHashLoadBalancerTest.java @@ -255,7 +255,7 @@ public void aggregateSubchannelStates_connectingReadyIdleFailure() { inOrder.verify(helper).refreshNameResolution(); inOrder.verify(helper).updateBalancingState(eq(CONNECTING), any()); } - verifyConnection(0); + verifyConnection(1); } private void verifyConnection(int times) { @@ -537,7 +537,7 @@ public void pickWithRandomHash_firstSubchannelInTransientFailure_remainingSubcha // Bring one subchannel to TRANSIENT_FAILURE. deliverSubchannelUnreachable(getSubChannel(servers.get(0))); verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(1); // Pick subchannel with random hash does trigger connection by walking the ring // and choosing the first (at most one) IDLE subchannel along the way. @@ -583,7 +583,7 @@ public void skipFailingHosts_pickNextNonFailingHost() { getSubChannel(servers.get(0)), ConnectivityStateInfo.forTransientFailure( Status.UNAVAILABLE.withDescription("unreachable"))); - verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); + verify(helper, atLeastOnce()).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); PickResult result = pickerCaptor.getValue().pickSubchannel(args); assertThat(result.getStatus().isOk()).isTrue(); @@ -649,7 +649,7 @@ public void skipFailingHosts_firstTwoHostsFailed_pickNextFirstReady() { ConnectivityStateInfo.forTransientFailure( Status.PERMISSION_DENIED.withDescription("permission denied"))); verify(helper).updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(2); PickResult result = pickerCaptor.getValue().pickSubchannel(args); // activate last subchannel assertThat(result.getStatus().isOk()).isTrue(); int expectedCount = PickFirstLoadBalancerProvider.isEnabledNewPickFirst() ? 0 : 1; @@ -721,7 +721,7 @@ public void allSubchannelsInTransientFailure() { } verify(helper, atLeastOnce()) .updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(2); // Picking subchannel triggers connection. RPC hash hits server0. PickSubchannelArgs args = getDefaultPickSubchannelArgsForServer(0); @@ -740,12 +740,13 @@ public void firstSubchannelIdle() { List servers = createWeightedServerAddrs(1, 1, 1); initializeLbSubchannels(config, servers); - // Go to TF does nothing, though PF will try to reconnect after backoff + // As per gRFC A61, entering TF triggers a proactive connection attempt + // on an IDLE subchannel because no other subchannel is currently CONNECTING. deliverSubchannelState(getSubchannel(servers, 1), ConnectivityStateInfo.forTransientFailure( Status.UNAVAILABLE.withDescription("unreachable"))); verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(1); // Picking subchannel triggers connection. RPC hash hits server0. PickSubchannelArgs args = getDefaultPickSubchannelArgs(hashFunc.hashVoid()); @@ -796,7 +797,7 @@ public void firstSubchannelFailure() { ConnectivityStateInfo.forTransientFailure( Status.UNAVAILABLE.withDescription("unreachable"))); verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(1); // Per GRFC A61 Picking subchannel should no longer request connections that were failing PickSubchannelArgs args = getDefaultPickSubchannelArgs(hashFunc.hashVoid()); @@ -824,7 +825,7 @@ public void secondSubchannelConnecting() { Subchannel firstSubchannel = getSubchannel(servers, 0); deliverSubchannelUnreachable(firstSubchannel); - verifyConnection(0); + verifyConnection(1); deliverSubchannelState(getSubchannel(servers, 2), CSI_CONNECTING); verify(helper, times(2)).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); @@ -833,7 +834,7 @@ public void secondSubchannelConnecting() { // Picking subchannel when idle triggers connection. deliverSubchannelState(getSubchannel(servers, 2), ConnectivityStateInfo.forNonError(IDLE)); - verifyConnection(0); + verifyConnection(1); PickSubchannelArgs args = getDefaultPickSubchannelArgs(hashFunc.hashVoid()); PickResult result = pickerCaptor.getValue().pickSubchannel(args); assertThat(result.getStatus().isOk()).isTrue(); @@ -857,7 +858,7 @@ public void secondSubchannelFailure() { deliverSubchannelUnreachable(firstSubchannel); deliverSubchannelUnreachable(getSubchannel(servers, 2)); verify(helper).updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(2); // Picking subchannel triggers connection. PickSubchannelArgs args = getDefaultPickSubchannelArgs(hashFunc.hashVoid()); @@ -887,7 +888,7 @@ public void thirdSubchannelConnecting() { deliverSubchannelState(getSubchannel(servers, 1), CSI_CONNECTING); verify(helper, atLeastOnce()) .updateBalancingState(eq(TRANSIENT_FAILURE), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(2); // Picking subchannel should not trigger connection per gRFC A61. PickSubchannelArgs args = getDefaultPickSubchannelArgs(hashFunc.hashVoid()); @@ -909,7 +910,7 @@ public void stickyTransientFailure() { deliverSubchannelUnreachable(firstSubchannel); verify(helper).updateBalancingState(eq(CONNECTING), pickerCaptor.capture()); - verifyConnection(0); + verifyConnection(1); reset(helper); deliverSubchannelState(firstSubchannel, ConnectivityStateInfo.forNonError(IDLE)); @@ -1127,6 +1128,39 @@ public void config_equalsTester() { .testEquals(); } + @Test + public void tfWithoutConnectingChild_triggersIdleChildConnection() { + RingHashConfig config = new RingHashConfig(10, 100, ""); + List servers = createWeightedServerAddrs(1, 1); + + initializeLbSubchannels(config, servers); + + Subchannel tfSubchannel = getSubchannel(servers, 0); + Subchannel idleSubchannel = getSubchannel(servers, 1); + + deliverSubchannelUnreachable(tfSubchannel); + + Subchannel requested = connectionRequestedQueue.poll(); + assertThat(requested).isSameInstanceAs(idleSubchannel); + assertThat(connectionRequestedQueue.poll()).isNull(); + } + + @Test + public void tfWithReadyChild_doesNotTriggerIdleChildConnection() { + RingHashConfig config = new RingHashConfig(10, 100, ""); + List servers = createWeightedServerAddrs(1, 1, 1); + + initializeLbSubchannels(config, servers); + + Subchannel tfSubchannel = getSubchannel(servers, 0); + Subchannel readySubchannel = getSubchannel(servers, 1); + + deliverSubchannelState(readySubchannel, ConnectivityStateInfo.forNonError(READY)); + deliverSubchannelUnreachable(tfSubchannel); + + assertThat(connectionRequestedQueue.poll()).isNull(); + } + private List initializeLbSubchannels(RingHashConfig config, List servers, InitializationFlags... initFlags) { diff --git a/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java b/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java index 24f1750d5a8..29b149f166f 100644 --- a/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java @@ -19,7 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; -import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -74,16 +74,24 @@ public class SharedXdsClientPoolProviderTest { private GrpcBootstrapperImpl bootstrapper; @Mock private ResourceWatcher ldsResourceWatcher; + @Deprecated @Test - public void noServer() throws XdsInitializationException { + public void sharedXdsClientObjectPool_deprecated() throws XdsInitializationException { + ServerInfo server = ServerInfo.create(SERVER_URI, InsecureChannelCredentials.create()); BootstrapInfo bootstrapInfo = - BootstrapInfo.builder().servers(Collections.emptyList()).node(node).build(); + BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); when(bootstrapper.bootstrap()).thenReturn(bootstrapInfo); + SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); - XdsInitializationException e = assertThrows(XdsInitializationException.class, - () -> provider.getOrCreate(DUMMY_TARGET, metricRecorder)); - assertThat(e).hasMessageThat().isEqualTo("No xDS server provided"); assertThat(provider.get(DUMMY_TARGET)).isNull(); + ObjectPool xdsClientPool = + provider.getOrCreate(DUMMY_TARGET, metricRecorder, null); + verify(bootstrapper).bootstrap(); + assertThat(provider.getOrCreate(DUMMY_TARGET, bootstrapInfo, metricRecorder)) + .isSameInstanceAs(xdsClientPool); + assertThat(provider.get(DUMMY_TARGET)).isNotNull(); + assertThat(provider.get(DUMMY_TARGET)).isSameInstanceAs(xdsClientPool); + verifyNoMoreInteractions(bootstrapper); } @Test @@ -91,13 +99,14 @@ public void sharedXdsClientObjectPool() throws XdsInitializationException { ServerInfo server = ServerInfo.create(SERVER_URI, InsecureChannelCredentials.create()); BootstrapInfo bootstrapInfo = BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); - when(bootstrapper.bootstrap()).thenReturn(bootstrapInfo); SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); assertThat(provider.get(DUMMY_TARGET)).isNull(); - ObjectPool xdsClientPool = provider.getOrCreate(DUMMY_TARGET, metricRecorder); - verify(bootstrapper).bootstrap(); - assertThat(provider.getOrCreate(DUMMY_TARGET, metricRecorder)).isSameInstanceAs(xdsClientPool); + ObjectPool xdsClientPool = + provider.getOrCreate(DUMMY_TARGET, bootstrapInfo, metricRecorder); + verify(bootstrapper, never()).bootstrap(); + assertThat(provider.getOrCreate(DUMMY_TARGET, bootstrapInfo, metricRecorder)) + .isSameInstanceAs(xdsClientPool); assertThat(provider.get(DUMMY_TARGET)).isNotNull(); assertThat(provider.get(DUMMY_TARGET)).isSameInstanceAs(xdsClientPool); verifyNoMoreInteractions(bootstrapper); @@ -108,7 +117,7 @@ public void refCountedXdsClientObjectPool_delayedCreation() { ServerInfo server = ServerInfo.create(SERVER_URI, InsecureChannelCredentials.create()); BootstrapInfo bootstrapInfo = BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); - SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); + SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(); RefCountedXdsClientObjectPool xdsClientPool = provider.new RefCountedXdsClientObjectPool(bootstrapInfo, DUMMY_TARGET, metricRecorder); assertThat(xdsClientPool.getXdsClientForTest()).isNull(); @@ -122,7 +131,7 @@ public void refCountedXdsClientObjectPool_refCounted() { ServerInfo server = ServerInfo.create(SERVER_URI, InsecureChannelCredentials.create()); BootstrapInfo bootstrapInfo = BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); - SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); + SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(); RefCountedXdsClientObjectPool xdsClientPool = provider.new RefCountedXdsClientObjectPool(bootstrapInfo, DUMMY_TARGET, metricRecorder); // getObject once @@ -143,7 +152,7 @@ public void refCountedXdsClientObjectPool_getObjectCreatesNewInstanceIfAlreadySh ServerInfo server = ServerInfo.create(SERVER_URI, InsecureChannelCredentials.create()); BootstrapInfo bootstrapInfo = BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); - SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); + SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(); RefCountedXdsClientObjectPool xdsClientPool = provider.new RefCountedXdsClientObjectPool(bootstrapInfo, DUMMY_TARGET, metricRecorder); XdsClient xdsClient1 = xdsClientPool.getObject(); @@ -189,8 +198,7 @@ public void xdsClient_usesCallCredentials() throws Exception { ServerInfo server = ServerInfo.create(xdsServerUri, InsecureChannelCredentials.create()); BootstrapInfo bootstrapInfo = BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); - when(bootstrapper.bootstrap()).thenReturn(bootstrapInfo); - SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); + SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(); // Create custom xDS transport CallCredentials CallCredentials sampleCreds = @@ -199,7 +207,7 @@ public void xdsClient_usesCallCredentials() throws Exception { // Create xDS client that uses the CallCredentials on the transport ObjectPool xdsClientPool = - provider.getOrCreate("target", metricRecorder, sampleCreds); + provider.getOrCreate("target", bootstrapInfo, metricRecorder, sampleCreds); XdsClient xdsClient = xdsClientPool.getObject(); xdsClient.watchXdsResource( XdsListenerResource.getInstance(), "someLDSresource", ldsResourceWatcher); diff --git a/xds/src/test/java/io/grpc/xds/StatefulFilter.java b/xds/src/test/java/io/grpc/xds/StatefulFilter.java index 4ef662c7ccd..28d9f1897fc 100644 --- a/xds/src/test/java/io/grpc/xds/StatefulFilter.java +++ b/xds/src/test/java/io/grpc/xds/StatefulFilter.java @@ -108,7 +108,7 @@ public boolean isServerFilter() { } @Override - public synchronized StatefulFilter newInstance(String name) { + public synchronized StatefulFilter newInstance(FilterContext context) { StatefulFilter filter = new StatefulFilter(counter++); instances.put(filter.idx, filter); return filter; @@ -128,12 +128,14 @@ public synchronized int getCount() { } @Override - public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + public ConfigOrError parseFilterConfig(Message rawProtoMessage, + FilterConfigParseContext context) { return ConfigOrError.fromConfig(Config.fromProto(rawProtoMessage, typeUrl)); } @Override - public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + public ConfigOrError parseFilterConfigOverride( + Message rawProtoMessage, FilterConfigParseContext context) { return ConfigOrError.fromConfig(Config.fromProto(rawProtoMessage, typeUrl)); } } diff --git a/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProviderTest.java b/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProviderTest.java index ddde84ca842..0bd3283cb79 100644 --- a/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerProviderTest.java @@ -29,6 +29,7 @@ import io.grpc.internal.FakeClock; import io.grpc.internal.JsonParser; import io.grpc.xds.WeightedRoundRobinLoadBalancer.WeightedRoundRobinLoadBalancerConfig; +import io.grpc.xds.internal.MetricReportUtils.ParsedMetricName; import java.io.IOException; import java.util.Map; import org.junit.Test; @@ -111,6 +112,25 @@ public void parseLoadBalancingConfigDefaultValues() throws IOException { assertThat(config.errorUtilizationPenalty).isEqualTo(1.0F); } + @Test + public void parseLoadBalancingConfigCustomMetricsIgnoresInvalid() throws IOException { + System.setProperty("GRPC_EXPERIMENTAL_WRR_CUSTOM_METRICS", "true"); + try { + String lbConfig = + "{\"metricNamesForComputingUtilization\" : " + + "[\"utilization.foo\", \"invalid_name\", \"named_metrics.bar\"]}"; + ConfigOrError configOrError = provider.parseLoadBalancingPolicyConfig( + parseJsonObject(lbConfig)); + assertThat(configOrError.getConfig()).isNotNull(); + WeightedRoundRobinLoadBalancerConfig config = + (WeightedRoundRobinLoadBalancerConfig) configOrError.getConfig(); + assertThat(config.parsedMetricNamesForComputingUtilization).containsExactly( + ParsedMetricName.parse("utilization.foo"), ParsedMetricName.parse("named_metrics.bar")); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_WRR_CUSTOM_METRICS"); + } + } + @SuppressWarnings("unchecked") private static Map parseJsonObject(String json) throws IOException { diff --git a/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerTest.java b/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerTest.java index 868bc811a70..bac62d1a103 100644 --- a/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerTest.java +++ b/xds/src/test/java/io/grpc/xds/WeightedRoundRobinLoadBalancerTest.java @@ -19,9 +19,10 @@ import static com.google.common.truth.Truth.assertThat; import static io.grpc.ConnectivityState.CONNECTING; import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.eq; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; @@ -32,6 +33,7 @@ import com.github.xds.data.orca.v3.OrcaLoadReport; import com.github.xds.service.orca.v3.OrcaLoadReportRequest; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; @@ -84,6 +86,7 @@ import io.grpc.xds.WeightedRoundRobinLoadBalancer.WeightedChildLbState; import io.grpc.xds.WeightedRoundRobinLoadBalancer.WeightedRoundRobinLoadBalancerConfig; import io.grpc.xds.WeightedRoundRobinLoadBalancer.WeightedRoundRobinPicker; +import io.grpc.xds.orca.OrcaOobUtilAccessor; import java.net.SocketAddress; import java.util.Arrays; import java.util.Collections; @@ -170,6 +173,10 @@ public WeightedRoundRobinLoadBalancerTest() { helper = mock(Helper.class, delegatesTo(testHelperInstance)); } + private static WeightedRoundRobinPicker getWrrPicker(SubchannelPicker picker) { + return (WeightedRoundRobinPicker) OrcaOobUtilAccessor.getDelegate(picker); + } + @Before public void setup() { for (int i = 0; i < 3; i++) { @@ -211,9 +218,8 @@ public void pickChildLbTF() throws Exception { .forTransientFailure(Status.UNAVAILABLE)); verify(helper).updateBalancingState( eq(ConnectivityState.TRANSIENT_FAILURE), pickerCaptor.capture()); - final WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getValue(); - weightedPicker.pickSubchannel(mockArgs); + final SubchannelPicker picker = pickerCaptor.getValue(); + picker.pickSubchannel(mockArgs); } @Test @@ -273,9 +279,9 @@ public void wrrLifeCycle() { eq(ConnectivityState.READY), pickerCaptor.capture()); assertThat(pickerCaptor.getAllValues().size()).isEqualTo(2); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(0); + getWrrPicker(pickerCaptor.getAllValues().get(0)); assertThat(weightedPicker.getChildren().size()).isEqualTo(1); - weightedPicker = (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + weightedPicker = getWrrPicker(pickerCaptor.getAllValues().get(1)); assertThat(weightedPicker.getChildren().size()).isEqualTo(2); String weightedPickerStr = weightedPicker.toString(); assertThat(weightedPickerStr).contains("enableOobLoadReport=false"); @@ -284,10 +290,12 @@ public void wrrLifeCycle() { WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); int expectedTasks = isEnabledHappyEyeballs() ? 2 : 1; @@ -336,13 +344,15 @@ public void enableOobLoadReportConfig() { verify(helper, times(2)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + getWrrPicker(pickerCaptor.getAllValues().get(1)); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.9, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); int expectedTasks = isEnabledHappyEyeballs() ? 2 : 1; @@ -360,8 +370,8 @@ weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).on .setAttributes(affinity).build())); verify(helper, times(3)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor2.capture()); - weightedPicker = (WeightedRoundRobinPicker) pickerCaptor2.getAllValues().get(2); - pickResult = weightedPicker.pickSubchannel(mockArgs); + SubchannelPicker rawPicker = pickerCaptor2.getAllValues().get(2); + pickResult = rawPicker.pickSubchannel(mockArgs); assertThat(getAddresses(pickResult)).isEqualTo(servers.get(0)); assertThat(pickResult.getStreamTracerFactory()).isNull(); OrcaLoadReportRequest golden = OrcaLoadReportRequest.newBuilder().setReportInterval( @@ -394,13 +404,16 @@ private void pickByWeight(MetricReport r1, MetricReport r2, MetricReport r3, verify(helper, times(3)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(2); + getWrrPicker(pickerCaptor.getAllValues().get(2)); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); WeightedChildLbState weightedChild3 = (WeightedChildLbState) getChild(weightedPicker, 2); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport(r1); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport(r2); - weightedChild3.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport(r3); + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport(r1); + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport(r2); + weightedChild3.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport(r3); assertThat(fakeClock.forwardTime(11, TimeUnit.SECONDS)).isEqualTo(1); Map pickCount = new HashMap<>(); @@ -594,13 +607,15 @@ public void blackoutPeriod() { verify(helper, times(2)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + getWrrPicker(pickerCaptor.getAllValues().get(1)); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); int expectedCount = isEnabledHappyEyeballs() ? 2 : 1; @@ -654,16 +669,18 @@ public void updateWeightTimer() { eq(ConnectivityState.READY), pickerCaptor.capture()); assertThat(pickerCaptor.getAllValues().size()).isEqualTo(2); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(0); + getWrrPicker(pickerCaptor.getAllValues().get(0)); assertThat(weightedPicker.getChildren().size()).isEqualTo(1); - weightedPicker = (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + weightedPicker = getWrrPicker(pickerCaptor.getAllValues().get(1)); assertThat(weightedPicker.getChildren().size()).isEqualTo(2); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); int expectedTasks = isEnabledHappyEyeballs() ? 2 : 1; @@ -677,10 +694,12 @@ weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).on .setAddresses(servers).setLoadBalancingPolicyConfig(weightedConfig) .setAttributes(affinity).build())); assertThat(getNumFilteredPendingTasks()).isEqualTo(1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); //timer fires, new weight updated @@ -709,13 +728,15 @@ public void weightExpired() { verify(helper, times(2)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + getWrrPicker(pickerCaptor.getAllValues().get(1)); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); int expectedTasks = isEnabledHappyEyeballs() ? 2 : 1; @@ -760,7 +781,7 @@ public void rrFallback() { verify(helper, times(2)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + getWrrPicker(pickerCaptor.getAllValues().get(1)); int expectedTasks = isEnabledHappyEyeballs() ? 2 : 1; assertThat(fakeClock.forwardTime(10, TimeUnit.SECONDS)).isEqualTo(expectedTasks); Map qpsByChannel = ImmutableMap.of(servers.get(0), 2, @@ -815,13 +836,15 @@ public void unknownWeightIsAvgWeight() { verify(helper, times(3)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(2); + getWrrPicker(pickerCaptor.getAllValues().get(2)); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); assertThat(fakeClock.forwardTime(10, TimeUnit.SECONDS)).isEqualTo(1); @@ -856,13 +879,15 @@ public void pickFromOtherThread() throws Exception { verify(helper, times(2)).updateBalancingState( eq(ConnectivityState.READY), pickerCaptor.capture()); WeightedRoundRobinPicker weightedPicker = - (WeightedRoundRobinPicker) pickerCaptor.getAllValues().get(1); + getWrrPicker(pickerCaptor.getAllValues().get(1)); WeightedChildLbState weightedChild1 = (WeightedChildLbState) getChild(weightedPicker, 0); WeightedChildLbState weightedChild2 = (WeightedChildLbState) getChild(weightedPicker, 1); - weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild1.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.1, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); - weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty).onLoadReport( + weightedChild2.new OrcaReportListener(weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization).onLoadReport( InternalCallMetricRecorder.createMetricReport( 0.2, 0, 0.1, 1, 0, new HashMap<>(), new HashMap<>(), new HashMap<>())); CyclicBarrier barrier = new CyclicBarrier(2); @@ -1097,7 +1122,7 @@ public void testImmediateWraparound() { .isLessThan(0.002); } } - + @Test public void testWraparound() { float[] weights = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f}; @@ -1198,22 +1223,25 @@ public void metrics() { // Send one child LB state an ORCA update with some valid utilization/qps data so that weights // can be calculated, but it's still essentially round_robin Iterator childLbStates = wrr.getChildLbStates().iterator(); - ((WeightedChildLbState)childLbStates.next()).new OrcaReportListener( - weightedConfig.errorUtilizationPenalty).onLoadReport( - InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, new HashMap<>(), - new HashMap<>(), new HashMap<>())); + ((WeightedChildLbState) childLbStates.next()).new OrcaReportListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization) + .onLoadReport(InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), new HashMap<>())); fakeClock.forwardTime(1, TimeUnit.SECONDS); // Now send a second child LB state an ORCA update, so there's real weights - ((WeightedChildLbState)childLbStates.next()).new OrcaReportListener( - weightedConfig.errorUtilizationPenalty).onLoadReport( - InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, new HashMap<>(), - new HashMap<>(), new HashMap<>())); - ((WeightedChildLbState)childLbStates.next()).new OrcaReportListener( - weightedConfig.errorUtilizationPenalty).onLoadReport( - InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, new HashMap<>(), - new HashMap<>(), new HashMap<>())); + ((WeightedChildLbState) childLbStates.next()).new OrcaReportListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization) + .onLoadReport(InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), new HashMap<>())); + ((WeightedChildLbState) childLbStates.next()).new OrcaReportListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization) + .onLoadReport(InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), new HashMap<>())); // Let's reset the mock MetricsRecorder so that it's easier to verify what happened after the // weights were updated @@ -1303,14 +1331,266 @@ public void metricWithRealChannel() throws Exception { assertThat(recorder.getError()).isNull(); // Make sure at least one metric works. The other tests will make sure other metrics and the - // edge cases are working. - verify(metrics).addLongCounter( + // edge cases are working. Since this is racy, we just care it happened at least once. + verify(metrics, atLeast(1)).addLongCounter( argThat((instr) -> instr.getName().equals("grpc.lb.wrr.rr_fallback")), eq(1L), eq(Arrays.asList("directaddress:///wrr-metrics")), eq(Arrays.asList("", ""))); } + + @Test + public void customMetric_priority_overAppUtil() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization(ImmutableList.of("named_metrics.cost")).build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", 0.5); + // App util = 0.8 + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0.8, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + // Custom metrics now take priority over app_util + // qps=1, util=0.5 -> weight=2.0 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(2.0), any(), + any()); + } + + @Test + public void customMetric_invalid_fallbackToAppUtil() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization(ImmutableList.of("named_metrics.cost")).build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + // custom metric is NaN, App util = 0.8 + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", Double.NaN); + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0.8, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + + // Should fallback to App Util (0.8) + // qps=1, util=0.8 -> weight=1.25 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(1.25), any(), + any()); + } + + @Test + public void customMetric_mapLookup_used() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization(ImmutableList.of("named_metrics.cost")).build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", 0.5); + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + // qps=1, util=0.5 -> weight=2.0 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(2.0), any(), + any()); + } + + @Test + public void customMetric_shouldFilterOutAndFallbackToCpu() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization(ImmutableList.of("named_metrics.cost")).build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + // custom metric is NaN, but CPU is 0.1 + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", Double.NaN); + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + + // Should fallback to CPU (0.1) + // fallback to cpu: qps=1, util=0.1 -> weight=10.0 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(10.0), any(), + any()); + } + + @Test + public void customMetric_multipleMetrics_maxUsed() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization( + ImmutableList.of("named_metrics.cost", "named_metrics.score")) + .build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", 0.5); + namedMetrics.put("score", 0.8); + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + // qps=1, util=0.8 (max of 0.5 and 0.8) -> weight=1.25 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(1.25), any(), + any()); + } + + @Test + public void customMetric_allInvalid_fallbackToCpu() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization( + ImmutableList.of("named_metrics.cost", "named_metrics.score", "named_metrics.other")) + .build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", Double.NaN); + namedMetrics.put("score", 0.0); + namedMetrics.put("other", -1.0); + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + // qps=1, util=0.1 (fallback to cpu) -> weight=10.0 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(10.0), any(), + any()); + } + + @Test + public void customMetric_mixInvalidAndValid_validUsed() { + weightedConfig = WeightedRoundRobinLoadBalancerConfig.newBuilder().setBlackoutPeriodNanos(0) + .setMetricNamesForComputingUtilization(ImmutableList.of("named_metrics.cost", + "named_metrics.score", "named_metrics.other1", "named_metrics.other2")) + .build(); + wrr = new WeightedRoundRobinLoadBalancer(helper, fakeClock.getDeadlineTicker()); + + syncContext.execute( + () -> wrr.acceptResolvedAddresses(ResolvedAddresses.newBuilder().setAddresses(servers) + .setLoadBalancingPolicyConfig(weightedConfig).setAttributes(affinity).build())); + + Iterator it = subchannels.values().iterator(); + Subchannel readySubchannel = it.next(); + getSubchannelStateListener(readySubchannel) + .onSubchannelState(ConnectivityStateInfo.forNonError(ConnectivityState.READY)); + + WeightedChildLbState weightedChild = + (WeightedChildLbState) wrr.getChildLbStates().iterator().next(); + WeightedChildLbState.OrcaReportListener listener = weightedChild.getOrCreateOrcaListener( + weightedConfig.errorUtilizationPenalty, + weightedConfig.parsedMetricNamesForComputingUtilization); + + Map namedMetrics = new HashMap<>(); + namedMetrics.put("cost", Double.NaN); + namedMetrics.put("score", 0.5); + namedMetrics.put("other1", 0.0); + namedMetrics.put("other2", -123.0); + MetricReport report = InternalCallMetricRecorder.createMetricReport(0.1, 0, 0.1, 1, 0, + new HashMap<>(), new HashMap<>(), namedMetrics); + listener.onLoadReport(report); + // qps=1, util=0.5 -> weight=2.0 + fakeClock.forwardTime(1100, TimeUnit.MILLISECONDS); + verify(mockMetricRecorder).recordDoubleHistogram( + argThat(instr -> instr.getName().equals("grpc.lb.wrr.endpoint_weights")), eq(2.0), any(), + any()); + } + // Verifies that the MetricRecorder has been called to record a long counter value of 1 for the // given metric name, the given number of times private void verifyLongCounterRecord(String name, int times, long value) { diff --git a/xds/src/test/java/io/grpc/xds/XdsClientFallbackTest.java b/xds/src/test/java/io/grpc/xds/XdsClientFallbackTest.java index 1e7ce6dc2a2..27ee8d22825 100644 --- a/xds/src/test/java/io/grpc/xds/XdsClientFallbackTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsClientFallbackTest.java @@ -20,7 +20,7 @@ import static com.google.common.truth.Truth.assertWithMessage; import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -34,9 +34,13 @@ import io.grpc.Grpc; import io.grpc.MetricRecorder; import io.grpc.Status; +import io.grpc.StatusOr; import io.grpc.internal.ExponentialBackoffPolicy; import io.grpc.internal.FakeClock; import io.grpc.internal.ObjectPool; +import io.grpc.xds.XdsClusterResource.CdsUpdate; +import io.grpc.xds.XdsListenerResource.LdsUpdate; +import io.grpc.xds.XdsRouteConfigureResource.RdsUpdate; import io.grpc.xds.client.Bootstrapper; import io.grpc.xds.client.CommonBootstrapperTestUtils; import io.grpc.xds.client.LoadReportClient; @@ -98,25 +102,25 @@ public class XdsClientFallbackTest { private XdsClientMetricReporter xdsClientMetricReporter; @Captor - private ArgumentCaptor errorCaptor; - + private ArgumentCaptor> ldsUpdateCaptor; + @Captor + private ArgumentCaptor> rdsUpdateCaptor; private final XdsClient.ResourceWatcher raalLdsWatcher = - new XdsClient.ResourceWatcher() { - - @Override - public void onChanged(XdsListenerResource.LdsUpdate update) { - log.log(Level.FINE, "LDS update: " + update); - } + new XdsClient.ResourceWatcher() { @Override - public void onError(Status error) { - log.log(Level.FINE, "LDS update error: " + error.getDescription()); + public void onResourceChanged(StatusOr update) { + if (update.hasValue()) { + log.log(Level.FINE, "LDS update: " + update.getValue()); + } else { + log.log(Level.FINE, "LDS resource error: " + update.getStatus().getDescription()); + } } @Override - public void onResourceDoesNotExist(String resourceName) { - log.log(Level.FINE, "LDS resource does not exist: " + resourceName); + public void onAmbientError(Status error) { + log.log(Level.FINE, "LDS ambient error: " + error.getDescription()); } }; @@ -133,30 +137,30 @@ public void onResourceDoesNotExist(String resourceName) { @Mock private XdsClient.ResourceWatcher rdsWatcher3; - private final XdsClient.ResourceWatcher raalCdsWatcher = - new XdsClient.ResourceWatcher() { - - @Override - public void onChanged(XdsClusterResource.CdsUpdate update) { - log.log(Level.FINE, "CDS update: " + update); - } + private final XdsClient.ResourceWatcher raalCdsWatcher = + new XdsClient.ResourceWatcher() { @Override - public void onError(Status error) { - log.log(Level.FINE, "CDS update error: " + error.getDescription()); + public void onResourceChanged(StatusOr update) { + if (update.hasValue()) { + log.log(Level.FINE, "CDS update: " + update.getValue()); + } else { + log.log(Level.FINE, "CDS resource error: " + update.getStatus().getDescription()); + } } @Override - public void onResourceDoesNotExist(String resourceName) { - log.log(Level.FINE, "CDS resource does not exist: " + resourceName); + public void onAmbientError(Status error) { + // Logic from the old onError method for transient errors. + log.log(Level.FINE, "CDS ambient error: " + error.getDescription()); } }; @SuppressWarnings("unchecked") - private final XdsClient.ResourceWatcher cdsWatcher = + private final XdsClient.ResourceWatcher cdsWatcher = mock(XdsClient.ResourceWatcher.class, delegatesTo(raalCdsWatcher)); @Mock - private XdsClient.ResourceWatcher cdsWatcher2; + private XdsClient.ResourceWatcher cdsWatcher2; @Rule(order = 0) public ControlPlaneRule mainXdsServer = @@ -178,14 +182,16 @@ public void setUp() throws XdsInitializationException { setAdsConfig(fallbackServer, FALLBACK_SERVER); SharedXdsClientPoolProvider clientPoolProvider = new SharedXdsClientPoolProvider(); - clientPoolProvider.setBootstrapOverride(defaultBootstrapOverride()); - xdsClientPool = clientPoolProvider.getOrCreate(DUMMY_TARGET, metricRecorder); + xdsClientPool = clientPoolProvider.getOrCreate( + DUMMY_TARGET, + new GrpcBootstrapperImpl().bootstrap(defaultBootstrapOverride()), + metricRecorder); } @After public void cleanUp() { - if (xdsClientPool != null) { - xdsClientPool.returnObject(xdsClient); + if (xdsClient != null) { + xdsClient = xdsClientPool.returnObject(xdsClient); } CommonBootstrapperTestUtils.setEnableXdsFallback(originalEnableXdsFallback); } @@ -221,12 +227,14 @@ public void everything_okay() { fallbackServer.restartXdsServer(); xdsClient = xdsClientPool.getObject(); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener( - MAIN_HTTP_CONNECTION_MANAGER)); + verify(ldsWatcher, timeout(5000)).onResourceChanged(ldsUpdateCaptor.capture()); + assertThat(ldsUpdateCaptor.getValue().hasValue()).isTrue(); + assertThat(ldsUpdateCaptor.getValue().getValue()).isEqualTo( + LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_NAME, rdsWatcher); - verify(rdsWatcher, timeout(5000)).onChanged(any()); + verify(rdsWatcher, timeout(5000)).onResourceChanged(rdsUpdateCaptor.capture()); + assertThat(rdsUpdateCaptor.getValue().hasValue()).isTrue(); } @Test @@ -238,9 +246,9 @@ public void mainServerDown_fallbackServerUp() { xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener( - FALLBACK_HTTP_CONNECTION_MANAGER)); + verify(ldsWatcher, timeout(5000)).onResourceChanged( + StatusOr.fromValue(XdsListenerResource.LdsUpdate.forApiListener( + FALLBACK_HTTP_CONNECTION_MANAGER))); } @Test @@ -251,7 +259,8 @@ public void useBadAuthority() { String badPrefix = "xdstp://authority.xds.bad/envoy.config.listener.v3.Listener/"; xdsClient.watchXdsResource(XdsListenerResource.getInstance(), badPrefix + "listener.googleapis.com", ldsWatcher); - inOrder.verify(ldsWatcher, timeout(5000)).onError(any()); + inOrder.verify(ldsWatcher, timeout(5000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue())); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), badPrefix + "route-config.googleapis.bad", rdsWatcher); @@ -259,14 +268,20 @@ public void useBadAuthority() { badPrefix + "route-config2.googleapis.bad", rdsWatcher2); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), badPrefix + "route-config3.googleapis.bad", rdsWatcher3); - inOrder.verify(rdsWatcher, timeout(5000).times(1)).onError(any()); - inOrder.verify(rdsWatcher2, timeout(5000).times(1)).onError(any()); - inOrder.verify(rdsWatcher3, timeout(5000).times(1)).onError(any()); - verify(rdsWatcher, never()).onChanged(any()); + inOrder.verify(rdsWatcher, timeout(5000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue())); + inOrder.verify(rdsWatcher2, timeout(5000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue())); + inOrder.verify(rdsWatcher3, timeout(5000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue())); + verify(rdsWatcher, never()).onResourceChanged(argThat(StatusOr::hasValue)); // even after an error, a valid one will still work xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher2); - verify(ldsWatcher2, timeout(5000)).onChanged( + verify(ldsWatcher2, timeout(5000)).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOr = ldsUpdateCaptor.getValue(); + assertThat(statusOr.hasValue()).isTrue(); + assertThat(statusOr.getValue()).isEqualTo( XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); } @@ -277,21 +292,24 @@ public void both_down_restart_main() { xdsClient = xdsClientPool.getObject(); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000).atLeastOnce()).onError(any()); - verify(ldsWatcher, timeout(5000).times(0)).onChanged(any()); + verify(ldsWatcher, timeout(5000).atLeastOnce()) + .onResourceChanged(argThat(statusOr -> !statusOr.hasValue())); + verify(ldsWatcher, never()).onResourceChanged(argThat(StatusOr::hasValue)); xdsClient.watchXdsResource( XdsRouteConfigureResource.getInstance(), RDS_NAME, rdsWatcher2); - verify(rdsWatcher2, timeout(5000).atLeastOnce()).onError(any()); + verify(rdsWatcher2, timeout(5000).atLeastOnce()) + .onResourceChanged(argThat(statusOr -> !statusOr.hasValue())); mainXdsServer.restartXdsServer(); xdsClient.watchXdsResource( XdsRouteConfigureResource.getInstance(), RDS_NAME, rdsWatcher); - verify(ldsWatcher, timeout(16000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); - verify(rdsWatcher, timeout(5000)).onChanged(any()); - verify(rdsWatcher2, timeout(5000)).onChanged(any()); + verify(ldsWatcher, timeout(16000)).onResourceChanged( + argThat(statusOr -> statusOr.hasValue() && statusOr.getValue().equals( + XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)))); + verify(rdsWatcher, timeout(5000)).onResourceChanged(argThat(StatusOr::hasValue)); + verify(rdsWatcher2, timeout(5000)).onResourceChanged(argThat(StatusOr::hasValue)); } @Test @@ -302,10 +320,16 @@ public void mainDown_fallbackUp_restart_main() { InOrder inOrder = inOrder(ldsWatcher, rdsWatcher, cdsWatcher, cdsWatcher2); xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - inOrder.verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER)); + inOrder.verify(ldsWatcher, timeout(5000)).onResourceChanged( + StatusOr.fromValue(XdsListenerResource.LdsUpdate.forApiListener( + FALLBACK_HTTP_CONNECTION_MANAGER))); + + // Watch another resource, also from the fallback server. xdsClient.watchXdsResource(XdsClusterResource.getInstance(), FALLBACK_CLUSTER_NAME, cdsWatcher); - inOrder.verify(cdsWatcher, timeout(5000)).onChanged(any()); + @SuppressWarnings("unchecked") + ArgumentCaptor> cdsUpdateCaptor1 = ArgumentCaptor.forClass(StatusOr.class); + inOrder.verify(cdsWatcher, timeout(5000)).onResourceChanged(cdsUpdateCaptor1.capture()); + assertThat(cdsUpdateCaptor1.getValue().getStatus().isOk()).isTrue(); assertThat(fallbackServer.getService().getSubscriberCounts() .get("type.googleapis.com/envoy.config.listener.v3.Listener")).isEqualTo(1); @@ -313,15 +337,26 @@ public void mainDown_fallbackUp_restart_main() { mainXdsServer.restartXdsServer(); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); + // The existing ldsWatcher should receive a new update from the main server. + // Note: This is not an inOrder verification because the timing of the switchover + // can vary. We just need to verify it happens. + verify(ldsWatcher, timeout(5000)).onResourceChanged( + StatusOr.fromValue(XdsListenerResource.LdsUpdate.forApiListener( + MAIN_HTTP_CONNECTION_MANAGER))); + // Watch a new resource; should now come from the main server. xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_NAME, rdsWatcher); - inOrder.verify(rdsWatcher, timeout(5000)).onChanged(any()); + @SuppressWarnings("unchecked") + ArgumentCaptor> rdsUpdateCaptor = ArgumentCaptor.forClass(StatusOr.class); + inOrder.verify(rdsWatcher, timeout(5000)).onResourceChanged(rdsUpdateCaptor.capture()); + assertThat(rdsUpdateCaptor.getValue().getStatus().isOk()).isTrue(); verifyNoSubscribers(fallbackServer); xdsClient.watchXdsResource(XdsClusterResource.getInstance(), CLUSTER_NAME, cdsWatcher2); - inOrder.verify(cdsWatcher2, timeout(5000)).onChanged(any()); + @SuppressWarnings("unchecked") + ArgumentCaptor> cdsUpdateCaptor2 = ArgumentCaptor.forClass(StatusOr.class); + inOrder.verify(cdsWatcher2, timeout(5000)).onResourceChanged(cdsUpdateCaptor2.capture()); + assertThat(cdsUpdateCaptor2.getValue().getStatus().isOk()).isTrue(); verifyNoSubscribers(fallbackServer); assertThat(mainXdsServer.getService().getSubscriberCounts() @@ -361,11 +396,12 @@ public XdsTransport create(Bootstrapper.ServerInfo serverInfo) { xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); + // Initial resource fetch from the main server + verify(ldsWatcher, timeout(5000)).onResourceChanged( + StatusOr.fromValue(LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER))); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_NAME, rdsWatcher); - verify(rdsWatcher, timeout(5000)).onChanged(any()); + verify(rdsWatcher, timeout(5000)).onResourceChanged(argThat(StatusOr::hasValue)); mainXdsServer.getServer().shutdownNow(); // Sleep for the ADS stream disconnect to be processed and for the retry to fail. Between those @@ -377,38 +413,43 @@ public XdsTransport create(Bootstrapper.ServerInfo serverInfo) { } // Shouldn't do fallback since all watchers are loaded - verify(ldsWatcher, never()).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER)); + verify(ldsWatcher, never()).onResourceChanged(StatusOr.fromValue( + XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER))); // Should just get from cache xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher2); xdsClient.watchXdsResource(XdsRouteConfigureResource.getInstance(), RDS_NAME, rdsWatcher2); - verify(ldsWatcher2, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); - verify(ldsWatcher, never()).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER)); + verify(ldsWatcher2, timeout(5000)).onResourceChanged(StatusOr.fromValue( + XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER))); + verify(ldsWatcher, never()).onResourceChanged(StatusOr.fromValue( + XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER))); // Make sure that rdsWatcher wasn't called again - verify(rdsWatcher, times(1)).onChanged(any()); - verify(rdsWatcher2, timeout(5000)).onChanged(any()); + verify(rdsWatcher, times(1)).onResourceChanged(any()); + verify(rdsWatcher2, timeout(5000)).onResourceChanged(argThat(StatusOr::hasValue)); // Asking for something not in cache should force a fallback xdsClient.watchXdsResource(XdsClusterResource.getInstance(), FALLBACK_CLUSTER_NAME, cdsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER)); - verify(ldsWatcher2, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER)); - verify(cdsWatcher, timeout(5000)).onChanged(any()); + verify(ldsWatcher, timeout(5000)).onResourceChanged(StatusOr.fromValue( + XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER))); + verify(ldsWatcher2, timeout(5000)).onResourceChanged(StatusOr.fromValue( + XdsListenerResource.LdsUpdate.forApiListener(FALLBACK_HTTP_CONNECTION_MANAGER))); + verify(cdsWatcher, timeout(5000)).onResourceChanged(argThat(StatusOr::hasValue)); xdsClient.watchXdsResource( XdsRouteConfigureResource.getInstance(), FALLBACK_RDS_NAME, rdsWatcher3); - verify(rdsWatcher3, timeout(5000)).onChanged(any()); + verify(rdsWatcher3, timeout(5000)).onResourceChanged(argThat(StatusOr::hasValue)); // Test that resource defined in main but not fallback is handled correctly xdsClient.watchXdsResource( XdsClusterResource.getInstance(), CLUSTER_NAME, cdsWatcher2); - verify(cdsWatcher2, never()).onResourceDoesNotExist(eq(CLUSTER_NAME)); + verify(cdsWatcher2, never()).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.NOT_FOUND)); fakeClock.forwardTime(15000, TimeUnit.MILLISECONDS); // Does not exist timer - verify(cdsWatcher2, timeout(5000)).onResourceDoesNotExist(eq(CLUSTER_NAME)); + verify(cdsWatcher2, timeout(5000)).onResourceChanged( + argThat(statusOr -> !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.NOT_FOUND + && statusOr.getStatus().getDescription().contains(CLUSTER_NAME))); xdsClient.shutdown(); executor.shutdown(); } @@ -417,22 +458,21 @@ public XdsTransport create(Bootstrapper.ServerInfo serverInfo) { public void connect_then_mainServerRestart_fallbackServerdown() { mainXdsServer.restartXdsServer(); xdsClient = xdsClientPool.getObject(); - xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); - + verify(ldsWatcher, timeout(5000)).onResourceChanged( + argThat(statusOr -> statusOr.hasValue() && statusOr.getValue().equals( + LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)))); mainXdsServer.getServer().shutdownNow(); fallbackServer.getServer().shutdownNow(); - xdsClient.watchXdsResource(XdsClusterResource.getInstance(), CLUSTER_NAME, cdsWatcher); - mainXdsServer.restartXdsServer(); - verify(cdsWatcher, timeout(5000)).onChanged(any()); - verify(ldsWatcher, timeout(5000).atLeastOnce()).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); + verify(cdsWatcher, timeout(5000)).onResourceChanged( + argThat(statusOr -> statusOr.hasValue())); + verify(ldsWatcher, timeout(5000).atLeastOnce()).onResourceChanged( + argThat(statusOr -> statusOr.hasValue() && statusOr.getValue().equals( + LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)))); } @Test @@ -452,10 +492,10 @@ public void fallbackFromBadUrlToGoodOne() { client.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); fakeClock.forwardTime(20, TimeUnit.SECONDS); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener( - MAIN_HTTP_CONNECTION_MANAGER)); - verify(ldsWatcher, never()).onError(any()); + verify(ldsWatcher, timeout(5000)).onResourceChanged( + StatusOr.fromValue(XdsListenerResource.LdsUpdate.forApiListener( + MAIN_HTTP_CONNECTION_MANAGER))); + verify(ldsWatcher, never()).onAmbientError(any(Status.class)); client.shutdown(); } @@ -476,10 +516,13 @@ public void testGoodUrlFollowedByBadUrl() { xdsClientMetricReporter); client.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener( - MAIN_HTTP_CONNECTION_MANAGER)); - verify(ldsWatcher, never()).onError(any()); + verify(ldsWatcher, timeout(5000)).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOr = ldsUpdateCaptor.getValue(); + assertThat(statusOr.hasValue()).isTrue(); + assertThat(statusOr.getValue()).isEqualTo( + XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); + verify(ldsWatcher, never()).onAmbientError(any()); + verify(ldsWatcher, times(1)).onResourceChanged(any()); client.shutdown(); } @@ -501,9 +544,12 @@ public void testTwoBadUrl() { client.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); fakeClock.forwardTime(20, TimeUnit.SECONDS); - verify(ldsWatcher, Mockito.timeout(5000).atLeastOnce()).onError(errorCaptor.capture()); - assertThat(errorCaptor.getValue().getDescription()).contains(garbageUri2); - verify(ldsWatcher, never()).onChanged(any()); + verify(ldsWatcher, Mockito.timeout(5000).atLeastOnce()) + .onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOr = ldsUpdateCaptor.getValue(); + assertThat(statusOr.hasValue()).isFalse(); + assertThat(statusOr.getStatus().getDescription()).contains(garbageUri2); + verify(ldsWatcher, never()).onResourceChanged(argThat(StatusOr::hasValue)); client.shutdown(); } @@ -523,8 +569,8 @@ public void used_then_mainServerRestart_fallbackServerUp() { xdsClient.watchXdsResource(XdsListenerResource.getInstance(), MAIN_SERVER, ldsWatcher); - verify(ldsWatcher, timeout(5000)).onChanged( - XdsListenerResource.LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER)); + verify(ldsWatcher, timeout(5000)).onResourceChanged( + StatusOr.fromValue(LdsUpdate.forApiListener(MAIN_HTTP_CONNECTION_MANAGER))); mainXdsServer.restartXdsServer(); @@ -533,7 +579,7 @@ public void used_then_mainServerRestart_fallbackServerUp() { xdsClient.watchXdsResource(XdsClusterResource.getInstance(), CLUSTER_NAME, cdsWatcher); - verify(cdsWatcher, timeout(5000)).onChanged(any()); + verify(cdsWatcher, timeout(5000)).onResourceChanged(any()); assertThat(getLrsServerInfo("localhost:" + fallbackServer.getServer().getPort())).isNull(); } @@ -561,5 +607,4 @@ public void used_then_mainServerRestart_fallbackServerUp() { "fallback-policy", "fallback" ); } - } diff --git a/xds/src/test/java/io/grpc/xds/XdsClientFederationTest.java b/xds/src/test/java/io/grpc/xds/XdsClientFederationTest.java index b2b713e9a8e..da310871c25 100644 --- a/xds/src/test/java/io/grpc/xds/XdsClientFederationTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsClientFederationTest.java @@ -17,6 +17,9 @@ package io.grpc.xds; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -24,6 +27,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import io.grpc.MetricRecorder; +import io.grpc.Status; +import io.grpc.StatusOr; import io.grpc.internal.ObjectPool; import io.grpc.xds.Filter.NamedFilterConfig; import io.grpc.xds.XdsListenerResource.LdsUpdate; @@ -46,6 +51,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; @@ -79,8 +85,10 @@ public class XdsClientFederationTest { @Before public void setUp() throws XdsInitializationException { SharedXdsClientPoolProvider clientPoolProvider = new SharedXdsClientPoolProvider(); - clientPoolProvider.setBootstrapOverride(defaultBootstrapOverride()); - xdsClientPool = clientPoolProvider.getOrCreate(DUMMY_TARGET, metricRecorder); + xdsClientPool = clientPoolProvider.getOrCreate( + DUMMY_TARGET, + new GrpcBootstrapperImpl().bootstrap(defaultBootstrapOverride()), + metricRecorder); xdsClient = xdsClientPool.getObject(); } @@ -105,14 +113,19 @@ public void isolatedResourceDeletions() { xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "xdstp://server-one/envoy.config.listener.v3.Listener/test-server", mockDirectPathWatcher); - verify(mockWatcher, timeout(10000)).onChanged( - LdsUpdate.forApiListener( - HttpConnectionManager.forRdsName(0, "route-config.googleapis.com", ImmutableList.of( - new NamedFilterConfig("terminal-filter", RouterFilter.ROUTER_CONFIG))))); - verify(mockDirectPathWatcher, timeout(10000)).onChanged( - LdsUpdate.forApiListener( - HttpConnectionManager.forRdsName(0, "route-config.googleapis.com", ImmutableList.of( - new NamedFilterConfig("terminal-filter", RouterFilter.ROUTER_CONFIG))))); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(StatusOr.class); + LdsUpdate expectedUpdate = LdsUpdate.forApiListener( + HttpConnectionManager.forRdsName(0, "route-config.googleapis.com", ImmutableList.of( + new NamedFilterConfig("terminal-filter", RouterFilter.ROUTER_CONFIG)))); + + verify(mockWatcher, timeout(10000)).onResourceChanged(captor.capture()); + assertThat(captor.getValue().hasValue()).isTrue(); + assertThat(captor.getValue().getValue()).isEqualTo(expectedUpdate); + + verify(mockDirectPathWatcher, timeout(10000)).onResourceChanged(captor.capture()); + assertThat(captor.getValue().hasValue()).isTrue(); + assertThat(captor.getValue().getValue()).isEqualTo(expectedUpdate); // By setting the LDS config with a new server name we effectively make the old server to go // away as it is not in the configuration anymore. This change in one control plane (here the @@ -120,9 +133,13 @@ public void isolatedResourceDeletions() { // watcher of another control plane (here the DirectPath one). trafficdirector.setLdsConfig(ControlPlaneRule.buildServerListener(), ControlPlaneRule.buildClientListener("new-server")); - verify(mockWatcher, timeout(20000)).onResourceDoesNotExist("test-server"); - verify(mockDirectPathWatcher, times(0)).onResourceDoesNotExist( - "xdstp://server-one/envoy.config.listener.v3.Listener/test-server"); + verify(mockWatcher, timeout(20000)).onResourceChanged(argThat(statusOr -> { + return !statusOr.hasValue() + && statusOr.getStatus().getCode() == Status.Code.NOT_FOUND + && statusOr.getStatus().getDescription().contains("test-server"); + })); + verify(mockDirectPathWatcher, times(1)).onResourceChanged(any()); + verify(mockDirectPathWatcher, never()).onAmbientError(any()); } /** @@ -154,7 +171,6 @@ public void lrsClientsStartedForLocalityStats() throws InterruptedException, Exe } } - /** * Assures that when an {@link XdsClient} is asked to add cluster locality stats it appropriately * starts {@link LoadReportClient}s to do that. diff --git a/xds/src/test/java/io/grpc/xds/XdsClientWrapperForServerSdsTestMisc.java b/xds/src/test/java/io/grpc/xds/XdsClientWrapperForServerSdsTestMisc.java index ff97afe6916..81186d0639c 100644 --- a/xds/src/test/java/io/grpc/xds/XdsClientWrapperForServerSdsTestMisc.java +++ b/xds/src/test/java/io/grpc/xds/XdsClientWrapperForServerSdsTestMisc.java @@ -36,6 +36,7 @@ import io.grpc.Server; import io.grpc.ServerBuilder; import io.grpc.Status; +import io.grpc.StatusOr; import io.grpc.inprocess.InProcessSocketAddress; import io.grpc.internal.TestUtils.NoopChannelLogger; import io.grpc.netty.GrpcHttp2ConnectionHandler; @@ -120,7 +121,8 @@ public void setUp() { when(mockBuilder.build()).thenReturn(mockServer); when(mockServer.isShutdown()).thenReturn(false); xdsServerWrapper = new XdsServerWrapper("0.0.0.0:" + PORT, mockBuilder, listener, - selectorManager, new FakeXdsClientPoolFactory(xdsClient), FilterRegistry.newRegistry()); + selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, FilterRegistry.newRegistry()); } @Test @@ -171,7 +173,7 @@ public void run() { null, Protocol.TCP); LdsUpdate listenerUpdate = LdsUpdate.forTcpListener(tcpListener); - xdsClient.ldsWatcher.onChanged(listenerUpdate); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromValue(listenerUpdate)); verify(listener, timeout(5000)).onServing(); start.get(START_WAIT_AFTER_LISTENER_MILLIS, TimeUnit.MILLISECONDS); FilterChainSelector selector = selectorManager.getSelectorToUpdateSelector(); @@ -192,7 +194,8 @@ public void run() { } }); String ldsWatched = xdsClient.ldsResource.get(5, TimeUnit.SECONDS); - xdsClient.ldsWatcher.onResourceDoesNotExist(ldsWatched); + Status status = Status.NOT_FOUND.withDescription("Resource not found: " + ldsWatched); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(status)); verify(listener, timeout(5000)).onNotServing(any()); try { start.get(START_WAIT_AFTER_LISTENER_MILLIS, TimeUnit.MILLISECONDS); @@ -277,7 +280,8 @@ public void releaseOldSupplierOnNotFound_verifyClose() throws Exception { getSslContextProviderSupplier(selectorManager.getSelectorToUpdateSelector()); assertThat(returnedSupplier.getTlsContext()).isSameInstanceAs(tlsContext1); callUpdateSslContext(returnedSupplier); - xdsClient.ldsWatcher.onResourceDoesNotExist("not-found Error"); + Status status = Status.NOT_FOUND.withDescription("not-found Error"); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(status)); verify(tlsContextManager, times(1)).releaseServerSslContextProvider(eq(sslContextProvider1)); } @@ -294,14 +298,14 @@ public void releaseOldSupplierOnTemporaryError_noClose() throws Exception { getSslContextProviderSupplier(selectorManager.getSelectorToUpdateSelector()); assertThat(returnedSupplier.getTlsContext()).isSameInstanceAs(tlsContext1); callUpdateSslContext(returnedSupplier); - xdsClient.ldsWatcher.onError(Status.CANCELLED); + xdsClient.ldsWatcher.onAmbientError(Status.CANCELLED); verify(tlsContextManager, never()).releaseServerSslContextProvider(eq(sslContextProvider1)); } private void callUpdateSslContext(SslContextProviderSupplier sslContextProviderSupplier) { assertThat(sslContextProviderSupplier).isNotNull(); SslContextProvider.Callback callback = mock(SslContextProvider.Callback.class); - sslContextProviderSupplier.updateSslContext(callback); + sslContextProviderSupplier.updateSslContext(callback, false); } private void sendListenerUpdate( diff --git a/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java b/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java index fca7b5220e6..522eb29c001 100644 --- a/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsDependencyManagerTest.java @@ -409,6 +409,32 @@ public void testTcpListenerErrors() { testWatcher.verifyStats(0, 1); } + @Test + public void testControlPlaneError() { + Status forcedStatus = Status.NOT_FOUND + .withDescription("expected") + .withCause(new IllegalArgumentException("a random exception")); + xdsClient.shutdown(); + xdsClient = XdsTestUtils.createXdsClient( + Collections.singletonList("control-plane"), + serverInfo -> new GrpcXdsTransportFactory.GrpcXdsTransport( + InProcessChannelBuilder.forName(serverInfo.target()) + .directExecutor() + .intercept(new FailingClientInterceptor(forcedStatus)) + .build()), + fakeClock); + xdsDependencyManager = new XdsDependencyManager( + xdsClient, syncContext, serverName, serverName, nameResolverArgs); + xdsDependencyManager.start(xdsConfigWatcher); + + verify(xdsConfigWatcher).onUpdate( + argThat(StatusOrMatcher.hasStatus( + statusHasCode(Status.Code.UNAVAILABLE) + .andDescriptionContains(forcedStatus.getDescription()) + .andCause(forcedStatus.getCause())))); + testWatcher.verifyStats(0, 1); + } + @Test public void testMissingRds() { String rdsName = "badRdsName"; @@ -853,7 +879,7 @@ public void ldsUpdateAfterShutdown() { serverName, resourceWatcher, MoreExecutors.directExecutor()); - verify(resourceWatcher).onChanged(any()); + verify(resourceWatcher).onResourceChanged(argThat(StatusOr::hasValue)); syncContext.execute(() -> { // Shutdown before any updates. This will unsubscribe from XdsClient, but only after this @@ -862,7 +888,7 @@ public void ldsUpdateAfterShutdown() { XdsTestUtils.setAdsConfig(controlPlaneService, serverName, "RDS2", "CDS", "EDS", ENDPOINT_HOSTNAME, ENDPOINT_PORT); - verify(resourceWatcher, times(2)).onChanged(any()); + verify(resourceWatcher, times(2)).onResourceChanged(argThat(StatusOr::hasValue)); xdsClient.cancelXdsResourceWatch( XdsListenerResource.getInstance(), serverName, resourceWatcher); }); @@ -885,7 +911,7 @@ public void rdsUpdateAfterShutdown() { "RDS", resourceWatcher, MoreExecutors.directExecutor()); - verify(resourceWatcher).onChanged(any()); + verify(resourceWatcher).onResourceChanged(argThat(StatusOr::hasValue)); syncContext.execute(() -> { // Shutdown before any updates. This will unsubscribe from XdsClient, but only after this @@ -894,7 +920,7 @@ public void rdsUpdateAfterShutdown() { XdsTestUtils.setAdsConfig(controlPlaneService, serverName, "RDS", "CDS2", "EDS", ENDPOINT_HOSTNAME, ENDPOINT_PORT); - verify(resourceWatcher, times(2)).onChanged(any()); + verify(resourceWatcher, times(2)).onResourceChanged(argThat(StatusOr::hasValue)); xdsClient.cancelXdsResourceWatch( XdsRouteConfigureResource.getInstance(), serverName, resourceWatcher); }); @@ -910,14 +936,14 @@ public void cdsUpdateAfterShutdown() { verify(xdsConfigWatcher).onUpdate(any()); @SuppressWarnings("unchecked") - XdsClient.ResourceWatcher resourceWatcher = + XdsClient.ResourceWatcher resourceWatcher = mock(XdsClient.ResourceWatcher.class); xdsClient.watchXdsResource( XdsClusterResource.getInstance(), "CDS", resourceWatcher, MoreExecutors.directExecutor()); - verify(resourceWatcher).onChanged(any()); + verify(resourceWatcher).onResourceChanged(argThat(StatusOr::hasValue)); syncContext.execute(() -> { // Shutdown before any updates. This will unsubscribe from XdsClient, but only after this @@ -926,7 +952,7 @@ public void cdsUpdateAfterShutdown() { XdsTestUtils.setAdsConfig(controlPlaneService, serverName, "RDS", "CDS", "EDS2", ENDPOINT_HOSTNAME, ENDPOINT_PORT); - verify(resourceWatcher, times(2)).onChanged(any()); + verify(resourceWatcher, times(2)).onResourceChanged(argThat(StatusOr::hasValue)); xdsClient.cancelXdsResourceWatch( XdsClusterResource.getInstance(), serverName, resourceWatcher); }); @@ -949,7 +975,7 @@ public void edsUpdateAfterShutdown() { "EDS", resourceWatcher, MoreExecutors.directExecutor()); - verify(resourceWatcher).onChanged(any()); + verify(resourceWatcher).onResourceChanged(argThat(StatusOr::hasValue)); syncContext.execute(() -> { // Shutdown before any updates. This will unsubscribe from XdsClient, but only after this @@ -958,7 +984,7 @@ public void edsUpdateAfterShutdown() { XdsTestUtils.setAdsConfig(controlPlaneService, serverName, "RDS", "CDS", "EDS", ENDPOINT_HOSTNAME + "2", ENDPOINT_PORT); - verify(resourceWatcher, times(2)).onChanged(any()); + verify(resourceWatcher, times(2)).onResourceChanged(argThat(StatusOr::hasValue)); xdsClient.cancelXdsResourceWatch( XdsEndpointResource.getInstance(), serverName, resourceWatcher); }); diff --git a/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java b/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java index 33aae268c60..8998a2bae99 100644 --- a/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java @@ -29,18 +29,22 @@ import io.grpc.NameResolverProvider; import io.grpc.NameResolverRegistry; import io.grpc.SynchronizationContext; +import io.grpc.Uri; import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; import java.net.URI; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; /** Unit tests for {@link XdsNameResolverProvider}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class XdsNameResolverProviderTest { private final SynchronizationContext syncContext = new SynchronizationContext( new Thread.UncaughtExceptionHandler() { @@ -63,6 +67,13 @@ public void uncaughtException(Thread t, Throwable e) { private XdsNameResolverProvider provider = new XdsNameResolverProvider(); + @Parameters(name = "enableRfc3986UrisParam={0}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{true}, {false}}); + } + + @Parameter public boolean enableRfc3986UrisParam; + @Test public void provided() { for (NameResolverProvider current @@ -81,48 +92,46 @@ public void isAvailable() { } @Test - public void newNameResolver() { - assertThat( - provider.newNameResolver(URI.create("xds://1.1.1.1/foo.googleapis.com"), args)) + public void newNameResolver_returnsExpectedType() { + assertThat(newNameResolver(provider, "xds://1.1.1.1/foo.googleapis.com", args)) .isInstanceOf(XdsNameResolver.class); - assertThat( - provider.newNameResolver(URI.create("xds:///foo.googleapis.com"), args)) + assertThat(newNameResolver(provider, "xds:///foo.googleapis.com", args)) .isInstanceOf(XdsNameResolver.class); - assertThat( - provider.newNameResolver(URI.create("notxds://1.1.1.1/foo.googleapis.com"), - args)) - .isNull(); + } + + @Test + public void newNameResolver_matchesExpectedScheme() { + assertThat(newNameResolver(provider, "notxds://1.1.1.1/foo.googleapis.com", args)).isNull(); } @Test public void validName_withAuthority() { - XdsNameResolver resolver = - provider.newNameResolver( - URI.create("xds://trafficdirector.google.com/foo.googleapis.com"), args); + NameResolver resolver = + newNameResolver(provider, "xds://trafficdirector.google.com/foo.googleapis.com", args); assertThat(resolver).isNotNull(); assertThat(resolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); } @Test public void validName_noAuthority() { - XdsNameResolver resolver = - provider.newNameResolver(URI.create("xds:///foo.googleapis.com"), args); + NameResolver resolver = newNameResolver(provider, "xds:///foo.googleapis.com", args); assertThat(resolver).isNotNull(); assertThat(resolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); } @Test public void validName_urlExtractedAuthorityInvalidWithoutEncoding() { - XdsNameResolver resolver = - provider.newNameResolver(URI.create("xds:///1234/path/foo.googleapis.com:8080"), args); + NameResolver resolver = + newNameResolver(provider, "xds:///1234/path/foo.googleapis.com:8080", args); assertThat(resolver).isNotNull(); assertThat(resolver.getServiceAuthority()).isEqualTo("1234%2Fpath%2Ffoo.googleapis.com:8080"); } @Test public void validName_urlwithTargetAuthorityAndExtractedAuthorityInvalidWithoutEncoding() { - XdsNameResolver resolver = provider.newNameResolver(URI.create( - "xds://trafficdirector.google.com/1234/path/foo.googleapis.com:8080"), args); + NameResolver resolver = + newNameResolver( + provider, "xds://trafficdirector.google.com/1234/path/foo.googleapis.com:8080", args); assertThat(resolver).isNotNull(); assertThat(resolver.getServiceAuthority()).isEqualTo("1234%2Fpath%2Ffoo.googleapis.com:8080"); } @@ -135,18 +144,14 @@ public void newProvider_multipleScheme() { XdsNameResolverProvider provider1 = XdsNameResolverProvider.createForTest("new-xds-scheme", new HashMap()); registry.register(provider1); - assertThat(registry.asFactory() - .newNameResolver(URI.create("xds:///localhost"), args)).isNotNull(); - assertThat(registry.asFactory() - .newNameResolver(URI.create("new-xds-scheme:///localhost"), args)).isNotNull(); - assertThat(registry.asFactory() - .newNameResolver(URI.create("no-scheme:///localhost"), args)).isNotNull(); + assertThat(newNameResolver(registry.asFactory(), "xds:///localhost", args)).isNotNull(); + assertThat(newNameResolver(registry.asFactory(), "new-xds-scheme:///localhost", args)) + .isNotNull(); + assertThat(newNameResolver(registry.asFactory(), "no-scheme:///localhost", args)).isNotNull(); registry.deregister(provider1); - assertThat(registry.asFactory() - .newNameResolver(URI.create("new-xds-scheme:///localhost"), args)).isNull(); + assertThat(newNameResolver(registry.asFactory(), "new-xds-scheme:///localhost", args)).isNull(); registry.deregister(provider0); - assertThat(registry.asFactory() - .newNameResolver(URI.create("xds:///localhost"), args)).isNotNull(); + assertThat(newNameResolver(registry.asFactory(), "xds:///localhost", args)).isNotNull(); } @Test @@ -176,4 +181,11 @@ public void newProvider_overrideBootstrap() { resolver.shutdown(); registry.deregister(provider); } + + private NameResolver newNameResolver( + NameResolver.Factory factory, String uriString, NameResolver.Args args) { + return enableRfc3986UrisParam + ? factory.newNameResolver(Uri.create(uriString), args) + : factory.newNameResolver(URI.create(uriString), args); + } } diff --git a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java index e80fcd008bd..044e1715def 100644 --- a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java @@ -67,9 +67,15 @@ import io.grpc.NameResolver.ServiceConfigParser; import io.grpc.NoopClientCall; import io.grpc.NoopClientCall.NoopClientCallListener; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.Status.Code; +import io.grpc.StatusOr; import io.grpc.SynchronizationContext; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.internal.AutoConfiguredLoadBalancerFactory; import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; @@ -78,11 +84,14 @@ import io.grpc.internal.ObjectPool; import io.grpc.internal.PickSubchannelArgsImpl; import io.grpc.internal.ScParser; +import io.grpc.stub.ClientCalls; +import io.grpc.testing.GrpcCleanupRule; import io.grpc.testing.TestMethodDescriptors; import io.grpc.xds.ClusterSpecifierPlugin.NamedPluginConfig; import io.grpc.xds.FaultConfig.FaultAbort; import io.grpc.xds.FaultConfig.FaultDelay; import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.Filter.NamedFilterConfig; import io.grpc.xds.RouteLookupServiceClusterSpecifierPlugin.RlsPluginConfig; import io.grpc.xds.VirtualHost.Route; @@ -101,11 +110,12 @@ import io.grpc.xds.client.Bootstrapper.ServerInfo; import io.grpc.xds.client.EnvoyProtoData.Node; import io.grpc.xds.client.XdsClient; -import io.grpc.xds.client.XdsInitializationException; import io.grpc.xds.client.XdsResourceType; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -150,6 +160,8 @@ public class XdsNameResolverTest { private static final String STATEFUL_1 = "test.stateful.filter.1"; private static final String STATEFUL_2 = "test.stateful.filter.2"; + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); @Rule public final MockitoRule mocks = MockitoJUnit.rule(); private final SynchronizationContext syncContext = new SynchronizationContext( @@ -174,6 +186,15 @@ public ConfigOrError parseServiceConfig(Map rawServiceConfig) { private final CallInfo call2 = new CallInfo("GreetService", "bye"); private final TestChannel channel = new TestChannel(); private final MetricRecorder metricRecorder = new MetricRecorder() {}; + private final Map rawBootstrap = ImmutableMap.of( + "xds_servers", ImmutableList.of( + ImmutableMap.of( + "server_uri", "td.googleapis.com", + "channel_creds", ImmutableList.of( + ImmutableMap.of( + "type", "insecure"))) + )); + private BootstrapInfo bootstrapInfo = BootstrapInfo.builder() .servers(ImmutableList.of(ServerInfo.create( "td.googleapis.com", InsecureChannelCredentials.create()))) @@ -192,7 +213,7 @@ public ConfigOrError parseServiceConfig(Map rawServiceConfig) { private XdsNameResolver resolver; private TestCall testCall; private boolean originalEnableTimeout; - private URI targetUri; + private String targetUri = AUTHORITY; private final NameResolver.Args nameResolverArgs = NameResolver.Args.newBuilder() .setDefaultPort(8080) .setProxyDetector(GrpcUtil.DEFAULT_PROXY_DETECTOR) @@ -207,12 +228,6 @@ public ConfigOrError parseServiceConfig(Map rawServiceConfig) { public void setUp() { lenient().doReturn(Status.OK).when(mockListener).onResult2(any()); - try { - targetUri = new URI(AUTHORITY); - } catch (URISyntaxException e) { - targetUri = null; - } - originalEnableTimeout = XdsNameResolver.enableTimeout; XdsNameResolver.enableTimeout = true; @@ -222,7 +237,7 @@ public void setUp() { // Lenient: suppress [MockitoHint] Unused warning, only used in resolved_fault* tests. lenient() .doReturn(new FaultFilter(mockRandom, new AtomicLong())) - .when(faultFilterProvider).newInstance(any(String.class)); + .when(faultFilterProvider).newInstance(any(FilterContext.class)); FilterRegistry filterRegistry = FilterRegistry.newRegistry().register( ROUTER_FILTER_PROVIDER, @@ -230,7 +245,8 @@ public void setUp() { resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, filterRegistry, null, metricRecorder, nameResolverArgs); + xdsClientPoolFactory, mockRandom, filterRegistry, rawBootstrap, metricRecorder, + nameResolverArgs); } @After @@ -250,46 +266,23 @@ public void tearDown() { @Test public void resolving_failToCreateXdsClientPool() { - XdsClientPoolFactory xdsClientPoolFactory = new XdsClientPoolFactory() { - @Override - public void setBootstrapOverride(Map bootstrap) { - } - - @Override - @Nullable - public ObjectPool get(String target) { - throw new UnsupportedOperationException("Should not be called"); - } - - @Override - public ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) - throws XdsInitializationException { - throw new XdsInitializationException("Fail to read bootstrap file"); - } - - @Override - public List getTargets() { - return null; - } - }; - resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, - metricRecorder, nameResolverArgs); + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), + Collections.emptyMap(), metricRecorder, nameResolverArgs); resolver.start(mockListener); verify(mockListener).onError(errorCaptor.capture()); Status error = errorCaptor.getValue(); assertThat(error.getCode()).isEqualTo(Code.UNAVAILABLE); assertThat(error.getDescription()).isEqualTo("Failed to initialize xDS"); - assertThat(error.getCause()).hasMessageThat().isEqualTo("Fail to read bootstrap file"); + assertThat(error.getCause()).hasMessageThat().contains("Invalid bootstrap"); } @Test public void resolving_withTargetAuthorityNotFound() { resolver = new XdsNameResolver(targetUri, "notfound.google.com", AUTHORITY, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); verify(mockListener).onError(errorCaptor.capture()); @@ -312,7 +305,42 @@ public void resolving_noTargetAuthority_templateWithoutXdstp() { resolver = new XdsNameResolver( targetUri, null, serviceAuthority, null, serviceConfigParser, syncContext, scheduler, xdsClientPoolFactory, - mockRandom, FilterRegistry.getDefaultRegistry(), null, metricRecorder, nameResolverArgs); + mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, + nameResolverArgs); + resolver.start(mockListener); + verify(mockListener, never()).onError(any(Status.class)); + } + + @Test + public void resolving_emptyTargetAuthority_templateWithXdstp() { + bootstrapInfo = + BootstrapInfo.builder() + .servers( + ImmutableList.of( + ServerInfo.create("td.googleapis.com", InsecureChannelCredentials.create()))) + .node(Node.newBuilder().build()) + .clientDefaultListenerResourceNameTemplate( + "xdstp://xds.authority.com/envoy.config.listener.v3.Listener/%s?id=1") + .build(); + String serviceAuthority = "[::FFFF:129.144.52.38]:80"; + expectedLdsResourceName = + "xdstp://xds.authority.com/envoy.config.listener.v3.Listener/" + + "%5B::FFFF:129.144.52.38%5D:80?id=1"; + resolver = + new XdsNameResolver( + "xds:///foo.googleapis.com", + "", + serviceAuthority, + null, + serviceConfigParser, + syncContext, + scheduler, + xdsClientPoolFactory, + mockRandom, + FilterRegistry.getDefaultRegistry(), + rawBootstrap, + metricRecorder, + nameResolverArgs); resolver.start(mockListener); verify(mockListener, never()).onError(any(Status.class)); } @@ -332,7 +360,7 @@ public void resolving_noTargetAuthority_templateWithXdstp() { + "%5B::FFFF:129.144.52.38%5D:80?id=1"; resolver = new XdsNameResolver( targetUri, null, serviceAuthority, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); verify(mockListener, never()).onError(any(Status.class)); @@ -353,7 +381,7 @@ public void resolving_noTargetAuthority_xdstpWithMultipleSlashes() { + "path/to/service?id=1"; resolver = new XdsNameResolver( targetUri, null, serviceAuthority, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); @@ -370,26 +398,27 @@ public void resolving_targetAuthorityInAuthoritiesMap() { String serviceAuthority = "[::FFFF:129.144.52.38]:80"; bootstrapInfo = BootstrapInfo.builder() .servers(ImmutableList.of(ServerInfo.create( - "td.googleapis.com", InsecureChannelCredentials.create(), true, true, false))) + "td.googleapis.com", InsecureChannelCredentials.create(), true, true, false, false))) .node(Node.newBuilder().build()) .authorities( ImmutableMap.of(targetAuthority, AuthorityInfo.create( "xdstp://" + targetAuthority + "/envoy.config.listener.v3.Listener/%s?foo=1&bar=2", ImmutableList.of(ServerInfo.create( - "td.googleapis.com", InsecureChannelCredentials.create(), true, true, false))))) + "td.googleapis.com", InsecureChannelCredentials.create(), + true, true, false, false))))) .build(); expectedLdsResourceName = "xdstp://xds.authority.com/envoy.config.listener.v3.Listener/" + "%5B::FFFF:129.144.52.38%5D:80?bar=2&foo=1"; // query param canonified resolver = new XdsNameResolver(targetUri, "xds.authority.com", serviceAuthority, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); verify(mockListener, never()).onError(any(Status.class)); } @Test - public void resolving_ldsResourceNotFound() { + public void resolving_ldsResourceNotFound() { // hi resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); xdsClient.deliverLdsResourceNotFound(); @@ -415,7 +444,7 @@ public void resolving_ldsResourceUpdateRdsName() { .build(); resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); // use different ldsResourceName and service authority. The virtualhost lookup should use // service authority. @@ -566,7 +595,7 @@ public void resolving_encounterErrorLdsWatcherOnly() { Status error = selectResult.getStatus(); assertThat(error.getCode()).isEqualTo(Code.UNAVAILABLE); assertThat(error.getDescription()).contains(AUTHORITY); - assertThat(error.getDescription()).contains("UNAVAILABLE: server unreachable"); + assertThat(error.getDescription()).contains("server unreachable"); } @Test @@ -582,7 +611,7 @@ public void resolving_translateErrorLds() { Status error = selectResult.getStatus(); assertThat(error.getCode()).isEqualTo(Code.UNAVAILABLE); assertThat(error.getDescription()).contains(AUTHORITY); - assertThat(error.getDescription()).contains("NOT_FOUND: server unreachable"); + assertThat(error.getDescription()).contains("server unreachable"); assertThat(error.getCause()).isNull(); } @@ -599,8 +628,10 @@ public void resolving_encounterErrorLdsAndRdsWatchers() { newPickSubchannelArgs(call1.methodDescriptor, new Metadata(), CallOptions.DEFAULT)); Status error = selectResult.getStatus(); assertThat(error.getCode()).isEqualTo(Code.UNAVAILABLE); - assertThat(error.getDescription()).contains(RDS_RESOURCE_NAME); - assertThat(error.getDescription()).contains("UNAVAILABLE: server unreachable"); + // XdsDepManager.buildUpdate doesn't allow this + // assertThat(error.getDescription()).contains(RDS_RESOURCE_NAME); + assertThat(error.getDescription()).contains(expectedLdsResourceName); + assertThat(error.getDescription()).contains("server unreachable"); } @SuppressWarnings("unchecked") @@ -617,7 +648,7 @@ public void resolving_matchingVirtualHostNotFound_matchingOverrideAuthority() { resolver = new XdsNameResolver(targetUri, null, AUTHORITY, "random", serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); @@ -642,7 +673,7 @@ public void resolving_matchingVirtualHostNotFound_notMatchingOverrideAuthority() resolver = new XdsNameResolver(targetUri, null, AUTHORITY, "random", serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); @@ -655,7 +686,7 @@ public void resolving_matchingVirtualHostNotFound_notMatchingOverrideAuthority() public void resolving_matchingVirtualHostNotFoundForOverrideAuthority() { resolver = new XdsNameResolver(targetUri, null, AUTHORITY, AUTHORITY, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); @@ -740,8 +771,8 @@ public void retryPolicyInPerMethodConfigGeneratedByResolverIsValid() { ServiceConfigParser realParser = new ScParser( true, 5, 5, new AutoConfiguredLoadBalancerFactory("pick-first")); resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, realParser, syncContext, - scheduler, xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, - metricRecorder, nameResolverArgs); + scheduler, xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), + rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); RetryPolicy retryPolicy = RetryPolicy.create( @@ -822,7 +853,7 @@ public void resolved_simpleCallFailedToRoute_routeWithNonForwardingAction() { assertServiceConfigForLoadBalancingConfig( Collections.singletonList(cluster2), (Map) result.getServiceConfig().getConfig()); - assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT_POOL)).isNotNull(); + assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT)).isNotNull(); assertThat(result.getAttributes().get(XdsAttributes.CALL_COUNTER_PROVIDER)).isNotNull(); InternalConfigSelector configSelector = result.getAttributes().get(InternalConfigSelector.KEY); // Simulates making a call1 RPC. @@ -952,7 +983,7 @@ public void resolved_rpcHashingByChannelId() { when(mockRandom.nextLong()).thenReturn(123L); resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, syncContext, scheduler, - xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), null, + xdsClientPoolFactory, mockRandom, FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); xdsClient = (FakeXdsClient) resolver.getXdsClient(); @@ -986,7 +1017,7 @@ public void resolved_rpcHashingByChannelId() { public void resolved_routeActionHasAutoHostRewrite_emitsCallOptionForTheSame() { resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, syncContext, scheduler, xdsClientPoolFactory, mockRandom, - FilterRegistry.getDefaultRegistry(), null, metricRecorder, nameResolverArgs); + FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); xdsClient.deliverLdsUpdate( @@ -1017,7 +1048,7 @@ public void resolved_routeActionHasAutoHostRewrite_emitsCallOptionForTheSame() { public void resolved_routeActionNoAutoHostRewrite_doesntEmitCallOptionForTheSame() { resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, syncContext, scheduler, xdsClientPoolFactory, mockRandom, - FilterRegistry.getDefaultRegistry(), null, metricRecorder, nameResolverArgs); + FilterRegistry.getDefaultRegistry(), rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); xdsClient.deliverLdsUpdate( @@ -1235,7 +1266,7 @@ public void resolved_simpleCallSucceeds_routeToWeightedCluster() { assertThat(result.getAddressesOrError().getValue()).isEmpty(); assertServiceConfigForLoadBalancingConfig( Arrays.asList(cluster1, cluster2), (Map) result.getServiceConfig().getConfig()); - assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT_POOL)).isNotNull(); + assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT)).isNotNull(); InternalConfigSelector configSelector = result.getAttributes().get(InternalConfigSelector.KEY); assertCallSelectClusterResult(call1, configSelector, cluster2, 20.0); assertCallSelectClusterResult(call1, configSelector, cluster1, 20.0); @@ -1246,8 +1277,8 @@ private static void createAndDeliverClusterUpdates( FakeXdsClient xdsClient, String... clusterNames) { for (String clusterName : clusterNames) { CdsUpdate.Builder forEds = CdsUpdate - .forEds(clusterName, clusterName, null, null, null, null, false) - .roundRobinLbPolicy(); + .forEds(clusterName, clusterName, null, null, null, null, false, null) + .lbPolicyConfigJsonForTesting(ImmutableMap.of("round_robin", ImmutableMap.of())); xdsClient.deliverCdsUpdate(clusterName, forEds.build()); EdsUpdate edsUpdate = new EdsUpdate(clusterName, XdsTestUtils.createMinimalLbEndpointsMap("127.0.0.3"), Collections.emptyList()); @@ -1300,7 +1331,7 @@ public void resolved_simpleCallSucceeds_routeToRls() { ImmutableList.of(ImmutableMap.of("rls_experimental", expectedRlsLbConfig))))); assertThat(clusterManagerLbConfig).isEqualTo(expectedClusterManagerLbConfig); - assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT_POOL)).isNotNull(); + assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT)).isNotNull(); InternalConfigSelector configSelector = result.getAttributes().get(InternalConfigSelector.KEY); assertCallSelectRlsPluginResult( call1, configSelector, "rls-plugin-foo", 20.0); @@ -1489,7 +1520,7 @@ public void filterState_specialCase_sameNameDifferentTypeUrl() { FilterRegistry filterRegistry = FilterRegistry.newRegistry() .register(statefulFilterProvider, altStatefulFilterProvider, ROUTER_FILTER_PROVIDER); resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, - syncContext, scheduler, xdsClientPoolFactory, mockRandom, filterRegistry, null, + syncContext, scheduler, xdsClientPoolFactory, mockRandom, filterRegistry, rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); @@ -1565,6 +1596,33 @@ public void filterState_shutdown_onLdsNotFound() { assertThat(lds1Filter2.isShutdown()).isTrue(); } + @Test + public void filterState_noShutdown_onLdsDeletion() { + StatefulFilter.Provider statefulFilterProvider = filterStateTestSetupResolver(); + FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); + VirtualHost vhost = filterStateTestVhost(); + + xdsClient.deliverLdsUpdateWithFilters(vhost, filterStateTestConfigs(STATEFUL_1, STATEFUL_2)); + createAndDeliverClusterUpdates(xdsClient, cluster1); + assertClusterResolutionResult(call1, cluster1); + ImmutableList lds1Snapshot = statefulFilterProvider.getAllInstances(); + assertWithMessage("LDS 1: expected to create filter instances").that(lds1Snapshot).hasSize(2); + StatefulFilter lds1Filter1 = lds1Snapshot.get(0); + StatefulFilter lds1Filter2 = lds1Snapshot.get(1); + + // LDS 2: Deliver a resource deletion, which is now an ambient error. + reset(mockListener); + when(mockListener.onResult2(any())).thenReturn(Status.OK); + xdsClient.deliverLdsResourceDeletion(); + + // With an ambient error, no new resolution should happen. + verify(mockListener, never()).onResult2(any()); + + // Verify that the filters are NOT shut down. + assertThat(lds1Filter1.isShutdown()).isFalse(); + assertThat(lds1Filter2.isShutdown()).isFalse(); + } + /** * Verifies that all filter instances are shutdown (closed) on LDS ResourceWatcher shutdown. */ @@ -1599,6 +1657,35 @@ public void filterState_shutdown_onResolverShutdown() { public void filterState_shutdown_onRdsNotFound() { StatefulFilter.Provider statefulFilterProvider = filterStateTestSetupResolver(); FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); + xdsClient.deliverLdsUpdateForRdsNameWithFilters( + RDS_RESOURCE_NAME, + filterStateTestConfigs(STATEFUL_1, STATEFUL_2)); + xdsClient.deliverRdsUpdate( + RDS_RESOURCE_NAME, + Collections.singletonList(filterStateTestVhost())); + createAndDeliverClusterUpdates(xdsClient, cluster1); + assertClusterResolutionResult(call1, cluster1); + + ImmutableList rds1Snapshot = statefulFilterProvider.getAllInstances(); + assertWithMessage("RDS 1: Expected to create filter instances").that(rds1Snapshot).hasSize(2); + StatefulFilter rds1Filter1 = rds1Snapshot.get(0); + StatefulFilter rds1Filter2 = rds1Snapshot.get(1); + assertThat(rds1Filter1.isShutdown()).isFalse(); + assertThat(rds1Filter2.isShutdown()).isFalse(); + + reset(mockListener); + when(mockListener.onResult2(any())).thenReturn(Status.OK); + xdsClient.deliverRdsResourceNotFound(RDS_RESOURCE_NAME); + + assertEmptyResolutionResult(RDS_RESOURCE_NAME); + assertThat(rds1Filter1.isShutdown()).isTrue(); + assertThat(rds1Filter2.isShutdown()).isTrue(); + } + + @Test + public void filterState_noShutdown_onRdsAmbientError() { + StatefulFilter.Provider statefulFilterProvider = filterStateTestSetupResolver(); + FakeXdsClient xdsClient = (FakeXdsClient) resolver.getXdsClient(); // LDS 1. xdsClient.deliverLdsUpdateForRdsNameWithFilters(RDS_RESOURCE_NAME, @@ -1616,10 +1703,10 @@ public void filterState_shutdown_onRdsNotFound() { // RDS 2: RDS_RESOURCE_NAME not found. reset(mockListener); when(mockListener.onResult2(any())).thenReturn(Status.OK); - xdsClient.deliverRdsResourceNotFound(RDS_RESOURCE_NAME); - assertEmptyResolutionResult(RDS_RESOURCE_NAME); - assertThat(lds1Filter1.isShutdown()).isTrue(); - assertThat(lds1Filter2.isShutdown()).isTrue(); + xdsClient.deliverRdsAmbientError(RDS_RESOURCE_NAME, Status.NOT_FOUND); + verify(mockListener, never()).onResult2(any()); + assertThat(lds1Filter1.isShutdown()).isFalse(); + assertThat(lds1Filter2.isShutdown()).isFalse(); } private StatefulFilter.Provider filterStateTestSetupResolver() { @@ -1627,7 +1714,7 @@ private StatefulFilter.Provider filterStateTestSetupResolver() { FilterRegistry filterRegistry = FilterRegistry.newRegistry() .register(statefulFilterProvider, ROUTER_FILTER_PROVIDER); resolver = new XdsNameResolver(targetUri, null, AUTHORITY, null, serviceConfigParser, - syncContext, scheduler, xdsClientPoolFactory, mockRandom, filterRegistry, null, + syncContext, scheduler, xdsClientPoolFactory, mockRandom, filterRegistry, rawBootstrap, metricRecorder, nameResolverArgs); resolver.start(mockListener); return statefulFilterProvider; @@ -1761,7 +1848,7 @@ private InternalConfigSelector resolveToClusters() { assertThat(result.getAddressesOrError().getValue()).isEmpty(); assertServiceConfigForLoadBalancingConfig( Arrays.asList(cluster1, cluster2), (Map) result.getServiceConfig().getConfig()); - assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT_POOL)).isNotNull(); + assertThat(result.getAttributes().get(XdsAttributes.XDS_CLIENT)).isNotNull(); assertThat(result.getAttributes().get(XdsAttributes.CALL_COUNTER_PROVIDER)).isNotNull(); return result.getAttributes().get(InternalConfigSelector.KEY); } @@ -1916,7 +2003,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 @@ -1969,7 +2056,7 @@ public void generateServiceConfig_forPerMethodConfig() throws IOException { // retry with emtry retriable status codes only assertThat(XdsNameResolver.generateServiceConfigWithMethodConfig( - null, retryPolicyWithEmptyStatusCodes)) + null, retryPolicyWithEmptyStatusCodes)) .isEqualTo(expectedServiceConfig); } @@ -2252,7 +2339,7 @@ public long nanoTime() { assertThat(testCall).isNull(); verifyRpcDelayedThenAborted(observer, 4000L, Status.DEADLINE_EXCEEDED.withDescription( "Deadline exceeded after up to 5000 ns of fault-injected delay:" - + " Deadline CallOptions was exceeded after 0.000004000s")); + + " Deadline CallOptions was exceeded after 0.000004000s waiting for httpfault_filter")); } @Test @@ -2423,9 +2510,6 @@ private final class FakeXdsClientPoolFactory implements XdsClientPoolFactory { Set targets = new HashSet<>(); XdsClient xdsClient = new FakeXdsClient(); - @Override - public void setBootstrapOverride(Map bootstrap) {} - @Override @Nullable public ObjectPool get(String target) { @@ -2433,8 +2517,8 @@ public ObjectPool get(String target) { } @Override - public ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) - throws XdsInitializationException { + public ObjectPool getOrCreate( + String target, BootstrapInfo bootstrapInfo, MetricRecorder metricRecorder) { targets.add(target); return new ObjectPool() { @Override @@ -2476,9 +2560,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": @@ -2507,8 +2591,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(); @@ -2539,10 +2623,22 @@ public void cancelXdsResourceWatch(XdsResourceType } } + void deliverRdsAmbientError(String resourceName, Status status) { + if (!rdsWatchers.containsKey(resourceName)) { + return; + } + syncContext.execute(() -> { + List> resourceWatchers = + ImmutableList.copyOf(rdsWatchers.get(resourceName)); + resourceWatchers.forEach(w -> w.onAmbientError(status)); + }); + } + void deliverLdsUpdateOnly(long httpMaxStreamDurationNano, List virtualHosts) { syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( - httpMaxStreamDurationNano, virtualHosts, null))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( + httpMaxStreamDurationNano, virtualHosts, null)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); }); } @@ -2553,8 +2649,9 @@ void deliverLdsUpdate(long httpMaxStreamDurationNano, List virtualH } syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( - httpMaxStreamDurationNano, virtualHosts, null))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( + httpMaxStreamDurationNano, virtualHosts, null)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); createAndDeliverClusterUpdates(this, clusterNames.toArray(new String[0])); }); } @@ -2567,8 +2664,9 @@ void deliverLdsUpdate(final List routes) { List clusterNames = getClusterNames(routes); syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( - 0L, Collections.singletonList(virtualHost), null))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( + 0L, Collections.singletonList(virtualHost), null)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); if (!clusterNames.isEmpty()) { createAndDeliverClusterUpdates(this, clusterNames.toArray(new String[0])); } @@ -2577,8 +2675,9 @@ void deliverLdsUpdate(final List routes) { void deliverLdsUpdateWithFilters(VirtualHost vhost, List filterConfigs) { syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( - 0L, Collections.singletonList(vhost), filterConfigs))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( + 0L, Collections.singletonList(vhost), filterConfigs)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); }); } @@ -2625,8 +2724,9 @@ void deliverLdsUpdateWithFaultInjection( Collections.singletonList(route), overrideConfig); syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( - 0L, Collections.singletonList(virtualHost), filterChain))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( + 0L, Collections.singletonList(virtualHost), filterChain)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); createAndDeliverClusterUpdates(this, cluster); }); } @@ -2641,8 +2741,9 @@ void deliverLdsUpdateForRdsNameWithFaultInjection( new NamedFilterConfig(FAULT_FILTER_INSTANCE_NAME, httpFilterFaultConfig), new NamedFilterConfig(ROUTER_FILTER_INSTANCE_NAME, RouterFilter.ROUTER_CONFIG)); syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forRdsName( - 0L, rdsName, filterChain))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forRdsName( + 0L, rdsName, filterChain)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); }); } @@ -2654,14 +2755,27 @@ void deliverLdsUpdateForRdsNameWithFilters( String rdsName, @Nullable List filterConfigs) { syncContext.execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forRdsName( - 0, rdsName, filterConfigs))); + LdsUpdate ldsUpdate = LdsUpdate.forApiListener(HttpConnectionManager.forRdsName( + 0, rdsName, filterConfigs)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate)); + }); + } + + void deliverLdsResourceDeletion() { + Status status = Status.NOT_FOUND.withDescription( + "Resource not found: " + expectedLdsResourceName); + syncContext.execute(() -> { + ldsWatcher.onAmbientError(status); }); } void deliverLdsResourceNotFound() { + Status notFoundStatus = Status.UNAVAILABLE.withDescription( + "Resource not found: " + expectedLdsResourceName); syncContext.execute(() -> { - ldsWatcher.onResourceDoesNotExist(expectedLdsResourceName); + if (ldsWatcher != null) { + ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); + } }); } @@ -2731,7 +2845,7 @@ void deliverRdsUpdate(String resourceName, List virtualHosts) { RdsUpdate update = new RdsUpdate(virtualHosts); List> resourceWatchers = ImmutableList.copyOf(rdsWatchers.get(resourceName)); - resourceWatchers.forEach(w -> w.onChanged(update)); + resourceWatchers.forEach(w -> w.onResourceChanged(StatusOr.fromValue(update))); }); } @@ -2746,7 +2860,8 @@ void deliverRdsResourceNotFound(String resourceName) { syncContext.execute(() -> { List> resourceWatchers = ImmutableList.copyOf(rdsWatchers.get(resourceName)); - resourceWatchers.forEach(w -> w.onResourceDoesNotExist(resourceName)); + Status status = Status.UNAVAILABLE.withDescription("Resource not found: " + resourceName); + resourceWatchers.forEach(w -> w.onResourceChanged(StatusOr.fromStatus(status))); }); } @@ -2757,7 +2872,7 @@ private void deliverCdsUpdate(String clusterName, CdsUpdate update) { syncContext.execute(() -> { List> resourceWatchers = ImmutableList.copyOf(cdsWatchers.get(clusterName)); - resourceWatchers.forEach(w -> w.onChanged(update)); + resourceWatchers.forEach(w -> w.onResourceChanged(StatusOr.fromValue(update))); }); } @@ -2768,7 +2883,7 @@ private void deliverEdsUpdate(String name, EdsUpdate update) { } List> resourceWatchers = ImmutableList.copyOf(edsWatchers.get(name)); - resourceWatchers.forEach(w -> w.onChanged(update)); + resourceWatchers.forEach(w -> w.onResourceChanged(StatusOr.fromValue(update))); }); } @@ -2776,16 +2891,19 @@ private void deliverEdsUpdate(String name, EdsUpdate update) { void deliverError(final Status error) { if (ldsWatcher != null) { syncContext.execute(() -> { - ldsWatcher.onError(error); + ldsWatcher.onResourceChanged(StatusOr.fromStatus(error)); }); } syncContext.execute(() -> { - rdsWatchers.values().stream() - .flatMap(List::stream) - .forEach(w -> w.onError(error)); - cdsWatchers.values().stream() - .flatMap(List::stream) - .forEach(w -> w.onError(error)); + List> rdsCopy = rdsWatchers.values().stream() + .flatMap(List::stream).collect(java.util.stream.Collectors.toList()); + List> cdsCopy = cdsWatchers.values().stream() + .flatMap(List::stream).collect(java.util.stream.Collectors.toList()); + List> edsCopy = edsWatchers.values().stream() + .flatMap(List::stream).collect(java.util.stream.Collectors.toList()); + rdsCopy.forEach(w -> w.onResourceChanged(StatusOr.fromStatus(error))); + cdsCopy.forEach(w -> w.onResourceChanged(StatusOr.fromStatus(error))); + edsCopy.forEach(w -> w.onResourceChanged(StatusOr.fromStatus(error))); }); } } @@ -2853,4 +2971,71 @@ void deliverErrorStatus() { listener.onClose(Status.UNAVAILABLE, new Metadata()); } } + + 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 final MethodDescriptor METHOD_SAY_HELLO = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test.TestService/SayHello") + .setRequestMarshaller(new StringMarshaller()) + .setResponseMarshaller(new StringMarshaller()) + .build(); + + @Test + public void rawMessageClientInterceptor_unaryRpc() throws Exception { + String serverName = InProcessServerBuilder.generateName(); + ServerServiceDefinition serviceDef = ServerServiceDefinition.builder("test.TestService") + .addMethod(METHOD_SAY_HELLO, new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + call.request(1); + return new ServerCall.Listener() { + @Override + public void onMessage(String message) { + call.sendHeaders(new Metadata()); + call.sendMessage("Hello " + message); + call.close(Status.OK, new Metadata()); + } + }; + } + }).build(); + + grpcCleanup.register(InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(serviceDef) + .build() + .start()); + + Channel channel = grpcCleanup.register(InProcessChannelBuilder.forName(serverName) + .directExecutor() + .intercept(new XdsNameResolver.RawMessageClientInterceptor()) + .build()); + + String response = ClientCalls.blockingUnaryCall( + channel, METHOD_SAY_HELLO, CallOptions.DEFAULT, "World"); + assertThat(response).isEqualTo("Hello World"); + } } diff --git a/xds/src/test/java/io/grpc/xds/XdsSecurityClientServerTest.java b/xds/src/test/java/io/grpc/xds/XdsSecurityClientServerTest.java index 23068d665bf..6b39106f18c 100644 --- a/xds/src/test/java/io/grpc/xds/XdsSecurityClientServerTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsSecurityClientServerTest.java @@ -37,6 +37,7 @@ import com.google.common.util.concurrent.SettableFuture; import io.envoyproxy.envoy.config.core.v3.SocketAddress.Protocol; import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.CertificateValidationContext; +import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.Grpc; @@ -70,11 +71,13 @@ import io.grpc.xds.client.Bootstrapper; import io.grpc.xds.client.CommonBootstrapperTestUtils; import io.grpc.xds.internal.Matchers.HeaderMatcher; +import io.grpc.xds.internal.XdsInternalAttributes; import io.grpc.xds.internal.security.CommonTlsContextTestsUtil; import io.grpc.xds.internal.security.SecurityProtocolNegotiators; import io.grpc.xds.internal.security.SslContextProviderSupplier; import io.grpc.xds.internal.security.TlsContextManagerImpl; import io.grpc.xds.internal.security.certprovider.FileWatcherCertificateProviderProvider; +import io.grpc.xds.internal.security.trust.CertificateUtils; import io.netty.handler.ssl.NotSslRecordException; import java.io.File; import java.io.FileOutputStream; @@ -83,7 +86,6 @@ import java.net.Inet4Address; import java.net.InetSocketAddress; import java.net.URI; -import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; @@ -116,6 +118,8 @@ */ @RunWith(Parameterized.class) public class XdsSecurityClientServerTest { + + private static final String SNI_IN_UTC = "waterzooi.test.google.be"; @Parameter public Boolean enableSpiffe; @@ -206,7 +210,8 @@ public void tlsClientServer_noClientAuthentication() throws Exception { * Uses common_tls_context.combined_validation_context in upstream_tls_context. */ @Test - public void tlsClientServer_useSystemRootCerts_useCombinedValidationContext() throws Exception { + public void tlsClientServer_useSystemRootCerts_noMtls_useCombinedValidationContext() + throws Exception { Path trustStoreFilePath = getCacertFilePathForTestCa(); try { setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); @@ -217,7 +222,7 @@ public void tlsClientServer_useSystemRootCerts_useCombinedValidationContext() th UpstreamTlsContext upstreamTlsContext = setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, - CLIENT_PEM_FILE, true); + CLIENT_PEM_FILE, true, SNI_IN_UTC, false, "", false, false); SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); @@ -233,7 +238,7 @@ public void tlsClientServer_useSystemRootCerts_useCombinedValidationContext() th * Uses common_tls_context.validation_context in upstream_tls_context. */ @Test - public void tlsClientServer_useSystemRootCerts_validationContext() throws Exception { + public void tlsClientServer_useSystemRootCerts_noMtls_validationContext() throws Exception { Path trustStoreFilePath = getCacertFilePathForTestCa().toAbsolutePath(); try { setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); @@ -244,7 +249,7 @@ public void tlsClientServer_useSystemRootCerts_validationContext() throws Except UpstreamTlsContext upstreamTlsContext = setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, - CLIENT_PEM_FILE, false); + CLIENT_PEM_FILE, false, SNI_IN_UTC, false, null, false, false); SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); @@ -255,9 +260,158 @@ public void tlsClientServer_useSystemRootCerts_validationContext() throws Except } } + @Test + public void tlsClientServer_useSystemRootCerts_mtls() throws Exception { + Path trustStoreFilePath = getCacertFilePathForTestCa(); + try { + setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); + DownstreamTlsContext downstreamTlsContext = + setBootstrapInfoAndBuildDownstreamTlsContext(SERVER_1_PEM_FILE, null, null, null, null, + null, false, true); + buildServerWithTlsContext(downstreamTlsContext); + + UpstreamTlsContext upstreamTlsContext = + setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, + CLIENT_PEM_FILE, true, SNI_IN_UTC, true, "", false, false); + + SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = + getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); + assertThat(unaryRpc(/* requestMessage= */ "buddy", blockingStub)).isEqualTo("Hello buddy"); + } finally { + Files.deleteIfExists(trustStoreFilePath); + clearTrustStoreSystemProperties(); + } + } + + @Test + public void tlsClientServer_noAutoSniValidation_failureToMatchSubjAltNames() + throws Exception { + Path trustStoreFilePath = getCacertFilePathForTestCa(); + try { + setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); + DownstreamTlsContext downstreamTlsContext = + setBootstrapInfoAndBuildDownstreamTlsContext(SERVER_1_PEM_FILE, null, null, null, null, + null, false, false); + buildServerWithTlsContext(downstreamTlsContext); + + UpstreamTlsContext upstreamTlsContext = + setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, + CLIENT_PEM_FILE, true, "server1.test.google.in", false, "", false, false); + + SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = + getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); + unaryRpc(/* requestMessage= */ "buddy", blockingStub); + fail("Expected handshake failure exception"); + } catch (StatusRuntimeException e) { + assertThat(e.getCause()).isInstanceOf(SSLHandshakeException.class); + assertThat(e.getCause().getCause()).isInstanceOf(CertificateException.class); + assertThat(e.getCause().getCause().getMessage()).isEqualTo( + "Peer certificate SAN check failed"); + } finally { + Files.deleteIfExists(trustStoreFilePath); + clearTrustStoreSystemProperties(); + } + } + + + @Test + public void tlsClientServer_autoSniValidation_sniInUtc() + throws Exception { + Path trustStoreFilePath = getCacertFilePathForTestCa(); + try { + setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); + DownstreamTlsContext downstreamTlsContext = + setBootstrapInfoAndBuildDownstreamTlsContext(SERVER_1_PEM_FILE, null, null, null, null, + null, false, false); + buildServerWithTlsContext(downstreamTlsContext); + + UpstreamTlsContext upstreamTlsContext = + setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, + CLIENT_PEM_FILE, true, + // SAN matcher in CommonValidationContext. Will be overridden by autoSniSanValidation + "server1.test.google.in", + false, + SNI_IN_UTC, + false, true); + + SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = + getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); + unaryRpc(/* requestMessage= */ "buddy", blockingStub); + } finally { + Files.deleteIfExists(trustStoreFilePath); + clearTrustStoreSystemProperties(); + } + } + + @Test + public void tlsClientServer_autoSniValidation_sniFromHostname() + throws Exception { + Path trustStoreFilePath = getCacertFilePathForTestCa(); + try { + setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); + DownstreamTlsContext downstreamTlsContext = + setBootstrapInfoAndBuildDownstreamTlsContext(SERVER_1_PEM_FILE, null, null, null, null, + null, false, false); + buildServerWithTlsContext(downstreamTlsContext); + + UpstreamTlsContext upstreamTlsContext = + setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, + CLIENT_PEM_FILE, true, + // SAN matcher in CommonValidationContext. Will be overridden by autoSniSanValidation + "server1.test.google.in", + false, + "", + true, true); + + // TODO: Change this to foo.test.gooogle.fr that needs wildcard matching after + // https://github.com/grpc/grpc-java/pull/12345 is done + SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = + getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY, + "waterzooi.test.google.be"); + unaryRpc(/* requestMessage= */ "buddy", blockingStub); + } finally { + Files.deleteIfExists(trustStoreFilePath); + clearTrustStoreSystemProperties(); + } + } + + @Test + public void tlsClientServer_autoSniValidation_noSniApplicable_usesMatcherFromCmnVdnCtx() + throws Exception { + Path trustStoreFilePath = getCacertFilePathForTestCa(); + boolean originalUseChannelAuthorityIfNoSniApplicable = + CertificateUtils.useChannelAuthorityIfNoSniApplicable; + try { + CertificateUtils.useChannelAuthorityIfNoSniApplicable = + true; + setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); + DownstreamTlsContext downstreamTlsContext = + setBootstrapInfoAndBuildDownstreamTlsContext(SERVER_1_PEM_FILE, null, null, null, null, + null, false, false); + buildServerWithTlsContext(downstreamTlsContext); + + UpstreamTlsContext upstreamTlsContext = + setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, + CLIENT_PEM_FILE, true, + // This is what will get used for the SAN validation since no SNI was used + "waterzooi.test.google.be", + false, + "", + false, true); + + SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = + getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); + unaryRpc(/* requestMessage= */ "buddy", blockingStub); + } finally { + CertificateUtils.useChannelAuthorityIfNoSniApplicable = + originalUseChannelAuthorityIfNoSniApplicable; + Files.deleteIfExists(trustStoreFilePath); + clearTrustStoreSystemProperties(); + } + } + /** * Use system root ca cert for TLS channel - mTLS. - * Uses common_tls_context.combined_validation_context in upstream_tls_context. */ @Test public void tlsClientServer_useSystemRootCerts_requireClientAuth() throws Exception { @@ -266,13 +420,12 @@ public void tlsClientServer_useSystemRootCerts_requireClientAuth() throws Except setTrustStoreSystemProperties(trustStoreFilePath.toAbsolutePath().toString()); DownstreamTlsContext downstreamTlsContext = setBootstrapInfoAndBuildDownstreamTlsContext(SERVER_1_PEM_FILE, null, null, null, null, - null, false, false); + null, false, true); buildServerWithTlsContext(downstreamTlsContext); UpstreamTlsContext upstreamTlsContext = setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts(CLIENT_KEY_FILE, - CLIENT_PEM_FILE, true); - + CLIENT_PEM_FILE, true, SNI_IN_UTC, false, "", false, false); SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = getBlockingStub(upstreamTlsContext, /* overrideAuthority= */ OVERRIDE_AUTHORITY); assertThat(unaryRpc(/* requestMessage= */ "buddy", blockingStub)).isEqualTo("Hello buddy"); @@ -549,21 +702,30 @@ private UpstreamTlsContext setBootstrapInfoAndBuildUpstreamTlsContext(String cli .buildUpstreamTlsContext("google_cloud_private_spiffe-client", hasIdentityCert); } + @SuppressWarnings("deprecation") // gRFC A29 predates match_typed_subject_alt_names private UpstreamTlsContext setBootstrapInfoAndBuildUpstreamTlsContextForUsingSystemRootCerts( String clientKeyFile, String clientPemFile, - boolean useCombinedValidationContext) { + boolean useCombinedValidationContext, + String sanToMatch, + boolean isMtls, + String sniInUpstreamTlsContext, + boolean autoHostSni, boolean autoSniSanValidation) { bootstrapInfoForClient = CommonBootstrapperTestUtils .buildBootstrapInfo("google_cloud_private_spiffe-client", clientKeyFile, clientPemFile, CA_PEM_FILE, null, null, null, null, null); if (useCombinedValidationContext) { return CommonTlsContextTestsUtil.buildUpstreamTlsContextForCertProviderInstance( - "google_cloud_private_spiffe-client", "ROOT", null, + isMtls ? "google_cloud_private_spiffe-client" : null, + isMtls ? "ROOT" : null, null, null, null, CertificateValidationContext.newBuilder() .setSystemRootCerts( CertificateValidationContext.SystemRootCerts.newBuilder().build()) - .build()); + .addMatchSubjectAltNames( + StringMatcher.newBuilder() + .setExact(sanToMatch)) + .build(), sniInUpstreamTlsContext, autoHostSni, autoSniSanValidation); } return CommonTlsContextTestsUtil.buildNewUpstreamTlsContextForCertProviderInstance( "google_cloud_private_spiffe-client", "ROOT", null, @@ -591,6 +753,7 @@ private void buildServerWithFallbackServerCredentials( ServerCredentials xdsCredentials = XdsServerCredentials.create(fallbackCredentials); XdsServerBuilder builder = XdsServerBuilder.forPort(0, xdsCredentials) .xdsClientPoolFactory(fakePoolFactory) + .overrideBootstrapForTest(XdsServerTestHelper.RAW_BOOTSTRAP) .addService(new SimpleServiceImpl()); buildServer(builder, downstreamTlsContext); } @@ -648,8 +811,20 @@ static EnvoyServerProtoData.Listener buildListener( } private SimpleServiceGrpc.SimpleServiceBlockingStub getBlockingStub( - final UpstreamTlsContext upstreamTlsContext, String overrideAuthority) - throws URISyntaxException { + final UpstreamTlsContext upstreamTlsContext, String overrideAuthority) { + return getBlockingStub(upstreamTlsContext, overrideAuthority, overrideAuthority); + } + + // Two separate parameters for overrideAuthority and addrAttribute is for the SAN SNI validation + // test tlsClientServer_useSystemRootCerts_sni_san_validation_from_hostname that uses hostname + // passed for SNI. foo.test.google.fr is used for virtual host matching via authority but it + // can't be used for SNI in this testcase because foo.test.google.fr needs wildcard matching to + // match against *.test.google.fr in the certificate SNI, which isn't implemented yet + // (https://github.com/grpc/grpc-java/pull/12345 implements it) + // so use an exact match SAN such as waterzooi.test.google.be for SNI for this testcase. + private SimpleServiceGrpc.SimpleServiceBlockingStub getBlockingStub( + final UpstreamTlsContext upstreamTlsContext, String overrideAuthority, + String addrNameAttribute) { ManagedChannelBuilder channelBuilder = Grpc.newChannelBuilder( "sectest://localhost:" + port, @@ -661,14 +836,16 @@ private SimpleServiceGrpc.SimpleServiceBlockingStub getBlockingStub( InetSocketAddress socketAddress = new InetSocketAddress(Inet4Address.getLoopbackAddress(), port); tlsContextManagerForClient = new TlsContextManagerImpl(bootstrapInfoForClient); - sslContextAttributes = - (upstreamTlsContext != null) - ? Attributes.newBuilder() - .set(SecurityProtocolNegotiators.ATTR_SSL_CONTEXT_PROVIDER_SUPPLIER, - new SslContextProviderSupplier( - upstreamTlsContext, tlsContextManagerForClient)) - .build() - : Attributes.EMPTY; + Attributes.Builder sslContextAttributesBuilder = (upstreamTlsContext != null) + ? Attributes.newBuilder() + .set(SecurityProtocolNegotiators.ATTR_SSL_CONTEXT_PROVIDER_SUPPLIER, + new SslContextProviderSupplier( + upstreamTlsContext, tlsContextManagerForClient)) + : Attributes.newBuilder(); + if (addrNameAttribute != null) { + sslContextAttributesBuilder.set(XdsInternalAttributes.ATTR_ADDRESS_NAME, addrNameAttribute); + } + sslContextAttributes = sslContextAttributesBuilder.build(); fakeNameResolverFactory.setServers( ImmutableList.of(new EquivalentAddressGroup(socketAddress, sslContextAttributes))); return SimpleServiceGrpc.newBlockingStub(cleanupRule.register(channelBuilder.build())); @@ -696,7 +873,18 @@ public void run() { } } }); - xdsClient.ldsResource.get(8000, TimeUnit.MILLISECONDS); + try { + xdsClient.ldsResource.get(8000, TimeUnit.MILLISECONDS); + } catch (Exception ex) { + // start() probably failed, so throw its exception + if (settableFuture.isDone()) { + Throwable t = settableFuture.get(); + if (t != null) { + throw new Exception(t); + } + } + throw ex; + } return settableFuture; } diff --git a/xds/src/test/java/io/grpc/xds/XdsServerBuilderTest.java b/xds/src/test/java/io/grpc/xds/XdsServerBuilderTest.java index 2c168c65869..ac990226259 100644 --- a/xds/src/test/java/io/grpc/xds/XdsServerBuilderTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsServerBuilderTest.java @@ -34,6 +34,7 @@ import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.StatusException; +import io.grpc.StatusOr; import io.grpc.testing.GrpcCleanupRule; import io.grpc.xds.XdsListenerResource.LdsUpdate; import io.grpc.xds.XdsServerTestHelper.FakeXdsClient; @@ -43,7 +44,6 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.SocketAddress; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -84,6 +84,7 @@ private void buildBuilder(XdsServerBuilder.XdsServingStatusListener xdsServingSt XdsServerBuilder.forPort( port, XdsServerCredentials.create(InsecureServerCredentials.create())); builder.xdsClientPoolFactory(xdsClientPoolFactory); + builder.overrideBootstrapForTest(XdsServerTestHelper.RAW_BOOTSTRAP); if (xdsServingStatusListener != null) { builder.xdsServingStatusListener(xdsServingStatusListener); } @@ -138,7 +139,18 @@ public void run() { } } }); - xdsClient.ldsResource.get(5000, TimeUnit.MILLISECONDS); + try { + xdsClient.ldsResource.get(5000, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + // start() probably failed, so throw its exception + if (settableFuture.isDone()) { + Throwable t = settableFuture.get(); + if (t != null) { + throw new ExecutionException(t); + } + } + throw ex; + } return settableFuture; } @@ -198,13 +210,14 @@ public void xdsServer_discoverState() throws Exception { CommonTlsContextTestsUtil.buildTestInternalDownstreamTlsContext("CERT1", "VA1"), tlsContextManager); future.get(5000, TimeUnit.MILLISECONDS); - xdsClient.ldsWatcher.onError(Status.ABORTED); + xdsClient.ldsWatcher.onAmbientError(Status.ABORTED); verify(mockXdsServingStatusListener, never()).onNotServing(any(StatusException.class)); reset(mockXdsServingStatusListener); - xdsClient.ldsWatcher.onError(Status.CANCELLED); + xdsClient.ldsWatcher.onAmbientError(Status.CANCELLED); verify(mockXdsServingStatusListener, never()).onNotServing(any(StatusException.class)); reset(mockXdsServingStatusListener); - xdsClient.ldsWatcher.onResourceDoesNotExist("not found error"); + Status notFoundStatus = Status.NOT_FOUND.withDescription("not found error"); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); verify(mockXdsServingStatusListener).onNotServing(any(StatusException.class)); reset(mockXdsServingStatusListener); XdsServerTestHelper.generateListenerUpdate( @@ -255,7 +268,7 @@ public void xdsServerStartSecondUpdateAndError() tlsContextManager); verify(mockXdsServingStatusListener, never()).onNotServing(any(Throwable.class)); verifyServer(future, mockXdsServingStatusListener, null); - xdsClient.ldsWatcher.onError(Status.ABORTED); + xdsClient.ldsWatcher.onAmbientError(Status.ABORTED); verifyServer(null, mockXdsServingStatusListener, null); } @@ -304,9 +317,12 @@ public void drainGraceTime_negativeThrows() throws IOException { @Test public void testOverrideBootstrap() throws Exception { - Map b = new HashMap<>(); + Map b = XdsServerTestHelper.RAW_BOOTSTRAP; buildBuilder(null); builder.overrideBootstrapForTest(b); - assertThat(xdsClientPoolFactory.savedBootstrap).isEqualTo(b); + xdsServer = cleanupRule.register((XdsServerWrapper) builder.build()); + Future unused = startServerAsync(); + assertThat(xdsClientPoolFactory.savedBootstrapInfo.node().getId()) + .isEqualTo(XdsServerTestHelper.BOOTSTRAP_INFO.node().getId()); } } diff --git a/xds/src/test/java/io/grpc/xds/XdsServerTestHelper.java b/xds/src/test/java/io/grpc/xds/XdsServerTestHelper.java index b0472b4729d..386793299d8 100644 --- a/xds/src/test/java/io/grpc/xds/XdsServerTestHelper.java +++ b/xds/src/test/java/io/grpc/xds/XdsServerTestHelper.java @@ -24,6 +24,8 @@ import io.envoyproxy.envoy.config.core.v3.SocketAddress.Protocol; import io.grpc.InsecureChannelCredentials; import io.grpc.MetricRecorder; +import io.grpc.Status; +import io.grpc.StatusOr; import io.grpc.internal.ObjectPool; import io.grpc.xds.EnvoyServerProtoData.ConnectionSourceType; import io.grpc.xds.EnvoyServerProtoData.FilterChain; @@ -37,7 +39,6 @@ import io.grpc.xds.client.Bootstrapper.BootstrapInfo; import io.grpc.xds.client.EnvoyProtoData; import io.grpc.xds.client.XdsClient; -import io.grpc.xds.client.XdsInitializationException; import io.grpc.xds.client.XdsResourceType; import java.time.Duration; import java.util.ArrayList; @@ -63,6 +64,17 @@ public class XdsServerTestHelper { "projects/42/networks/default/nodes/5c85b298-6f5b-4722-b74a-f7d1f0ccf5ad"; private static final EnvoyProtoData.Node BOOTSTRAP_NODE = EnvoyProtoData.Node.newBuilder().setId(NODE_ID).build(); + static final Map RAW_BOOTSTRAP = ImmutableMap.of( + "node", ImmutableMap.of( + "id", NODE_ID), + "server_listener_resource_name_template", "grpc/server?udpa.resource.listening_address=%s", + "xds_servers", ImmutableList.of( + ImmutableMap.of( + "server_uri", SERVER_URI, + "channel_creds", ImmutableList.of( + ImmutableMap.of( + "type", "insecure"))) + )); static final Bootstrapper.BootstrapInfo BOOTSTRAP_INFO = Bootstrapper.BootstrapInfo.builder() .servers(Arrays.asList( @@ -140,17 +152,12 @@ static final class FakeXdsClientPoolFactory implements XdsClientPoolFactory { private XdsClient xdsClient; - Map savedBootstrap; + BootstrapInfo savedBootstrapInfo; FakeXdsClientPoolFactory(XdsClient xdsClient) { this.xdsClient = xdsClient; } - @Override - public void setBootstrapOverride(Map bootstrap) { - this.savedBootstrap = bootstrap; - } - @Override @Nullable public ObjectPool get(String target) { @@ -158,8 +165,9 @@ public ObjectPool get(String target) { } @Override - public ObjectPool getOrCreate(String target, MetricRecorder metricRecorder) - throws XdsInitializationException { + public ObjectPool getOrCreate( + String target, BootstrapInfo bootstrapInfo, MetricRecorder metricRecorder) { + this.savedBootstrapInfo = bootstrapInfo; return new ObjectPool() { @Override public XdsClient getObject() { @@ -295,13 +303,14 @@ private String awaitLdsResource(Duration timeout) { void deliverLdsUpdateWithApiListener(long httpMaxStreamDurationNano, List virtualHosts) { execute(() -> { - ldsWatcher.onChanged(LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( - httpMaxStreamDurationNano, virtualHosts, null))); + LdsUpdate update = LdsUpdate.forApiListener(HttpConnectionManager.forVirtualHosts( + httpMaxStreamDurationNano, virtualHosts, null)); + ldsWatcher.onResourceChanged(StatusOr.fromValue(update)); }); } void deliverLdsUpdate(LdsUpdate ldsUpdate) { - execute(() -> ldsWatcher.onChanged(ldsUpdate)); + execute(() -> ldsWatcher.onResourceChanged(StatusOr.fromValue(ldsUpdate))); } void deliverLdsUpdate( @@ -316,11 +325,14 @@ void deliverLdsUpdate(FilterChain filterChain, @Nullable FilterChain defaultFilt } void deliverLdsResourceNotFound() { - execute(() -> ldsWatcher.onResourceDoesNotExist(awaitLdsResource(DEFAULT_TIMEOUT))); + String resourceName = awaitLdsResource(DEFAULT_TIMEOUT); + Status status = Status.NOT_FOUND.withDescription("Resource not found: " + resourceName); + execute(() -> ldsWatcher.onResourceChanged(StatusOr.fromStatus(status))); } void deliverRdsUpdate(String resourceName, List virtualHosts) { - execute(() -> rdsWatchers.get(resourceName).onChanged(new RdsUpdate(virtualHosts))); + RdsUpdate update = new RdsUpdate(virtualHosts); + execute(() -> rdsWatchers.get(resourceName).onResourceChanged(StatusOr.fromValue(update))); } void deliverRdsUpdate(String resourceName, VirtualHost virtualHost) { @@ -328,7 +340,8 @@ void deliverRdsUpdate(String resourceName, VirtualHost virtualHost) { } void deliverRdsResourceNotFound(String resourceName) { - execute(() -> rdsWatchers.get(resourceName).onResourceDoesNotExist(resourceName)); + Status status = Status.NOT_FOUND.withDescription("Resource not found: " + resourceName); + execute(() -> rdsWatchers.get(resourceName).onResourceChanged(StatusOr.fromStatus(status))); } } } diff --git a/xds/src/test/java/io/grpc/xds/XdsServerWrapperTest.java b/xds/src/test/java/io/grpc/xds/XdsServerWrapperTest.java index ce1c6b94a35..b50f6cd98e5 100644 --- a/xds/src/test/java/io/grpc/xds/XdsServerWrapperTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsServerWrapperTest.java @@ -49,6 +49,7 @@ import io.grpc.ServerInterceptor; import io.grpc.Status; import io.grpc.StatusException; +import io.grpc.StatusOr; import io.grpc.SynchronizationContext; import io.grpc.internal.FakeClock; import io.grpc.testing.TestMethodDescriptors; @@ -57,6 +58,7 @@ import io.grpc.xds.EnvoyServerProtoData.FilterChainMatch; import io.grpc.xds.EnvoyServerProtoData.Listener; import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.Filter.FilterContext; import io.grpc.xds.Filter.NamedFilterConfig; import io.grpc.xds.FilterChainMatchingProtocolNegotiators.FilterChainMatchingHandler.FilterChainSelector; import io.grpc.xds.StatefulFilter.Config; @@ -135,6 +137,7 @@ public void setup() { when(mockBuilder.build()).thenReturn(mockServer); xdsServerWrapper = new XdsServerWrapper("0.0.0.0:1", mockBuilder, listener, selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry, executor.getScheduledExecutorService()); } @@ -157,7 +160,8 @@ public void testBootstrap() throws Exception { XdsListenerResource listenerResource = XdsListenerResource.getInstance(); when(xdsClient.getBootstrapInfo()).thenReturn(b); xdsServerWrapper = new XdsServerWrapper("[::FFFF:129.144.52.38]:80", mockBuilder, listener, - selectorManager, new FakeXdsClientPoolFactory(xdsClient), filterRegistry); + selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry); Executors.newSingleThreadExecutor().execute(new Runnable() { @Override public void run() { @@ -190,7 +194,8 @@ private void verifyBootstrapFail(Bootstrapper.BootstrapInfo b) throws Exception XdsClient xdsClient = mock(XdsClient.class); when(xdsClient.getBootstrapInfo()).thenReturn(b); xdsServerWrapper = new XdsServerWrapper("0.0.0.0:1", mockBuilder, listener, - selectorManager, new FakeXdsClientPoolFactory(xdsClient), filterRegistry); + selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry); final SettableFuture start = SettableFuture.create(); Executors.newSingleThreadExecutor().execute(new Runnable() { @Override @@ -229,7 +234,8 @@ public void testBootstrap_templateWithXdstp() throws Exception { XdsListenerResource listenerResource = XdsListenerResource.getInstance(); when(xdsClient.getBootstrapInfo()).thenReturn(b); xdsServerWrapper = new XdsServerWrapper("[::FFFF:129.144.52.38]:80", mockBuilder, listener, - selectorManager, new FakeXdsClientPoolFactory(xdsClient), filterRegistry); + selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry); Executors.newSingleThreadExecutor().execute(new Runnable() { @Override public void run() { @@ -341,7 +347,8 @@ public void run() { } }); String ldsResource = xdsClient.ldsResource.get(5, TimeUnit.SECONDS); - xdsClient.ldsWatcher.onResourceDoesNotExist(ldsResource); + Status notFoundStatus = Status.NOT_FOUND.withDescription("Resource not found: " + ldsResource); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); verify(listener, timeout(5000)).onNotServing(any()); try { start.get(START_WAIT_AFTER_LISTENER_MILLIS, TimeUnit.MILLISECONDS); @@ -530,7 +537,8 @@ public void run() { verify(mockServer).start(); // server shutdown after resourceDoesNotExist - xdsClient.ldsWatcher.onResourceDoesNotExist(ldsResource); + Status notFoundStatus = Status.NOT_FOUND.withDescription("Resource not found: " + ldsResource); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); verify(mockServer).shutdown(); // re-deliver lds resource @@ -546,6 +554,7 @@ public void onChanged_listenerIsNull() throws ExecutionException, InterruptedException, TimeoutException { xdsServerWrapper = new XdsServerWrapper("10.1.2.3:1", mockBuilder, listener, selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry, executor.getScheduledExecutorService()); final SettableFuture start = SettableFuture.create(); Executors.newSingleThreadExecutor().execute(new Runnable() { @@ -575,6 +584,7 @@ public void onChanged_listenerAddressMissingPort() throws ExecutionException, InterruptedException, TimeoutException { xdsServerWrapper = new XdsServerWrapper("10.1.2.3:1", mockBuilder, listener, selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry, executor.getScheduledExecutorService()); final SettableFuture start = SettableFuture.create(); Executors.newSingleThreadExecutor().execute(new Runnable() { @@ -612,6 +622,7 @@ public void onChanged_listenerAddressMismatch() throws ExecutionException, InterruptedException, TimeoutException { xdsServerWrapper = new XdsServerWrapper("10.1.2.3:1", mockBuilder, listener, selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry, executor.getScheduledExecutorService()); final SettableFuture start = SettableFuture.create(); Executors.newSingleThreadExecutor().execute(new Runnable() { @@ -649,6 +660,7 @@ public void onChanged_listenerAddressPortMismatch() throws ExecutionException, InterruptedException, TimeoutException { xdsServerWrapper = new XdsServerWrapper("10.1.2.3:1", mockBuilder, listener, selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry, executor.getScheduledExecutorService()); final SettableFuture start = SettableFuture.create(); Executors.newSingleThreadExecutor().execute(new Runnable() { @@ -845,7 +857,7 @@ public void run() { EnvoyServerProtoData.FilterChain f1 = createFilterChain("filter-chain-1", createRds("r0")); xdsClient.deliverLdsUpdate(Arrays.asList(f0, f1), null); xdsClient.awaitRds(FakeXdsClient.DEFAULT_TIMEOUT); - xdsClient.rdsWatchers.get("r0").onError(Status.CANCELLED); + xdsClient.rdsWatchers.get("r0").onResourceChanged(StatusOr.fromStatus(Status.CANCELLED)); start.get(5000, TimeUnit.MILLISECONDS); assertThat(selectorManager.getSelectorToUpdateSelector().getRoutingConfigs().size()) .isEqualTo(2); @@ -865,13 +877,14 @@ public void run() { Collections.singletonList(createVirtualHost("virtual-host-1"))); assertThat(realConfig.interceptors()).isEqualTo(ImmutableMap.of()); - xdsClient.rdsWatchers.get("r0").onError(Status.CANCELLED); + xdsClient.rdsWatchers.get("r0").onAmbientError(Status.CANCELLED); realConfig = selectorManager.getSelectorToUpdateSelector().getRoutingConfigs().get(f1).get(); assertThat(realConfig.virtualHosts()).isEqualTo( Collections.singletonList(createVirtualHost("virtual-host-1"))); assertThat(realConfig.interceptors()).isEqualTo(ImmutableMap.of()); - xdsClient.rdsWatchers.get("r0").onResourceDoesNotExist("r0"); + Status notFoundStatus = Status.NOT_FOUND.withDescription("Resource r0 does not exist"); + xdsClient.rdsWatchers.get("r0").onResourceChanged(StatusOr.fromStatus(notFoundStatus)); realConfig = selectorManager.getSelectorToUpdateSelector().getRoutingConfigs().get(f1).get(); assertThat(realConfig.virtualHosts()).isEmpty(); assertThat(realConfig.interceptors()).isEmpty(); @@ -891,7 +904,9 @@ public void run() { } }); String ldsResource = xdsClient.ldsResource.get(5, TimeUnit.SECONDS); - xdsClient.ldsWatcher.onResourceDoesNotExist(ldsResource); + Status notFoundStatus = Status.NOT_FOUND.withDescription( + "FakeXdsClient: Resource not found: " + ldsResource); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); verify(listener, timeout(5000)).onNotServing(any()); try { start.get(START_WAIT_AFTER_LISTENER_MILLIS, TimeUnit.MILLISECONDS); @@ -905,10 +920,10 @@ public void run() { FilterChain filterChain0 = createFilterChain("filter-chain-0", createRds("rds")); SslContextProviderSupplier sslSupplier0 = filterChain0.sslContextProviderSupplier(); xdsClient.deliverLdsUpdate(Collections.singletonList(filterChain0), null); - xdsClient.ldsWatcher.onError(Status.INTERNAL); + ResourceWatcher saveRdsWatcher = xdsClient.rdsWatchers.get("rds"); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(Status.INTERNAL)); assertThat(selectorManager.getSelectorToUpdateSelector()) .isSameInstanceAs(FilterChainSelector.NO_FILTER_CHAIN); - ResourceWatcher saveRdsWatcher = xdsClient.rdsWatchers.get("rds"); verify(mockBuilder, times(1)).build(); verify(listener, times(2)).onNotServing(any(StatusException.class)); assertThat(sslSupplier0.isShutdown()).isFalse(); @@ -944,7 +959,7 @@ public void run() { xdsClient.deliverRdsUpdate("rds", Collections.singletonList(createVirtualHost("virtual-host-2"))); assertThat(sslSupplier1.isShutdown()).isFalse(); - xdsClient.ldsWatcher.onError(Status.DEADLINE_EXCEEDED); + xdsClient.ldsWatcher.onAmbientError(Status.DEADLINE_EXCEEDED); verify(mockBuilder, times(1)).build(); verify(mockServer, times(2)).start(); verify(listener, times(2)).onNotServing(any(StatusException.class)); @@ -959,17 +974,18 @@ public void run() { assertThat(sslSupplier1.isShutdown()).isFalse(); // not serving after serving - xdsClient.ldsWatcher.onResourceDoesNotExist(ldsResource); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); assertThat(xdsClient.rdsWatchers).isEmpty(); - verify(mockServer, times(2)).shutdown(); + verify(mockServer, times(3)).shutdown(); // This is the 3rd shutdown in the test. when(mockServer.isShutdown()).thenReturn(true); assertThat(selectorManager.getSelectorToUpdateSelector()) .isSameInstanceAs(FilterChainSelector.NO_FILTER_CHAIN); verify(listener, times(3)).onNotServing(any(StatusException.class)); assertThat(sslSupplier1.isShutdown()).isTrue(); + assertThat(xdsClient.rdsWatchers.get("rds")).isNull(); // no op - saveRdsWatcher.onChanged( - new RdsUpdate(Collections.singletonList(createVirtualHost("virtual-host-1")))); + saveRdsWatcher.onResourceChanged(StatusOr.fromValue( + new RdsUpdate(Collections.singletonList(createVirtualHost("virtual-host-1"))))); verify(mockBuilder, times(1)).build(); verify(mockServer, times(2)).start(); verify(listener, times(1)).onServing(); @@ -998,8 +1014,8 @@ public void run() { assertThat(realConfig.interceptors()).isEqualTo(ImmutableMap.of()); assertThat(executor.numPendingTasks()).isEqualTo(1); - xdsClient.ldsWatcher.onResourceDoesNotExist(ldsResource); - verify(mockServer, times(3)).shutdown(); + xdsClient.ldsWatcher.onResourceChanged(StatusOr.fromStatus(notFoundStatus)); + verify(mockServer, times(4)).shutdown(); verify(listener, times(4)).onNotServing(any(StatusException.class)); verify(listener, times(1)).onNotServing(any(IOException.class)); when(mockServer.isShutdown()).thenReturn(true); @@ -1027,7 +1043,7 @@ public void run() { assertThat(realConfig.interceptors()).isEqualTo(ImmutableMap.of()); xdsServerWrapper.shutdown(); - verify(mockServer, times(4)).shutdown(); + verify(mockServer, times(5)).shutdown(); assertThat(sslSupplier3.isShutdown()).isTrue(); when(mockServer.awaitTermination(anyLong(), any(TimeUnit.class))).thenReturn(true); assertThat(xdsServerWrapper.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); @@ -1278,7 +1294,7 @@ public void run() { Filter.Provider filterProvider = mock(Filter.Provider.class); when(filterProvider.typeUrls()).thenReturn(new String[]{"filter-type-url"}); when(filterProvider.isServerFilter()).thenReturn(true); - when(filterProvider.newInstance(any(String.class))).thenReturn(filter); + when(filterProvider.newInstance(any(FilterContext.class))).thenReturn(filter); filterRegistry.register(filterProvider); FilterConfig f0 = mock(FilterConfig.class); @@ -1351,7 +1367,7 @@ public void run() { Filter.Provider filterProvider = mock(Filter.Provider.class); when(filterProvider.typeUrls()).thenReturn(new String[]{"filter-type-url"}); when(filterProvider.isServerFilter()).thenReturn(true); - when(filterProvider.newInstance(any(String.class))).thenReturn(filter); + when(filterProvider.newInstance(any(FilterContext.class))).thenReturn(filter); filterRegistry.register(filterProvider); FilterConfig f0 = mock(FilterConfig.class); @@ -1421,7 +1437,8 @@ public ServerCall.Listener interceptCall(ServerCall filterStateTestStartServer(FilterRegistry filterRegistry) { xdsServerWrapper = new XdsServerWrapper("0.0.0.0:1", mockBuilder, listener, - selectorManager, new FakeXdsClientPoolFactory(xdsClient), filterRegistry); + selectorManager, new FakeXdsClientPoolFactory(xdsClient), + XdsServerTestHelper.RAW_BOOTSTRAP, filterRegistry); SettableFuture serverStart = SettableFuture.create(); scheduleServerStart(xdsServerWrapper, serverStart); return serverStart; diff --git a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java index b1818445bea..a0ebd0ea1bf 100644 --- a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java +++ b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java @@ -47,10 +47,10 @@ import io.grpc.BindableService; import io.grpc.Context; import io.grpc.Context.CancellationListener; +import io.grpc.InsecureChannelCredentials; import io.grpc.StatusOr; import io.grpc.internal.ExponentialBackoffPolicy; import io.grpc.internal.FakeClock; -import io.grpc.internal.JsonParser; import io.grpc.stub.StreamObserver; import io.grpc.xds.Endpoints.LbEndpoint; import io.grpc.xds.Endpoints.LocalityLbEndpoints; @@ -84,6 +84,14 @@ public class XdsTestUtils { static final String HTTP_CONNECTION_MANAGER_TYPE_URL = "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3" + ".HttpConnectionManager"; + 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; @@ -247,8 +255,8 @@ static XdsConfig getDefaultXdsConfig(String serverHostName) RouteConfiguration routeConfiguration = buildRouteConfiguration(serverHostName, RDS_NAME, CLUSTER_NAME); - Bootstrapper.ServerInfo serverInfo = null; - XdsResourceType.Args args = new XdsResourceType.Args(serverInfo, "0", "0", null, null, null); + XdsResourceType.Args args = new XdsResourceType.Args( + EMPTY_BOOTSTRAPPER_SERVER_INFO, "0", "0", EMPTY_BOOTSTRAP, null, null); XdsRouteConfigureResource.RdsUpdate rdsUpdate = XdsRouteConfigureResource.getInstance().doParse(args, routeConfiguration); @@ -268,8 +276,8 @@ static XdsConfig getDefaultXdsConfig(String serverHostName) XdsEndpointResource.EdsUpdate edsUpdate = new XdsEndpointResource.EdsUpdate( EDS_NAME, lbEndpointsMap, Collections.emptyList()); XdsClusterResource.CdsUpdate cdsUpdate = XdsClusterResource.CdsUpdate.forEds( - CLUSTER_NAME, EDS_NAME, serverInfo, null, null, null, false) - .lbPolicyConfig(getWrrLbConfigAsMap()).build(); + CLUSTER_NAME, EDS_NAME, null, null, null, null, false, null) + .lbPolicyConfigJsonForTesting(getWrrLbConfigAsMap()).build(); XdsConfig.XdsClusterConfig clusterConfig = new XdsConfig.XdsClusterConfig( CLUSTER_NAME, cdsUpdate, new EndpointConfig(StatusOr.fromValue(edsUpdate))); @@ -292,12 +300,9 @@ static Map createMinimalLbEndpointsMap(String ser return lbEndpointsMap; } - @SuppressWarnings("unchecked") - static ImmutableMap getWrrLbConfigAsMap() throws IOException { - String lbConfigStr = "{\"wrr_locality_experimental\" : " - + "{ \"childPolicy\" : [{\"round_robin\" : {}}]}}"; - - return ImmutableMap.copyOf((Map) JsonParser.parse(lbConfigStr)); + static ImmutableMap getWrrLbConfigAsMap() { + return ImmutableMap.of("wrr_locality_experimental", ImmutableMap.of("childPolicy", + ImmutableList.of(ImmutableMap.of("round_robin", ImmutableMap.of())))); } static RouteConfiguration buildRouteConfiguration(String authority, String rdsName, diff --git a/xds/src/test/java/io/grpc/xds/client/BackendMetricPropagationTest.java b/xds/src/test/java/io/grpc/xds/client/BackendMetricPropagationTest.java new file mode 100644 index 00000000000..31ad6f9c47f --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/client/BackendMetricPropagationTest.java @@ -0,0 +1,151 @@ +/* + * 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.client; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.Arrays.asList; + +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link BackendMetricPropagation}. + */ +@RunWith(JUnit4.class) +public class BackendMetricPropagationTest { + + @Test + public void fromMetricSpecs_nullInput() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs(null); + + assertThat(config.propagateCpuUtilization).isFalse(); + assertThat(config.propagateMemUtilization).isFalse(); + assertThat(config.propagateApplicationUtilization).isFalse(); + assertThat(config.shouldPropagateNamedMetric("any")).isFalse(); + } + + @Test + public void fromMetricSpecs_emptyInput() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs(ImmutableList.of()); + + assertThat(config.propagateCpuUtilization).isFalse(); + assertThat(config.propagateMemUtilization).isFalse(); + assertThat(config.propagateApplicationUtilization).isFalse(); + assertThat(config.shouldPropagateNamedMetric("any")).isFalse(); + } + + @Test + public void fromMetricSpecs_partialStandardMetrics() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of("cpu_utilization", "mem_utilization")); + + assertThat(config.propagateCpuUtilization).isTrue(); + assertThat(config.propagateMemUtilization).isTrue(); + assertThat(config.propagateApplicationUtilization).isFalse(); + assertThat(config.shouldPropagateNamedMetric("any")).isFalse(); + } + + @Test + public void fromMetricSpecs_allStandardMetrics() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of("cpu_utilization", "mem_utilization", "application_utilization")); + + assertThat(config.propagateCpuUtilization).isTrue(); + assertThat(config.propagateMemUtilization).isTrue(); + assertThat(config.propagateApplicationUtilization).isTrue(); + assertThat(config.shouldPropagateNamedMetric("any")).isFalse(); + } + + @Test + public void fromMetricSpecs_wildcardNamedMetrics() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of("named_metrics.*")); + + assertThat(config.propagateCpuUtilization).isFalse(); + assertThat(config.propagateMemUtilization).isFalse(); + assertThat(config.propagateApplicationUtilization).isFalse(); + assertThat(config.shouldPropagateNamedMetric("any_key")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("another_key")).isTrue(); + } + + @Test + public void fromMetricSpecs_specificNamedMetrics() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of("named_metrics.foo", "named_metrics.bar")); + + assertThat(config.shouldPropagateNamedMetric("foo")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("bar")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("baz")).isFalse(); + assertThat(config.shouldPropagateNamedMetric("any")).isFalse(); + } + + @Test + public void fromMetricSpecs_mixedStandardAndNamed() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of("cpu_utilization", "named_metrics.foo", "named_metrics.bar")); + + assertThat(config.propagateCpuUtilization).isTrue(); + assertThat(config.propagateMemUtilization).isFalse(); + assertThat(config.shouldPropagateNamedMetric("foo")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("bar")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("baz")).isFalse(); + } + + @Test + public void fromMetricSpecs_wildcardAndSpecificNamedMetrics() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of("named_metrics.foo", "named_metrics.*")); + + assertThat(config.shouldPropagateNamedMetric("foo")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("bar")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("any_other_key")).isTrue(); + } + + @Test + public void fromMetricSpecs_malformedAndUnknownSpecs_areIgnored() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + asList( + "cpu_utilization", + null, // ignored + "disk_utilization", + "named_metrics.", // empty key + "named_metrics.valid" + )); + + assertThat(config.propagateCpuUtilization).isTrue(); + assertThat(config.propagateMemUtilization).isFalse(); + assertThat(config.shouldPropagateNamedMetric("disk_utilization")).isFalse(); + assertThat(config.shouldPropagateNamedMetric("valid")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("")).isFalse(); // from the empty key + } + + @Test + public void fromMetricSpecs_duplicateSpecs_areHandledGracefully() { + BackendMetricPropagation config = BackendMetricPropagation.fromMetricSpecs( + ImmutableList.of( + "cpu_utilization", + "named_metrics.foo", + "cpu_utilization", + "named_metrics.foo")); + + assertThat(config.propagateCpuUtilization).isTrue(); + assertThat(config.shouldPropagateNamedMetric("foo")).isTrue(); + assertThat(config.shouldPropagateNamedMetric("bar")).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java b/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java index 754e903f8a9..e3760bd983f 100644 --- a/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java +++ b/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java @@ -203,7 +203,7 @@ public static Bootstrapper.BootstrapInfo buildBootStrap(List serverUris) List serverInfos = new ArrayList<>(); for (String uri : serverUris) { - serverInfos.add(ServerInfo.create(uri, CHANNEL_CREDENTIALS, false, true, false)); + serverInfos.add(ServerInfo.create(uri, CHANNEL_CREDENTIALS, false, true, false, false)); } EnvoyProtoData.Node node = EnvoyProtoData.Node.newBuilder().setId("node-id").build(); diff --git a/xds/src/test/java/io/grpc/xds/client/ControlPlaneClientTest.java b/xds/src/test/java/io/grpc/xds/client/ControlPlaneClientTest.java new file mode 100644 index 00000000000..64786c4fb3b --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/client/ControlPlaneClientTest.java @@ -0,0 +1,279 @@ +/* + * 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.client; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.base.Stopwatch; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import io.envoyproxy.envoy.service.discovery.v3.DiscoveryRequest; +import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse; +import io.grpc.InsecureChannelCredentials; +import io.grpc.MethodDescriptor; +import io.grpc.SynchronizationContext; +import io.grpc.internal.BackoffPolicy; +import io.grpc.internal.FakeClock; +import io.grpc.xds.client.Bootstrapper.ServerInfo; +import io.grpc.xds.client.EnvoyProtoData.Node; +import io.grpc.xds.client.XdsClient.ResourceStore; +import io.grpc.xds.client.XdsClient.XdsResponseHandler; +import io.grpc.xds.client.XdsTransportFactory.EventHandler; +import io.grpc.xds.client.XdsTransportFactory.StreamingCall; +import io.grpc.xds.client.XdsTransportFactory.XdsTransport; +import java.util.Collections; +import java.util.Map; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link ControlPlaneClient}. */ +@RunWith(JUnit4.class) +public class ControlPlaneClientTest { + + private static final String CDS_TYPE_URL = "type.googleapis.com/envoy.config.cluster.v3.Cluster"; + private static final String EDS_TYPE_URL = + "type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment"; + + private final SynchronizationContext syncContext = + new SynchronizationContext((t, e) -> { + throw new AssertionError("Uncaught exception in sync context", e); + }); + private final FakeClock fakeClock = new FakeClock(); + private final ServerInfo serverInfo = + ServerInfo.create("eds-control-plane:8443", InsecureChannelCredentials.create()); + private final Node bootstrapNode = Node.newBuilder().setId("test-node").build(); + + @Mock private XdsTransport xdsTransport; + @Mock private StreamingCall streamingCall; + @Mock private XdsResponseHandler responseHandler; + @Mock private ResourceStore resourceStore; + @Mock private BackoffPolicy.Provider backoffPolicyProvider; + @Mock private MessagePrettyPrinter messagePrinter; + @Mock private XdsResourceType cdsType; + @Mock private XdsResourceType edsType; + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private ControlPlaneClient cpc; + private ArgumentCaptor> handlerCaptor; + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + when(cdsType.typeUrl()).thenReturn(CDS_TYPE_URL); + when(cdsType.typeName()).thenReturn("CDS"); + when(edsType.typeUrl()).thenReturn(EDS_TYPE_URL); + when(edsType.typeName()).thenReturn("EDS"); + + when(xdsTransport.createStreamingCall( + anyString(), + any(MethodDescriptor.Marshaller.class), + any(MethodDescriptor.Marshaller.class))) + .thenReturn(streamingCall); + when(streamingCall.isReady()).thenReturn(true); + + handlerCaptor = ArgumentCaptor.forClass(EventHandler.class); + + cpc = new ControlPlaneClient( + xdsTransport, + serverInfo, + bootstrapNode, + responseHandler, + resourceStore, + fakeClock.getScheduledExecutorService(), + syncContext, + backoffPolicyProvider, + () -> Stopwatch.createUnstarted(fakeClock.getTicker()), + messagePrinter); + } + + /** + * Reproduces the bug where, when an ADS stream is opened to an authority-specific server (e.g. + * an EDS-only control plane), {@code sendDiscoveryRequests} previously emitted an empty + * DiscoveryRequest for every globally-subscribed resource type — including types this server + * does not handle. Authority-specific servers may reject those requests with UNIMPLEMENTED and + * tear down the stream, blocking the legitimate request that follows. + * + *

    Asserts that the empty CDS request is suppressed and only the EDS request (which has + * resources for this server) goes on the wire. + */ + @Test + public void streamReady_skipsEmptyDiscoveryRequestForUnsubscribedType() { + // CDS is globally subscribed (e.g. against a different authority) but has no resources on + // this server. EDS has one resource on this server. + Map> subscribedTypes = + ImmutableMap.of(CDS_TYPE_URL, cdsType, EDS_TYPE_URL, edsType); + when(resourceStore.getSubscribedResourceTypesWithTypeUrl()).thenReturn(subscribedTypes); + when(resourceStore.getSubscribedResources(serverInfo, cdsType)).thenReturn(null); + when(resourceStore.getSubscribedResources(serverInfo, edsType)) + .thenReturn(ImmutableList.of("foo-endpoint")); + + // Triggers stream creation and registers the EventHandler. + syncContext.execute(cpc::sendDiscoveryRequests); + verify(streamingCall).start(handlerCaptor.capture()); + + // Drive the stream into the connected state. onReady() flips sentInitialRequest=true and + // re-invokes sendDiscoveryRequests, which iterates the globally-subscribed types. + handlerCaptor.getValue().onReady(); + + // EDS request was sent with the one resource for this server. + ArgumentCaptor sent = ArgumentCaptor.forClass(DiscoveryRequest.class); + verify(streamingCall, atLeastOnce()).sendMessage(sent.capture()); + ImmutableSet sentTypes = sent.getAllValues().stream() + .map(DiscoveryRequest::getTypeUrl) + .collect(ImmutableSet.toImmutableSet()); + assertThat(sentTypes).contains(EDS_TYPE_URL); + assertThat(sentTypes).doesNotContain(CDS_TYPE_URL); + + // Confirm the EDS request actually carried the resource name. + DiscoveryRequest edsReq = sent.getAllValues().stream() + .filter(r -> r.getTypeUrl().equals(EDS_TYPE_URL)) + .findFirst() + .orElseThrow(() -> new AssertionError("EDS request not sent")); + assertThat(edsReq.getResourceNamesList()).containsExactly("foo-endpoint"); + } + + /** + * If a server has resources for every globally-subscribed type, the empty-skip guard is a + * no-op: a DiscoveryRequest is sent for every type. This guards against the skip becoming + * over-eager and dropping legitimate subscriptions. + */ + @Test + public void streamReady_sendsRequestForAllTypesWhenAllHaveResources() { + Map> subscribedTypes = + ImmutableMap.of(CDS_TYPE_URL, cdsType, EDS_TYPE_URL, edsType); + when(resourceStore.getSubscribedResourceTypesWithTypeUrl()).thenReturn(subscribedTypes); + when(resourceStore.getSubscribedResources(serverInfo, cdsType)) + .thenReturn(ImmutableList.of("foo-cluster")); + when(resourceStore.getSubscribedResources(serverInfo, edsType)) + .thenReturn(ImmutableList.of("foo-endpoint")); + + syncContext.execute(cpc::sendDiscoveryRequests); + verify(streamingCall).start(handlerCaptor.capture()); + handlerCaptor.getValue().onReady(); + + ArgumentCaptor sent = ArgumentCaptor.forClass(DiscoveryRequest.class); + verify(streamingCall, times(2)).sendMessage(sent.capture()); + ImmutableSet sentTypes = sent.getAllValues().stream() + .map(DiscoveryRequest::getTypeUrl) + .collect(ImmutableSet.toImmutableSet()); + assertThat(sentTypes).containsExactly(CDS_TYPE_URL, EDS_TYPE_URL); + } + + /** + * If only one type has a subscription on this server, no request is sent for the unsubscribed + * type. This is the canonical multi-authority federation case (e.g. fabric authority owns CDS, + * eds-control-plane owns EDS — the eds-control-plane stream should only see EDS). + */ + @Test + public void streamReady_skipsTypeWithNoSubscription() { + Map> subscribedTypes = + ImmutableMap.of(CDS_TYPE_URL, cdsType, EDS_TYPE_URL, edsType); + when(resourceStore.getSubscribedResourceTypesWithTypeUrl()).thenReturn(subscribedTypes); + when(resourceStore.getSubscribedResources(serverInfo, cdsType)).thenReturn(null); + when(resourceStore.getSubscribedResources(serverInfo, edsType)) + .thenReturn(ImmutableList.of("foo-endpoint")); + + syncContext.execute(cpc::sendDiscoveryRequests); + verify(streamingCall).start(handlerCaptor.capture()); + handlerCaptor.getValue().onReady(); + + verify(streamingCall, never()).sendMessage( + argThatTypeUrlIs(CDS_TYPE_URL)); + verify(streamingCall).sendMessage(argThatTypeUrlIs(EDS_TYPE_URL)); + } + + /** + * Per the ResourceStore contract in XdsClient.java, an empty collection from + * getSubscribedResources indicates a wildcard subscription. The skip-on-empty guard must not + * suppress wildcard requests on initial stream ready — the server needs the empty-resource-list + * DiscoveryRequest to start streaming, and the watcher's missing-resource timers must start. + */ + @Test + public void streamReady_sendsWildcardRequestAndStartsTimers() { + Map> subscribedTypes = ImmutableMap.of(CDS_TYPE_URL, cdsType); + when(resourceStore.getSubscribedResourceTypesWithTypeUrl()).thenReturn(subscribedTypes); + // Empty collection == wildcard subscription per the ResourceStore contract. + when(resourceStore.getSubscribedResources(serverInfo, cdsType)) + .thenReturn(Collections.emptyList()); + + syncContext.execute(cpc::sendDiscoveryRequests); + verify(streamingCall).start(handlerCaptor.capture()); + handlerCaptor.getValue().onReady(); + + ArgumentCaptor sent = ArgumentCaptor.forClass(DiscoveryRequest.class); + verify(streamingCall, atLeastOnce()).sendMessage(sent.capture()); + DiscoveryRequest cdsReq = sent.getAllValues().stream() + .filter(r -> r.getTypeUrl().equals(CDS_TYPE_URL)) + .findFirst() + .orElseThrow(() -> new AssertionError("CDS wildcard request not sent")); + assertThat(cdsReq.getResourceNamesList()).isEmpty(); + + verify(resourceStore).startMissingResourceTimers(Collections.emptyList(), cdsType); + } + + /** + * If a watch is canceled after the initial DiscoveryRequest goes out but before any response + * is ACKed, the empty unsubscribe must still be sent — otherwise the server keeps the stale + * subscription until the stream resets. The skip guard must gate on per-stream send history, + * not on the {@code versions} map (which is only populated on ACK). + */ + @Test + public void cancelBeforeAck_sendsEmptyUnsubscribe() { + Map> subscribedTypes = ImmutableMap.of(CDS_TYPE_URL, cdsType); + when(resourceStore.getSubscribedResourceTypesWithTypeUrl()).thenReturn(subscribedTypes); + when(resourceStore.getSubscribedResources(serverInfo, cdsType)) + .thenReturn(ImmutableList.of("foo-cluster")); + + syncContext.execute(cpc::sendDiscoveryRequests); + verify(streamingCall).start(handlerCaptor.capture()); + handlerCaptor.getValue().onReady(); + + // Initial DiscoveryRequest with the resource went out. No DiscoveryResponse has been ACKed. + verify(streamingCall).sendMessage(argThatTypeUrlIs(CDS_TYPE_URL)); + + // Cancel the watch before any response arrives: store now reports no subscription. + when(resourceStore.getSubscribedResources(serverInfo, cdsType)).thenReturn(null); + syncContext.execute(() -> cpc.adjustResourceSubscription(cdsType)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(DiscoveryRequest.class); + verify(streamingCall, times(2)).sendMessage(sent.capture()); + DiscoveryRequest unsub = sent.getAllValues().get(1); + assertThat(unsub.getTypeUrl()).isEqualTo(CDS_TYPE_URL); + assertThat(unsub.getResourceNamesList()).isEmpty(); + } + + private static DiscoveryRequest argThatTypeUrlIs(String typeUrl) { + return argThat(req -> req != null && typeUrl.equals(req.getTypeUrl())); + } +} \ No newline at end of file diff --git a/xds/src/test/java/io/grpc/xds/client/LoadStatsManager2Test.java b/xds/src/test/java/io/grpc/xds/client/LoadStatsManager2Test.java index 9a90a92dcbd..e6af888f353 100644 --- a/xds/src/test/java/io/grpc/xds/client/LoadStatsManager2Test.java +++ b/xds/src/test/java/io/grpc/xds/client/LoadStatsManager2Test.java @@ -27,6 +27,7 @@ import io.grpc.xds.client.Stats.ClusterStats; import io.grpc.xds.client.Stats.DroppedRequests; import io.grpc.xds.client.Stats.UpstreamLocalityStats; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -52,6 +53,9 @@ public class LoadStatsManager2Test { private static final Locality LOCALITY3 = Locality.create("test_region3", "test_zone3", "test_subzone3"); + private static final BackendMetricPropagation PROPAGATE_ALL = + BackendMetricPropagation.fromMetricSpecs(Arrays.asList("named_metrics.*")); + private final FakeClock fakeClock = new FakeClock(); private final LoadStatsManager2 loadStatsManager = new LoadStatsManager2(fakeClock.getStopwatchSupplier()); @@ -63,11 +67,11 @@ public void recordAndGetReport() { ClusterDropStats dropCounter2 = loadStatsManager.getClusterDropStats( CLUSTER_NAME1, EDS_SERVICE_NAME2); ClusterLocalityStats loadCounter1 = loadStatsManager.getClusterLocalityStats( - CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1); + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1, PROPAGATE_ALL); ClusterLocalityStats loadCounter2 = loadStatsManager.getClusterLocalityStats( - CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY2); + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY2, PROPAGATE_ALL); ClusterLocalityStats loadCounter3 = loadStatsManager.getClusterLocalityStats( - CLUSTER_NAME2, null, LOCALITY3); + CLUSTER_NAME2, null, LOCALITY3, PROPAGATE_ALL); dropCounter1.recordDroppedRequest("lb"); dropCounter1.recordDroppedRequest("throttle"); for (int i = 0; i < 19; i++) { @@ -105,18 +109,18 @@ public void recordAndGetReport() { assertThat(loadStats1.totalSuccessfulRequests()).isEqualTo(1L); assertThat(loadStats1.totalErrorRequests()).isEqualTo(0L); assertThat(loadStats1.totalRequestsInProgress()).isEqualTo(19L - 1L); - assertThat(loadStats1.loadMetricStatsMap().containsKey("named1")).isTrue(); - assertThat(loadStats1.loadMetricStatsMap().containsKey("named2")).isTrue(); + assertThat(loadStats1.loadMetricStatsMap().containsKey("named_metrics.named1")).isTrue(); + assertThat(loadStats1.loadMetricStatsMap().containsKey("named_metrics.named2")).isTrue(); assertThat( - loadStats1.loadMetricStatsMap().get("named1").numRequestsFinishedWithMetric()).isEqualTo( - 4L); - assertThat(loadStats1.loadMetricStatsMap().get("named1").totalMetricValue()).isWithin(TOLERANCE) - .of(3.14159 + 1.618 + 99 - 97.23); + loadStats1.loadMetricStatsMap().get("named_metrics.named1") + .numRequestsFinishedWithMetric()).isEqualTo(4L); + assertThat(loadStats1.loadMetricStatsMap().get("named_metrics.named1").totalMetricValue()) + .isWithin(TOLERANCE).of(3.14159 + 1.618 + 99 - 97.23); assertThat( - loadStats1.loadMetricStatsMap().get("named2").numRequestsFinishedWithMetric()).isEqualTo( - 1L); - assertThat(loadStats1.loadMetricStatsMap().get("named2").totalMetricValue()).isWithin(TOLERANCE) - .of(-2.718); + loadStats1.loadMetricStatsMap().get("named_metrics.named2") + .numRequestsFinishedWithMetric()).isEqualTo(1L); + assertThat(loadStats1.loadMetricStatsMap().get("named_metrics.named2").totalMetricValue()) + .isWithin(TOLERANCE).of(-2.718); UpstreamLocalityStats loadStats2 = findLocalityStats(stats1.upstreamLocalityStatsList(), LOCALITY2); @@ -124,12 +128,12 @@ public void recordAndGetReport() { assertThat(loadStats2.totalSuccessfulRequests()).isEqualTo(0L); assertThat(loadStats2.totalErrorRequests()).isEqualTo(1L); assertThat(loadStats2.totalRequestsInProgress()).isEqualTo(9L - 1L); - assertThat(loadStats2.loadMetricStatsMap().containsKey("named3")).isTrue(); + assertThat(loadStats2.loadMetricStatsMap().containsKey("named_metrics.named3")).isTrue(); assertThat( - loadStats2.loadMetricStatsMap().get("named3").numRequestsFinishedWithMetric()).isEqualTo( - 1L); - assertThat(loadStats2.loadMetricStatsMap().get("named3").totalMetricValue()).isWithin(TOLERANCE) - .of(0.0009); + loadStats2.loadMetricStatsMap().get("named_metrics.named3") + .numRequestsFinishedWithMetric()).isEqualTo(1L); + assertThat(loadStats2.loadMetricStatsMap().get("named_metrics.named3").totalMetricValue()) + .isWithin(TOLERANCE).of(0.0009); ClusterStats stats2 = findClusterStats(allStats, CLUSTER_NAME1, EDS_SERVICE_NAME2); assertThat(stats2.loadReportIntervalNano()).isEqualTo(TimeUnit.SECONDS.toNanos(5L + 10L)); @@ -220,9 +224,9 @@ public void dropCounterDelayedDeletionAfterReported() { @Test public void sharedLoadCounterStatsAggregation() { ClusterLocalityStats ref1 = loadStatsManager.getClusterLocalityStats( - CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1); + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1, PROPAGATE_ALL); ClusterLocalityStats ref2 = loadStatsManager.getClusterLocalityStats( - CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1); + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1, PROPAGATE_ALL); ref1.recordCallStarted(); ref1.recordBackendLoadMetricStats(ImmutableMap.of("named1", 1.618)); ref1.recordBackendLoadMetricStats(ImmutableMap.of("named1", 3.14159)); @@ -240,24 +244,77 @@ public void sharedLoadCounterStatsAggregation() { assertThat(localityStats.totalSuccessfulRequests()).isEqualTo(1L); assertThat(localityStats.totalErrorRequests()).isEqualTo(1L); assertThat(localityStats.totalRequestsInProgress()).isEqualTo(1L + 2L - 1L - 1L); - assertThat(localityStats.loadMetricStatsMap().containsKey("named1")).isTrue(); - assertThat(localityStats.loadMetricStatsMap().containsKey("named2")).isTrue(); + assertThat(localityStats.loadMetricStatsMap().containsKey("named_metrics.named1")).isTrue(); + assertThat(localityStats.loadMetricStatsMap().containsKey("named_metrics.named2")).isTrue(); assertThat( - localityStats.loadMetricStatsMap().get("named1").numRequestsFinishedWithMetric()).isEqualTo( - 3L); - assertThat(localityStats.loadMetricStatsMap().get("named1").totalMetricValue()).isWithin( - TOLERANCE).of(1.618 + 3.14159 - 1); + localityStats.loadMetricStatsMap().get("named_metrics.named1") + .numRequestsFinishedWithMetric()).isEqualTo(3L); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named1") + .totalMetricValue()).isWithin(TOLERANCE).of(1.618 + 3.14159 - 1); assertThat( - localityStats.loadMetricStatsMap().get("named2").numRequestsFinishedWithMetric()).isEqualTo( - 1L); - assertThat(localityStats.loadMetricStatsMap().get("named2").totalMetricValue()).isEqualTo( - 2.718); + localityStats.loadMetricStatsMap().get("named_metrics.named2") + .numRequestsFinishedWithMetric()).isEqualTo(1L); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named2") + .totalMetricValue()).isEqualTo(2.718); + } + + @Test + public void recordMetrics_orcaLrsPropagationEnabled_specificMetrics() { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = true; + BackendMetricPropagation backendMetricPropagation = BackendMetricPropagation.fromMetricSpecs( + Arrays.asList("cpu_utilization", "named_metrics.named1")); + ClusterLocalityStats stats = loadStatsManager.getClusterLocalityStats( + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1, backendMetricPropagation); + + stats.recordTopLevelMetrics(0.8, 0.5, 0.0); // cpu, mem, app + stats.recordBackendLoadMetricStats(ImmutableMap.of("named1", 123.4, "named2", 567.8)); + stats.recordCallFinished(Status.OK); + ClusterStats report = Iterables.getOnlyElement( + loadStatsManager.getClusterStatsReports(CLUSTER_NAME1)); + UpstreamLocalityStats localityStats = + Iterables.getOnlyElement(report.upstreamLocalityStatsList()); + + assertThat(localityStats.loadMetricStatsMap()).containsKey("cpu_utilization"); + assertThat(localityStats.loadMetricStatsMap().get("cpu_utilization").totalMetricValue()) + .isWithin(TOLERANCE).of(0.8); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("mem_utilization"); + assertThat(localityStats.loadMetricStatsMap()).containsKey("named_metrics.named1"); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named1").totalMetricValue()) + .isWithin(TOLERANCE).of(123.4); + assertThat(localityStats.loadMetricStatsMap()).doesNotContainKey("named_metrics.named2"); + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; + } + + @Test + public void recordMetrics_orcaLrsPropagationEnabled_wildcardNamedMetrics() { + boolean originalVal = LoadStatsManager2.isEnabledOrcaLrsPropagation; + LoadStatsManager2.isEnabledOrcaLrsPropagation = true; + BackendMetricPropagation backendMetricPropagation = BackendMetricPropagation.fromMetricSpecs( + Arrays.asList("named_metrics.*")); + ClusterLocalityStats stats = loadStatsManager.getClusterLocalityStats( + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1, backendMetricPropagation); + + stats.recordBackendLoadMetricStats(ImmutableMap.of("named1", 123.4, "named2", 567.8)); + stats.recordCallFinished(Status.OK); + ClusterStats report = Iterables.getOnlyElement( + loadStatsManager.getClusterStatsReports(CLUSTER_NAME1)); + UpstreamLocalityStats localityStats = + Iterables.getOnlyElement(report.upstreamLocalityStatsList()); + + assertThat(localityStats.loadMetricStatsMap()).containsKey("named_metrics.named1"); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named1").totalMetricValue()) + .isWithin(TOLERANCE).of(123.4); + assertThat(localityStats.loadMetricStatsMap()).containsKey("named_metrics.named2"); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named2").totalMetricValue()) + .isWithin(TOLERANCE).of(567.8); + LoadStatsManager2.isEnabledOrcaLrsPropagation = originalVal; } @Test public void loadCounterDelayedDeletionAfterAllInProgressRequestsReported() { ClusterLocalityStats counter = loadStatsManager.getClusterLocalityStats( - CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1); + CLUSTER_NAME1, EDS_SERVICE_NAME1, LOCALITY1, PROPAGATE_ALL); counter.recordCallStarted(); counter.recordCallStarted(); counter.recordBackendLoadMetricStats(ImmutableMap.of("named1", 2.718)); @@ -271,12 +328,12 @@ public void loadCounterDelayedDeletionAfterAllInProgressRequestsReported() { assertThat(localityStats.totalSuccessfulRequests()).isEqualTo(0L); assertThat(localityStats.totalErrorRequests()).isEqualTo(0L); assertThat(localityStats.totalRequestsInProgress()).isEqualTo(2L); - assertThat(localityStats.loadMetricStatsMap().containsKey("named1")).isTrue(); + assertThat(localityStats.loadMetricStatsMap().containsKey("named_metrics.named1")).isTrue(); assertThat( - localityStats.loadMetricStatsMap().get("named1").numRequestsFinishedWithMetric()).isEqualTo( - 2L); - assertThat(localityStats.loadMetricStatsMap().get("named1").totalMetricValue()).isEqualTo( - 2.718 + 1.414); + localityStats.loadMetricStatsMap().get("named_metrics.named1") + .numRequestsFinishedWithMetric()).isEqualTo(2L); + assertThat(localityStats.loadMetricStatsMap().get("named_metrics.named1") + .totalMetricValue()).isEqualTo(2.718 + 1.414); // release the counter, but requests still in-flight counter.release(); diff --git a/xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java b/xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java new file mode 100644 index 00000000000..86a6a95fd4b --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/MatcherParserTest.java @@ -0,0 +1,85 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +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 org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class MatcherParserTest { + + @Test + public void parseStringMatcher_exact() { + StringMatcher proto = + StringMatcher.newBuilder().setExact("exact-match").setIgnoreCase(true).build(); + Matchers.StringMatcher matcher = MatcherParser.parseStringMatcher(proto); + assertThat(matcher).isNotNull(); + } + + @Test + public void parseStringMatcher_allTypes() { + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setExact("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setPrefix("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setSuffix("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder().setContains("test").build()); + MatcherParser.parseStringMatcher(StringMatcher.newBuilder() + .setSafeRegex(RegexMatcher.newBuilder().setRegex(".*").build()).build()); + } + + @Test + public void parseStringMatcher_unknownTypeThrows() { + StringMatcher unknownProto = StringMatcher.getDefaultInstance(); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> MatcherParser.parseStringMatcher(unknownProto)); + assertThat(exception).hasMessageThat().contains("Unknown StringMatcher match pattern"); + } + + @Test + public void parseFractionMatcher_denominators() { + Matchers.FractionMatcher hundred = MatcherParser.parseFractionMatcher(FractionalPercent + .newBuilder().setNumerator(1).setDenominator(DenominatorType.HUNDRED).build()); + assertThat(hundred.numerator()).isEqualTo(1); + assertThat(hundred.denominator()).isEqualTo(100); + + Matchers.FractionMatcher tenThousand = MatcherParser.parseFractionMatcher(FractionalPercent + .newBuilder().setNumerator(2).setDenominator(DenominatorType.TEN_THOUSAND).build()); + assertThat(tenThousand.numerator()).isEqualTo(2); + assertThat(tenThousand.denominator()).isEqualTo(10_000); + + Matchers.FractionMatcher million = MatcherParser.parseFractionMatcher(FractionalPercent + .newBuilder().setNumerator(3).setDenominator(DenominatorType.MILLION).build()); + assertThat(million.numerator()).isEqualTo(3); + assertThat(million.denominator()).isEqualTo(1_000_000); + } + + @Test + public void parseFractionMatcher_unknownDenominatorThrows() { + FractionalPercent unknownProto = + FractionalPercent.newBuilder().setDenominatorValue(999).build(); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> MatcherParser.parseFractionMatcher(unknownProto)); + assertThat(exception).hasMessageThat().contains("Unknown denominator type"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/MetricReportUtilsTest.java b/xds/src/test/java/io/grpc/xds/internal/MetricReportUtilsTest.java new file mode 100644 index 00000000000..9d7a3910216 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/MetricReportUtilsTest.java @@ -0,0 +1,115 @@ +/* + * 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import io.grpc.services.InternalCallMetricRecorder; +import io.grpc.services.MetricReport; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalDouble; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link MetricReportUtils}. */ +@RunWith(JUnit4.class) +public class MetricReportUtilsTest { + + @Test + public void getMetricValue_cpuUtilization() { + MetricReport report = createMetricReport(0.5, 0.1, 0.2, 10.0, 5.0, Collections.emptyMap()); + MetricReportUtils.ParsedMetricName parsed = + MetricReportUtils.ParsedMetricName.parse("cpu_utilization"); + OptionalDouble result = MetricReportUtils.getMetricValue(report, parsed); + assertTrue(result.isPresent()); + assertEquals(0.5, result.getAsDouble(), 0.0001); + } + + @Test + public void getMetricValue_applicationUtilization() { + MetricReport report = createMetricReport(0.5, 0.1, 0.2, 10.0, 5.0, Collections.emptyMap()); + MetricReportUtils.ParsedMetricName parsed = + MetricReportUtils.ParsedMetricName.parse("application_utilization"); + OptionalDouble result = MetricReportUtils.getMetricValue(report, parsed); + assertTrue(result.isPresent()); + assertEquals(0.1, result.getAsDouble(), 0.0001); + } + + @Test + public void getMetricValue_memUtilization() { + MetricReport report = createMetricReport(0.5, 0.1, 0.2, 10.0, 5.0, Collections.emptyMap()); + MetricReportUtils.ParsedMetricName parsed = + MetricReportUtils.ParsedMetricName.parse("mem_utilization"); + OptionalDouble result = MetricReportUtils.getMetricValue(report, parsed); + assertTrue(result.isPresent()); + assertEquals(0.2, result.getAsDouble(), 0.0001); + } + + @Test + public void getMetricValue_utilizationMetric() { + Map utilizationMetrics = new HashMap<>(); + utilizationMetrics.put("foo", 1.23); + MetricReport report = InternalCallMetricRecorder.createMetricReport( + 0, 0, 0, 0, 0, Collections.emptyMap(), utilizationMetrics, Collections.emptyMap()); + + MetricReportUtils.ParsedMetricName parsed = + MetricReportUtils.ParsedMetricName.parse("utilization.foo"); + OptionalDouble result = MetricReportUtils.getMetricValue(report, parsed); + assertTrue(result.isPresent()); + assertEquals(1.23, result.getAsDouble(), 0.0001); + + MetricReportUtils.ParsedMetricName bad = + MetricReportUtils.ParsedMetricName.parse("utilization.bar"); + assertFalse(MetricReportUtils.getMetricValue(report, bad).isPresent()); + } + + @Test + public void getMetricValue_namedMetric() { + Map namedMetrics = new HashMap<>(); + namedMetrics.put("foo", 7.89); + MetricReport report = createMetricReport(0, 0, 0, 0, 0, namedMetrics); + + MetricReportUtils.ParsedMetricName parsed = + MetricReportUtils.ParsedMetricName.parse("named_metrics.foo"); + OptionalDouble result = MetricReportUtils.getMetricValue(report, parsed); + assertTrue(result.isPresent()); + assertEquals(7.89, result.getAsDouble(), 0.0001); + + MetricReportUtils.ParsedMetricName bad = + MetricReportUtils.ParsedMetricName.parse("named_metrics.bar"); + assertFalse(MetricReportUtils.getMetricValue(report, bad).isPresent()); + } + + @Test + public void getMetricValue_invalidMetric() { + MetricReport report = createMetricReport(0.5, 0.1, 0.2, 10.0, 5.0, Collections.emptyMap()); + MetricReportUtils.ParsedMetricName invalid = + MetricReportUtils.ParsedMetricName.parse("invalid_metric"); + assertFalse(MetricReportUtils.getMetricValue(report, invalid).isPresent()); + } + + private MetricReport createMetricReport(double cpu, double app, double mem, double qps, + double eps, Map namedMetrics) { + return InternalCallMetricRecorder.createMetricReport( + cpu, app, mem, qps, eps, Collections.emptyMap(), Collections.emptyMap(), namedMetrics); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java new file mode 100644 index 00000000000..40b099699de --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueTest.java @@ -0,0 +1,60 @@ +/* + * 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.grpcservice; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.protobuf.ByteString; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderValueTest { + + @Test + public void create_withStringValue_success() { + HeaderValue headerValue = HeaderValue.create("key1", "value1"); + assertThat(headerValue.key()).isEqualTo("key1"); + assertThat(headerValue.value().isPresent()).isTrue(); + assertThat(headerValue.value().get()).isEqualTo("value1"); + assertThat(headerValue.rawValue().isPresent()).isFalse(); + } + + @Test + public void create_withByteStringValue_success() { + ByteString rawValue = ByteString.copyFromUtf8("raw_value"); + HeaderValue headerValue = HeaderValue.create("key2", rawValue); + assertThat(headerValue.key()).isEqualTo("key2"); + assertThat(headerValue.rawValue().isPresent()).isTrue(); + assertThat(headerValue.rawValue().get()).isEqualTo(rawValue); + assertThat(headerValue.value().isPresent()).isFalse(); + } + + @Test(expected = IllegalArgumentException.class) + public void create_emptyKey_throws() { + HeaderValue.create("", "value"); + } + + @Test(expected = IllegalArgumentException.class) + public void create_tooLongValue_throws() { + String longVal = new String(new char[16385]).replace('\0', 'v'); + HeaderValue.create("key", longVal); + } + + +} diff --git a/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java new file mode 100644 index 00000000000..68435a0c2b9 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/grpcservice/HeaderValueValidationUtilsTest.java @@ -0,0 +1,102 @@ +/* + * 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.truth.Truth.assertThat; + +import com.google.protobuf.ByteString; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Unit tests for {@link HeaderValueValidationUtils}. + */ +@RunWith(JUnit4.class) +public class HeaderValueValidationUtilsTest { + + @Test + public void isDisallowed_string_emptyKey() { + assertThat(HeaderValueValidationUtils.isDisallowed("")).isTrue(); + } + + @Test + public void isDisallowed_string_tooLongKey() { + String longKey = new String(new char[16385]).replace('\0', 'a'); + assertThat(HeaderValueValidationUtils.isDisallowed(longKey)).isTrue(); + } + + @Test + public void isDisallowed_string_notLowercase() { + assertThat(HeaderValueValidationUtils.isDisallowed("Content-Type")).isTrue(); + } + + @Test + public void isDisallowed_string_grpcPrefix() { + assertThat(HeaderValueValidationUtils.isDisallowed("grpc-timeout")).isTrue(); + } + + @Test + public void isDisallowed_string_systemHeader_colon() { + assertThat(HeaderValueValidationUtils.isDisallowed(":authority")).isTrue(); + } + + @Test + public void isDisallowed_string_systemHeader_host() { + assertThat(HeaderValueValidationUtils.isDisallowed("host")).isTrue(); + } + + @Test + public void isDisallowed_string_valid() { + assertThat(HeaderValueValidationUtils.isDisallowed("content-type")).isFalse(); + } + + @Test + public void isDisallowed_headerValue_valid() { + HeaderValue header = HeaderValue.create("content-type", "application/grpc"); + assertThat(HeaderValueValidationUtils.isDisallowed(header)).isFalse(); + } + + @Test(expected = IllegalArgumentException.class) + public void isDisallowed_headerValue_invalidAsciiString() { + HeaderValue.create("content-type", "application/grpc\n"); + } + + @Test(expected = IllegalArgumentException.class) + public void isDisallowed_headerValue_invalidAsciiRaw() { + HeaderValue.create("content-type", ByteString.copyFrom(new byte[]{0x61, 0x7F})); + } + + @Test + public void isDisallowed_headerValue_validAsciiWithTab() { + HeaderValue header = HeaderValue.create("content-type", "application/grpc\t"); + assertThat(HeaderValueValidationUtils.isDisallowed(header)).isFalse(); + } + + @Test + public void isDisallowed_headerValue_binaryHeaderWithArbitraryBytes() { + // Binary headers ending in -bin can contain any arbitrary bytes (like 0x00, 0x7F, etc.) + HeaderValue headerString = HeaderValue.create( + "custom-bin", + new String(new byte[]{0x00, 0x7F}, java.nio.charset.StandardCharsets.UTF_8)); + assertThat(HeaderValueValidationUtils.isDisallowed(headerString)).isFalse(); + + HeaderValue headerRaw = HeaderValue.create( + "custom-bin", ByteString.copyFrom(new byte[]{0x00, 0x7F})); + assertThat(HeaderValueValidationUtils.isDisallowed(headerRaw)).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java new file mode 100644 index 00000000000..ce8ffbb3efb --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationFilterTest.java @@ -0,0 +1,218 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.re2j.Pattern; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationFilterTest { + private static HeaderValueOption header(String key, String value) { + return HeaderValueOption.create(HeaderValue.create(key, value), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + } + + @Test + public void filter_validationRules_dropsInvalidHeaders() throws Exception { + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.empty()); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of( + header("add-key", "add-value"), header(":authority", "new-authority"), + header("host", "new-host"), header(":scheme", "https"), header(":method", "PUT"), + header("resp-add-key", "resp-add-value"), header(":scheme", "https"), + header(":path", "/new-path"), header(":grpc-trace-bin", "binary-value"), + header(":alt-svc", "h3=:443"), header("user-agent", "new-agent"), + header("Valid-Key", "value"), + header("grpc-timeout", "10S"), header("valid-key-lower", "value")), + ImmutableList.of("remove-key", "host", ":authority", ":scheme", ":method", ":foo", ":bar", + "Valid-Key", "grpc-timeout", "UPPER-REMOVE", "lower-remove")); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.headersToRemove()).containsExactly("remove-key", "lower-remove"); + assertThat(filtered.headers()).containsExactly( + header("add-key", "add-value"), header("resp-add-key", "resp-add-value"), + header("user-agent", "new-agent"), header("valid-key-lower", "value")); + } + + @Test + public void filter_validationRules_throwsOnInvalidHeaders() throws Exception { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowIsError(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Test system headers modification + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header(":path", "/new-path")), ImmutableList.of()))); + + // Test system headers removal + assertThrows(HeaderMutationDisallowedException.class, + () -> filter.filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of(":path")))); + + // Test uppercase header modification + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("Valid-Key", "value")), ImmutableList.of()))); + + // Test uppercase header removal + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of("UPPER-REMOVE")))); + } + + + @Test + public void filter_mutationRules_disallowAll_dropsAll() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder().disallowAll(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of(header("add-key", "add-value"), header("resp-add-key", "resp-add-value")), + ImmutableList.of("remove-key")); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.headers()).isEmpty(); + assertThat(filtered.headersToRemove()).isEmpty(); + } + + @Test + public void filter_mutationRules_disallowAll_throws() throws Exception { + HeaderMutationRulesConfig rules = + HeaderMutationRulesConfig.builder().disallowAll(true).disallowIsError(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Test add header + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("add-key", "add-value")), ImmutableList.of()))); + + // Test remove header + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of("remove-key")))); + + // Test response header + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("resp-add-key", "resp-add-value")), ImmutableList.of()))); + } + + + @Test + public void filter_mutationRules_disallowExpression_dropsMatching() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowExpression(Pattern.compile("^x-private-.*")).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of(header("x-public", "value"), header("x-private-key", "value"), + header("x-public-resp", "value"), header("x-private-resp", "value")), + ImmutableList.of("x-public-remove", "x-private-remove")); + + HeaderMutations filtered = filter.filter(mutations); + + assertThat(filtered.headersToRemove()).containsExactly("x-public-remove"); + assertThat(filtered.headers()).containsExactly(header("x-public", "value"), + header("x-public-resp", "value")); + } + + @Test + public void filter_mutationRules_disallowExpression_throws() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowExpression(Pattern.compile("^x-private-.*")).disallowIsError(true).build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Test disallowed key modification + assertThrows(HeaderMutationDisallowedException.class, () -> filter.filter(HeaderMutations + .create( + ImmutableList.of(header("x-private-key", "value")), ImmutableList.of()))); + + // Test disallowed key removal + assertThrows(HeaderMutationDisallowedException.class, () -> filter + .filter(HeaderMutations.create( + ImmutableList.of(), ImmutableList.of("x-private-remove")))); + } + + + @Test + public void filter_mutationRules_precedence() throws Exception { + HeaderMutationRulesConfig rules = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .allowExpression(Pattern.compile("^x-allowed-.*")) + .disallowExpression(Pattern.compile("^x-allowed-but-disallowed-.*")) + .build(); + HeaderMutationFilter filter = new HeaderMutationFilter(Optional.of(rules)); + + // Case 1: allowExpression overrides disallowAll + HeaderMutations mutations1 = HeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value"), header("not-allowed", "value")), + ImmutableList.of("x-allowed-remove", "not-allowed-remove")); + HeaderMutations filtered1 = filter.filter(mutations1); + assertThat(filtered1.headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered1.headers()).containsExactly(header("x-allowed-key", "value")); + + // Case 2: disallowExpression overrides allowExpression + HeaderMutations mutations2 = HeaderMutations.create( + ImmutableList.of(header("x-allowed-but-disallowed-key", "value")), + ImmutableList.of("x-allowed-but-disallowed-remove")); + HeaderMutations filtered2 = filter.filter(mutations2); + assertThat(filtered2.headers()).isEmpty(); + assertThat(filtered2.headersToRemove()).isEmpty(); + } + + @Test + public void filter_mutationRules_precedence_throws() throws Exception { + // Case 1: allowExpression overrides disallowAll (does not throw) + HeaderMutationRulesConfig rules1 = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .allowExpression(Pattern.compile("^x-allowed-.*")) + .disallowIsError(true) + .build(); + HeaderMutationFilter filter1 = new HeaderMutationFilter(Optional.of(rules1)); + HeaderMutations mutations1 = HeaderMutations.create( + ImmutableList.of(header("x-allowed-key", "value")), ImmutableList.of("x-allowed-remove")); + HeaderMutations filtered1 = filter1.filter(mutations1); + assertThat(filtered1.headersToRemove()).containsExactly("x-allowed-remove"); + assertThat(filtered1.headers()).containsExactly(header("x-allowed-key", "value")); + + // Case 2: disallowExpression overrides allowExpression (throws) + HeaderMutationRulesConfig rules2 = HeaderMutationRulesConfig.builder() + .allowExpression(Pattern.compile("^x-allowed-.*")) + .disallowExpression(Pattern.compile("^x-allowed-but-disallowed-.*")) + .disallowIsError(true) + .build(); + HeaderMutationFilter filter2 = new HeaderMutationFilter(Optional.of(rules2)); + assertThrows(HeaderMutationDisallowedException.class, + () -> filter2.filter(HeaderMutations.create( + ImmutableList.of(header("x-allowed-but-disallowed-key", "value")), + ImmutableList.of()))); + + assertThrows(HeaderMutationDisallowedException.class, () -> filter2.filter(HeaderMutations + .create( + ImmutableList.of(), ImmutableList.of("x-allowed-but-disallowed-remove")))); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java new file mode 100644 index 00000000000..9f5cb75460f --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesConfigTest.java @@ -0,0 +1,84 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.re2j.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationRulesConfigTest { + @Test + public void testBuilderDefaultValues() { + HeaderMutationRulesConfig config = HeaderMutationRulesConfig.builder().build(); + assertFalse(config.disallowAll()); + assertFalse(config.disallowIsError()); + assertThat(config.allowExpression()).isEmpty(); + assertThat(config.disallowExpression()).isEmpty(); + } + + @Test + public void testBuilder_setDisallowAll() { + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowAll(true).build(); + assertTrue(config.disallowAll()); + } + + @Test + public void testBuilder_setDisallowIsError() { + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowIsError(true).build(); + assertTrue(config.disallowIsError()); + } + + @Test + public void testBuilder_setAllowExpression() { + Pattern pattern = Pattern.compile("allow.*"); + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().allowExpression(pattern).build(); + assertThat(config.allowExpression()).hasValue(pattern); + } + + @Test + public void testBuilder_setDisallowExpression() { + Pattern pattern = Pattern.compile("disallow.*"); + HeaderMutationRulesConfig config = + HeaderMutationRulesConfig.builder().disallowExpression(pattern).build(); + assertThat(config.disallowExpression()).hasValue(pattern); + } + + @Test + public void testBuilder_setAll() { + Pattern allowPattern = Pattern.compile("allow.*"); + Pattern disallowPattern = Pattern.compile("disallow.*"); + HeaderMutationRulesConfig config = HeaderMutationRulesConfig.builder() + .disallowAll(true) + .disallowIsError(true) + .allowExpression(allowPattern) + .disallowExpression(disallowPattern) + .build(); + assertTrue(config.disallowAll()); + assertTrue(config.disallowIsError()); + assertThat(config.allowExpression()).hasValue(allowPattern); + assertThat(config.disallowExpression()).hasValue(disallowPattern); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java new file mode 100644 index 00000000000..e880c197450 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationRulesParserTest.java @@ -0,0 +1,90 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.protobuf.BoolValue; +import io.envoyproxy.envoy.config.common.mutation_rules.v3.HeaderMutationRules; +import io.envoyproxy.envoy.type.matcher.v3.RegexMatcher; +import io.grpc.xds.internal.headermutations.HeaderMutationRulesParseException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationRulesParserTest { + + @Test + public void parse_protoWithAllFields_success() throws Exception { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow-.*")) + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow-.*")) + .setDisallowAll(BoolValue.newBuilder().setValue(true).build()) + .setDisallowIsError(BoolValue.newBuilder().setValue(true).build()) + .build(); + + HeaderMutationRulesConfig config = HeaderMutationRulesParser.parse(proto); + + assertThat(config.allowExpression().isPresent()).isTrue(); + assertThat(config.allowExpression().get().pattern()).isEqualTo("allow-.*"); + + assertThat(config.disallowExpression().isPresent()).isTrue(); + assertThat(config.disallowExpression().get().pattern()).isEqualTo("disallow-.*"); + + assertThat(config.disallowAll()).isTrue(); + assertThat(config.disallowIsError()).isTrue(); + } + + @Test + public void parse_protoWithNoExpressions_success() throws Exception { + HeaderMutationRules proto = HeaderMutationRules.newBuilder().build(); + + HeaderMutationRulesConfig config = HeaderMutationRulesParser.parse(proto); + + assertThat(config.allowExpression().isPresent()).isFalse(); + assertThat(config.disallowExpression().isPresent()).isFalse(); + assertThat(config.disallowAll()).isFalse(); + assertThat(config.disallowIsError()).isFalse(); + } + + @Test + public void parse_invalidRegexAllowExpression_throwsHeaderMutationRulesParseException() { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setAllowExpression(RegexMatcher.newBuilder().setRegex("allow-[")) + .build(); + + HeaderMutationRulesParseException exception = assertThrows( + HeaderMutationRulesParseException.class, () -> HeaderMutationRulesParser.parse(proto)); + + assertThat(exception).hasMessageThat().contains("Invalid regex pattern for allow_expression"); + } + + @Test + public void parse_invalidRegexDisallowExpression_throwsHeaderMutationRulesParseException() { + HeaderMutationRules proto = HeaderMutationRules.newBuilder() + .setDisallowExpression(RegexMatcher.newBuilder().setRegex("disallow-[")) + .build(); + + HeaderMutationRulesParseException exception = assertThrows( + HeaderMutationRulesParseException.class, () -> HeaderMutationRulesParser.parse(proto)); + + assertThat(exception).hasMessageThat() + .contains("Invalid regex pattern for disallow_expression"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java new file mode 100644 index 00000000000..af4849a126c --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutationsTest.java @@ -0,0 +1,40 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutationsTest { + @Test + public void testCreate() { + HeaderValueOption header = HeaderValueOption.create( + HeaderValue.create("key", "value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + HeaderMutations mutations = HeaderMutations.create( + ImmutableList.of(header), ImmutableList.of("remove-key")); + assertThat(mutations.headers()).containsExactly(header); + assertThat(mutations.headersToRemove()).containsExactly("remove-key"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java new file mode 100644 index 00000000000..ff426931700 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderMutatorTest.java @@ -0,0 +1,308 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.testing.TestLogHandler; +import com.google.protobuf.ByteString; +import io.grpc.Metadata; +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderMutations; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderMutatorTest { + + private static final Metadata.Key BINARY_KEY = + Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + private static final Metadata.Key APPEND_KEY = + Metadata.Key.of("append-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key ADD_KEY = + Metadata.Key.of("add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_KEY = + Metadata.Key.of("overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key REMOVE_KEY = + Metadata.Key.of("remove-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_ADD_KEY = + Metadata.Key.of("new-add-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key NEW_OVERWRITE_KEY = + Metadata.Key.of("new-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_KEY = + Metadata.Key.of("overwrite-if-exists-key", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key OVERWRITE_IF_EXISTS_ABSENT_KEY = + Metadata.Key.of("overwrite-if-exists-absent-key", Metadata.ASCII_STRING_MARSHALLER); + + private final HeaderMutator headerMutator = HeaderMutator.create(); + + private static final TestLogHandler logHandler = new TestLogHandler(); + private static final Logger logger = Logger.getLogger(HeaderMutator.class.getName()); + + @Before + public void setUp() { + logHandler.clear(); + logger.addHandler(logHandler); + logger.setLevel(Level.WARNING); + } + + @After + public void tearDown() { + logger.removeHandler(logHandler); + } + + private static HeaderValueOption header(String key, String value, HeaderAppendAction action) { + return HeaderValueOption.create(HeaderValue.create(key, value), action); + } + + @Test + public void applyMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + headers.put(REMOVE_KEY, "remove-value-original"); + headers.put(OVERWRITE_IF_EXISTS_KEY, "original-value"); + + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header( + APPEND_KEY.name(), + "append-value-2", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header( + OVERWRITE_KEY.name(), + "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + NEW_OVERWRITE_KEY.name(), + "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + OVERWRITE_IF_EXISTS_KEY.name(), + "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS), + header( + OVERWRITE_IF_EXISTS_ABSENT_KEY.name(), + "new-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + assertThat(headers.get(OVERWRITE_IF_EXISTS_KEY)).isEqualTo("new-value"); + assertThat(headers.containsKey(OVERWRITE_IF_EXISTS_ABSENT_KEY)).isFalse(); + } + + @Test + public void applyMutations_removalHasPriority() { + Metadata headers = new Metadata(); + headers.put(REMOVE_KEY, "value"); + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header( + REMOVE_KEY.name(), "new-value", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), + ImmutableList.of(REMOVE_KEY.name())); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.containsKey(REMOVE_KEY)).isFalse(); + } + + @Test + public void applyMutations_binary() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = + HeaderValueOption.create( + HeaderValue.create(BINARY_KEY.name(), ByteString.copyFrom(value)), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyResponseMutations_asciiHeaders() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "append-value-1"); + headers.put(ADD_KEY, "add-value-original"); + headers.put(OVERWRITE_KEY, "overwrite-value-original"); + + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header( + APPEND_KEY.name(), + "append-value-2", + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "add-value-new", HeaderAppendAction.ADD_IF_ABSENT), + header(NEW_ADD_KEY.name(), "new-add-value", HeaderAppendAction.ADD_IF_ABSENT), + header( + OVERWRITE_KEY.name(), + "overwrite-value-new", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header( + NEW_OVERWRITE_KEY.name(), + "new-overwrite-value", + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), ImmutableList.of()); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.getAll(APPEND_KEY)).containsExactly("append-value-1", "append-value-2"); + assertThat(headers.get(ADD_KEY)).isEqualTo("add-value-original"); + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo("new-add-value"); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo("overwrite-value-new"); + assertThat(headers.get(NEW_OVERWRITE_KEY)).isEqualTo("new-overwrite-value"); + } + + @Test + public void applyResponseMutations_binary() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + HeaderValueOption option = + HeaderValueOption.create( + HeaderValue.create(BINARY_KEY.name(), ByteString.copyFrom(value)), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + assertThat(headers.get(BINARY_KEY)).isEqualTo(value); + } + + @Test + public void applyMutations_emptyValuesAreKept() { + Metadata headers = new Metadata(); + headers.put(APPEND_KEY, "existing-value"); + headers.put(OVERWRITE_KEY, "existing-value"); + headers.put(OVERWRITE_IF_EXISTS_KEY, "existing-value"); + + HeaderMutations mutations = + HeaderMutations.create( + ImmutableList.of( + header(NEW_ADD_KEY.name(), "", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(APPEND_KEY.name(), "", HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + header(OVERWRITE_KEY.name(), "", HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + header(ADD_KEY.name(), "", HeaderAppendAction.ADD_IF_ABSENT), + header(OVERWRITE_IF_EXISTS_KEY.name(), "", HeaderAppendAction.OVERWRITE_IF_EXISTS), + HeaderValueOption.create( + HeaderValue.create("keep-empty-key", ""), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + HeaderValueOption.create( + HeaderValue.create("keep-empty-overwrite-key", ""), + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD), + HeaderValueOption.create( + HeaderValue.create("keep-empty-bin-key-bin", ByteString.EMPTY), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + HeaderValueOption.create( + HeaderValue.create("ignore-empty-bin-key-bin", ByteString.EMPTY), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD), + HeaderValueOption.create( + HeaderValue.create("overwrite-empty-bin-key-bin", ByteString.EMPTY), + HeaderAppendAction.OVERWRITE_IF_EXISTS_OR_ADD)), + ImmutableList.of()); + + headers.put( + Metadata.Key.of("keep-empty-overwrite-key", Metadata.ASCII_STRING_MARSHALLER), "old"); + + Metadata.Key overwriteEmptyBinKey = + Metadata.Key.of("overwrite-empty-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + byte[] originalBinValue = new byte[] {1, 2, 3}; + headers.put(overwriteEmptyBinKey, originalBinValue); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.get(NEW_ADD_KEY)).isEqualTo(""); + assertThat(headers.getAll(APPEND_KEY)).containsExactly("existing-value", ""); + assertThat(headers.get(OVERWRITE_KEY)).isEqualTo(""); + assertThat(headers.get(ADD_KEY)).isEqualTo(""); + assertThat(headers.get(OVERWRITE_IF_EXISTS_KEY)).isEqualTo(""); + + Metadata.Key keepEmptyKey = + Metadata.Key.of("keep-empty-key", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key keepEmptyOverwriteKey = + Metadata.Key.of("keep-empty-overwrite-key", Metadata.ASCII_STRING_MARSHALLER); + + assertThat(headers.get(keepEmptyKey)).isEqualTo(""); + assertThat(headers.get(keepEmptyOverwriteKey)).isEqualTo(""); + + Metadata.Key keepEmptyBinKey = + Metadata.Key.of("keep-empty-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + Metadata.Key ignoreEmptyBinKey = + Metadata.Key.of("ignore-empty-bin-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + + assertThat(headers.get(keepEmptyBinKey)).isEqualTo(new byte[0]); + assertThat(headers.get(ignoreEmptyBinKey)).isEqualTo(new byte[0]); + assertThat(headers.get(overwriteEmptyBinKey)).isEqualTo(new byte[0]); + } + + @Test + public void applyMutations_binaryRemoval() { + Metadata headers = new Metadata(); + byte[] value = new byte[] {1, 2, 3}; + headers.put(BINARY_KEY, value); + HeaderMutations mutations = + HeaderMutations.create(ImmutableList.of(), ImmutableList.of(BINARY_KEY.name())); + + headerMutator.applyMutations(mutations, headers); + + assertThat(headers.containsKey(BINARY_KEY)).isFalse(); + } + + @Test + public void applyMutations_stringValueWithBinaryKey_ignored() { + Metadata headers = new Metadata(); + HeaderValueOption option = HeaderValueOption.create(HeaderValue.create("some-key-bin", "value"), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + + Metadata.Key key = Metadata.Key.of("some-key-bin", Metadata.BINARY_BYTE_MARSHALLER); + assertThat(headers.containsKey(key)).isFalse(); + } + + @Test + public void applyMutations_binaryValueWithAsciiKey_ignored() { + Metadata headers = new Metadata(); + HeaderValueOption option = HeaderValueOption.create( + HeaderValue.create("some-key", ByteString.copyFrom(new byte[] {0x61})), + HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + + headerMutator.applyMutations( + HeaderMutations.create(ImmutableList.of(option), ImmutableList.of()), headers); + + Metadata.Key key = Metadata.Key.of("some-key", Metadata.ASCII_STRING_MARSHALLER); + assertThat(headers.containsKey(key)).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java new file mode 100644 index 00000000000..c2ee4b747ba --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/headermutations/HeaderValueOptionTest.java @@ -0,0 +1,39 @@ +/* + * 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.headermutations; + +import static com.google.common.truth.Truth.assertThat; + +import io.grpc.xds.internal.grpcservice.HeaderValue; +import io.grpc.xds.internal.headermutations.HeaderValueOption.HeaderAppendAction; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HeaderValueOptionTest { + + @Test + public void create_withAllFields_success() { + HeaderValue header = HeaderValue.create("key1", "value1"); + HeaderValueOption option = HeaderValueOption.create( + header, HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + + assertThat(option.header()).isEqualTo(header); + assertThat(option.appendAction()).isEqualTo(HeaderAppendAction.APPEND_IF_EXISTS_OR_ADD); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java new file mode 100644 index 00000000000..fd865144d9b --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelCommonTest.java @@ -0,0 +1,125 @@ +/* + * 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.matcher; + +import static org.junit.Assert.fail; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelFunctionDecl; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelCommonTest { + private static CelCompiler COMPILER; + + @BeforeClass + public static void setupCompiler() { + COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .addVar("unknown_var", SimpleType.STRING) + .addFunctionDeclarations( + CelFunctionDecl.newFunctionDeclaration( + "my_custom_func", + CelOverloadDecl.newGlobalOverload( + "my_custom_func_overload", SimpleType.BOOL, SimpleType.STRING))) + .build(); + } + + private void assertAllowed(String expression) throws Exception { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + CelCommon.checkAllowedReferences(ast); + } + + private void assertDisallowed(String expression) throws Exception { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + try { + CelCommon.checkAllowedReferences(ast); + fail("Should have thrown IllegalArgumentException for expression: " + expression); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + public void checkAllowedReferences_variables() throws Exception { + assertAllowed("request == 'foo'"); + assertDisallowed("unknown_var == 'foo'"); + } + + @Test + public void checkAllowedReferences_operators() throws Exception { + assertAllowed("request == 'foo'"); + assertAllowed("request != 'foo'"); + assertAllowed("1 < 2"); + assertAllowed("1 <= 2"); + assertAllowed("1 > 2"); + assertAllowed("1 >= 2"); + assertAllowed("1 + 2 == 3"); + assertAllowed("1 - 2 == -1"); + assertAllowed("1 * 2 == 2"); + assertAllowed("1 / 2 == 0"); + assertAllowed("1 % 2 == 1"); + assertAllowed("true && false == false"); + assertAllowed("true || false == true"); + assertAllowed("!true == false"); + } + + @Test + public void checkAllowedReferences_indexing() throws Exception { + assertAllowed("request['key'] == 'val'"); + } + + @Test + public void checkAllowedReferences_functions() throws Exception { + assertAllowed("size('foo') == 3"); + assertAllowed("'foo'.matches('.*')"); + assertAllowed("'foo'.contains('o')"); + assertAllowed("'foo'.startsWith('f')"); + assertAllowed("'foo'.endsWith('o')"); + assertAllowed("int(1) == 1"); + assertAllowed("uint(1) == 1u"); + assertAllowed("double(1) == 1.0"); + + // Disallowed functions / overloads + assertDisallowed("string(1) == '1'"); + assertDisallowed("'a' + 'b' == 'ab'"); + assertDisallowed("[1] + [2] == [1, 2]"); + assertDisallowed("my_custom_func('foo')"); + } + + @Test + public void checkAllowedReferences_additionalFunctions() throws Exception { + assertAllowed("timestamp('2026-04-20T00:00:00Z') != timestamp('2026-04-21T00:00:00Z')"); + assertAllowed("duration('1s') != duration('2s')"); + assertAllowed("'foo' in ['foo', 'bar']"); + assertAllowed("bytes('foo') == b'foo'"); + assertAllowed("bool('true') == true"); + } + + @Test + public void checkAllowedReferences_negation() throws Exception { + assertAllowed("-(1) == -1"); + assertAllowed("-(1.0) == -1.0"); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java new file mode 100644 index 00000000000..f3bd97bb878 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelEnvironmentTest.java @@ -0,0 +1,551 @@ +/* + * 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.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.common.io.BaseEncoding; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelRuntime; +import io.grpc.Metadata; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelEnvironmentTest { + + private static final CelCompiler LENIENT_COMPILER = + CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .build(); + + private static CelAbstractSyntaxTree compileLenientAst(String expression) + throws CelValidationException { + return LENIENT_COMPILER.compile(expression).getAst(); + } + + @Test + public void headersWrapper_resolvesPseudoHeaders() { + MatchContext context = MatchContext.newBuilder() + .setPath("/path") + .setMethod("POST") + .setHost("example.com") + .build(); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get(":path")).isEqualTo("/path"); + assertThat(headers.get(":method")).isEqualTo("POST"); + assertThat(headers.get(":authority")).isEqualTo("example.com"); + } + + @Test + public void headersWrapper_resolvesStandardHeaders() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("custom-key", Metadata.ASCII_STRING_MARSHALLER), "custom-val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + + assertThat(headers.get("custom-key")).isEqualTo("custom-val"); + assertThat(headers.containsKey("custom-key")).isTrue(); + } + + @Test + @SuppressWarnings("DoNotCall") + public void headersWrapper_entrySet_unsupported() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + Map headers = new HeadersWrapper(context); + + try { + headers.entrySet(); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + assertThat(e).hasMessageThat().contains("Should not be called"); + } + } + + @Test + public void celEnvironment_resolvesRequestField() { + MatchContext context = MatchContext.newBuilder() + .setPath("/foo") + .setHost("example.com") + .setMethod("POST") + .build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isInstanceOf(Map.class); + + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("path")).isEqualTo("/foo"); + } + + @Test + public void headers_caseInsensitive() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("User-Agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-java"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + + // CEL lookup with different casing + assertThat(headers.get("user-agent")).isEqualTo("grpc-java"); + assertThat(headers.get("USER-AGENT")).isEqualTo("grpc-java"); + assertThat(headers.containsKey("User-Agent")).isTrue(); + assertThat(headers.containsKey("user-agent")).isTrue(); + } + + @Test + public void headers_ignoreTe() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("te", Metadata.ASCII_STRING_MARSHALLER), "trailers"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + + // "te" should be hidden + assertThat(headers.get("te")).isNull(); + assertThat(headers.containsKey("te")).isFalse(); + // Case insensitive check for "TE" logic too + assertThat(headers.get("TE")).isNull(); + assertThat(headers.containsKey("TE")).isFalse(); + + // Ensure "te" is also excluded from keySet() and size() + assertThat(headers.keySet()).doesNotContain("te"); + assertThat(headers.keySet()).doesNotContain("TE"); + assertThat(headers.size()).isEqualTo(4); // Pseudo (:method, :path, :authority) + "host" = 4 + } + + @Test + public void headers_hostAliasing() { + MatchContext context = MatchContext.newBuilder() + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + Map headers = new HeadersWrapper(context); + + assertThat(headers.get("host")).isEqualTo("example.com"); + assertThat(headers.get("HOST")).isEqualTo("example.com"); + assertThat(headers.get(":authority")).isEqualTo("example.com"); + + // Verify containsKey and keySet contract works correctly for host alias + assertThat(headers.containsKey("host")).isTrue(); + assertThat(headers.containsKey("HOST")).isTrue(); + assertThat(headers.keySet()).contains("host"); + } + + @Test + public void headers_binaryHeader() { + Metadata metadata = new Metadata(); + byte[] bytes = new byte[] { 0, 1, 2, 3 }; + metadata.put(Metadata.Key.of("test-bin", Metadata.BINARY_BYTE_MARSHALLER), bytes); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + // Expect Base64 encoded string without padding + String expected = BaseEncoding.base64().omitPadding().encode(bytes); + assertThat(headers.get("test-bin")).isEqualTo(expected); + assertThat(headers.containsKey("test-bin")).isTrue(); + } + + @Test + public void headersWrapper_invalidHeaderNames_returnsNullAndFalse() { + MatchContext context = MatchContext.newBuilder() + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + + // Invalid characters (spaces, special symbols) that gRPC Metadata.Key validations reject. + assertThat(headers.get("invalid header name")).isNull(); + assertThat(headers.get("bad@key")).isNull(); + assertThat(headers.containsKey("invalid header name")).isFalse(); + assertThat(headers.containsKey("bad@key")).isFalse(); + assertThat(headers.get("bad@key-bin")).isNull(); + assertThat(headers.containsKey("bad@key-bin")).isFalse(); + } + + @Test + public void celEnvironment_disabledFeatures_throwsValidationException() { + // String concatenation + try { + CelMatcherTestHelper.compileAst("'a' + 'b'"); + Assert.fail("String concatenation should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("found no matching overload for '_+_'"); + } + + // List concatenation + try { + CelMatcherTestHelper.compileAst("[1] + [2]"); + Assert.fail("List concatenation should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("found no matching overload for '_+_'"); + } + + // String conversion + try { + CelMatcherTestHelper.compileAst("string(1)"); + Assert.fail("String conversion should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("undeclared reference to 'string'"); + } + + // Comprehensions + try { + CelMatcherTestHelper.compileAst("[1, 2, 3].all(x, x > 0)"); + Assert.fail("Comprehensions should be disabled"); + } catch (CelValidationException e) { + assertThat(e).hasMessageThat().contains("undeclared reference to 'all'"); + } + } + + @Test + public void celEnvironment_runtime_disabledFeatures_throwsException() throws Exception { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment resolver = new GrpcCelEnvironment(context); + + // String concatenation fails at runtime evaluation (missing overload) + CelAbstractSyntaxTree stringConcatAst = compileLenientAst("'a' + 'b'"); + CelRuntime.Program stringConcatProgram = CelCommon.RUNTIME.createProgram(stringConcatAst); + Assert.assertThrows( + "String concatenation evaluation should fail", + CelEvaluationException.class, + () -> stringConcatProgram.eval(resolver)); + + // List concatenation fails at runtime evaluation + CelAbstractSyntaxTree listConcatAst = compileLenientAst("[1] + [2]"); + CelRuntime.Program listConcatProgram = CelCommon.RUNTIME.createProgram(listConcatAst); + Assert.assertThrows( + "List concatenation evaluation should fail", + CelEvaluationException.class, + () -> listConcatProgram.eval(resolver)); + + // String conversion fails at runtime evaluation + CelAbstractSyntaxTree stringConvAst = compileLenientAst("string(1)"); + CelRuntime.Program stringConvProgram = CelCommon.RUNTIME.createProgram(stringConvAst); + Assert.assertThrows( + "String conversion evaluation should fail", + CelEvaluationException.class, + () -> stringConvProgram.eval(resolver)); + } + + @Test + public void celEnvironment_resolvesLazyRequestMap() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + assertThat(result.get()).isInstanceOf(Map.class); + + Map map = (Map) result.get(); + assertThat(map.containsKey("path")).isTrue(); + assertThat(map.size()).isAtLeast(1); + + try { + map.entrySet(); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void celEnvironment_timeField_supportedButNull() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.containsKey("time")).isTrue(); + assertThat(requestMap.get("time")).isNull(); + } + + + @Test + public void headersWrapper_size() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("k1", Metadata.ASCII_STRING_MARSHALLER), "v1"); + metadata.put(Metadata.Key.of("k2", Metadata.ASCII_STRING_MARSHALLER), "v2"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + HeadersWrapper headers = new HeadersWrapper(context); + + // 2 custom headers + 3 pseudo headers (:method, :authority, :path) + "host" alias = 6 + assertThat(headers.size()).isEqualTo(6); + } + + @Test + public void celEnvironment_accessAllFields() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER), "id-value"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("host") + .setMethod("GET") + .build(); + + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + + assertThat(requestMap.get("host")).isEqualTo("host"); + assertThat(requestMap.get("id")).isEqualTo("id-value"); + assertThat(requestMap.get("method")).isEqualTo("GET"); + assertThat(requestMap.get("scheme")).isEqualTo(""); + assertThat(requestMap.get("protocol")).isEqualTo(""); + assertThat(requestMap.get("query")).isEqualTo(""); + assertThat(requestMap.get("headers")).isInstanceOf(HeadersWrapper.class); + } + + @Test + public void celEnvironment_find_unknownField() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("unknown")).isNull(); + + assertThat(env.find("other").isPresent()).isFalse(); + } + + @Test + public void lazyRequestMap_unknownKey_returnsNull() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Map map = (Map) env.find("request").get(); + + assertThat(map.get("unknown")).isNull(); + assertThat(map.get(new Object())).isNull(); + assertThat(map.containsKey(new Object())).isFalse(); + } + + @Test + public void headersWrapper_get_nonStringKey_returnsNull() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.get(new Object())).isNull(); + } + + @Test + public void headersWrapper_getHeader_binary_multipleValues() { + Metadata metadata = new Metadata(); + byte[] val1 = new byte[] { 1, 2, 3 }; + byte[] val2 = new byte[] { 4, 5, 6 }; + Metadata.Key key = Metadata.Key.of("bin-header-bin", Metadata.BINARY_BYTE_MARSHALLER); + metadata.put(key, val1); + metadata.put(key, val2); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + String expected = com.google.common.io.BaseEncoding.base64().omitPadding().encode(val1) + "," + + com.google.common.io.BaseEncoding.base64().omitPadding().encode(val2); + assertThat(headers.get("bin-header-bin")).isEqualTo(expected); + } + + @Test + public void headersWrapper_containsKey_nonStringKey_returnsFalse() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.containsKey(new Object())).isFalse(); + } + + @Test + public void headersWrapper_containsKey_pseudoHeader_returnsTrue() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.containsKey(":method")).isTrue(); + assertThat(headers.containsKey(":path")).isTrue(); + assertThat(headers.containsKey(":authority")).isTrue(); + } + + @Test + public void headersWrapper_keySet_containsExpectedKeys() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("custom-key", Metadata.ASCII_STRING_MARSHALLER), "val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + + Map headers = new HeadersWrapper(context); + Set keys = headers.keySet(); + + assertThat(keys).containsAtLeast("custom-key", ":method", ":path", ":authority"); + } + + @Test + public void headersWrapper_getHeader_missingBinaryHeader_returnsNull() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + Map headers = new HeadersWrapper(context); + assertThat(headers.get("missing-bin")).isNull(); + } + + @Test + public void celEnvironment_resolvesRefererAndUserAgent() { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER), "http://example.com"); + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-test"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("referer")).isEqualTo("http://example.com"); + assertThat(requestMap.get("useragent")).isEqualTo("grpc-test"); + } + + @Test + public void celEnvironment_joinsMultipleHeaderValues() { + Metadata metadata = new Metadata(); + Metadata.Key key = Metadata.Key.of("referer", Metadata.ASCII_STRING_MARSHALLER); + metadata.put(key, "v1"); + metadata.put(key, "v2"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .setPath("/") + .setHost("example.com") + .setMethod("POST") + .build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("referer")).isEqualTo("v1,v2"); + } + + @Test + public void celEnvironment_find_invalidFormat() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + assertThat(env.find("other.path").isPresent()).isFalse(); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("a")).isNull(); + } + + @Test + public void lazyRequestMap_additionalMethods() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + Map map = (Map) env.find("request").get(); + + assertThat(map.isEmpty()).isFalse(); + assertThat(map.get("time")).isNull(); + } + + @Test + public void celEnvironment_missingHeader_returnsEmptyString() { + MatchContext context = MatchContext.newBuilder() + .setPath("/").setHost("example.com").setMethod("POST").build(); + GrpcCelEnvironment env = new GrpcCelEnvironment(context); + + Optional result = env.find("request"); + assertThat(result.isPresent()).isTrue(); + @SuppressWarnings("unchecked") + Map requestMap = (Map) result.get(); + assertThat(requestMap.get("referer")).isEqualTo(""); + } + + + +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java new file mode 100644 index 00000000000..cb5e8a54017 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelMatcherTestHelper.java @@ -0,0 +1,68 @@ +/* + * 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.matcher; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOptions; +import dev.cel.common.CelValidationException; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; + +public final class CelMatcherTestHelper { + private static final dev.cel.checker.CelStandardDeclarations DECLARATIONS = + dev.cel.checker.CelStandardDeclarations.newBuilder() + .filterFunctions((func, over) -> { + if (func == dev.cel.checker.CelStandardDeclarations.StandardFunction.STRING) { + return false; + } + if (func == dev.cel.checker.CelStandardDeclarations.StandardFunction.ADD) { + String id = over.celOverloadDecl().overloadId(); + return !id.equals("add_string") && !id.equals("add_list"); + } + return true; + }) + .build(); + + private static final CelCompiler COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .setStandardEnvironmentEnabled(false) + .setStandardDeclarations(DECLARATIONS) + .addVar("request", SimpleType.DYN) + .setOptions(CelOptions.newBuilder() + .enableComprehension(false) + .build()) + .build(); + + private CelMatcherTestHelper() {} + + public static CelAbstractSyntaxTree compileAst(String expression) + throws CelValidationException { + return COMPILER.compile(expression).getAst(); + } + + public static CelMatcher compile(String expression) + throws dev.cel.common.CelException { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + return CelMatcher.compile(ast); + } + + public static CelStringExtractor compileStringExtractor(String expression) + throws dev.cel.common.CelException { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + return CelStringExtractor.compile(ast); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java new file mode 100644 index 00000000000..91d51d86d41 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStateMatcherTest.java @@ -0,0 +1,515 @@ +/* + * 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.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.CelMatcher; +import com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput; +import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.matcher.v3.StringMatcher; +import com.github.xds.type.v3.CelExpression; +import com.github.xds.type.v3.CelExtractString; +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.compiler.CelCompiler; +import dev.cel.compiler.CelCompilerFactory; +import dev.cel.expr.ParsedExpr; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class CelStateMatcherTest { + + private static CelCompiler COMPILER; + + @BeforeClass + public static void setupCompiler() { + COMPILER = CelCompilerFactory.standardCelCompilerBuilder() + .addVar("request", SimpleType.DYN) + .build(); + } + + private static CelMatcher createCelMatcher(String expression) { + try { + CelAbstractSyntaxTree ast = COMPILER.compile(expression).getAst(); + CelProtoAbstractSyntaxTree protoAst = CelProtoAbstractSyntaxTree.fromCelAst(ast); + return CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() + .setCelExprChecked(protoAst.toCheckedExpr()) + .build()) + .build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create CelMatcher for test", e); + } + } + + @Test + public void verifyCelExtractStringInputNotSupported() { + CelExtractString proto = CelExtractString.getDefaultInstance(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } + + @Test + public void celMatcher_match() { + CelMatcher celMatcher = createCelMatcher("request.path == '/good'"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setPath("/good") + .setMetadata(new Metadata()) + .setId("123") + .build(); + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + TypedExtensionConfig action = result.actions.get(result.actions.size() - 1); + assertThat(action.getName()).isEqualTo("action1"); + + context = MatchContext.newBuilder() + .setPath("/bad") + .setMetadata(new Metadata()) + .setId("123") + .build(); + result = matcher.match(context); + TypedExtensionConfig noMatchAction = result.actions.get(result.actions.size() - 1); + assertThat(noMatchAction.getName()).isEqualTo("no-match"); + } + + @Test + public void celMatcher_throwsIfReturnsString() { + try { + CelMatcherTestHelper.compile("'should be bool'"); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to boolean"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void celMatcher_runtimeReturnsString_throwsCelEvaluationException() throws Exception { + dev.cel.runtime.CelRuntime.Program mockProgram = + org.mockito.Mockito.mock(dev.cel.runtime.CelRuntime.Program.class); + org.mockito.Mockito.when( + mockProgram.eval( + org.mockito.ArgumentMatchers.any(dev.cel.runtime.CelVariableResolver.class))) + .thenReturn("not-a-bool"); + + java.lang.reflect.Constructor constructor = + io.grpc.xds.internal.matcher.CelMatcher.class.getDeclaredConstructor( + dev.cel.runtime.CelRuntime.Program.class); + constructor.setAccessible(true); + io.grpc.xds.internal.matcher.CelMatcher celMatcher = constructor.newInstance(mockProgram); + + try { + dev.cel.runtime.CelVariableResolver resolver = new dev.cel.runtime.CelVariableResolver() { + @Override + public java.util.Optional find(String name) { + return java.util.Optional.empty(); + } + }; + celMatcher.match(resolver); + fail("Should have thrown CelEvaluationException"); + } catch (dev.cel.runtime.CelEvaluationException e) { + assertThat(e).hasMessageThat() + .contains("CEL expression must evaluate to boolean, got: java.lang.String"); + } + } + + @Test + public void celMatcher_evaluationError_returnsFalse() { + CelMatcher celMatcher = createCelMatcher("int(request.path) == 0"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setPath("not-an-int") + .setMetadata(new Metadata()) + .setId("1") + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("no-match"); + } + + @Test + public void celStringExtractor_throwsIfReturnsBool() { + try { + CelMatcherTestHelper.compileStringExtractor("true"); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("must evaluate to string"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void celMatcher_unsupportedInputThrows() { + try { + io.grpc.xds.internal.matcher.CelMatcher celMatcher = CelMatcherTestHelper.compile("true"); + celMatcher.match("Not a CelVariableResolver"); + fail("Should have thrown CelEvaluationException"); + } catch (Exception e) { + assertThat(e.getClass().getName()) + .isEqualTo("dev.cel.runtime.CelEvaluationException"); + assertThat(e).hasMessageThat() + .contains("Unsupported input type for CEL evaluation: java.lang.String"); + } + } + + @Test + public void celMatcher_nullInputThrows() { + try { + io.grpc.xds.internal.matcher.CelMatcher celMatcher = CelMatcherTestHelper.compile("true"); + celMatcher.match(null); + fail("Should have thrown CelEvaluationException"); + } catch (Exception e) { + assertThat(e.getClass().getName()).isEqualTo("dev.cel.runtime.CelEvaluationException"); + assertThat(e).hasMessageThat().contains("Unsupported input type for CEL evaluation: null"); + } + } + + @Test + public void celMatcher_headers() { + CelMatcher celMatcher = createCelMatcher("request.headers['x-test'] == 'value'"); + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(predicate)) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-test", Metadata.ASCII_STRING_MARSHALLER), "value"); + MatchContext context = MatchContext.newBuilder() + .setPath("/") + .setMetadata(headers) + .setId("123") + .build(); + + MatchResult result = matcher.match(context); + TypedExtensionConfig action = result.actions.get(result.actions.size() - 1); + assertThat(action.getName()).isEqualTo("matched"); + } + + @Test + public void requestUrlPath_available() { + CelMatcher celMatcher = createCelMatcher("request.url_path == '/path/without/query'"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + MatchContext context = MatchContext.newBuilder() + .setPath("/path/without/query") + .setMetadata(new Metadata()) + .setId("123") + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("matched"); + } + + @Test + public void requestHeaders_equalityCheck_failsSafely() { + // request.headers == {'a':'1','b':'2','c':'3'} requires entrySet(), + // which throws UnsupportedOperationException + // We want to ensure this is caught and treated as a mismatch or error, not a crash. + // HeadersWrapper has 3 pseudo headers and 1 host alias by default, so size is 4. + // We match size to force entrySet check. + CelMatcher celMatcher = createCelMatcher( + "request.headers == {'a':'1','b':'2','c':'3','d':'4'}"); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched"))))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setMetadata(new Metadata()) + .build(); + MatchResult result = matcher.match(context); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("no-match"); + } + + @Test + public void celMatcher_missingExprMatch_throws() { + CelMatcher celProto = + CelMatcher.getDefaultInstance(); + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack(celProto)))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); + } + } + + @Test + public void invalidInputCombination_stringMatcherWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setValueMatch(StringMatcher.newBuilder() + .setExact("any")))))) + .build()); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("Type mismatch"); + } + } + + @Test + public void celMatcher_wrongInput_throws() { + HttpRequestHeaderMatchInput inputProto = + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("test").build(); + CelMatcher celMatcherProto = createCelMatcher("true"); + + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(inputProto))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcherProto)) + .setName("cel_matcher")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + fail("Should have thrown IllegalArgumentException for incompatible input"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("Type mismatch"); + } + } + + @Test + public void celMatcher_withoutCelExprChecked_throws() { + CelMatcher celMatcherParsed = + CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() + .setCelExprParsed(ParsedExpr.getDefaultInstance())) + .build(); + + try { + UnifiedMatcher.fromProto(wrapInMatcher(celMatcherParsed)); + fail( + "Should have thrown IllegalArgumentException for missing cel_expr_checked"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); + } + } + + @Test + public void celMatcher_withCelExprString_throws() { + ParsedExpr parsedExpr = + ParsedExpr.getDefaultInstance(); + CelMatcher celMatcherParsed = + CelMatcher.newBuilder() + .setExprMatch(CelExpression.newBuilder() + .setCelExprParsed(parsedExpr) + .build()) + .build(); + Matcher proto = wrapInMatcher(celMatcherParsed); + + try { + UnifiedMatcher.fromProto(proto); + fail( + "Should have thrown IllegalArgumentException for using cel_expr_parsed"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); + } + } + + private Matcher wrapInMatcher(CelMatcher celMatcher) { + return Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(celMatcher)) + .setName("cel_matcher")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build(); + } + + @Test + public void singlePredicate_invalidCelMatcherProto_throws() { + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/xds.type.matcher.v3.CelMatcher") + .setValue(ByteString.copyFromUtf8("invalid")) + .build()) + .build(); + + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(config) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid CelMatcher config"); + } + } + + @Test + public void singlePredicate_celEvalError_returnsFalse() { + // We rely on a runtime failure (division by zero) to trigger CelEvaluationException. + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(createCelMatcher("1/0 == 0")))) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + assertThat(evaluator.evaluate(MatchContext.newBuilder().build())).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java new file mode 100644 index 00000000000..9b8e5753e10 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/CelStringExtractorTest.java @@ -0,0 +1,180 @@ +/* + * 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.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.runtime.CelEvaluationException; +import dev.cel.runtime.CelVariableResolver; +import java.util.Collections; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelStringExtractorTest { + + @Test + public void extract_simpleString() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("'foo'"); + CelVariableResolver resolver = name -> Optional.empty(); + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("foo"); + } + + @Test + public void extract_resolvesVariable() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("request['key']"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(Collections.singletonMap("key", "value")); + } + return Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("value"); + } + + @Test + public void extract_nonStringResult_returnsNull() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("request"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(123); + } + return Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isNull(); + } + + @Test + public void extract_evaluationError_throws() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("request.bad"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of("foo"); + } + return Optional.empty(); + }; + + try { + extractor.extract(resolver); + fail("Should throw CelEvaluationException"); + } catch (CelEvaluationException e) { + // Expected + } + } + + @Test + public void extract_nonStringResult_returnsDefaultValue() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(123); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void extract_evaluationError_returnsDefaultValue() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request.bad"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of("foo"); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void extract_withCelVariableResolver_resolvesVariable() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request['key']"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of(Collections.singletonMap("key", "value")); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("value"); + } + + @Test + public void extract_withCelVariableResolver_evalError_returnsDefaultValue() throws Exception { + CelAbstractSyntaxTree ast = CelMatcherTestHelper.compileAst("request.bad"); + CelStringExtractor extractor = CelStringExtractor.compile(ast, "default_val"); + + CelVariableResolver resolver = name -> { + if ("request".equals(name)) { + return Optional.of("foo"); + } + return java.util.Optional.empty(); + }; + + String result = extractor.extract(resolver); + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void compile_invalidSyntax_throws() { + try { + CelMatcherTestHelper.compileStringExtractor("invalid syntax ???"); + fail("Should throw CelValidationException"); + } catch (dev.cel.common.CelValidationException e) { + assertThat(e).hasMessageThat().isNotEmpty(); + } catch (Exception e) { + fail("Threw wrong exception type: " + e.getClass().getName()); + } + } + + @Test + public void extract_withCelVariableResolver() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("'val'"); + CelVariableResolver resolver = name -> Optional.empty(); + + assertThat(extractor.extract(resolver)).isEqualTo("val"); + } + + @Test + public void extract_unsupportedInputType_throws() throws Exception { + CelStringExtractor extractor = CelMatcherTestHelper.compileStringExtractor("'foo'"); + try { + extractor.extract("not-a-map"); + fail("Should have thrown CelEvaluationException"); + } catch (CelEvaluationException e) { + assertThat(e).hasMessageThat().contains("Unsupported input type"); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java new file mode 100644 index 00000000000..25ae38b55ad --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/MatcherTreeTest.java @@ -0,0 +1,554 @@ +/* + * 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.matcher; + +import static com.google.common.truth.Truth.assertThat; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.Any; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class MatcherTreeTest { + + @Test + public void matcherTree_missingInput_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder().build(); + try { + new MatcherTree(proto, null, s -> true); + Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("MatcherTree must have input"); + } + } + + @Test + public void matcherTree_unsupportedCelInput_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpAttributesCelMatchInput + .getDefaultInstance()))) + .build(); + try { + new MatcherTree(proto, null, s -> true); + Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + @Test + public void matcherTree_emptyMaps_throws() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.getDefaultInstance()) + .build(); + try { + new MatcherTree(proto, null, s -> true); + Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("MatcherTree exact_match_map must contain at least one entry"); + } + } + + @Test + public void matcherTree_nonStringInput_fallsBack() { + + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + MatcherTree tree = new MatcherTree(proto, + Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback")).build(), + s -> true); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(new Metadata()) + .build(); + + MatchResult result = tree.match(context); + assertThat(result.matched).isTrue(); // onNoMatch matched + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherTree_noExactMatch_fallsBackToOnNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build())) + .build(); + + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("fallback")) + .build(); + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "other"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = tree.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("fallback"); + } + + @Test + public void matcherTree_prefixFoundButNestedFailed_returnNoMatch_notOnNoMatch() { + Matcher.MatcherTree nestedProto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("bar").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("baz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("n/a")).build())) + .build(); + + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder().setMatcherTree(nestedProto)) + .build())) + .build(); + + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("should-not-be-called")) + .build(); + + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), + "/prefix/foo"); + metadata.put(Metadata.Key.of("bar", Metadata.ASCII_STRING_MARSHALLER), "wrong"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = tree.match(context); + + assertThat(result.matched).isFalse(); + assertThat(result.actions).isEmpty(); + } + + @Test + public void matcherTree_noMap_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build())))) + .build()); + Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("must have either exact_match_map or prefix_match_map"); + } + } + + @Test + public void matcherTree_customMatch_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("custom"))) + .build()); + Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("does not support custom_match"); + } + } + + @Test + public void matcherTree_exactMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_foo")).build()) + .putMap("bar", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("matched_bar")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("x-key", Metadata.ASCII_STRING_MARSHALLER), "foo"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(headers) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("matched_foo"); + } + + @Test + public void matcherTree_prefixMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/api", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("api")).build()) + .putMap("/api/v1", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("apiv1")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/api/v1/users"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(headers) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + // Longest prefix wins + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("apiv1"); + } + + @Test + public void matcherTree_prefixMatch_emptyPrefix() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("empty_prefix")).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no_match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/any/path"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(headers) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + // Empty prefix matches anything + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("empty_prefix"); + } + + @Test + public void matcherTree_keepMatching_aggregate() { + // MatcherTree: Input = 'path'. + // Map: '/prefix' -> Action 'A1', KeepMatching=True + // OnNoMatch -> Action 'A2' + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true) + .build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/prefix/something"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isFalse(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void matcherTree_keepMatching_longestPrefixFirst() { + // Prefix /abc: A1, keep=true + // Prefix /ab: A2, keep=true + // Prefix /a: A3, keep=FALSE + + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/abc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()) + .putMap("/ab", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2")) + .setKeepMatching(true).build()) + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A3")) + .setKeepMatching(false).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(map)) + .build(); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("path", "/abc")) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + MatchResult result = matcher.match(context); + + assertThat(result.matched).isTrue(); + // Implementation sorts longest to shortest: /abc, /ab, /a + // 1. /abc matches -> A1. keep=true. + // 2. /ab matches -> A2. keep=true. + // 3. /a matches -> A3. keep=false -> STOP. + assertThat(result.actions).hasSize(3); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + assertThat(result.actions.get(1).getName()).isEqualTo("A2"); + assertThat(result.actions.get(2).getName()).isEqualTo("A3"); + } + + @Test + public void matcherTree_example4_prefixMap() { + Matcher.MatcherTree.MatchMap map = Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("grpc", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("shorter_prefix")).build()) + .putMap("grpc.channelz", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("longer_prefix")).build()) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("x-user-segment").build()))) + .setPrefixMatchMap(map)) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto, (t) -> true); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("x-user-segment", "grpc.channelz.v1.Channelz/GetTopChannels")) + .build(); + MatchResult result = matcher.match(context); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("longer_prefix"); + } + + @Test + public void invalidInputCombination_matcherTreeWithCelInput_throws() { + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpAttributesCelMatchInput + .getDefaultInstance()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action")).build()))) + .build()); + Assert.fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .contains("HttpAttributesCelMatchInput cannot be used with MatcherTree"); + } + } + + @Test + public void matcherTree_prefixMap_noMatch_shouldFallbackToOnNoMatch() { + Matcher.MatcherTree proto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("path").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/a", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("actionA").build()) + .build())) + .build(); + Matcher.OnMatch onNoMatch = Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("actionB")).build(); + MatcherTree tree = new MatcherTree(proto, onNoMatch, s -> true); + + Metadata metadata = new Metadata(); + metadata.put( + Metadata.Key.of("path", Metadata.ASCII_STRING_MARSHALLER), "/b"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = tree.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("actionB"); + } + + @Test + public void matcherTree_exactMatch_keepMatching_aggregate() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("key", Metadata.ASCII_STRING_MARSHALLER), "val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isFalse(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + @Test + public void matcherTree_exactMatch_keepMatching_nestedMatcher_fails() { + // Exact match "val" -> Nested Matcher (always fails), keepMatching=true + // Nested no-match aborts the match; + // keepMatching does not trigger onNoMatch fallback here. + + Matcher.MatcherTree nestedProto = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("bar").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("foo", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("n/a")).build())) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("val", Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder().setMatcherTree(nestedProto)) + .setKeepMatching(true).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A2"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("key", Metadata.ASCII_STRING_MARSHALLER), "val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isFalse(); + // nested matcher failed, so it should abort unconditionally, keepMatching=true is ignored. + assertThat(result.actions).isEmpty(); + } + + @Test + public void matcherTree_prefixMatch_keepMatching_aggregate_noOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("/prefix", Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("A1")) + .setKeepMatching(true).build()))) + // No onNoMatch configured + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + + Metadata metadata = new Metadata(); + // "/prefix/val" matches "/prefix" -> A1, keepMatching=true + metadata.put(Metadata.Key.of("key", Metadata.ASCII_STRING_MARSHALLER), "/prefix/val"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isFalse(); + assertThat(result.actions).hasSize(1); + assertThat(result.actions.get(0).getName()).isEqualTo("A1"); + } + + private Metadata metadataWith(String key, String value) { + Metadata m = new Metadata(); + m.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return m; + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java new file mode 100644 index 00000000000..05b282c4d35 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherTest.java @@ -0,0 +1,668 @@ +/* + * 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.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.matcher.v3.RegexMatcher; +import com.github.xds.type.matcher.v3.StringMatcher; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.Any; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.Metadata; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UnifiedMatcherTest { + + @Test + public void matcherList_proceedsToNextMatcher_ifNestedNoMatch() { + // matcher1: predicate matches, but nested matcher returns no-match (matched=false) + // matcher2: predicate matches, and returns action "action2" + // Expect: matcher1 fails to match, so we proceed to evaluate matcher2, + // which successfully matches and returns action2. + + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder())) // nested matcher that doesn't match + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1) + .addMatchers(fm2)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .setPath("/test/path") + .setHost("test.host") + .setMethod("GET") + .setId("test-id") + .build(); + + assertThat(context.getId()).isEqualTo("test-id"); + assertThat(context.getPath()).isEqualTo("/test/path"); + assertThat(context.getHost()).isEqualTo("test.host"); + assertThat(context.getMethod()).isEqualTo("GET"); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("action2"); + } + + @Test + public void matcherList_noMatch_returnsNoMatch() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "missing")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1)) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isFalse(); + assertThat(result.actions).isEmpty(); + } + + @Test + public void matcherTree_exactMatch_shouldNotFallBackToOnNoMatch_ifKeyFound() { + Matcher nestedNoMatch = Matcher.newBuilder() + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("found", Matcher.OnMatch.newBuilder().setMatcher(nestedNoMatch).build()))) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("tree-no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("key", "found")) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isFalse(); + assertThat(result.actions).isEmpty(); + } + + @Test + public void stringMatcherAdapter_nonStringInputThrows() throws Exception { + io.grpc.xds.internal.Matchers.StringMatcher stringMatcher = + io.grpc.xds.internal.Matchers.StringMatcher.forExact("val", false); + Class adapterClass = Class.forName( + "io.grpc.xds.internal.matcher.PredicateEvaluator" + + "$SinglePredicateEvaluator$StringMatcherAdapter"); + java.lang.reflect.Constructor constructor = + adapterClass.getDeclaredConstructor(io.grpc.xds.internal.Matchers.StringMatcher.class); + constructor.setAccessible(true); + io.grpc.xds.internal.matcher.Matcher adapter = + (io.grpc.xds.internal.matcher.Matcher) constructor.newInstance(stringMatcher); + + try { + adapter.match(123); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "StringMatcher expected a String input, but received: java.lang.Integer"); + } + } + + @Test + public void stringMatcher_contains_ignoreCase() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setValueMatch(StringMatcher.newBuilder() + .setContains("WoRlD") + .setIgnoreCase(true)) + .build(); + + PredicateEvaluator evaluator = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("key", "hello world")) + .build(); + + assertThat(evaluator.evaluate(context)).isTrue(); + } + + @Test + public void andMatcher_allTrue_matches() { + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "v"); + + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + assertThat(eval.evaluate(context)).isTrue(); + } + + @Test + public void andMatcher_oneFalse_fails() { + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "x"); + + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setAndMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + assertThat(eval.evaluate(context)).isFalse(); + } + + @Test + public void orMatcher_oneTrue_matches() { + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "x"); // fail + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "v"); // match + + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + assertThat(eval.evaluate(context)).isTrue(); + } + + @Test + public void orMatcher_allFalse_fails() { + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "x"); // fail + Matcher.MatcherList.Predicate h2 = createHeaderMatchPredicate("h", "y"); // fail + + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(h1).addPredicate(h2)).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + assertThat(eval.evaluate(context)).isFalse(); + } + + @Test + public void notMatcher_invert() { + Matcher.MatcherList.Predicate h1 = createHeaderMatchPredicate("h", "v"); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder() + .setNotMatcher(h1).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + assertThat(eval.evaluate(context)).isFalse(); + + context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "x")) + .build(); + assertThat(eval.evaluate(context)).isTrue(); + } + + @Test + public void matchInput_headerName_binary() { + String headerName = "test-bin"; + byte[] bytes = new byte[] {1, 2, 3}; + String expected = BaseEncoding.base64().encode(bytes); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), bytes); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName).build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)).build()); + + assertThat(input.apply(context)).isEqualTo(expected); + } + + @Test + public void matchInput_headerName_binary_aggregation() { + String headerName = "test-bin"; + byte[] v1 = new byte[] {1, 2, 3}; + byte[] v2 = new byte[] {4, 5, 6}; + String expected = BaseEncoding.base64().encode(v1) + "," + + BaseEncoding.base64().encode(v2); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), v1); + metadata.put(Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER), v2); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(headerName).build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)).build()); + + assertThat(input.apply(context)).isEqualTo(expected); + } + + @Test + public void matchInput_headerName_binary_missing() { + MatchContext context = MatchContext.newBuilder() + .setMetadata(new Metadata()) + .build(); + + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("missing-bin").build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)).build()); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void matchInput_headerName_te_returnsNull() { + String headerName = "te"; + Metadata metadata = new Metadata(); + // "te" is technically ASCII. + metadata.put( + Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER), "trailers"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + HttpRequestHeaderMatchInput proto = + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("te").build(); + MatchInput input = UnifiedMatcher.resolveInput( + TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack(proto)).build()); + + assertThat(input.apply(context)).isNull(); + } + + @Test + public void noOpMatcher_delegatesToOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match-action"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(MatchContext.newBuilder().build()); + + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()) + .isEqualTo("no-match-action"); + } + + @Test + public void matcherRunner_checkMatch_returnsActions() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))) + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + List actions = + MatcherRunner.checkMatch(matcher, MatchContext.newBuilder().build()); + assertThat(actions).hasSize(1); + assertThat(actions.get(0).getName()).isEqualTo("action"); + } + + @Test + public void matcherRunner_checkMatch_returnsNullOnNoMatch() { + Matcher proto = Matcher.newBuilder() + .build(); + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + List actions = + MatcherRunner.checkMatch(matcher, MatchContext.newBuilder().build()); + assertThat(actions).isNull(); + } + + @Test + public void singlePredicate_stringMatcher_safeRegex_matches() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(StringMatcher.newBuilder() + .setSafeRegex(RegexMatcher.newBuilder() + .setRegex("v.*"))) // v followed by anything + .build(); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "val")) + .build(); + assertThat(eval.evaluate(context)).isTrue(); + + context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "xal")) + .build(); + assertThat(eval.evaluate(context)).isFalse(); + } + + @Test + public void singlePredicate_stringMatcher_suffix_matches() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(StringMatcher.newBuilder() + .setSuffix("tail")) + .build(); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "detail")) + .build(); + assertThat(eval.evaluate(context)).isTrue(); + + context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "detai")) + .build(); + assertThat(eval.evaluate(context)).isFalse(); + } + + @Test + public void singlePredicate_stringMatcher_prefix_matches() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(StringMatcher.newBuilder() + .setPrefix("pre")) + .build(); + PredicateEvaluator eval = PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("k", "prefix")) + .build(); + assertThat(eval.evaluate(context)).isTrue(); + } + + @Test + public void matcherList_keepMatching() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); + + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h2", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1) + .addMatchers(fm2)) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v"); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action2"); + } + + @Test + public void matcherList_keepMatching_fallsThroughToOnNoMatch() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1)) + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("no-match"))) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h1", "v")) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + // onNoMatch IS executed because m1 had keepMatching=true and we reached end of list + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + assertThat(result.actions.get(1).getName()).isEqualTo("no-match"); + } + + @Test + public void matcherList_example1_simpleLinearMatch() { + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) // No match + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1"))) + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h2", "v")) // Match + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1).addMatchers(fm2)) + .build(); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h2", "v")) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("action2"); + } + + @Test + public void matcherList_example2_keepMatching() { + // M1 matches, keep_matching=true -> action1 + // M2 matches -> action2 + // Result: [action1, action2] + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))) + .build(); + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1).addMatchers(fm2)) + .build(); + + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(2); + assertThat(result.actions.get(0).getName()).isEqualTo("action1"); + assertThat(result.actions.get(1).getName()).isEqualTo("action2"); + } + + @Test + public void matcherList_example3_nestedMatcher() { + // M1 matches -> nested M2 + // M2 matches -> action2 + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h1", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setMatcher(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h2", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action2"))))))) + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder().addMatchers(fm1)) + .build(); + + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("h1", Metadata.ASCII_STRING_MARSHALLER), "v"); + metadata.put(Metadata.Key.of("h2", Metadata.ASCII_STRING_MARSHALLER), "v"); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadata) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).isNotEmpty(); + assertThat(result.actions.get(result.actions.size() - 1).getName()).isEqualTo("action2"); + } + + + @Test + public void matcherList_keepMatching_verification() { + // Verifying gRFC logic: + // If a matcher sets keep_matching=true, we add its action and continue. + // If subsequent matchers also match, we add their actions too. + + Matcher.MatcherList.FieldMatcher fm1 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("a1")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher fm2 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("a2")) + .setKeepMatching(true)) + .build(); + Matcher.MatcherList.FieldMatcher fm3 = Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(createHeaderMatchPredicate("h", "v")) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("a3"))) // stops here + .build(); + + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(fm1).addMatchers(fm2).addMatchers(fm3)) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.fromProto(proto); + MatchContext context = MatchContext.newBuilder() + .setMetadata(metadataWith("h", "v")) + .build(); + + MatchResult result = matcher.match(context); + assertThat(result.matched).isTrue(); + assertThat(result.actions).hasSize(3); + assertThat(result.actions.get(0).getName()).isEqualTo("a1"); + assertThat(result.actions.get(1).getName()).isEqualTo("a2"); + assertThat(result.actions.get(2).getName()).isEqualTo("a3"); + } + + private static Matcher.MatcherList.Predicate createHeaderMatchPredicate( + String name, String value) { + return Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName(name).build()))) + .setValueMatch(StringMatcher.newBuilder() + .setExact(value))) + .build(); + } + + private static Metadata metadataWith(String key, String value) { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return metadata; + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java new file mode 100644 index 00000000000..0681ebfbf5e --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/matcher/UnifiedMatcherValidationTest.java @@ -0,0 +1,586 @@ +/* + * 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.matcher; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.protobuf.Any; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UnifiedMatcherValidationTest { + + @Test + public void actionValidation_acceptsSupportedType() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance())))) + .build(); + String supportedType = + "type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput"; + + UnifiedMatcher.fromProto(proto, (type) -> type.equals(supportedType)); + } + + @Test + public void actionValidation_rejectsUnsupportedType() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput + .getDefaultInstance())))) + .build(); + + try { + UnifiedMatcher.fromProto(proto, (type) -> false); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported action type"); + } + } + + @Test + public void recursionLimit_validation_should_fail_at_parse_time() { + Matcher current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("leaf"))) + .build(); + + for (int i = 0; i < 20; i++) { + Matcher wrapper = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setCustomMatch(TypedExtensionConfig.newBuilder().setName("dummy")))) + .setOnMatch(Matcher.OnMatch.newBuilder().setMatcher(current)))) + .build(); + current = wrapper; + } + + try { + UnifiedMatcher.fromProto(current); + fail("Should have thrown IllegalArgumentException for depth > 16"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void predicate_missingType_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.getDefaultInstance(); + try { + PredicateEvaluator.fromProto(proto); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Predicate must have one of"); + } + } + + @Test + public void singlePredicate_unsupportedCustomMatcher_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance()))) + .setCustomMatch(TypedExtensionConfig.newBuilder().setTypedConfig( + Any.pack(com.google.protobuf.Empty.getDefaultInstance())))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unsupported custom_match matcher"); + } + } + + @Test + public void singlePredicate_missingInput_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("foo"))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("SinglePredicate must have input"); + } + } + + @Test + public void singlePredicate_missingMatcher_throws() { + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( + com.github.xds.type.matcher.v3.HttpAttributesCelMatchInput.getDefaultInstance())))) + .build(); + try { + PredicateEvaluator.fromProto(proto); + fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "SinglePredicate must have either value_match or custom_match"); + } + } + + @Test + public void orMatcher_tooFewPredicates_throws() { + Matcher.MatcherList.Predicate.PredicateList protoList = + Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("i")) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("v")))) + .build(); + Matcher.MatcherList.Predicate proto = Matcher.MatcherList.Predicate.newBuilder() + .setOrMatcher(protoList) + .build(); + try { + PredicateEvaluator.fromProto(proto); + fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OrMatcher must have at least 2 predicates"); + } + } + + @Test + public void andMatcher_tooFewPredicates_throws() { + Matcher.MatcherList.Predicate.PredicateList proto = + Matcher.MatcherList.Predicate.PredicateList.newBuilder() + .addPredicate(Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("i")) + .setValueMatch( + com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setExact("v")))) + .build(); + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setAndMatcher(proto).build()); + fail("Should have thrown"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("AndMatcher must have at least 2 predicates"); + } + } + + @Test + public void matcherTree_emptyMap_throws() { + // Empty exact match map + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder())) // Empty map + .build()); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exact_match_map must contain at least one entry"); + } + + // Empty prefix match map + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("key").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder())) // Empty map + .build()); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("prefix_match_map must contain at least one entry"); + } + } + + @Test + public void stringMatcher_emptyPatterns_throws() { + // Empty Prefix + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setPrefix("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + fail("Should have thrown IllegalArgumentException for empty prefix"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("prefix (match_pattern) must be non-empty"); + } + + // Empty Suffix + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSuffix("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + fail("Should have thrown IllegalArgumentException for empty suffix"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("suffix (match_pattern) must be non-empty"); + } + + // Empty Contains + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setContains("")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + fail("Should have thrown IllegalArgumentException for empty contains"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("contains (match_pattern) must be non-empty"); + } + + // Empty Regex + try { + UnifiedMatcher.fromProto(Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setSafeRegex(com.github.xds.type.matcher.v3.RegexMatcher.newBuilder() + .setRegex(""))))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("action"))))) + .build()); + fail("Should have thrown IllegalArgumentException for empty regex"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("regex (match_pattern) must be non-empty"); + } + } + + @Test + public void resolveInput_malformedProto_throws() { + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput") + .setValue(com.google.protobuf.ByteString.copyFromUtf8("invalid-bytes")) + .build()) + .build(); + try { + UnifiedMatcher.resolveInput(config); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid input config"); + } + } + + @Test + public void matchInput_headerName_invalidCharacters_throws() { + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput proto = + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("invalid$header") + .build(); + TypedExtensionConfig config = TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack(proto)) + .build(); + try { + UnifiedMatcher.resolveInput(config); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Invalid header name"); + } + } + + @Test + public void checkRecursionDepth_nestedInTree_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder().setMatcher(current).build()))) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void checkRecursionDepth_nestedInPrefixTree_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("prefix", Matcher.OnMatch.newBuilder().setMatcher(current).build()))) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void checkRecursionDepth_nestedInOnNoMatch_throws() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 17; i++) { + current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder().setMatcher(current)) + .build(); + } + try { + UnifiedMatcher.fromProto(current); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("exceeds limit"); + } + } + + @Test + public void checkRecursionDepth_nestedInOnNoMatch_success() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 5; i++) { + current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder().setMatcher(current)) + .build(); + } + // This should not throw any exception as the depth (5) is within the limit (16). + UnifiedMatcher.fromProto(current); + } + + @Test + public void checkRecursionDepth_nestedInList_success() { + Matcher current = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.newBuilder().setName("leaf"))) + .build(); + + for (int i = 0; i < 5; i++) { + current = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3 + .HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher + .newBuilder().setExact("exact")))) + .setOnMatch(Matcher.OnMatch.newBuilder().setMatcher(current)))) + .build(); + } + // This should not throw any exception as the depth (5) is within the limit (16). + UnifiedMatcher.fromProto(current, (type) -> true); + } + + @Test + public void checkRecursionDepth_nestedInTree_success() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 5; i++) { + current = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("key", Matcher.OnMatch.newBuilder().setMatcher(current).build()))) + .build(); + } + // This should not throw any exception as the depth (5) is within the limit (16). + UnifiedMatcher.fromProto(current); + } + + @Test + public void checkRecursionDepth_nestedInPrefixTree_success() { + Matcher current = Matcher.newBuilder().build(); + for (int i = 0; i < 5; i++) { + current = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(com.google.protobuf.Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("k").build()))) + .setPrefixMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("prefix", Matcher.OnMatch.newBuilder().setMatcher(current).build()))) + .build(); + } + // This should not throw any exception as the depth (5) is within the limit (16). + UnifiedMatcher.fromProto(current); + } + + @Test + public void onMatch_empty_throws() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder()) + .build(); + + try { + UnifiedMatcher.fromProto(proto); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("OnMatch must have either matcher or action"); + } + } + + @Test + public void matcherList_empty_throws() { + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder()) + .build(); + + try { + UnifiedMatcher.fromProto(proto); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("MatcherList must contain at least one FieldMatcher"); + } + } + + @Test + public void stringMatcher_emptySuffix_throws() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder().setSuffix("")) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains( + "StringMatcher suffix (match_pattern) must be non-empty"); + } + } + + @Test + public void stringMatcher_unknownPattern_throws() { + Matcher.MatcherList.Predicate.SinglePredicate predicate = + Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder() + .setTypedConfig(Any.pack( + io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("host").build()))) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.getDefaultInstance()) + .build(); + + try { + PredicateEvaluator.fromProto( + Matcher.MatcherList.Predicate.newBuilder().setSinglePredicate(predicate).build()); + fail("Should have thrown IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().contains("Unknown StringMatcher match pattern"); + } + } + + @Test + public void headerName_empty_throws() { + IllegalArgumentException e = org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> + new HeaderMatchInput("")); + assertThat(e).hasMessageThat().contains("Header name length must be in range [1, 16384)"); + } + + @Test + public void headerName_tooLong_throws() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16384; i++) { + sb.append("a"); + } + String longHeader = sb.toString(); + IllegalArgumentException e = org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> + new HeaderMatchInput(longHeader)); + assertThat(e).hasMessageThat().contains("Header name length must be in range [1, 16384)"); + } + + @Test + public void headerName_uppercase_throws() { + IllegalArgumentException e = org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> + new HeaderMatchInput("X-Custom-Header")); + assertThat(e).hasMessageThat().contains("Header name must be lowercase"); + } + + @Test + public void headerName_valid() { + HeaderMatchInput input = new HeaderMatchInput("x-custom-header"); + assertThat(input).isNotNull(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/security/ClientSslContextProviderFactoryTest.java b/xds/src/test/java/io/grpc/xds/internal/security/ClientSslContextProviderFactoryTest.java index 397fe01e0f5..a0eac581d5c 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/ClientSslContextProviderFactoryTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/ClientSslContextProviderFactoryTest.java @@ -37,6 +37,7 @@ import io.grpc.xds.internal.security.certprovider.CertificateProviderProvider; import io.grpc.xds.internal.security.certprovider.CertificateProviderRegistry; import io.grpc.xds.internal.security.certprovider.CertificateProviderStore; +import io.grpc.xds.internal.security.certprovider.IgnoreUpdatesWatcher; import io.grpc.xds.internal.security.certprovider.TestCertificateProvider; import org.junit.Before; import org.junit.Test; @@ -84,7 +85,7 @@ public void createCertProviderClientSslContextProvider() throws XdsInitializatio clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], false); // verify that bootstrapInfo is cached... sslContextProvider = clientSslContextProviderFactory.create(upstreamTlsContext); @@ -119,7 +120,7 @@ public void bothPresent_expectCertProviderClientSslContextProvider() clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); } @Test @@ -145,7 +146,7 @@ public void createCertProviderClientSslContextProvider_onlyRootCert() clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); } @Test @@ -179,7 +180,7 @@ public void createCertProviderClientSslContextProvider_withStaticContext() clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); } @Test @@ -209,8 +210,8 @@ public void createCertProviderClientSslContextProvider_2providers() clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); - verifyWatcher(sslContextProvider, watcherCaptor[1]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); + verifyWatcher(sslContextProvider, watcherCaptor[1], true); } @Test @@ -246,8 +247,8 @@ public void createNewCertProviderClientSslContextProvider_withSans() { clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); - verifyWatcher(sslContextProvider, watcherCaptor[1]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); + verifyWatcher(sslContextProvider, watcherCaptor[1], true); } @Test @@ -280,7 +281,7 @@ public void createNewCertProviderClientSslContextProvider_onlyRootCert() { clientSslContextProviderFactory.create(upstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderClientSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); } static void createAndRegisterProviderProvider( @@ -310,11 +311,18 @@ public CertificateProvider answer(InvocationOnMock invocation) throws Throwable } static void verifyWatcher( - SslContextProvider sslContextProvider, CertificateProvider.DistributorWatcher watcherCaptor) { + SslContextProvider sslContextProvider, CertificateProvider.DistributorWatcher watcherCaptor, + boolean usesDelegateWatcher) { assertThat(watcherCaptor).isNotNull(); assertThat(watcherCaptor.getDownstreamWatchers()).hasSize(1); - assertThat(watcherCaptor.getDownstreamWatchers().iterator().next()) - .isSameInstanceAs(sslContextProvider); + if (usesDelegateWatcher) { + assertThat(((IgnoreUpdatesWatcher) watcherCaptor.getDownstreamWatchers().iterator().next()) + .getDelegate()) + .isSameInstanceAs(sslContextProvider); + } else { + assertThat(watcherCaptor.getDownstreamWatchers().iterator().next()) + .isSameInstanceAs(sslContextProvider); + } } static CommonTlsContext.Builder addFilenames( diff --git a/xds/src/test/java/io/grpc/xds/internal/security/CommonTlsContextTestsUtil.java b/xds/src/test/java/io/grpc/xds/internal/security/CommonTlsContextTestsUtil.java index 48814dece1d..abacd2038f8 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/CommonTlsContextTestsUtil.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/CommonTlsContextTestsUtil.java @@ -36,10 +36,12 @@ import java.io.InputStream; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.AbstractMap; import java.util.Arrays; import java.util.List; import java.util.concurrent.Executor; import javax.annotation.Nullable; +import javax.net.ssl.X509TrustManager; /** Utility class for client and server ssl provider tests. */ public class CommonTlsContextTestsUtil { @@ -60,6 +62,8 @@ public class CommonTlsContextTestsUtil { public static final String BAD_SERVER_KEY_FILE = "badserver.key"; public static final String BAD_CLIENT_PEM_FILE = "badclient.pem"; public static final String BAD_CLIENT_KEY_FILE = "badclient.key"; + public static final String BAD_WILDCARD_DNS_PEM_FILE = + "sni-test-certs/bad_wildcard_dns_certificate.pem"; /** takes additional values and creates CombinedCertificateValidationContext as needed. */ private static CommonTlsContext buildCommonTlsContextWithAdditionalValues( @@ -149,11 +153,24 @@ public static String getTempFileNameForResourcesFile(String resFile) throws IOEx * Helper method to build UpstreamTlsContext for above tests. Called from other classes as well. */ static EnvoyServerProtoData.UpstreamTlsContext buildUpstreamTlsContext( - CommonTlsContext commonTlsContext) { - UpstreamTlsContext upstreamTlsContext = - UpstreamTlsContext.newBuilder().setCommonTlsContext(commonTlsContext).build(); + CommonTlsContext commonTlsContext) { + return buildUpstreamTlsContext(commonTlsContext, "", false, false); + } + + /** + * Helper method to build UpstreamTlsContext with SNI info. + */ + static EnvoyServerProtoData.UpstreamTlsContext buildUpstreamTlsContext( + CommonTlsContext commonTlsContext, String sni, boolean autoHostSni, + boolean autoSniSanValidation) { + UpstreamTlsContext.Builder upstreamTlsContext = + UpstreamTlsContext.newBuilder() + .setCommonTlsContext(commonTlsContext) + .setAutoHostSni(autoHostSni) + .setAutoSniSanValidation(autoSniSanValidation) + .setSni(sni); return EnvoyServerProtoData.UpstreamTlsContext.fromEnvoyProtoUpstreamTlsContext( - upstreamTlsContext); + upstreamTlsContext.build()); } /** Helper method to build UpstreamTlsContext for multiple test classes. */ @@ -168,6 +185,21 @@ public static EnvoyServerProtoData.UpstreamTlsContext buildUpstreamTlsContext( null); } + /** Helper method to build UpstreamTlsContext with SNI info. */ + public static EnvoyServerProtoData.UpstreamTlsContext buildUpstreamTlsContext( + String commonInstanceName, boolean hasIdentityCert, String sni, boolean autoHostSni) { + return buildUpstreamTlsContextForCertProviderInstance( + hasIdentityCert ? commonInstanceName : null, + hasIdentityCert ? "default" : null, + commonInstanceName, + "ROOT", + null, + null, + sni, + autoHostSni, + false); + } + /** Gets a cert from contents of a resource. */ public static X509Certificate getCertFromResourceName(String resourceName) throws IOException, CertificateException { @@ -200,6 +232,33 @@ private static CommonTlsContext buildCommonTlsContextForCertProviderInstance( return builder.build(); } + /** Helper method to build CommonTlsContext using deprecated certificate provider field. */ + @SuppressWarnings("deprecation") + public static CommonTlsContext buildCommonTlsContextWithDeprecatedCertProviderInstance( + String certInstanceName, + String certName, + String rootInstanceName, + String rootCertName, + Iterable alpnProtocols, + CertificateValidationContext staticCertValidationContext) { + CommonTlsContext.Builder builder = CommonTlsContext.newBuilder(); + if (certInstanceName != null) { + // Use deprecated field (field 11) instead of current field (field 14) + builder = + builder.setTlsCertificateCertificateProviderInstance( + CommonTlsContext.CertificateProviderInstance.newBuilder() + .setInstanceName(certInstanceName) + .setCertificateName(certName)); + } + builder = + addCertificateValidationContext( + builder, rootInstanceName, rootCertName, staticCertValidationContext); + if (alpnProtocols != null) { + builder.addAllAlpnProtocols(alpnProtocols); + } + return builder.build(); + } + private static CommonTlsContext buildNewCommonTlsContextForCertProviderInstance( String certInstanceName, String certName, @@ -284,7 +343,31 @@ private static CommonTlsContext.Builder addNewCertificateValidationContext( rootInstanceName, rootCertName, alpnProtocols, - staticCertValidationContext)); + staticCertValidationContext), + "", false, false); + } + + /** Helper method to build UpstreamTlsContext with SNI info for CertProvider tests. */ + public static EnvoyServerProtoData.UpstreamTlsContext + buildUpstreamTlsContextForCertProviderInstance( + @Nullable String certInstanceName, + @Nullable String certName, + @Nullable String rootInstanceName, + @Nullable String rootCertName, + Iterable alpnProtocols, + CertificateValidationContext staticCertValidationContext, + String sni, + boolean autoHostSni, + boolean autoSniSanValidation) { + return buildUpstreamTlsContext( + buildCommonTlsContextForCertProviderInstance( + certInstanceName, + certName, + rootInstanceName, + rootCertName, + alpnProtocols, + staticCertValidationContext), + sni, autoHostSni, autoSniSanValidation); } /** Helper method to build UpstreamTlsContext for CertProvider tests. */ @@ -303,7 +386,8 @@ private static CommonTlsContext.Builder addNewCertificateValidationContext( rootInstanceName, rootCertName, alpnProtocols, - staticCertValidationContext)); + staticCertValidationContext), + "", false, false); } /** Helper method to build DownstreamTlsContext for CertProvider tests. */ @@ -347,14 +431,15 @@ private static CommonTlsContext.Builder addNewCertificateValidationContext( } /** Perform some simple checks on sslContext. */ - public static void doChecksOnSslContext(boolean server, SslContext sslContext, + public static void doChecksOnSslContext(boolean server, + AbstractMap.SimpleImmutableEntry sslContextAndTm, List expectedApnProtos) { if (server) { - assertThat(sslContext.isServer()).isTrue(); + assertThat(sslContextAndTm.getKey().isServer()).isTrue(); } else { - assertThat(sslContext.isClient()).isTrue(); + assertThat(sslContextAndTm.getKey().isClient()).isTrue(); } - List apnProtos = sslContext.applicationProtocolNegotiator().protocols(); + List apnProtos = sslContextAndTm.getKey().applicationProtocolNegotiator().protocols(); assertThat(apnProtos).isNotNull(); if (expectedApnProtos != null) { assertThat(apnProtos).isEqualTo(expectedApnProtos); @@ -380,7 +465,7 @@ public static TestCallback getValueThruCallback(SslContextProvider provider, Exe public static class TestCallback extends SslContextProvider.Callback { - public SslContext updatedSslContext; + public AbstractMap.SimpleImmutableEntry updatedSslContext; public Throwable updatedThrowable; public TestCallback(Executor executor) { @@ -388,7 +473,8 @@ public TestCallback(Executor executor) { } @Override - public void updateSslContext(SslContext sslContext) { + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContext) { updatedSslContext = sslContext; } diff --git a/xds/src/test/java/io/grpc/xds/internal/security/SecurityProtocolNegotiatorsTest.java b/xds/src/test/java/io/grpc/xds/internal/security/SecurityProtocolNegotiatorsTest.java index a0139618f9f..125b7e65aa6 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/SecurityProtocolNegotiatorsTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/SecurityProtocolNegotiatorsTest.java @@ -50,6 +50,7 @@ import io.grpc.xds.TlsContextManager; import io.grpc.xds.client.Bootstrapper; import io.grpc.xds.client.CommonBootstrapperTestUtils; +import io.grpc.xds.internal.XdsInternalAttributes; import io.grpc.xds.internal.security.SecurityProtocolNegotiators.ClientSecurityHandler; import io.grpc.xds.internal.security.SecurityProtocolNegotiators.ClientSecurityProtocolNegotiator; import io.grpc.xds.internal.security.certprovider.CommonCertProviderTestUtils; @@ -73,11 +74,13 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.security.cert.CertStoreException; +import java.util.AbstractMap; import java.util.Iterator; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import javax.net.ssl.X509TrustManager; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -86,6 +89,10 @@ @RunWith(JUnit4.class) public class SecurityProtocolNegotiatorsTest { + private static final String HOSTNAME = "hostname"; + private static final String SNI_IN_UTC = "sni-in-upstream-tls-context"; + private static final String FAKE_AUTHORITY = "authority"; + private final GrpcHttp2ConnectionHandler grpcHandler = FakeGrpcHttp2ConnectionHandler.newHandler(); @@ -121,8 +128,31 @@ public void clientSecurityProtocolNegotiatorNewHandler_noFallback_expectExceptio @Test public void clientSecurityProtocolNegotiatorNewHandler_withTlsContextAttribute() { + UpstreamTlsContext upstreamTlsContext = CommonTlsContextTestsUtil.buildUpstreamTlsContext( + CommonTlsContext.newBuilder().build()); + ClientSecurityProtocolNegotiator pn = + new ClientSecurityProtocolNegotiator(InternalProtocolNegotiators.plaintext()); + GrpcHttp2ConnectionHandler mockHandler = mock(GrpcHttp2ConnectionHandler.class); + ChannelLogger logger = mock(ChannelLogger.class); + doNothing().when(logger).log(any(ChannelLogLevel.class), anyString()); + when(mockHandler.getNegotiationLogger()).thenReturn(logger); + TlsContextManager mockTlsContextManager = mock(TlsContextManager.class); + when(mockHandler.getEagAttributes()) + .thenReturn( + Attributes.newBuilder() + .set(SecurityProtocolNegotiators.ATTR_SSL_CONTEXT_PROVIDER_SUPPLIER, + new SslContextProviderSupplier(upstreamTlsContext, mockTlsContextManager)) + .build()); + ChannelHandler newHandler = pn.newHandler(mockHandler); + assertThat(newHandler).isNotNull(); + assertThat(newHandler).isInstanceOf(ClientSecurityHandler.class); + } + + @Test + public void clientSecurityProtocolNegotiator_autoHostSni_hostnamePassedToClientSecurityHandlr() { UpstreamTlsContext upstreamTlsContext = - CommonTlsContextTestsUtil.buildUpstreamTlsContext(CommonTlsContext.newBuilder().build()); + CommonTlsContextTestsUtil.buildUpstreamTlsContext( + CommonTlsContext.newBuilder().build(), "", true, false); ClientSecurityProtocolNegotiator pn = new ClientSecurityProtocolNegotiator(InternalProtocolNegotiators.plaintext()); GrpcHttp2ConnectionHandler mockHandler = mock(GrpcHttp2ConnectionHandler.class); @@ -135,10 +165,12 @@ public void clientSecurityProtocolNegotiatorNewHandler_withTlsContextAttribute() Attributes.newBuilder() .set(SecurityProtocolNegotiators.ATTR_SSL_CONTEXT_PROVIDER_SUPPLIER, new SslContextProviderSupplier(upstreamTlsContext, mockTlsContextManager)) + .set(XdsInternalAttributes.ATTR_ADDRESS_NAME, FAKE_AUTHORITY) .build()); ChannelHandler newHandler = pn.newHandler(mockHandler); assertThat(newHandler).isNotNull(); assertThat(newHandler).isInstanceOf(ClientSecurityHandler.class); + assertThat(((ClientSecurityHandler) newHandler).getSni()).isEqualTo(FAKE_AUTHORITY); } @Test @@ -157,7 +189,7 @@ public void clientSecurityHandler_addLast() new SslContextProviderSupplier(upstreamTlsContext, new TlsContextManagerImpl(bootstrapInfoForClient)); ClientSecurityHandler clientSecurityHandler = - new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier); + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, HOSTNAME); pipeline.addLast(clientSecurityHandler); channelHandlerCtx = pipeline.context(clientSecurityHandler); assertNotNull(channelHandlerCtx); @@ -168,19 +200,20 @@ public void clientSecurityHandler_addLast() sslContextProviderSupplier .updateSslContext(new SslContextProvider.Callback(MoreExecutors.directExecutor()) { @Override - public void updateSslContext(SslContext sslContext) { - future.set(sslContext); + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContextAndTm) { + future.set(sslContextAndTm); } @Override protected void onException(Throwable throwable) { future.set(throwable); } - }); + }, true); assertThat(executor.runDueTasks()).isEqualTo(1); channel.runPendingTasks(); Object fromFuture = future.get(2, TimeUnit.SECONDS); - assertThat(fromFuture).isInstanceOf(SslContext.class); + assertThat(fromFuture).isInstanceOf(AbstractMap.SimpleImmutableEntry.class); channel.runPendingTasks(); channelHandlerCtx = pipeline.context(clientSecurityHandler); assertThat(channelHandlerCtx).isNull(); @@ -194,6 +227,75 @@ protected void onException(Throwable throwable) { CommonCertProviderTestUtils.register0(); } + @Test + public void sniInClientSecurityHandler_autoHostSniIsTrue_usesEndpointHostname() { + Bootstrapper.BootstrapInfo bootstrapInfoForClient = CommonBootstrapperTestUtils + .buildBootstrapInfo("google_cloud_private_spiffe-client", CLIENT_KEY_FILE, + CLIENT_PEM_FILE, CA_PEM_FILE, null, null, null, null, null); + UpstreamTlsContext upstreamTlsContext = + CommonTlsContextTestsUtil + .buildUpstreamTlsContext("google_cloud_private_spiffe-client", true, "", true); + SslContextProviderSupplier sslContextProviderSupplier = + new SslContextProviderSupplier(upstreamTlsContext, + new TlsContextManagerImpl(bootstrapInfoForClient)); + + ClientSecurityHandler clientSecurityHandler = + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, HOSTNAME); + + assertThat(clientSecurityHandler.getSni()).isEqualTo(HOSTNAME); + } + + @Test + public void sniInClientSecurityHandler_autoHostSni_endpointHostnameIsEmpty_usesSniFromUtc() { + Bootstrapper.BootstrapInfo bootstrapInfoForClient = CommonBootstrapperTestUtils + .buildBootstrapInfo("google_cloud_private_spiffe-client", CLIENT_KEY_FILE, + CLIENT_PEM_FILE, CA_PEM_FILE, null, null, null, null, null); + UpstreamTlsContext upstreamTlsContext = CommonTlsContextTestsUtil.buildUpstreamTlsContext( + "google_cloud_private_spiffe-client", true, SNI_IN_UTC, true); + SslContextProviderSupplier sslContextProviderSupplier = + new SslContextProviderSupplier(upstreamTlsContext, + new TlsContextManagerImpl(bootstrapInfoForClient)); + + ClientSecurityHandler clientSecurityHandler = + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, ""); + + assertThat(clientSecurityHandler.getSni()).isEqualTo(SNI_IN_UTC); + } + + @Test + public void sniInClientSecurityHandler_autoHostSni_endpointHostnameIsNull_usesSniFromUtc() { + Bootstrapper.BootstrapInfo bootstrapInfoForClient = CommonBootstrapperTestUtils + .buildBootstrapInfo("google_cloud_private_spiffe-client", CLIENT_KEY_FILE, + CLIENT_PEM_FILE, CA_PEM_FILE, null, null, null, null, null); + UpstreamTlsContext upstreamTlsContext = CommonTlsContextTestsUtil.buildUpstreamTlsContext( + "google_cloud_private_spiffe-client", true, SNI_IN_UTC, true); + SslContextProviderSupplier sslContextProviderSupplier = + new SslContextProviderSupplier(upstreamTlsContext, + new TlsContextManagerImpl(bootstrapInfoForClient)); + + ClientSecurityHandler clientSecurityHandler = + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, null); + + assertThat(clientSecurityHandler.getSni()).isEqualTo(SNI_IN_UTC); + } + + @Test + public void sniInClientSecurityHandler_autoHostSniIsFalse_usesSniFromUpstreamTlsContext() { + Bootstrapper.BootstrapInfo bootstrapInfoForClient = CommonBootstrapperTestUtils + .buildBootstrapInfo("google_cloud_private_spiffe-client", CLIENT_KEY_FILE, + CLIENT_PEM_FILE, CA_PEM_FILE, null, null, null, null, null); + UpstreamTlsContext upstreamTlsContext = CommonTlsContextTestsUtil.buildUpstreamTlsContext( + "google_cloud_private_spiffe-client", true, SNI_IN_UTC, false); + SslContextProviderSupplier sslContextProviderSupplier = + new SslContextProviderSupplier(upstreamTlsContext, + new TlsContextManagerImpl(bootstrapInfoForClient)); + + ClientSecurityHandler clientSecurityHandler = + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, HOSTNAME); + + assertThat(clientSecurityHandler.getSni()).isEqualTo(SNI_IN_UTC); + } + @Test public void serverSecurityHandler_addLast() throws InterruptedException, TimeoutException, ExecutionException { @@ -245,19 +347,20 @@ public SocketAddress remoteAddress() { sslContextProviderSupplier .updateSslContext(new SslContextProvider.Callback(MoreExecutors.directExecutor()) { @Override - public void updateSslContext(SslContext sslContext) { - future.set(sslContext); + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContextAndTm) { + future.set(sslContextAndTm); } @Override protected void onException(Throwable throwable) { future.set(throwable); } - }); + }, true); channel.runPendingTasks(); // need this for tasks to execute on eventLoop assertThat(executor.runDueTasks()).isEqualTo(1); Object fromFuture = future.get(2, TimeUnit.SECONDS); - assertThat(fromFuture).isInstanceOf(SslContext.class); + assertThat(fromFuture).isInstanceOf(AbstractMap.SimpleImmutableEntry.class); channel.runPendingTasks(); channelHandlerCtx = pipeline.context(SecurityProtocolNegotiators.ServerSecurityHandler.class); assertThat(channelHandlerCtx).isNull(); @@ -355,12 +458,12 @@ public void nullTlsContext_nullFallbackProtocolNegotiator_expectException() { @Test public void clientSecurityProtocolNegotiatorNewHandler_fireProtocolNegotiationEvent() - throws InterruptedException, TimeoutException, ExecutionException { + throws InterruptedException, TimeoutException, ExecutionException { FakeClock executor = new FakeClock(); CommonCertProviderTestUtils.register(executor); Bootstrapper.BootstrapInfo bootstrapInfoForClient = CommonBootstrapperTestUtils - .buildBootstrapInfo("google_cloud_private_spiffe-client", CLIENT_KEY_FILE, CLIENT_PEM_FILE, - CA_PEM_FILE, null, null, null, null, null); + .buildBootstrapInfo("google_cloud_private_spiffe-client", CLIENT_KEY_FILE, + CLIENT_PEM_FILE, CA_PEM_FILE, null, null, null, null, null); UpstreamTlsContext upstreamTlsContext = CommonTlsContextTestsUtil .buildUpstreamTlsContext("google_cloud_private_spiffe-client", true); @@ -369,7 +472,7 @@ public void clientSecurityProtocolNegotiatorNewHandler_fireProtocolNegotiationEv new SslContextProviderSupplier(upstreamTlsContext, new TlsContextManagerImpl(bootstrapInfoForClient)); ClientSecurityHandler clientSecurityHandler = - new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier); + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, HOSTNAME); pipeline.addLast(clientSecurityHandler); channelHandlerCtx = pipeline.context(clientSecurityHandler); @@ -381,19 +484,20 @@ public void clientSecurityProtocolNegotiatorNewHandler_fireProtocolNegotiationEv sslContextProviderSupplier .updateSslContext(new SslContextProvider.Callback(MoreExecutors.directExecutor()) { @Override - public void updateSslContext(SslContext sslContext) { - future.set(sslContext); + public void updateSslContextAndExtendedX509TrustManager( + AbstractMap.SimpleImmutableEntry sslContextAndTm) { + future.set(sslContextAndTm); } @Override protected void onException(Throwable throwable) { future.set(throwable); } - }); + }, true); executor.runDueTasks(); channel.runPendingTasks(); // need this for tasks to execute on eventLoop Object fromFuture = future.get(5, TimeUnit.SECONDS); - assertThat(fromFuture).isInstanceOf(SslContext.class); + assertThat(fromFuture).isInstanceOf(AbstractMap.SimpleImmutableEntry.class); channel.runPendingTasks(); channelHandlerCtx = pipeline.context(clientSecurityHandler); assertThat(channelHandlerCtx).isNull(); @@ -420,7 +524,7 @@ public void clientSecurityProtocolNegotiatorNewHandler_handleHandlerRemoved() { new SslContextProviderSupplier(upstreamTlsContext, new TlsContextManagerImpl(bootstrapInfoForClient)); ClientSecurityHandler clientSecurityHandler = - new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier); + new ClientSecurityHandler(grpcHandler, sslContextProviderSupplier, HOSTNAME); pipeline.addLast(clientSecurityHandler); channelHandlerCtx = pipeline.context(clientSecurityHandler); @@ -458,7 +562,7 @@ static FakeGrpcHttp2ConnectionHandler newHandler() { @Override public String getAuthority() { - return "authority"; + return FAKE_AUTHORITY; } } } diff --git a/xds/src/test/java/io/grpc/xds/internal/security/ServerSslContextProviderFactoryTest.java b/xds/src/test/java/io/grpc/xds/internal/security/ServerSslContextProviderFactoryTest.java index cf86b511f1f..7a5a6c00639 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/ServerSslContextProviderFactoryTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/ServerSslContextProviderFactoryTest.java @@ -78,7 +78,7 @@ public void createCertProviderServerSslContextProvider() throws XdsInitializatio serverSslContextProviderFactory.create(downstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderServerSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], false); // verify that bootstrapInfo is cached... sslContextProvider = serverSslContextProviderFactory.create(downstreamTlsContext); @@ -117,7 +117,7 @@ public void bothPresent_expectCertProviderServerSslContextProvider() serverSslContextProviderFactory.create(downstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderServerSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); } @Test @@ -144,7 +144,7 @@ public void createCertProviderServerSslContextProvider_onlyCertInstance() serverSslContextProviderFactory.create(downstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderServerSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); } @Test @@ -179,7 +179,7 @@ public void createCertProviderServerSslContextProvider_withStaticContext() serverSslContextProviderFactory.create(downstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderServerSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); + verifyWatcher(sslContextProvider, watcherCaptor[0], false); } @Test @@ -210,8 +210,8 @@ public void createCertProviderServerSslContextProvider_2providers() serverSslContextProviderFactory.create(downstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderServerSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); - verifyWatcher(sslContextProvider, watcherCaptor[1]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); + verifyWatcher(sslContextProvider, watcherCaptor[1], true); } @Test @@ -249,7 +249,7 @@ public void createNewCertProviderServerSslContextProvider_withSans() serverSslContextProviderFactory.create(downstreamTlsContext); assertThat(sslContextProvider.getClass().getSimpleName()).isEqualTo( "CertProviderServerSslContextProvider"); - verifyWatcher(sslContextProvider, watcherCaptor[0]); - verifyWatcher(sslContextProvider, watcherCaptor[1]); + verifyWatcher(sslContextProvider, watcherCaptor[0], true); + verifyWatcher(sslContextProvider, watcherCaptor[1], true); } } diff --git a/xds/src/test/java/io/grpc/xds/internal/security/SslContextProviderSupplierTest.java b/xds/src/test/java/io/grpc/xds/internal/security/SslContextProviderSupplierTest.java index f476818297d..70a53c53205 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/SslContextProviderSupplierTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/SslContextProviderSupplierTest.java @@ -17,8 +17,9 @@ package io.grpc.xds.internal.security; import static com.google.common.truth.Truth.assertThat; +import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.buildUpstreamTlsContext; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; @@ -26,10 +27,13 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.CertificateValidationContext; import io.grpc.xds.EnvoyServerProtoData; import io.grpc.xds.TlsContextManager; import io.netty.handler.ssl.SslContext; +import java.util.AbstractMap; import java.util.concurrent.Executor; +import javax.net.ssl.X509TrustManager; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -47,14 +51,14 @@ public class SslContextProviderSupplierTest { @Rule public final MockitoRule mocks = MockitoJUnit.rule(); @Mock private TlsContextManager mockTlsContextManager; + @Mock private Executor mockExecutor; private SslContextProviderSupplier supplier; private SslContextProvider mockSslContextProvider; - private EnvoyServerProtoData.UpstreamTlsContext upstreamTlsContext; + private EnvoyServerProtoData.UpstreamTlsContext upstreamTlsContext = + buildUpstreamTlsContext("google_cloud_private_spiffe", true); private SslContextProvider.Callback mockCallback; private void prepareSupplier() { - upstreamTlsContext = - CommonTlsContextTestsUtil.buildUpstreamTlsContext("google_cloud_private_spiffe", true); mockSslContextProvider = mock(SslContextProvider.class); doReturn(mockSslContextProvider) .when(mockTlsContextManager) @@ -64,9 +68,8 @@ private void prepareSupplier() { private void callUpdateSslContext() { mockCallback = mock(SslContextProvider.Callback.class); - Executor mockExecutor = mock(Executor.class); doReturn(mockExecutor).when(mockCallback).getExecutor(); - supplier.updateSslContext(mockCallback); + supplier.updateSslContext(mockCallback, false); } @Test @@ -82,26 +85,57 @@ public void get_updateSecret() { verify(mockSslContextProvider, times(1)).addCallback(callbackCaptor.capture()); SslContextProvider.Callback capturedCallback = callbackCaptor.getValue(); assertThat(capturedCallback).isNotNull(); - SslContext mockSslContext = mock(SslContext.class); - capturedCallback.updateSslContext(mockSslContext); - verify(mockCallback, times(1)).updateSslContext(eq(mockSslContext)); + @SuppressWarnings("unchecked") + AbstractMap.SimpleImmutableEntry mockSslContextAndTm = + mock(AbstractMap.SimpleImmutableEntry.class); + capturedCallback.updateSslContextAndExtendedX509TrustManager(mockSslContextAndTm); + verify(mockCallback, times(1)) + .updateSslContextAndExtendedX509TrustManager(eq(mockSslContextAndTm)); verify(mockTlsContextManager, times(1)) .releaseClientSslContextProvider(eq(mockSslContextProvider)); SslContextProvider.Callback mockCallback = mock(SslContextProvider.Callback.class); - supplier.updateSslContext(mockCallback); + supplier.updateSslContext(mockCallback, false); verify(mockTlsContextManager, times(3)) .findOrCreateClientSslContextProvider(eq(upstreamTlsContext)); } @Test - public void get_onException() { + public void autoHostSniFalse_usesSniFromUpstreamTlsContext() { prepareSupplier(); callUpdateSslContext(); + verify(mockTlsContextManager, times(2)) + .findOrCreateClientSslContextProvider(eq(upstreamTlsContext)); + verify(mockTlsContextManager, times(0)) + .releaseClientSslContextProvider(any(SslContextProvider.class)); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(SslContextProvider.Callback.class); verify(mockSslContextProvider, times(1)).addCallback(callbackCaptor.capture()); SslContextProvider.Callback capturedCallback = callbackCaptor.getValue(); assertThat(capturedCallback).isNotNull(); + @SuppressWarnings("unchecked") + AbstractMap.SimpleImmutableEntry mockSslContextAndTm = + mock(AbstractMap.SimpleImmutableEntry.class); + capturedCallback.updateSslContextAndExtendedX509TrustManager(mockSslContextAndTm); + verify(mockCallback, times(1)) + .updateSslContextAndExtendedX509TrustManager(eq(mockSslContextAndTm)); + verify(mockTlsContextManager, times(1)) + .releaseClientSslContextProvider(eq(mockSslContextProvider)); + SslContextProvider.Callback mockCallback = mock(SslContextProvider.Callback.class); + supplier.updateSslContext(mockCallback, false); + verify(mockTlsContextManager, times(3)) + .findOrCreateClientSslContextProvider(eq(upstreamTlsContext)); + } + + @Test + public void get_onException() { + prepareSupplier(); + callUpdateSslContext(); + ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(SslContextProvider.Callback.class); + verify(mockSslContextProvider, times(1)) + .addCallback(callbackCaptor.capture()); + SslContextProvider.Callback capturedCallback = callbackCaptor.getValue(); + assertThat(capturedCallback).isNotNull(); Exception exception = new Exception("test"); capturedCallback.onException(exception); verify(mockCallback, times(1)).onException(eq(exception)); @@ -109,6 +143,46 @@ public void get_onException() { .releaseClientSslContextProvider(eq(mockSslContextProvider)); } + @Test + public void systemRootCertsWithMtls_callbackExecutedFromProvider() { + upstreamTlsContext = + CommonTlsContextTestsUtil.buildNewUpstreamTlsContextForCertProviderInstance( + "gcp_id", + "cert-default", + null, + "root-default", + null, + CertificateValidationContext.newBuilder() + .setSystemRootCerts( + CertificateValidationContext.SystemRootCerts.getDefaultInstance()) + .build()); + prepareSupplier(); + + callUpdateSslContext(); + + verify(mockTlsContextManager, times(2)) + .findOrCreateClientSslContextProvider(eq(upstreamTlsContext)); + verify(mockTlsContextManager, times(0)) + .releaseClientSslContextProvider(any(SslContextProvider.class)); + ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(SslContextProvider.Callback.class); + verify(mockSslContextProvider, times(1)).addCallback(callbackCaptor.capture()); + SslContextProvider.Callback capturedCallback = callbackCaptor.getValue(); + assertThat(capturedCallback).isNotNull(); + @SuppressWarnings("unchecked") + AbstractMap.SimpleImmutableEntry mockSslContextAndTm = + mock(AbstractMap.SimpleImmutableEntry.class); + capturedCallback.updateSslContextAndExtendedX509TrustManager(mockSslContextAndTm); + verify(mockCallback, times(1)) + .updateSslContextAndExtendedX509TrustManager(eq(mockSslContextAndTm)); + verify(mockTlsContextManager, times(1)) + .releaseClientSslContextProvider(eq(mockSslContextProvider)); + SslContextProvider.Callback mockCallback = mock(SslContextProvider.Callback.class); + supplier.updateSslContext(mockCallback, false); + verify(mockTlsContextManager, times(3)) + .findOrCreateClientSslContextProvider(eq(upstreamTlsContext)); + } + @Test public void testClose() { prepareSupplier(); @@ -116,7 +190,7 @@ public void testClose() { supplier.close(); verify(mockTlsContextManager, times(1)) .releaseClientSslContextProvider(eq(mockSslContextProvider)); - supplier.updateSslContext(mockCallback); + supplier.updateSslContext(mockCallback, false); verify(mockTlsContextManager, times(3)) .findOrCreateClientSslContextProvider(eq(upstreamTlsContext)); verify(mockTlsContextManager, times(1)) diff --git a/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProviderTest.java b/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProviderTest.java index b0800458d66..91f02863ca4 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderClientSslContextProviderTest.java @@ -72,15 +72,28 @@ private CertProviderClientSslContextProvider getSslContextProvider( String rootInstanceName, Bootstrapper.BootstrapInfo bootstrapInfo, Iterable alpnProtocols, - CertificateValidationContext staticCertValidationContext) { - EnvoyServerProtoData.UpstreamTlsContext upstreamTlsContext = - CommonTlsContextTestsUtil.buildUpstreamTlsContextForCertProviderInstance( - certInstanceName, - "cert-default", - rootInstanceName, - "root-default", - alpnProtocols, - staticCertValidationContext); + CertificateValidationContext staticCertValidationContext, + boolean useSystemRootCerts) { + EnvoyServerProtoData.UpstreamTlsContext upstreamTlsContext; + if (useSystemRootCerts) { + upstreamTlsContext = + CommonTlsContextTestsUtil.buildNewUpstreamTlsContextForCertProviderInstance( + certInstanceName, + "cert-default", + rootInstanceName, + "root-default", + alpnProtocols, + staticCertValidationContext); + } else { + upstreamTlsContext = + CommonTlsContextTestsUtil.buildUpstreamTlsContextForCertProviderInstance( + certInstanceName, + "cert-default", + rootInstanceName, + "root-default", + alpnProtocols, + staticCertValidationContext); + } return (CertProviderClientSslContextProvider) certProviderClientSslContextProviderFactory.getProvider( upstreamTlsContext, @@ -122,12 +135,12 @@ public void testProviderForClient_mtls() throws Exception { "gcp_id", CommonBootstrapperTestUtils.getTestBootstrapInfo(), /* alpnProtocols= */ null, - /* staticCertValidationContext= */ null); + /* staticCertValidationContext= */ null, false); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate cert update watcherCaptor[0].updateCertificate( @@ -135,11 +148,11 @@ public void testProviderForClient_mtls() throws Exception { ImmutableList.of(getCertFromResourceName(CLIENT_PEM_FILE))); assertThat(provider.savedKey).isNotNull(); assertThat(provider.savedCertChain).isNotNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate root cert update watcherCaptor[0].updateTrustedRoots(ImmutableList.of(getCertFromResourceName(CA_PEM_FILE))); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); @@ -168,11 +181,92 @@ public void testProviderForClient_mtls() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); testCallback1 = CommonTlsContextTestsUtil.getValueThruCallback(provider); assertThat(testCallback1.updatedSslContext).isNotSameInstanceAs(testCallback.updatedSslContext); } + @Test + public void testProviderForClient_systemRootCerts_mtls() throws Exception { + final CertificateProvider.DistributorWatcher[] watcherCaptor = + new CertificateProvider.DistributorWatcher[1]; + TestCertificateProvider.createAndRegisterProviderProvider( + certificateProviderRegistry, watcherCaptor, "testca", 0); + CertProviderClientSslContextProvider provider = + getSslContextProvider( + "gcp_id", + null, + CommonBootstrapperTestUtils.getTestBootstrapInfo(), + /* alpnProtocols= */ null, + CertificateValidationContext.newBuilder() + .setSystemRootCerts( + CertificateValidationContext.SystemRootCerts.getDefaultInstance()) + .build(), + true); + + assertThat(provider.savedKey).isNull(); + assertThat(provider.savedCertChain).isNull(); + assertThat(provider.savedTrustedRoots).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); + + // now generate cert update + watcherCaptor[0].updateCertificate( + CommonCertProviderTestUtils.getPrivateKey(CLIENT_KEY_FILE), + ImmutableList.of(getCertFromResourceName(CLIENT_PEM_FILE))); + assertThat(provider.savedKey).isNull(); + assertThat(provider.savedCertChain).isNull(); + assertThat(provider.savedTrustedRoots).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); + + TestCallback testCallback = + CommonTlsContextTestsUtil.getValueThruCallback(provider); + + doChecksOnSslContext(false, testCallback.updatedSslContext, /* expectedApnProtos= */ null); + TestCallback testCallback1 = + CommonTlsContextTestsUtil.getValueThruCallback(provider); + assertThat(testCallback1.updatedSslContext).isSameInstanceAs(testCallback.updatedSslContext); + + // now update id cert: sslContext should be updated i.e. different from the previous one + watcherCaptor[0].updateCertificate( + CommonCertProviderTestUtils.getPrivateKey(SERVER_1_KEY_FILE), + ImmutableList.of(getCertFromResourceName(SERVER_1_PEM_FILE))); + assertThat(provider.savedKey).isNull(); + assertThat(provider.savedCertChain).isNull(); + assertThat(provider.savedTrustedRoots).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); + testCallback1 = CommonTlsContextTestsUtil.getValueThruCallback(provider); + assertThat(testCallback1.updatedSslContext).isNotSameInstanceAs(testCallback.updatedSslContext); + } + + @Test + public void testProviderForClient_systemRootCerts_regularTls() { + final CertificateProvider.DistributorWatcher[] watcherCaptor = + new CertificateProvider.DistributorWatcher[1]; + TestCertificateProvider.createAndRegisterProviderProvider( + certificateProviderRegistry, watcherCaptor, "testca", 0); + CertProviderClientSslContextProvider provider = + getSslContextProvider( + null, + null, + CommonBootstrapperTestUtils.getTestBootstrapInfo(), + /* alpnProtocols= */ null, + CertificateValidationContext.newBuilder() + .setSystemRootCerts( + CertificateValidationContext.SystemRootCerts.getDefaultInstance()) + .build(), + true); + + assertThat(provider.savedKey).isNull(); + assertThat(provider.savedCertChain).isNull(); + assertThat(provider.savedTrustedRoots).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); + TestCallback testCallback = + CommonTlsContextTestsUtil.getValueThruCallback(provider); + assertThat(testCallback.updatedSslContext).isEqualTo(provider.getSslContextAndTrustManager()); + + assertThat(watcherCaptor[0]).isNull(); + } + @Test public void testProviderForClient_mtls_newXds() throws Exception { final CertificateProvider.DistributorWatcher[] watcherCaptor = @@ -190,7 +284,7 @@ public void testProviderForClient_mtls_newXds() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate cert update watcherCaptor[0].updateCertificate( @@ -198,11 +292,11 @@ public void testProviderForClient_mtls_newXds() throws Exception { ImmutableList.of(getCertFromResourceName(CLIENT_PEM_FILE))); assertThat(provider.savedKey).isNotNull(); assertThat(provider.savedCertChain).isNotNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate root cert update watcherCaptor[0].updateTrustedRoots(ImmutableList.of(getCertFromResourceName(CA_PEM_FILE))); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); @@ -231,7 +325,7 @@ public void testProviderForClient_mtls_newXds() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); testCallback1 = CommonTlsContextTestsUtil.getValueThruCallback(provider); assertThat(testCallback1.updatedSslContext).isNotSameInstanceAs(testCallback.updatedSslContext); } @@ -248,7 +342,7 @@ public void testProviderForClient_queueExecutor() throws Exception { "gcp_id", CommonBootstrapperTestUtils.getTestBootstrapInfo(), /* alpnProtocols= */ null, - /* staticCertValidationContext= */ null); + /* staticCertValidationContext= */ null, false); QueuedExecutor queuedExecutor = new QueuedExecutor(); TestCallback testCallback = @@ -281,16 +375,16 @@ public void testProviderForClient_tls() throws Exception { "gcp_id", CommonBootstrapperTestUtils.getTestBootstrapInfo(), /* alpnProtocols= */ null, - /* staticCertValidationContext= */ null); + /* staticCertValidationContext= */ null, false); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate root cert update watcherCaptor[0].updateTrustedRoots(ImmutableList.of(getCertFromResourceName(CA_PEM_FILE))); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); @@ -318,7 +412,7 @@ public void testProviderForClient_sslContextException_onError() throws Exception "gcp_id", CommonBootstrapperTestUtils.getTestBootstrapInfo(), /* alpnProtocols= */null, - staticCertValidationContext); + staticCertValidationContext, false); TestCallback testCallback = new TestCallback(MoreExecutors.directExecutor()); provider.addCallback(testCallback); @@ -350,7 +444,7 @@ public void testProviderForClient_rootInstanceNull_and_notUsingSystemRootCerts_e /* rootInstanceName= */ null, CommonBootstrapperTestUtils.getTestBootstrapInfo(), /* alpnProtocols= */ null, - /* staticCertValidationContext= */ null); + /* staticCertValidationContext= */ null, false); fail("exception expected"); } catch (UnsupportedOperationException expected) { assertThat(expected).hasMessageThat().contains("Unsupported configurations in " @@ -373,7 +467,59 @@ public void testProviderForClient_rootInstanceNull_but_isUsingSystemRootCerts_va CertificateValidationContext.newBuilder() .setSystemRootCerts( CertificateValidationContext.SystemRootCerts.newBuilder().build()) - .build()); + .build(), false); + } + + @Test + public void testProviderForClient_deprecatedCertProviderField() throws Exception { + final CertificateProvider.DistributorWatcher[] watcherCaptor = + new CertificateProvider.DistributorWatcher[1]; + TestCertificateProvider.createAndRegisterProviderProvider( + certificateProviderRegistry, watcherCaptor, "testca", 0); + + // Build UpstreamTlsContext using deprecated field + EnvoyServerProtoData.UpstreamTlsContext upstreamTlsContext = + new EnvoyServerProtoData.UpstreamTlsContext( + CommonTlsContextTestsUtil.buildCommonTlsContextWithDeprecatedCertProviderInstance( + "gcp_id", + "cert-default", + "gcp_id", + "root-default", + /* alpnProtocols= */ null, + /* staticCertValidationContext= */ null)); + + Bootstrapper.BootstrapInfo bootstrapInfo = CommonBootstrapperTestUtils.getTestBootstrapInfo(); + CertProviderClientSslContextProvider provider = + (CertProviderClientSslContextProvider) + certProviderClientSslContextProviderFactory.getProvider( + upstreamTlsContext, + bootstrapInfo.node().toEnvoyProtoNode(), + bootstrapInfo.certProviders()); + + assertThat(provider.savedKey).isNull(); + assertThat(provider.savedCertChain).isNull(); + assertThat(provider.savedTrustedRoots).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); + + // Generate cert update + watcherCaptor[0].updateCertificate( + CommonCertProviderTestUtils.getPrivateKey(CLIENT_KEY_FILE), + ImmutableList.of(getCertFromResourceName(CLIENT_PEM_FILE))); + assertThat(provider.savedKey).isNotNull(); + assertThat(provider.savedCertChain).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); + + // Generate root cert update + watcherCaptor[0].updateTrustedRoots(ImmutableList.of(getCertFromResourceName(CA_PEM_FILE))); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); + assertThat(provider.savedKey).isNull(); + assertThat(provider.savedCertChain).isNull(); + assertThat(provider.savedTrustedRoots).isNull(); + + TestCallback testCallback = + CommonTlsContextTestsUtil.getValueThruCallback(provider); + + doChecksOnSslContext(false, testCallback.updatedSslContext, /* expectedApnProtos= */ null); } static class QueuedExecutor implements Executor { diff --git a/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProviderTest.java b/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProviderTest.java index 423829ff5af..93559f47245 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/certprovider/CertProviderServerSslContextProviderTest.java @@ -127,7 +127,7 @@ public void testProviderForServer_mtls() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate cert update watcherCaptor[0].updateCertificate( @@ -135,11 +135,11 @@ public void testProviderForServer_mtls() throws Exception { ImmutableList.of(getCertFromResourceName(SERVER_0_PEM_FILE))); assertThat(provider.savedKey).isNotNull(); assertThat(provider.savedCertChain).isNotNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate root cert update watcherCaptor[0].updateTrustedRoots(ImmutableList.of(getCertFromResourceName(CA_PEM_FILE))); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); @@ -168,7 +168,7 @@ public void testProviderForServer_mtls() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); testCallback1 = CommonTlsContextTestsUtil.getValueThruCallback(provider); assertThat(testCallback1.updatedSslContext).isNotSameInstanceAs(testCallback.updatedSslContext); } @@ -196,7 +196,7 @@ public void testProviderForServer_mtls_newXds() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate cert update watcherCaptor[0].updateCertificate( @@ -204,11 +204,11 @@ public void testProviderForServer_mtls_newXds() throws Exception { ImmutableList.of(getCertFromResourceName(SERVER_0_PEM_FILE))); assertThat(provider.savedKey).isNotNull(); assertThat(provider.savedCertChain).isNotNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate root cert update watcherCaptor[0].updateTrustedRoots(ImmutableList.of(getCertFromResourceName(CA_PEM_FILE))); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); @@ -237,7 +237,7 @@ public void testProviderForServer_mtls_newXds() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); testCallback1 = CommonTlsContextTestsUtil.getValueThruCallback(provider); assertThat(testCallback1.updatedSslContext).isNotSameInstanceAs(testCallback.updatedSslContext); } @@ -294,14 +294,14 @@ public void testProviderForServer_tls() throws Exception { assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); - assertThat(provider.getSslContext()).isNull(); + assertThat(provider.getSslContextAndTrustManager()).isNull(); // now generate cert update watcherCaptor[0].updateCertificate( CommonCertProviderTestUtils.getPrivateKey(SERVER_0_KEY_FILE), ImmutableList.of(getCertFromResourceName(SERVER_0_PEM_FILE))); - assertThat(provider.getSslContext()).isNotNull(); + assertThat(provider.getSslContextAndTrustManager()).isNotNull(); assertThat(provider.savedKey).isNull(); assertThat(provider.savedCertChain).isNull(); assertThat(provider.savedTrustedRoots).isNull(); diff --git a/xds/src/test/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProviderTest.java b/xds/src/test/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProviderTest.java index 620ee0a7ff7..f6fdc51dece 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/certprovider/FileWatcherCertificateProviderTest.java @@ -261,6 +261,59 @@ public void certAndKeyFileUpdateOnly() verifyTimeServiceAndScheduledFuture(); } + @Test + public void certFileUpdateOnly() + throws IOException, CertificateException, InterruptedException { + TestScheduledFuture scheduledFuture = + new TestScheduledFuture<>(); + doReturn(scheduledFuture) + .when(timeService) + .schedule(any(Runnable.class), any(Long.TYPE), eq(TimeUnit.SECONDS)); + // Ideally we'd use a matching cert/key pair here, but we don't actually have any ready-made. + // The test doesn't notice they don't match though. + populateTarget( + CLIENT_PEM_FILE, SERVER_0_KEY_FILE, CA_PEM_FILE, null, false, false, false, false); + provider.checkAndReloadCertificates(); + + reset(mockWatcher, timeService); + doReturn(scheduledFuture) + .when(timeService) + .schedule(any(Runnable.class), any(Long.TYPE), eq(TimeUnit.SECONDS)); + timeProvider.forwardTime(1, TimeUnit.SECONDS); + // It's normal to get a newer cert while continuing to use the same private key + populateTarget(SERVER_0_PEM_FILE, null, null, null, false, false, false, false); + provider.checkAndReloadCertificates(); + verifyWatcherUpdates(SERVER_0_PEM_FILE, null, null); + verifyTimeServiceAndScheduledFuture(); + } + + @Test + public void keyFileUpdateOnly() + throws IOException, CertificateException, InterruptedException { + TestScheduledFuture scheduledFuture = + new TestScheduledFuture<>(); + doReturn(scheduledFuture) + .when(timeService) + .schedule(any(Runnable.class), any(Long.TYPE), eq(TimeUnit.SECONDS)); + // Assume the key/cert is not updated atomically and we see a tear between them. Or maybe this + // was just a bug. + populateTarget( + SERVER_0_PEM_FILE, CLIENT_KEY_FILE, CA_PEM_FILE, null, false, false, false, false); + provider.checkAndReloadCertificates(); + + reset(mockWatcher, timeService); + doReturn(scheduledFuture) + .when(timeService) + .schedule(any(Runnable.class), any(Long.TYPE), eq(TimeUnit.SECONDS)); + timeProvider.forwardTime(1, TimeUnit.SECONDS); + // Even though it is strange the key updated without a cert update, we do still want to use the + // new files, as this recovers from the earlier tear. + populateTarget(null, SERVER_0_KEY_FILE, null, null, false, false, false, false); + provider.checkAndReloadCertificates(); + verifyWatcherUpdates(SERVER_0_PEM_FILE, null, null); + verifyTimeServiceAndScheduledFuture(); + } + @Test public void spiffeTrustMapFileUpdateOnly() throws Exception { provider = new FileWatcherCertificateProvider(watcher, true, certFile, keyFile, null, diff --git a/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactoryTest.java b/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactoryTest.java index db83961cfc3..3077482b10b 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactoryTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsTrustManagerFactoryTest.java @@ -91,7 +91,7 @@ public void constructor_fromRootCert() CertificateValidationContext staticValidationContext = buildStaticValidationContext("san1", "san2"); XdsTrustManagerFactory factory = - new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext); + new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext, false); assertThat(factory).isNotNull(); TrustManager[] tms = factory.getTrustManagers(); assertThat(tms).isNotNull(); @@ -115,7 +115,7 @@ public void constructor_fromSpiffeTrustMap() "san2"); // Single domain and single cert XdsTrustManagerFactory factory = new XdsTrustManagerFactory(ImmutableMap - .of("example.com", ImmutableList.of(x509Cert)), staticValidationContext); + .of("example.com", ImmutableList.of(x509Cert)), staticValidationContext, false); assertThat(factory).isNotNull(); TrustManager[] tms = factory.getTrustManagers(); assertThat(tms).isNotNull(); @@ -131,7 +131,7 @@ public void constructor_fromSpiffeTrustMap() X509Certificate anotherCert = TestUtils.loadX509Cert(CLIENT_PEM_FILE); factory = new XdsTrustManagerFactory(ImmutableMap .of("example.com", ImmutableList.of(x509Cert), - "google.com", ImmutableList.of(x509Cert, anotherCert)), staticValidationContext); + "google.com", ImmutableList.of(x509Cert, anotherCert)), staticValidationContext, false); assertThat(factory).isNotNull(); tms = factory.getTrustManagers(); assertThat(tms).isNotNull(); @@ -154,7 +154,7 @@ public void constructorRootCert_checkServerTrusted() CertificateValidationContext staticValidationContext = buildStaticValidationContext("san1", "waterzooi.test.google.be"); XdsTrustManagerFactory factory = - new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext); + new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext, false); XdsX509TrustManager xdsX509TrustManager = (XdsX509TrustManager) factory.getTrustManagers()[0]; X509Certificate[] serverChain = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); @@ -167,7 +167,7 @@ public void constructorRootCert_nonStaticContext_throwsException() X509Certificate x509Cert = TestUtils.loadX509Cert(CA_PEM_FILE); try { new XdsTrustManagerFactory( - new X509Certificate[] {x509Cert}, getCertContextFromPath(CA_PEM_FILE)); + new X509Certificate[] {x509Cert}, getCertContextFromPath(CA_PEM_FILE), false); Assert.fail("no exception thrown"); } catch (IllegalArgumentException expected) { assertThat(expected) @@ -176,6 +176,19 @@ public void constructorRootCert_nonStaticContext_throwsException() } } + @Test + public void constructorRootCert_nonStaticContext_systemRootCerts_valid() + throws CertificateException, IOException, CertStoreException { + X509Certificate x509Cert = TestUtils.loadX509Cert(CA_PEM_FILE); + CertificateValidationContext certValidationContext = CertificateValidationContext.newBuilder() + .setTrustedCa( + DataSource.newBuilder().setFilename(TestUtils.loadCert(CA_PEM_FILE).getAbsolutePath())) + .setSystemRootCerts(CertificateValidationContext.SystemRootCerts.getDefaultInstance()) + .build(); + XdsTrustManagerFactory unused = + new XdsTrustManagerFactory(new X509Certificate[] {x509Cert}, certValidationContext, false); + } + @Test public void constructorRootCert_checkServerTrusted_throwsException() throws CertificateException, IOException, CertStoreException { @@ -183,7 +196,7 @@ public void constructorRootCert_checkServerTrusted_throwsException() CertificateValidationContext staticValidationContext = buildStaticValidationContext("san1", "san2"); XdsTrustManagerFactory factory = - new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext); + new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext, false); XdsX509TrustManager xdsX509TrustManager = (XdsX509TrustManager) factory.getTrustManagers()[0]; X509Certificate[] serverChain = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); @@ -204,7 +217,7 @@ public void constructorRootCert_checkClientTrusted_throwsException() CertificateValidationContext staticValidationContext = buildStaticValidationContext("san1", "san2"); XdsTrustManagerFactory factory = - new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext); + new XdsTrustManagerFactory(new X509Certificate[]{x509Cert}, staticValidationContext, false); XdsX509TrustManager xdsX509TrustManager = (XdsX509TrustManager) factory.getTrustManagers()[0]; X509Certificate[] clientChain = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); diff --git a/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsX509TrustManagerTest.java b/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsX509TrustManagerTest.java index 6fa3d2e7d24..820ad6d2266 100644 --- a/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsX509TrustManagerTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/security/trust/XdsX509TrustManagerTest.java @@ -18,13 +18,16 @@ import static com.google.common.truth.Truth.assertThat; import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.BAD_SERVER_PEM_FILE; +import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.BAD_WILDCARD_DNS_PEM_FILE; import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.CA_PEM_FILE; import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.CLIENT_PEM_FILE; import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.CLIENT_SPIFFE_PEM_FILE; +import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.SERVER_0_PEM_FILE; import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.SERVER_1_PEM_FILE; import static io.grpc.xds.internal.security.CommonTlsContextTestsUtil.SERVER_1_SPIFFE_PEM_FILE; import static org.junit.Assert.fail; import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -41,7 +44,9 @@ import java.security.cert.CertStoreException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import javax.net.ssl.SSLEngine; @@ -52,7 +57,8 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; @@ -60,7 +66,7 @@ /** * Unit tests for {@link XdsX509TrustManager}. */ -@RunWith(JUnit4.class) +@RunWith(Parameterized.class) public class XdsX509TrustManagerTest { @Rule @@ -74,32 +80,40 @@ public class XdsX509TrustManagerTest { private XdsX509TrustManager trustManager; + private final TestParam testParam; + + public XdsX509TrustManagerTest(TestParam testParam) { + this.testParam = testParam; + } + @Test public void nullCertContextTest() throws CertificateException, IOException { - trustManager = new XdsX509TrustManager(null, mockDelegate); + trustManager = new XdsX509TrustManager(null, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, new ArrayList<>()); } @Test + @SuppressWarnings("deprecation") public void emptySanListContextTest() throws CertificateException, IOException { CertificateValidationContext certContext = CertificateValidationContext.getDefaultInstance(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void missingPeerCerts() { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("foo.com").build(); @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); try { - trustManager.verifySubjectAltNameInChain(null); + trustManager.verifySubjectAltNameInChain(null, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate(s) missing"); @@ -107,14 +121,15 @@ public void missingPeerCerts() { } @Test + @SuppressWarnings("deprecation") public void emptyArrayPeerCerts() { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("foo.com").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); try { - trustManager.verifySubjectAltNameInChain(new X509Certificate[0]); + trustManager.verifySubjectAltNameInChain( + new X509Certificate[0], certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate(s) missing"); @@ -122,16 +137,16 @@ public void emptyArrayPeerCerts() { } @Test + @SuppressWarnings("deprecation") public void noSansInPeerCerts() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("foo.com").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(CLIENT_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -139,22 +154,23 @@ public void noSansInPeerCerts() throws CertificateException, IOException { } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsVerifies() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setExact("waterzooi.test.google.be") .setIgnoreCase(false) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsVerifies_differentCase_expectException() throws CertificateException, IOException { StringMatcher stringMatcher = @@ -162,14 +178,13 @@ public void oneSanInPeerCertsVerifies_differentCase_expectException() .setExact("waterZooi.test.Google.be") .setIgnoreCase(false) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -177,47 +192,48 @@ public void oneSanInPeerCertsVerifies_differentCase_expectException() } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsVerifies_ignoreCase() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("Waterzooi.Test.google.be").setIgnoreCase(true).build(); @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_prefix() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setPrefix("waterzooi.") // test.google.be .setIgnoreCase(false) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsPrefix_differentCase_expectException() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setPrefix("waterZooi.").setIgnoreCase(false).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -225,47 +241,47 @@ public void oneSanInPeerCertsPrefix_differentCase_expectException() } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_prefixIgnoreCase() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setPrefix("WaterZooi.") // test.google.be .setIgnoreCase(true) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_suffix() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setSuffix(".google.be").setIgnoreCase(false).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsSuffix_differentCase_expectException() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setSuffix(".gooGle.bE").setIgnoreCase(false).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -273,44 +289,45 @@ public void oneSanInPeerCertsSuffix_differentCase_expectException() } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_suffixIgnoreCase() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setSuffix(".GooGle.BE").setIgnoreCase(true).build(); @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_substring() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setContains("zooi.test.google").setIgnoreCase(false).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsSubstring_differentCase_expectException() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setContains("zooi.Test.gooGle").setIgnoreCase(false).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -318,81 +335,81 @@ public void oneSanInPeerCertsSubstring_differentCase_expectException() } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_substringIgnoreCase() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setContains("zooI.Test.Google").setIgnoreCase(true).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_safeRegex() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setSafeRegex( RegexMatcher.newBuilder().setRegex("water[[:alpha:]]{1}ooi\\.test\\.google\\.be")) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_safeRegex1() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setSafeRegex( RegexMatcher.newBuilder().setRegex("no-match-string|\\*\\.test\\.youtube\\.com")) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_safeRegex_ipAddress() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setSafeRegex( RegexMatcher.newBuilder().setRegex("([[:digit:]]{1,3}\\.){3}[[:digit:]]{1,3}")) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCerts_safeRegex_noMatch() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setSafeRegex( RegexMatcher.newBuilder().setRegex("water[[:alpha:]]{2}ooi\\.test\\.google\\.be")) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -400,35 +417,35 @@ public void oneSanInPeerCerts_safeRegex_noMatch() throws CertificateException, I } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsVerifiesMultipleVerifySans() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("x.foo.com").build(); StringMatcher stringMatcher1 = StringMatcher.newBuilder().setExact("waterzooi.test.google.be").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder() .addMatchSubjectAltNames(stringMatcher) .addMatchSubjectAltNames(stringMatcher1) .build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneSanInPeerCertsNotFoundException() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("x.foo.com").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -436,42 +453,43 @@ public void oneSanInPeerCertsNotFoundException() } @Test + @SuppressWarnings("deprecation") public void wildcardSanInPeerCertsVerifiesMultipleVerifySans() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("x.foo.com").build(); StringMatcher stringMatcher1 = StringMatcher.newBuilder().setSuffix("test.youTube.Com").setIgnoreCase(true).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder() .addMatchSubjectAltNames(stringMatcher) .addMatchSubjectAltNames(stringMatcher1) // should match suffix test.youTube.Com .build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void wildcardSanInPeerCertsVerifiesMultipleVerifySans1() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("x.foo.com").build(); StringMatcher stringMatcher1 = StringMatcher.newBuilder().setContains("est.Google.f").setIgnoreCase(true).build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder() .addMatchSubjectAltNames(stringMatcher) .addMatchSubjectAltNames(stringMatcher1) // should contain est.Google.f .build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void wildcardSanInPeerCertsSubdomainMismatch() throws CertificateException, IOException { // 2. Asterisk (*) cannot match across domain name labels. @@ -479,14 +497,13 @@ public void wildcardSanInPeerCertsSubdomainMismatch() // sub.test.example.com. StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("sub.abc.test.youtube.com").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -494,36 +511,36 @@ public void wildcardSanInPeerCertsSubdomainMismatch() } @Test + @SuppressWarnings("deprecation") public void oneIpAddressInPeerCertsVerifies() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("x.foo.com").build(); StringMatcher stringMatcher1 = StringMatcher.newBuilder().setExact("192.168.1.3").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder() .addMatchSubjectAltNames(stringMatcher) .addMatchSubjectAltNames(stringMatcher1) .build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); } @Test + @SuppressWarnings("deprecation") public void oneIpAddressInPeerCertsMismatch() throws CertificateException, IOException { StringMatcher stringMatcher = StringMatcher.newBuilder().setExact("x.foo.com").build(); StringMatcher stringMatcher1 = StringMatcher.newBuilder().setExact("192.168.2.3").build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder() .addMatchSubjectAltNames(stringMatcher) .addMatchSubjectAltNames(stringMatcher1) .build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate[] certs = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); @@ -537,7 +554,7 @@ public void checkServerTrustedSslEngine() X509Certificate[] serverCerts = CertificateUtils.toX509Certificates(TlsTesting.loadCert(SERVER_1_PEM_FILE)); trustManager.checkServerTrusted(serverCerts, "ECDHE_ECDSA", sslEngine); - verify(sslEngine, times(1)).getHandshakeSession(); + verify(sslEngine, atLeastOnce()).getHandshakeSession(); assertThat(sslEngine.getSSLParameters().getEndpointIdentificationAlgorithm()).isEmpty(); } @@ -550,7 +567,7 @@ public void checkServerTrustedSslEngineSpiffeTrustMap() List caCerts = Arrays.asList(CertificateUtils .toX509Certificates(TlsTesting.loadCert(CA_PEM_FILE))); trustManager = XdsTrustManagerFactory.createX509TrustManager( - ImmutableMap.of("example.com", caCerts), null); + ImmutableMap.of("example.com", caCerts), null, false); trustManager.checkServerTrusted(serverCerts, "ECDHE_ECDSA", sslEngine); verify(sslEngine, times(1)).getHandshakeSession(); assertThat(sslEngine.getSSLParameters().getEndpointIdentificationAlgorithm()).isEmpty(); @@ -565,7 +582,7 @@ public void checkServerTrustedSslEngineSpiffeTrustMap_missing_spiffe_id() List caCerts = Arrays.asList(CertificateUtils .toX509Certificates(TlsTesting.loadCert(CA_PEM_FILE))); trustManager = XdsTrustManagerFactory.createX509TrustManager( - ImmutableMap.of("example.com", caCerts), null); + ImmutableMap.of("example.com", caCerts), null, false); try { trustManager.checkServerTrusted(serverCerts, "ECDHE_ECDSA", sslEngine); fail("exception expected"); @@ -584,7 +601,7 @@ public void checkServerTrustedSpiffeSslEngineTrustMap_missing_trust_domain() List caCerts = Arrays.asList(CertificateUtils .toX509Certificates(TlsTesting.loadCert(CA_PEM_FILE))); trustManager = XdsTrustManagerFactory.createX509TrustManager( - ImmutableMap.of("unknown.com", caCerts), null); + ImmutableMap.of("unknown.com", caCerts), null, false); try { trustManager.checkServerTrusted(serverCerts, "ECDHE_ECDSA", sslEngine); fail("exception expected"); @@ -602,7 +619,7 @@ public void checkClientTrustedSpiffeTrustMap() List caCerts = Arrays.asList(CertificateUtils .toX509Certificates(TlsTesting.loadCert(CA_PEM_FILE))); trustManager = XdsTrustManagerFactory.createX509TrustManager( - ImmutableMap.of("foo.bar.com", caCerts), null); + ImmutableMap.of("foo.bar.com", caCerts), null, false); trustManager.checkClientTrusted(clientCerts, "RSA"); } @@ -643,7 +660,7 @@ public void checkServerTrustedSslSocketSpiffeTrustMap() List caCerts = Arrays.asList(CertificateUtils .toX509Certificates(TlsTesting.loadCert(CA_PEM_FILE))); trustManager = XdsTrustManagerFactory.createX509TrustManager( - ImmutableMap.of("example.com", caCerts), null); + ImmutableMap.of("example.com", caCerts), null, false); trustManager.checkServerTrusted(serverCerts, "ECDHE_ECDSA", sslSocket); verify(sslSocket, times(1)).isConnected(); verify(sslSocket, times(1)).getHandshakeSession(); @@ -668,29 +685,76 @@ public void checkServerTrustedSslSocket_untrustedServer_expectException() } @Test - public void unsupportedAltNameType() throws CertificateException, IOException { + @SuppressWarnings("deprecation") + public void unsupportedAltNameType() throws CertificateException { StringMatcher stringMatcher = StringMatcher.newBuilder() .setExact("waterzooi.test.google.be") .setIgnoreCase(false) .build(); - @SuppressWarnings("deprecation") CertificateValidationContext certContext = CertificateValidationContext.newBuilder().addMatchSubjectAltNames(stringMatcher).build(); - trustManager = new XdsX509TrustManager(certContext, mockDelegate); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); X509Certificate mockCert = mock(X509Certificate.class); when(mockCert.getSubjectAlternativeNames()) .thenReturn(Collections.>singleton(ImmutableList.of(Integer.valueOf(1), "foo"))); X509Certificate[] certs = new X509Certificate[] {mockCert}; try { - trustManager.verifySubjectAltNameInChain(certs); + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); fail("no exception thrown"); } catch (CertificateException expected) { assertThat(expected).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); } } + @Test + @SuppressWarnings("deprecation") + public void testDnsWildcardPatterns() + throws CertificateException, IOException { + StringMatcher stringMatcher = + StringMatcher.newBuilder() + .setExact(testParam.sanPattern) + .setIgnoreCase(testParam.ignoreCase) + .build(); + @SuppressWarnings("deprecation") + CertificateValidationContext certContext = + CertificateValidationContext.newBuilder() + .addMatchSubjectAltNames(stringMatcher) + .build(); + trustManager = new XdsX509TrustManager(certContext, mockDelegate, false); + X509Certificate[] certs = + CertificateUtils.toX509Certificates(TlsTesting.loadCert(testParam.certFile)); + try { + trustManager.verifySubjectAltNameInChain(certs, certContext.getMatchSubjectAltNamesList()); + assertThat(testParam.expected).isTrue(); + } catch (CertificateException certException) { + assertThat(testParam.expected).isFalse(); + assertThat(certException).hasMessageThat().isEqualTo("Peer certificate SAN check failed"); + } + } + + @Parameters(name = "{index}: {0}") + public static Collection getParameters() { + return Arrays.asList(new Object[][] { + {new TestParam("*.test.google.fr", SERVER_1_PEM_FILE, false, true)}, + {new TestParam("*.test.youtube.com", SERVER_1_PEM_FILE, false, true)}, + {new TestParam("waterzooi.test.google.be", SERVER_1_PEM_FILE, false, true)}, + {new TestParam("192.168.1.3", SERVER_1_PEM_FILE, false, true)}, + {new TestParam("*.TEST.YOUTUBE.com", SERVER_1_PEM_FILE, true, true)}, + {new TestParam("w*i.test.google.be", SERVER_1_PEM_FILE, false, true)}, + {new TestParam("w*a.test.google.be", SERVER_1_PEM_FILE, false, false)}, + {new TestParam("*.test.google.com.au", SERVER_0_PEM_FILE, false, false)}, + {new TestParam("*.TEST.YOUTUBE.com", SERVER_1_PEM_FILE, false, false)}, + {new TestParam("*waterzooi", SERVER_1_PEM_FILE, false, false)}, + {new TestParam("*.lyft.com", BAD_WILDCARD_DNS_PEM_FILE, false, false)}, + {new TestParam("ly**ft.com", BAD_WILDCARD_DNS_PEM_FILE, false, false)}, + {new TestParam("*yft.c*m", BAD_WILDCARD_DNS_PEM_FILE, false, false)}, + {new TestParam("xn--*.lyft.com", BAD_WILDCARD_DNS_PEM_FILE, false, false)}, + {new TestParam("", BAD_WILDCARD_DNS_PEM_FILE, false, false)}, + }); + } + private TestSslEngine buildTrustManagerAndGetSslEngine() throws CertificateException, IOException, CertStoreException { SSLParameters sslParams = buildTrustManagerAndGetSslParameters(); @@ -717,7 +781,7 @@ private SSLParameters buildTrustManagerAndGetSslParameters() X509Certificate[] caCerts = CertificateUtils.toX509Certificates(TlsTesting.loadCert(CA_PEM_FILE)); trustManager = XdsTrustManagerFactory.createX509TrustManager(caCerts, - null); + null, false); when(mockSession.getProtocol()).thenReturn("TLSv1.2"); when(mockSession.getPeerHost()).thenReturn("peer-host-from-mock"); SSLParameters sslParams = new SSLParameters(); @@ -754,4 +818,18 @@ public void setSSLParameters(SSLParameters sslParameters) { private SSLParameters sslParameters; } + + private static class TestParam { + final String sanPattern; + final String certFile; + final boolean ignoreCase; + final boolean expected; + + TestParam(String sanPattern, String certFile, boolean ignoreCase, boolean expected) { + this.sanPattern = sanPattern; + this.certFile = certFile; + this.ignoreCase = ignoreCase; + this.expected = expected; + } + } } diff --git a/xds/src/test/java/io/grpc/xds/orca/OrcaOobUtilAccessor.java b/xds/src/test/java/io/grpc/xds/orca/OrcaOobUtilAccessor.java new file mode 100644 index 00000000000..db9168dd08e --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/orca/OrcaOobUtilAccessor.java @@ -0,0 +1,35 @@ +/* + * 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.orca; + +import io.grpc.LoadBalancer; + +/** + * Accessor for white-box testing involving OrcaOobUtil. + */ +public final class OrcaOobUtilAccessor { + private OrcaOobUtilAccessor() { + // Do not instantiate + } + + public static LoadBalancer.SubchannelPicker getDelegate(LoadBalancer.SubchannelPicker picker) { + if (picker instanceof OrcaOobUtil.OrcaReportingHelper.OrcaOobPicker) { + return ((OrcaOobUtil.OrcaReportingHelper.OrcaOobPicker) picker).delegate; + } + return picker; + } +} diff --git a/xds/src/test/resources/certs/sni-test-certs/README b/xds/src/test/resources/certs/sni-test-certs/README new file mode 100644 index 00000000000..25e66021192 --- /dev/null +++ b/xds/src/test/resources/certs/sni-test-certs/README @@ -0,0 +1,55 @@ +Bad Wildcard DNS Certificate (bad_wildcard_dns_certificate.pem) +This certificate is used for testing SNI with invalid wildcard DNS SANs. It is issued by a custom, self-signed Certificate Authority (CA). + +1. Create the Certificate Authority (CA) +Create the CA's private key: +$ openssl genpkey -algorithm RSA -out ca.key -pkeyopt rsa_keygen_bits:2048 +Create the CA's self-signed certificate: +$ openssl req -x509 -new -nodes -key ca.key -sha256 -days 365 -out ca.pem -subj "/CN=My Internal CA" + +2. Generate the Server Certificate +Next, generate the server's private key and a Certificate Signing Request (CSR). +Create the server's private key: +$ openssl genpkey -algorithm RSA -out bad_wildcard_dns.key -pkeyopt rsa_keygen_bits:2048 +Create a configuration file named san.cnf with the following content. This file specifies the Subject Alternative Names (SANs) for the certificate. +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +C = US +ST = Illinois +L = Chicago +O = "Example, Co." +CN = *.test.google.com + +[v3_req] +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = *.test.google.fr +DNS.2 = *.test.youtube.com +DNS.3 = waterzooi.test.google.be +DNS.4 = 192.168.1.3 +DNS.5 = *.TEST.YOUTUBE.com +DNS.6 = w*i.test.google.be +DNS.7 = w*a.test.google.be +DNS.8 = *.test.google.com.au +DNS.9 = *waterzooi +DNS.10 = *.lyft.com +DNS.11 = ly**ft.com +DNS.12 = *yft.c*m +DNS.13 = xn--*.lyft.com + +Create the Certificate Signing Request (CSR): +$ openssl req -new -key bad_wildcard_dns.key -out bad_wildcard_dns.csr -config san.cnf + +3. Sign the Server Certificate +Finally, use the CA to sign the CSR, which will create the server certificate. +$ openssl x509 -req -in bad_wildcard_dns.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out bad_wildcard_dns_certificate.pem -days 365 -sha256 -extensions v3_req -extfile san.cnf + +4. Clean Up +$ rm bad_wildcard_dns.key san.cnf bad_wildcard_dns.csr ca.key ca.pem ca.srl diff --git a/xds/src/test/resources/certs/sni-test-certs/bad_wildcard_dns_certificate.pem b/xds/src/test/resources/certs/sni-test-certs/bad_wildcard_dns_certificate.pem new file mode 100644 index 00000000000..b015f62e51c --- /dev/null +++ b/xds/src/test/resources/certs/sni-test-certs/bad_wildcard_dns_certificate.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDsjCCApqgAwIBAgIUCs5j4C2KXgCRVFa48kc5TYRS1JwwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOTXkgSW50ZXJuYWwgQ0EwIBcNMjUwOTIzMDc1NDUzWhgP +MjEyNTA4MzAwNzU0NTNaMGUxCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhJbGxpbm9p +czEQMA4GA1UEBwwHQ2hpY2FnbzEVMBMGA1UECgwMRXhhbXBsZSwgQ28uMRowGAYD +VQQDDBEqLnRlc3QuZ29vZ2xlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAKoqcnNh9MV39GH6JjC5KVMN6MO1IoTw6wHJN0JJ/nGNx6ycIsBK8SgJ +eYRR2BEpT6WZba+f04KChcB4Z9tiPISNvUBpmEv76rAsdtcAZwSpF06q4wxHVE5F +rX6mNT8hk448mDBDGHUXNAT6g/e/Vlt6U0XRyuu713gbZq1X6JH29FG7EJ3LUx35 +h6sEkvTlZZ3m6NJr7zYoqrYh/gRkPigtPxaNcoXo0gVm4IEde0sYz27SWyNH4v/o +23NynSulOwx4DwEhBOXekLb5QJHBqwMTPynaMncBQIXF+PXeuxN9a3zR6DSn+jGw +g008tS0tn2FuAvJDBl0paEykdOr2rNMCAwEAAaOBozCBoDALBgNVHQ8EBAMCBeAw +EwYDVR0lBAwwCgYIKwYBBQUHAwEwPAYDVR0RBDUwM4IKbHkqKmZ0LmNvbYIIKnlm +dC5jKm2CCS5seWZ0LmNvbYIOeG4tLSoubHlmdC5jb22CADAdBgNVHQ4EFgQUZoL2 +OzBtK/BUzSYfgXDx3iDjcIQwHwYDVR0jBBgwFoAUHlstFN5WSLSqyJgUDy6BB0z0 +BrgwDQYJKoZIhvcNAQELBQADggEBAMYwVOT7XZJMQ6n32pQqtZhJ/Z0wlVfCAbm0 +7xospeBt6KtOz2zIsvPpq0aqPjowMAeL1EZaBvmfm/XgWUU5e/3hLUIHOHyKfswB +czDbY0RE8nfVDoF4Ck1ljPjvrFr4tSAxTzVA4JU5o3UXkblBg0LG6tTuLlZ3x5aF +KtkZnszxjE+vOg6J9MDbFP/xtA1oVHyCvk+cUgnBxAoPShI+87DINGVTmztBSetK +nJN9dOh7Q88NhTLHOe67Ora9Y0ZP+uFKHaqFv8qj8B/Q6ptb0CAksdL5EunkIHrq +glKdVdYgIP2JpRwtvVHK5FzWBlGXCi3DxTyYi6FWqsSJ+heCS2w= +-----END CERTIFICATE----- diff --git a/xds/third_party/envoy/import.sh b/xds/third_party/envoy/import.sh index ba657612586..55481d29b76 100755 --- a/xds/third_party/envoy/import.sh +++ b/xds/third_party/envoy/import.sh @@ -16,8 +16,8 @@ # Update VERSION then execute this script set -e -# import VERSION from the google internal copybara_version.txt for Envoy -VERSION=1128a52d227efb8c798478d293fdc05e8075ebcd +# import VERSION from the google internal go/envoy-import-status +VERSION=a0b3df32ba54c92a08d3636a9a36013cb920e471 DOWNLOAD_URL="https://github.com/envoyproxy/envoy/archive/${VERSION}.tar.gz" DOWNLOAD_BASE_DIR="envoy-${VERSION}" SOURCE_PROTO_BASE_DIR="${DOWNLOAD_BASE_DIR}/api" @@ -33,9 +33,11 @@ envoy/config/cluster/v3/circuit_breaker.proto envoy/config/cluster/v3/cluster.proto envoy/config/cluster/v3/filter.proto envoy/config/cluster/v3/outlier_detection.proto +envoy/config/common/mutation_rules/v3/mutation_rules.proto envoy/config/core/v3/address.proto envoy/config/core/v3/backoff.proto envoy/config/core/v3/base.proto +envoy/config/core/v3/cel.proto envoy/config/core/v3/config_source.proto envoy/config/core/v3/event_service_config.proto envoy/config/core/v3/extension.proto @@ -74,12 +76,23 @@ envoy/config/trace/v3/zipkin.proto 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 envoy/extensions/filters/http/rate_limit_quota/v3/rate_limit_quota.proto envoy/extensions/filters/http/gcp_authn/v3/gcp_authn.proto envoy/extensions/filters/http/rbac/v3/rbac.proto envoy/extensions/filters/http/router/v3/router.proto envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto +envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto +envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto +envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto +envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto +envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto +envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto envoy/extensions/load_balancing_policies/client_side_weighted_round_robin/v3/client_side_weighted_round_robin.proto envoy/extensions/load_balancing_policies/common/v3/common.proto envoy/extensions/load_balancing_policies/least_request/v3/least_request.proto @@ -92,8 +105,11 @@ envoy/extensions/transport_sockets/tls/v3/cert.proto envoy/extensions/transport_sockets/tls/v3/common.proto envoy/extensions/transport_sockets/tls/v3/secret.proto envoy/extensions/transport_sockets/tls/v3/tls.proto +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 @@ -103,6 +119,7 @@ envoy/type/matcher/v3/filter_state.proto envoy/type/matcher/v3/http_inputs.proto envoy/type/matcher/v3/metadata.proto envoy/type/matcher/v3/node.proto +envoy/config/common/matcher/v3/matcher.proto envoy/type/matcher/v3/number.proto envoy/type/matcher/v3/path.proto envoy/type/matcher/v3/regex.proto diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/accesslog/v3/accesslog.proto b/xds/third_party/envoy/src/main/proto/envoy/config/accesslog/v3/accesslog.proto index 6753ab6ab52..f273f2e695f 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/accesslog/v3/accesslog.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/accesslog/v3/accesslog.proto @@ -108,6 +108,9 @@ message ComparisonFilter { // <= LE = 2; + + // != + NE = 3; } // Comparison operator. diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto b/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto index bf65f3df45c..7b862c1021a 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/bootstrap/v3/bootstrap.proto @@ -16,6 +16,7 @@ import "envoy/config/metrics/v3/stats.proto"; import "envoy/config/overload/v3/overload.proto"; import "envoy/config/trace/v3/http_tracer.proto"; import "envoy/extensions/transport_sockets/tls/v3/secret.proto"; +import "envoy/type/matcher/v3/string.proto"; import "envoy/type/v3/percent.proto"; import "google/protobuf/duration.proto"; @@ -41,7 +42,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // ` for more detail. // Bootstrap :ref:`configuration overview `. -// [#next-free-field: 42] +// [#next-free-field: 43] message Bootstrap { option (udpa.annotations.versioning).previous_message_type = "envoy.config.bootstrap.v2.Bootstrap"; @@ -76,7 +77,7 @@ message Bootstrap { // :ref:`LDS ` configuration source. core.v3.ConfigSource lds_config = 1; - // xdstp:// resource locator for listener collection. + // ``xdstp://`` resource locator for listener collection. // [#not-implemented-hide:] string lds_resources_locator = 5; @@ -85,7 +86,7 @@ message Bootstrap { // configuration source. core.v3.ConfigSource cds_config = 2; - // xdstp:// resource locator for cluster collection. + // ``xdstp://`` resource locator for cluster collection. // [#not-implemented-hide:] string cds_resources_locator = 6; @@ -126,17 +127,19 @@ message Bootstrap { // When the flag is enabled, Envoy will lazily initialize a subset of the stats (see below). // This will save memory and CPU cycles when creating the objects that own these stats, if those // stats are never referenced throughout the lifetime of the process. However, it will incur additional - // memory overhead for these objects, and a small increase of CPU usage when a at least one of the stats + // memory overhead for these objects, and a small increase of CPU usage when at least one of the stats // is updated for the first time. + // // Groups of stats that will be lazily initialized: + // // - Cluster traffic stats: a subgroup of the :ref:`cluster statistics ` - // that are used when requests are routed to the cluster. + // that are used when requests are routed to the cluster. bool enable_deferred_creation_stats = 1; } message GrpcAsyncClientManagerConfig { // Optional field to set the expiration time for the cached gRPC client object. - // The minimal value is 5s and the default is 50s. + // The minimal value is ``5s`` and the default is ``50s``. google.protobuf.Duration max_cached_entry_idle_duration = 1 [(validate.rules).duration = {gte {seconds: 5}}]; } @@ -151,25 +154,25 @@ message Bootstrap { // A list of :ref:`Node ` field names // that will be included in the context parameters of the effective - // xdstp:// URL that is sent in a discovery request when resource + // ``xdstp://`` URL that is sent in a discovery request when resource // locators are used for LDS/CDS. Any non-string field will have its JSON // encoding set as the context parameter value, with the exception of // metadata, which will be flattened (see example below). The supported field // names are: - // - "cluster" - // - "id" - // - "locality.region" - // - "locality.sub_zone" - // - "locality.zone" - // - "metadata" - // - "user_agent_build_version.metadata" - // - "user_agent_build_version.version" - // - "user_agent_name" - // - "user_agent_version" + // - ``cluster`` + // - ``id`` + // - ``locality.region`` + // - ``locality.sub_zone`` + // - ``locality.zone`` + // - ``metadata`` + // - ``user_agent_build_version.metadata`` + // - ``user_agent_build_version.version`` + // - ``user_agent_name`` + // - ``user_agent_version`` // // The node context parameters act as a base layer dictionary for the context // parameters (i.e. more specific resource specific context parameters will - // override). Field names will be prefixed with “udpa.node.” when included in + // override). Field names will be prefixed with ````"udpa.node."```` when included in // context parameters. // // For example, if node_context_params is ``["user_agent_name", "metadata"]``, @@ -211,10 +214,10 @@ message Bootstrap { // Optional duration between flushes to configured stats sinks. For // performance reasons Envoy latches counters and only flushes counters and - // gauges at a periodic interval. If not specified the default is 5000ms (5 - // seconds). Only one of ``stats_flush_interval`` or ``stats_flush_on_admin`` + // gauges at a periodic interval. If not specified the default is ``5000ms`` (``5`` seconds). + // Only one of ``stats_flush_interval`` or ``stats_flush_on_admin`` // can be set. - // Duration must be at least 1ms and at most 5 min. + // Duration must be at least ``1ms`` and at most ``5 min``. google.protobuf.Duration stats_flush_interval = 7 [ (validate.rules).duration = { lt {seconds: 300} @@ -230,6 +233,14 @@ message Bootstrap { bool stats_flush_on_admin = 29 [(validate.rules).bool = {const: true}]; } + oneof stats_eviction { + // Optional duration to perform metric eviction. At every interval, during the stats flush + // the unused metrics are removed from the worker caches and the used metrics + // are marked as unused. Must be a multiple of the ``stats_flush_interval``. + google.protobuf.Duration stats_eviction_interval = 42 + [(validate.rules).duration = {gte {nanos: 1000000}}]; + } + // Optional watchdog configuration. // This is for a single watchdog configuration for the entire system. // Deprecated in favor of ``watchdogs`` which has finer granularity. @@ -263,23 +274,28 @@ message Bootstrap { (udpa.annotations.security).configure_for_untrusted_upstream = true ]; - // Enable :ref:`stats for event dispatcher `, defaults to false. - // Note that this records a value for each iteration of the event loop on every thread. This - // should normally be minimal overhead, but when using - // :ref:`statsd `, it will send each observed value - // over the wire individually because the statsd protocol doesn't have any way to represent a - // histogram summary. Be aware that this can be a very large volume of data. + // Enable :ref:`stats for event dispatcher `. Defaults to ``false``. + // + // .. note:: + // + // This records a value for each iteration of the event loop on every thread. This + // should normally be minimal overhead, but when using + // :ref:`statsd `, it will send each observed value + // over the wire individually because the statsd protocol doesn't have any way to represent a + // histogram summary. Be aware that this can be a very large volume of data. bool enable_dispatcher_stats = 16; - // Optional string which will be used in lieu of x-envoy in prefixing headers. + // Optional string which will be used in lieu of ``x-envoy`` in prefixing headers. // - // For example, if this string is present and set to X-Foo, then x-envoy-retry-on will be - // transformed into x-foo-retry-on etc. + // For example, if this string is present and set to ``X-Foo``, then ``x-envoy-retry-on`` will be + // transformed into ``x-foo-retry-on`` etc. // - // Note this applies to the headers Envoy will generate, the headers Envoy will sanitize, and the - // headers Envoy will trust for core code and core extensions only. Be VERY careful making - // changes to this string, especially in multi-layer Envoy deployments or deployments using - // extensions which are not upstream. + // .. note:: + // + // This applies to the headers Envoy will generate, the headers Envoy will sanitize, and the + // headers Envoy will trust for core code and core extensions only. Be VERY careful making + // changes to this string, especially in multi-layer Envoy deployments or deployments using + // extensions which are not upstream. string header_prefix = 18; // Optional proxy version which will be used to set the value of :ref:`server.version statistic @@ -287,8 +303,8 @@ message Bootstrap { // :ref:`stats sinks `. google.protobuf.UInt64Value stats_server_version_override = 19; - // Always use TCP queries instead of UDP queries for DNS lookups. - // This may be overridden on a per-cluster basis in cds_config, + // Always use ``TCP`` queries instead of ``UDP`` queries for DNS lookups. + // This may be overridden on a per-cluster basis in ``cds_config``, // when :ref:`dns_resolvers ` and // :ref:`use_tcp_for_dns_lookups ` are // specified. @@ -297,8 +313,8 @@ message Bootstrap { bool use_tcp_for_dns_lookups = 20 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; - // DNS resolution configuration which includes the underlying dns resolver addresses and options. - // This may be overridden on a per-cluster basis in cds_config, when + // DNS resolution configuration which includes the underlying DNS resolver addresses and options. + // This may be overridden on a per-cluster basis in ``cds_config``, when // :ref:`dns_resolution_config ` // is specified. // This field is deprecated in favor of @@ -306,14 +322,15 @@ message Bootstrap { core.v3.DnsResolutionConfig dns_resolution_config = 30 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; - // DNS resolver type configuration extension. This extension can be used to configure c-ares, apple, + // DNS resolver type configuration extension. This extension can be used to configure ``c-ares``, ``apple``, // or any other DNS resolver types and the related parameters. // For example, an object of // :ref:`CaresDnsResolverConfig ` // can be packed into this ``typed_dns_resolver_config``. This configuration replaces the // :ref:`dns_resolution_config ` // configuration. - // During the transition period when both ``dns_resolution_config`` and ``typed_dns_resolver_config`` exists, + // + // During the transition period when both ``dns_resolution_config`` and ``typed_dns_resolver_config`` exist, // when ``typed_dns_resolver_config`` is in place, Envoy will use it and ignore ``dns_resolution_config``. // When ``typed_dns_resolver_config`` is missing, the default behavior is in place. // [#extension-category: envoy.network.dns_resolver] @@ -329,9 +346,10 @@ message Bootstrap { repeated FatalAction fatal_actions = 28; // Configuration sources that will participate in - // xdstp:// URL authority resolution. The algorithm is as + // ``xdstp://`` URL authority resolution. The algorithm is as // follows: - // 1. The authority field is taken from the xdstp:// URL, call + // + // 1. The authority field is taken from the ``xdstp://`` URL, call // this ``resource_authority``. // 2. ``resource_authority`` is compared against the authorities in any peer // ``ConfigSource``. The peer ``ConfigSource`` is the configuration source @@ -347,7 +365,7 @@ message Bootstrap { // [#not-implemented-hide:] repeated core.v3.ConfigSource config_sources = 22; - // Default configuration source for xdstp:// URLs if all + // Default configuration source for ``xdstp://`` URLs if all // other resolution fails. // [#not-implemented-hide:] core.v3.ConfigSource default_config_source = 23; @@ -367,28 +385,30 @@ message Bootstrap { // allows users to customize the inline headers on-demand at Envoy startup without modifying // Envoy's source code. // - // Note that the 'set-cookie' header cannot be registered as inline header. + // .. note:: + // + // The ``set-cookie`` header cannot be registered as inline header. repeated CustomInlineHeader inline_headers = 32; - // Optional path to a file with performance tracing data created by "Perfetto" SDK in binary - // ProtoBuf format. The default value is "envoy.pftrace". + // Optional path to a file with performance tracing data created by ``Perfetto`` SDK in binary + // ProtoBuf format. The default value is ``envoy.pftrace``. string perf_tracing_file_path = 33; // Optional overriding of default regex engine. - // If the value is not specified, Google RE2 will be used by default. + // If the value is not specified, ``Google RE2`` will be used by default. // [#extension-category: envoy.regex_engines] core.v3.TypedExtensionConfig default_regex_engine = 34; // Optional XdsResourcesDelegate configuration, which allows plugging custom logic into both // fetch and load events during xDS processing. - // If a value is not specified, no XdsResourcesDelegate will be used. + // If a value is not specified, no ``XdsResourcesDelegate`` will be used. // TODO(abeyad): Add public-facing documentation. // [#not-implemented-hide:] core.v3.TypedExtensionConfig xds_delegate_extension = 35; // Optional XdsConfigTracker configuration, which allows tracking xDS responses in external components, // e.g., external tracer or monitor. It provides the process point when receive, ingest, or fail to - // process xDS resources and messages. If a value is not specified, no XdsConfigTracker will be used. + // process xDS resources and messages. If a value is not specified, no ``XdsConfigTracker`` will be used. // // .. note:: // @@ -400,14 +420,14 @@ message Bootstrap { // [#not-implemented-hide:] // This controls the type of listener manager configured for Envoy. Currently - // Envoy only supports ListenerManager for this field and Envoy Mobile - // supports ApiListenerManager. + // Envoy only supports ``ListenerManager`` for this field and Envoy Mobile + // supports ``ApiListenerManager``. core.v3.TypedExtensionConfig listener_manager = 37; // Optional application log configuration. ApplicationLogConfig application_log_config = 38; - // Optional gRPC async manager config. + // Optional gRPC async client manager config. GrpcAsyncClientManagerConfig grpc_async_client_manager_config = 40; // Optional configuration for memory allocation manager. @@ -417,7 +437,7 @@ message Bootstrap { // Administration interface :ref:`operations documentation // `. -// [#next-free-field: 7] +// [#next-free-field: 8] message Admin { option (udpa.annotations.versioning).previous_message_type = "envoy.config.bootstrap.v2.Admin"; @@ -426,14 +446,14 @@ message Admin { repeated accesslog.v3.AccessLog access_log = 5; // The path to write the access log for the administration server. If no - // access log is desired specify ‘/dev/null’. This is only required if + // access log is desired specify ``/dev/null``. This is only required if // :ref:`address ` is set. // Deprecated in favor of ``access_log`` which offers more options. string access_log_path = 1 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; - // The cpu profiler output path for the administration server. If no profile - // path is specified, the default is ‘/var/log/envoy/envoy.prof’. + // The CPU profiler output path for the administration server. If no profile + // path is specified, the default is ``/var/log/envoy/envoy.prof``. string profile_path = 2; // The TCP address that the administration server will listen on. @@ -447,6 +467,21 @@ message Admin { // Indicates whether :ref:`global_downstream_max_connections ` // should apply to the admin interface or not. bool ignore_global_conn_limit = 6; + + // List of admin paths that are accessible. If not specified, all admin endpoints are accessible. + // + // When specified, only paths in this list will be accessible, all others will return ``HTTP 403 Forbidden``. + // + // Example: + // + // .. code-block:: yaml + // + // allow_paths: + // - exact: /stats + // - exact: /ready + // - prefix: /healthcheck + // + repeated type.matcher.v3.StringMatcher allow_paths = 7; } // Cluster manager :ref:`architecture overview `. @@ -483,7 +518,7 @@ message ClusterManager { OutlierDetection outlier_detection = 2; // Optional configuration used to bind newly established upstream connections. - // This may be overridden on a per-cluster basis by upstream_bind_config in the cds_config. + // This may be overridden on a per-cluster basis by ``upstream_bind_config`` in the ``cds_config``. core.v3.BindConfig upstream_bind_config = 3; // A management server endpoint to stream load stats to via @@ -494,7 +529,7 @@ message ClusterManager { // Whether the ClusterManager will create clusters on the worker threads // inline during requests. This will save memory and CPU cycles in cases where - // there are lots of inactive clusters and > 1 worker thread. + // there are lots of inactive clusters and ``> 1`` worker thread. bool enable_deferred_cluster_creation = 5; } @@ -517,12 +552,12 @@ message Watchdog { option (udpa.annotations.versioning).previous_message_type = "envoy.config.bootstrap.v2.Watchdog"; message WatchdogAction { - // The events are fired in this order: KILL, MULTIKILL, MEGAMISS, MISS. + // The events are fired in this order: ``KILL``, ``MULTIKILL``, ``MEGAMISS``, ``MISS``. // Within an event type, actions execute in the order they are configured. - // For KILL/MULTIKILL there is a default PANIC that will run after the + // For ``KILL``/``MULTIKILL`` there is a default ``PANIC`` that will run after the // registered actions and kills the process if it wasn't already killed. // It might be useful to specify several debug actions, and possibly an - // alternate FATAL action. + // alternate ``FATAL`` action. enum WatchdogEvent { UNKNOWN = 0; KILL = 1; @@ -537,46 +572,48 @@ message Watchdog { WatchdogEvent event = 2 [(validate.rules).enum = {defined_only: true}]; } - // Register actions that will fire on given WatchDog events. - // See ``WatchDogAction`` for priority of events. + // Register actions that will fire on given Watchdog events. + // See ``WatchdogAction`` for priority of events. repeated WatchdogAction actions = 7; // The duration after which Envoy counts a nonresponsive thread in the - // ``watchdog_miss`` statistic. If not specified the default is 200ms. + // ``watchdog_miss`` statistic. If not specified the default is ``200ms``. google.protobuf.Duration miss_timeout = 1; // The duration after which Envoy counts a nonresponsive thread in the - // ``watchdog_mega_miss`` statistic. If not specified the default is - // 1000ms. + // ``watchdog_mega_miss`` statistic. If not specified the default is ``1000ms``. google.protobuf.Duration megamiss_timeout = 2; // If a watched thread has been nonresponsive for this duration, assume a - // programming error and kill the entire Envoy process. Set to 0 to disable - // kill behavior. If not specified the default is 0 (disabled). + // programming error and kill the entire Envoy process. Set to ``0`` to disable + // kill behavior. If not specified the default is ``0`` (disabled). google.protobuf.Duration kill_timeout = 3; // Defines the maximum jitter used to adjust the ``kill_timeout`` if ``kill_timeout`` is // enabled. Enabling this feature would help to reduce risk of synchronized - // watchdog kill events across proxies due to external triggers. Set to 0 to - // disable. If not specified the default is 0 (disabled). + // watchdog kill events across proxies due to external triggers. Set to ``0`` to + // disable. If not specified the default is ``0`` (disabled). google.protobuf.Duration max_kill_timeout_jitter = 6 [(validate.rules).duration = {gte {}}]; - // If ``max(2, ceil(registered_threads * Fraction(*multikill_threshold*)))`` + // If ``max(2, ceil(registered_threads * Fraction(multikill_threshold)))`` // threads have been nonresponsive for at least this duration kill the entire - // Envoy process. Set to 0 to disable this behavior. If not specified the - // default is 0 (disabled). + // Envoy process. Set to ``0`` to disable this behavior. If not specified the + // default is ``0`` (disabled). google.protobuf.Duration multikill_timeout = 4; // Sets the threshold for ``multikill_timeout`` in terms of the percentage of // nonresponsive threads required for the ``multikill_timeout``. - // If not specified the default is 0. + // If not specified the default is ``0``. type.v3.Percent multikill_threshold = 5; } // Fatal actions to run while crashing. Actions can be safe (meaning they are // async-signal safe) or unsafe. We run all safe actions before we run unsafe actions. -// If using an unsafe action that could get stuck or deadlock, it important to -// have an out of band system to terminate the process. +// +// .. note:: +// +// If using an unsafe action that could get stuck or deadlock, it is important to +// have an out of band system to terminate the process. // // The interface for the extension is ``Envoy::Server::Configuration::FatalAction``. // ``FatalAction`` extensions live in the ``envoy.extensions.fatal_actions`` API @@ -659,7 +696,7 @@ message RuntimeLayer { option (udpa.annotations.versioning).previous_message_type = "envoy.config.bootstrap.v2.RuntimeLayer.RtdsLayer"; - // Resource to subscribe to at ``rtds_config`` for the RTDS layer. + // Resource to subscribe to at the ``rtds_config`` for the RTDS layer. string name = 1; // RTDS configuration source. @@ -700,11 +737,11 @@ message LayeredRuntime { // Used to specify the header that needs to be registered as an inline header. // // If request or response contain multiple headers with the same name and the header -// name is registered as an inline header. Then multiple headers will be folded +// name is registered as an inline header, then multiple headers will be folded // into one, and multiple header values will be concatenated by a suitable delimiter. // The delimiter is generally a comma. // -// For example, if 'foo' is registered as an inline header, and the headers contains +// For example, if ``foo`` is registered as an inline header, and the headers contain // the following two headers: // // .. code-block:: text @@ -744,6 +781,6 @@ message MemoryAllocatorManager { // Interval in milliseconds for memory releasing. If specified, during every // interval Envoy will try to release ``bytes_to_release`` of free memory back to operating system for reuse. - // Defaults to 1000 milliseconds. + // Defaults to ``1000`` milliseconds. google.protobuf.Duration memory_release_interval = 2; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto b/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto index 51180b1e855..192409096af 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/cluster/v3/cluster.proto @@ -22,6 +22,7 @@ import "google/protobuf/struct.proto"; import "google/protobuf/wrappers.proto"; import "xds/core/v3/collection_entry.proto"; +import "xds/type/matcher/v3/matcher.proto"; import "envoy/annotations/deprecation.proto"; import "udpa/annotations/migrate.proto"; @@ -45,7 +46,7 @@ message ClusterCollection { } // Configuration for a single upstream cluster. -// [#next-free-field: 59] +// [#next-free-field: 60] message Cluster { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.Cluster"; @@ -652,9 +653,10 @@ message Cluster { // If this is not set, we default to a merge window of 1000ms. To disable it, set the merge // window to 0. // - // Note: merging does not apply to cluster membership changes (e.g.: adds/removes); this is - // because merging those updates isn't currently safe. See - // https://github.com/envoyproxy/envoy/pull/3941. + // .. note:: + // Merging does not apply to cluster membership changes (e.g.: adds/removes); this is + // because merging those updates isn't currently safe. See + // https://github.com/envoyproxy/envoy/pull/3941. google.protobuf.Duration update_merge_window = 4; // If set to true, Envoy will :ref:`exclude ` new hosts @@ -746,6 +748,9 @@ message Cluster { // If both this and preconnect_ratio are set, Envoy will make sure both predicted needs are met, // basically preconnecting max(predictive-preconnect, per-upstream-preconnect), for each // upstream. + // + // This is limited somewhat arbitrarily to 3 because preconnecting too aggressively can + // harm latency more than the preconnecting helps. google.protobuf.DoubleValue predictive_preconnect_ratio = 2 [(validate.rules).double = {lte: 3.0 gte: 1.0}]; } @@ -808,6 +813,41 @@ message Cluster { // [#comment:TODO(incfly): add a detailed architecture doc on intended usage.] repeated TransportSocketMatch transport_socket_matches = 43; + // Optional matcher that selects a transport socket from + // :ref:`transport_socket_matches `. + // + // This matcher uses the generic xDS matcher framework to select a named transport socket + // based on various inputs available at transport socket selection time. + // + // Supported matching inputs: + // + // * ``endpoint_metadata``: Extract values from the selected endpoint's metadata. + // * ``locality_metadata``: Extract values from the endpoint's locality metadata. + // * ``transport_socket_filter_state``: Extract values from filter state that was explicitly shared from + // downstream to upstream via ``TransportSocketOptions``. This enables flexible + // downstream-connection-based matching, such as: + // + // - Network namespace matching. + // - Custom connection attributes. + // - Any data explicitly passed via filter state. + // + // .. note:: + // Filter state sharing follows the same pattern as tunneling in Envoy. Filters must explicitly + // share data by setting filter state with the appropriate sharing mode. The filter state is + // then accessible via the ``transport_socket_filter_state`` input during transport socket selection. + // + // If this field is set, it takes precedence over legacy metadata-based selection + // performed by :ref:`transport_socket_matches + // ` alone. + // If the matcher does not yield a match, Envoy uses the default transport socket + // configured for the cluster. + // + // When using this field, each entry in + // :ref:`transport_socket_matches ` + // must have a unique ``name``. The matcher outcome is expected to reference one of + // these names. + xds.type.matcher.v3.Matcher transport_socket_matcher = 59; + // Supplies the name of the cluster which must be unique across all clusters. // The cluster name is used when emitting // :ref:`statistics ` if :ref:`alt_stat_name @@ -816,12 +856,14 @@ message Cluster { string name = 1 [(validate.rules).string = {min_len: 1}]; // An optional alternative to the cluster name to be used for observability. This name is used - // emitting stats for the cluster and access logging the cluster name. This will appear as + // for emitting stats for the cluster and access logging the cluster name. This will appear as // additional information in configuration dumps of a cluster's current status as // :ref:`observability_name ` - // and as an additional tag "upstream_cluster.name" while tracing. Note: Any ``:`` in the name - // will be converted to ``_`` when emitting statistics. This should not be confused with - // :ref:`Router Filter Header `. + // and as an additional tag "upstream_cluster.name" while tracing. + // + // .. note:: + // Any ``:`` in the name will be converted to ``_`` when emitting statistics. This should not be confused with + // :ref:`Router Filter Header `. string alt_stat_name = 28 [(udpa.annotations.field_migrate).rename = "observability_name"]; oneof cluster_discovery_type { diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/common/matcher/v3/matcher.proto b/xds/third_party/envoy/src/main/proto/envoy/config/common/matcher/v3/matcher.proto new file mode 100644 index 00000000000..9b189d1aa77 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/config/common/matcher/v3/matcher.proto @@ -0,0 +1,239 @@ +syntax = "proto3"; + +package envoy.config.common.matcher.v3; + +import "envoy/config/core/v3/extension.proto"; +import "envoy/config/route/v3/route_components.proto"; +import "envoy/type/matcher/v3/string.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.config.common.matcher.v3"; +option java_outer_classname = "MatcherProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/config/common/matcher/v3;matcherv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Unified Matcher API] + +// A matcher, which may traverse a matching tree in order to result in a match action. +// During matching, the tree will be traversed until a match is found, or if no match +// is found the action specified by the most specific on_no_match will be evaluated. +// As an on_no_match might result in another matching tree being evaluated, this process +// might repeat several times until the final OnMatch (or no match) is decided. +// +// .. note:: +// Please use the syntactically equivalent :ref:`matching API ` +message Matcher { + // What to do if a match is successful. + message OnMatch { + oneof on_match { + option (validate.required) = true; + + // Nested matcher to evaluate. + // If the nested matcher does not match and does not specify + // on_no_match, then this matcher is considered not to have + // matched, even if a predicate at this level or above returned + // true. + Matcher matcher = 1; + + // Protocol-specific action to take. + core.v3.TypedExtensionConfig action = 2; + } + + // If true, the action will be taken but the caller will behave as if no + // match was found. This applies both to actions directly encoded in the + // action field and to actions returned from a nested matcher tree in the + // matcher field. A subsequent matcher on_no_match action will be used + // instead. + // + // This field is not supported in all contexts in which the matcher API is + // used. If this field is set in a context in which it's not supported, + // the resource will be rejected. + bool keep_matching = 3; + } + + // A linear list of field matchers. + // The field matchers are evaluated in order, and the first match + // wins. + message MatcherList { + // Predicate to determine if a match is successful. + message Predicate { + // Predicate for a single input field. + message SinglePredicate { + // Protocol-specific specification of input field to match on. + // [#extension-category: envoy.matching.common_inputs] + core.v3.TypedExtensionConfig input = 1 [(validate.rules).message = {required: true}]; + + oneof matcher { + option (validate.required) = true; + + // Built-in string matcher. + type.matcher.v3.StringMatcher value_match = 2; + + // Extension for custom matching logic. + // [#extension-category: envoy.matching.input_matchers] + core.v3.TypedExtensionConfig custom_match = 3; + } + } + + // A list of two or more matchers. Used to allow using a list within a oneof. + message PredicateList { + repeated Predicate predicate = 1 [(validate.rules).repeated = {min_items: 2}]; + } + + oneof match_type { + option (validate.required) = true; + + // A single predicate to evaluate. + SinglePredicate single_predicate = 1; + + // A list of predicates to be OR-ed together. + PredicateList or_matcher = 2; + + // A list of predicates to be AND-ed together. + PredicateList and_matcher = 3; + + // The inverse of a predicate + Predicate not_matcher = 4; + } + } + + // An individual matcher. + message FieldMatcher { + // Determines if the match succeeds. + Predicate predicate = 1 [(validate.rules).message = {required: true}]; + + // What to do if the match succeeds. + OnMatch on_match = 2 [(validate.rules).message = {required: true}]; + } + + // A list of matchers. First match wins. + repeated FieldMatcher matchers = 1 [(validate.rules).repeated = {min_items: 1}]; + } + + message MatcherTree { + // A map of configured matchers. Used to allow using a map within a oneof. + message MatchMap { + map map = 1 [(validate.rules).map = {min_pairs: 1}]; + } + + // Protocol-specific specification of input field to match on. + core.v3.TypedExtensionConfig input = 1 [(validate.rules).message = {required: true}]; + + // Exact or prefix match maps in which to look up the input value. + // If the lookup succeeds, the match is considered successful, and + // the corresponding OnMatch is used. + oneof tree_type { + option (validate.required) = true; + + MatchMap exact_match_map = 2; + + // Longest matching prefix wins. + MatchMap prefix_match_map = 3; + + // Extension for custom matching logic. + core.v3.TypedExtensionConfig custom_match = 4; + } + } + + oneof matcher_type { + option (validate.required) = true; + + // A linear list of matchers to evaluate. + MatcherList matcher_list = 1; + + // A match tree to evaluate. + MatcherTree matcher_tree = 2; + } + + // Optional ``OnMatch`` to use if the matcher failed. + // If specified, the ``OnMatch`` is used, and the matcher is considered + // to have matched. + // If not specified, the matcher is considered not to have matched. + OnMatch on_no_match = 3; +} + +// Match configuration. This is a recursive structure which allows complex nested match +// configurations to be built using various logical operators. +// [#next-free-field: 11] +message MatchPredicate { + // A set of match configurations used for logical operations. + message MatchSet { + // The list of rules that make up the set. + repeated MatchPredicate rules = 1 [(validate.rules).repeated = {min_items: 2}]; + } + + oneof rule { + option (validate.required) = true; + + // A set that describes a logical OR. If any member of the set matches, the match configuration + // matches. + MatchSet or_match = 1; + + // A set that describes a logical AND. If all members of the set match, the match configuration + // matches. + MatchSet and_match = 2; + + // A negation match. The match configuration will match if the negated match condition matches. + MatchPredicate not_match = 3; + + // The match configuration will always match. + bool any_match = 4 [(validate.rules).bool = {const: true}]; + + // HTTP request headers match configuration. + HttpHeadersMatch http_request_headers_match = 5; + + // HTTP request trailers match configuration. + HttpHeadersMatch http_request_trailers_match = 6; + + // HTTP response headers match configuration. + HttpHeadersMatch http_response_headers_match = 7; + + // HTTP response trailers match configuration. + HttpHeadersMatch http_response_trailers_match = 8; + + // HTTP request generic body match configuration. + HttpGenericBodyMatch http_request_generic_body_match = 9; + + // HTTP response generic body match configuration. + HttpGenericBodyMatch http_response_generic_body_match = 10; + } +} + +// HTTP headers match configuration. +message HttpHeadersMatch { + // HTTP headers to match. + repeated route.v3.HeaderMatcher headers = 1; +} + +// HTTP generic body match configuration. +// List of text strings and hex strings to be located in HTTP body. +// All specified strings must be found in the HTTP body for positive match. +// The search may be limited to specified number of bytes from the body start. +// +// .. attention:: +// +// Searching for patterns in HTTP body is potentially CPU-intensive. For each specified pattern, HTTP body is scanned byte by byte to find a match. +// If multiple patterns are specified, the process is repeated for each pattern. If location of a pattern is known, ``bytes_limit`` should be specified +// to scan only part of the HTTP body. +message HttpGenericBodyMatch { + message GenericTextMatch { + oneof rule { + option (validate.required) = true; + + // Text string to be located in HTTP body. + string string_match = 1 [(validate.rules).string = {min_len: 1}]; + + // Sequence of bytes to be located in HTTP body. + bytes binary_match = 2 [(validate.rules).bytes = {min_len: 1}]; + } + } + + // Limits search to specified number of bytes - default zero (no limit - match entire captured buffer). + uint32 bytes_limit = 1; + + // List of patterns to match. + repeated GenericTextMatch patterns = 2 [(validate.rules).repeated = {min_items: 1}]; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto b/xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto new file mode 100644 index 00000000000..c015db21431 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/config/common/mutation_rules/v3/mutation_rules.proto @@ -0,0 +1,113 @@ +syntax = "proto3"; + +package envoy.config.common.mutation_rules.v3; + +import "envoy/config/core/v3/base.proto"; +import "envoy/type/matcher/v3/regex.proto"; +import "envoy/type/matcher/v3/string.proto"; + +import "google/protobuf/wrappers.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.config.common.mutation_rules.v3"; +option java_outer_classname = "MutationRulesProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/config/common/mutation_rules/v3;mutation_rulesv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Header mutation rules] + +// The HeaderMutationRules structure specifies what headers may be +// manipulated by a processing filter. This set of rules makes it +// possible to control which modifications a filter may make. +// +// By default, an external processing server may add, modify, or remove +// any header except for an "Envoy internal" header (which is typically +// denoted by an x-envoy prefix) or specific headers that may affect +// further filter processing: +// +// * ``host`` +// * ``:authority`` +// * ``:scheme`` +// * ``:method`` +// +// Every attempt to add, change, append, or remove a header will be +// tested against the rules here. Disallowed header mutations will be +// ignored unless ``disallow_is_error`` is set to true. +// +// Attempts to remove headers are further constrained -- regardless of the +// settings, system-defined headers (that start with ``:``) and the ``host`` +// header may never be removed. +// +// In addition, a counter will be incremented whenever a mutation is +// rejected. In the ext_proc filter, that counter is named +// ``rejected_header_mutations``. +// [#next-free-field: 8] +message HeaderMutationRules { + // By default, certain headers that could affect processing of subsequent + // filters or request routing cannot be modified. These headers are + // ``host``, ``:authority``, ``:scheme``, and ``:method``. Setting this parameter + // to true allows these headers to be modified as well. + google.protobuf.BoolValue allow_all_routing = 1; + + // If true, allow modification of envoy internal headers. By default, these + // start with ``x-envoy`` but this may be overridden in the ``Bootstrap`` + // configuration using the + // :ref:`header_prefix ` + // field. Default is false. + google.protobuf.BoolValue allow_envoy = 2; + + // If true, prevent modification of any system header, defined as a header + // that starts with a ``:`` character, regardless of any other settings. + // A processing server may still override the ``:status`` of an HTTP response + // using an ``ImmediateResponse`` message. Default is false. + google.protobuf.BoolValue disallow_system = 3; + + // If true, prevent modifications of all header values, regardless of any + // other settings. A processing server may still override the ``:status`` + // of an HTTP response using an ``ImmediateResponse`` message. Default is false. + google.protobuf.BoolValue disallow_all = 4; + + // If set, specifically allow any header that matches this regular + // expression. This overrides all other settings except for + // ``disallow_expression``. + type.matcher.v3.RegexMatcher allow_expression = 5; + + // If set, specifically disallow any header that matches this regular + // expression regardless of any other settings. + type.matcher.v3.RegexMatcher disallow_expression = 6; + + // If true, and if the rules in this list cause a header mutation to be + // disallowed, then the filter using this configuration will terminate the + // request with a 500 error. In addition, regardless of the setting of this + // parameter, any attempt to set, add, or modify a disallowed header will + // cause the ``rejected_header_mutations`` counter to be incremented. + // Default is false. + google.protobuf.BoolValue disallow_is_error = 7; +} + +// The HeaderMutation structure specifies an action that may be taken on HTTP +// headers. +message HeaderMutation { + message RemoveOnMatch { + // A string matcher that will be applied to the header key. If the header key + // matches, the header will be removed. + type.matcher.v3.StringMatcher key_matcher = 1 [(validate.rules).message = {required: true}]; + } + + oneof action { + option (validate.required) = true; + + // Remove the specified header if it exists. + string remove = 1 + [(validate.rules).string = {well_known_regex: HTTP_HEADER_VALUE strict: false}]; + + // Append new header by the specified HeaderValueOption. + core.v3.HeaderValueOption append = 2; + + // Remove the header if the key matches the specified string matcher. + RemoveOnMatch remove_on_match = 3; + } +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto index 56796fc721a..17a68269e34 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/address.proto @@ -105,9 +105,6 @@ message SocketAddress { // .. note:: // Setting this parameter requires Envoy to run with the ``CAP_NET_ADMIN`` capability. // - // .. note:: - // Currently only used for Listener sockets. - // // .. attention:: // Network namespaces are only configurable on Linux. Otherwise, this field has no effect. string network_namespace_filepath = 7; @@ -118,16 +115,18 @@ message TcpKeepalive { // Maximum number of keepalive probes to send without response before deciding // the connection is dead. Default is to use the OS level configuration (unless - // overridden, Linux defaults to 9.) + // overridden, Linux defaults to 9.) Setting this to ``0`` disables TCP keepalive. google.protobuf.UInt32Value keepalive_probes = 1; // The number of seconds a connection needs to be idle before keep-alive probes // start being sent. Default is to use the OS level configuration (unless - // overridden, Linux defaults to 7200s (i.e., 2 hours.) + // overridden, Linux defaults to 7200s (i.e., 2 hours.) Setting this to ``0`` disables + // TCP keepalive. google.protobuf.UInt32Value keepalive_time = 2; // The number of seconds between keep-alive probes. Default is to use the OS - // level configuration (unless overridden, Linux defaults to 75s.) + // level configuration (unless overridden, Linux defaults to 75s.) Setting this to + // ``0`` disables TCP keepalive. google.protobuf.UInt32Value keepalive_interval = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/cel.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/cel.proto new file mode 100644 index 00000000000..940a66d0b10 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/cel.proto @@ -0,0 +1,63 @@ +syntax = "proto3"; + +package envoy.config.core.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.config.core.v3"; +option java_outer_classname = "CelProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/config/core/v3;corev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: CEL Expression Configuration] + +// CEL expression evaluation configuration. +// These options control the behavior of the Common Expression Language runtime for +// individual CEL expressions. +message CelExpressionConfig { + // Enable string conversion functions for CEL expressions. When enabled, CEL expressions + // can convert values to strings using the ``string()`` function. + // + // .. attention:: + // + // This option is disabled by default to avoid unbounded memory allocation. + // CEL evaluation cost is typically bounded by the expression size, but converting + // arbitrary values (e.g., large messages, lists, or maps) to strings may allocate + // memory proportional to input data size, which can be unbounded and lead to + // memory exhaustion. + bool enable_string_conversion = 1; + + // Enable string concatenation for CEL expressions. When enabled, CEL expressions + // can concatenate strings using the ``+`` operator. + // + // .. attention:: + // + // This option is disabled by default to avoid unbounded memory allocation. + // While CEL normally bounds evaluation by expression size, enabling string + // concatenation allows building outputs whose size depends on input data, + // potentially causing large intermediate allocations and memory exhaustion. + bool enable_string_concat = 2; + + // Enable string manipulation functions for CEL expressions. When enabled, CEL + // expressions can use additional string functions: + // + // * ``replace(old, new)`` - Replaces all occurrences of ``old`` with ``new``. + // * ``split(separator)`` - Splits a string into a list of substrings. + // * ``lowerAscii()`` - Converts ASCII characters to lowercase. + // * ``upperAscii()`` - Converts ASCII characters to uppercase. + // + // .. note:: + // + // Standard CEL string functions like ``contains()``, ``startsWith()``, and + // ``endsWith()`` are always available regardless of this setting. + // + // .. attention:: + // + // This option is disabled by default to avoid unbounded memory allocation. + // Although CEL generally bounds evaluation by expression size, functions such as + // ``replace``, ``split``, ``lowerAscii()``, and ``upperAscii()`` can allocate memory + // proportional to input data size. Under adversarial inputs this can lead to + // unbounded allocations and memory exhaustion. + bool enable_string_functions = 3; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto index f0effd99e45..430562aa5bd 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/config_source.proto @@ -276,7 +276,8 @@ message ExtensionConfigSource { // to be supplied. bool apply_default_config_without_warming = 3; - // A set of permitted extension type URLs. Extension configuration updates are rejected - // if they do not match any type URL in the set. + // A set of permitted extension type URLs for the type encoded inside of the + // :ref:`TypedExtensionConfig `. Extension + // configuration updates are rejected if they do not match any type URL in the set. repeated string type_urls = 4 [(validate.rules).repeated = {min_items: 1}]; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto index 5fd7921a806..9c44006b2a9 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/grpc_service.proto @@ -45,10 +45,20 @@ message GrpcService { [(validate.rules).string = {min_len: 0 max_bytes: 16384 well_known_regex: HTTP_HEADER_VALUE strict: false}]; - // Indicates the retry policy for re-establishing the gRPC stream - // This field is optional. If max interval is not provided, it will be set to ten times the provided base interval. - // Currently only supported for xDS gRPC streams. - // If not set, xDS gRPC streams default base interval:500ms, maximum interval:30s will be applied. + // Specifies the retry backoff policy for re-establishing long‑lived xDS gRPC streams. + // + // This field is optional. If ``retry_back_off.max_interval`` is not provided, it will be set to + // ten times the configured ``retry_back_off.base_interval``. + // + // .. note:: + // + // This field is only honored for management‑plane xDS gRPC streams created from + // :ref:`ApiConfigSource ` that use + // ``envoy_grpc``. Data‑plane gRPC clients (for example external authorization or external + // processing filters) must use :ref:`GrpcService.retry_policy + // ` instead. + // + // If not set, xDS gRPC streams default to a base interval of 500ms and a maximum interval of 30s. RetryPolicy retry_policy = 3; // Maximum gRPC message size that is allowed to be received. @@ -64,7 +74,7 @@ message GrpcService { bool skip_envoy_headers = 5; } - // [#next-free-field: 9] + // [#next-free-field: 11] message GoogleGrpc { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.core.GrpcService.GoogleGrpc"; @@ -249,16 +259,31 @@ message GrpcService { } // The target URI when using the `Google C++ gRPC client - // `_. SSL credentials will be supplied in - // :ref:`channel_credentials `. + // `_. string target_uri = 1 [(validate.rules).string = {min_len: 1}]; + // The channel credentials to use. See `channel credentials + // `_. + // Ignored if ``channel_credentials_plugin`` is set. ChannelCredentials channel_credentials = 2; - // A set of call credentials that can be composed with `channel credentials + // A list of channel credentials plugins. + // The data plane will iterate over the list in order and stop at the first credential type + // that it supports. This provides a mechanism for starting to use new credential types that + // are not yet supported by all data planes. + // [#not-implemented-hide:] + repeated google.protobuf.Any channel_credentials_plugin = 9; + + // The call credentials to use. See `channel credentials // `_. + // Ignored if ``call_credentials_plugin`` is set. repeated CallCredentials call_credentials = 3; + // A list of call credentials plugins. All supported plugins will be used. + // Unsupported plugin types will be ignored. + // [#not-implemented-hide:] + repeated google.protobuf.Any call_credentials_plugin = 10; + // The human readable prefix to use when emitting statistics for the gRPC // service. // @@ -314,7 +339,17 @@ message GrpcService { // `. repeated HeaderValue initial_metadata = 5; - // Optional default retry policy for streams toward the service. - // If an async stream doesn't have retry policy configured in its stream options, this retry policy is used. + // Optional default retry policy for RPCs or streams initiated toward this gRPC service. + // + // If an async stream does not have a retry policy configured in its per‑stream options, this + // policy is used as the default. + // + // .. note:: + // + // This field is only applied by Envoy gRPC (``envoy_grpc``) clients. Google gRPC + // (``google_grpc``) clients currently ignore this field. + // + // If not specified, no default retry policy is applied at the client level and retries only occur + // when explicitly configured in per‑stream options. RetryPolicy retry_policy = 6; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto index fd4440d8fa5..a4ed6e91818 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/health_check.proto @@ -102,7 +102,8 @@ message HealthCheck { // ``/healthcheck``. string path = 2 [(validate.rules).string = {min_len: 1 well_known_regex: HTTP_HEADER_VALUE}]; - // [#not-implemented-hide:] HTTP specific payload. + // HTTP specific payload to be sent as the request body during health checking. + // If specified, the method should support a request body (POST, PUT, PATCH, etc.). Payload send = 3; // Specifies a list of HTTP expected responses to match in the first ``response_buffer_size`` bytes of the response body. @@ -161,7 +162,8 @@ message HealthCheck { type.matcher.v3.StringMatcher service_name_matcher = 11; // HTTP Method that will be used for health checking, default is "GET". - // GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE, PATCH methods are supported, but making request body is not supported. + // GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE, PATCH methods are supported. + // Request body payloads are supported for POST, PUT, PATCH, and OPTIONS methods only. // CONNECT method is disallowed because it is not appropriate for health check request. // If a non-200 response is expected by the method, it needs to be set in :ref:`expected_statuses `. RequestMethod method = 13 [(validate.rules).enum = {defined_only: true not_in: 6}]; diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto index edab4cd79c6..63e189e689e 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/protocol.proto @@ -31,10 +31,13 @@ message TcpProtocolOptions { } // Config for keepalive probes in a QUIC connection. -// Note that QUIC keep-alive probing packets work differently from HTTP/2 keep-alive PINGs in a sense that the probing packet -// itself doesn't timeout waiting for a probing response. Quic has a shorter idle timeout than TCP, so it doesn't rely on such probing to discover dead connections. If the peer fails to respond, the connection will idle timeout eventually. Thus, they are configured differently from :ref:`connection_keepalive `. +// +// .. note:: +// +// QUIC keep-alive probing packets work differently from HTTP/2 keep-alive PINGs in a sense that the probing packet +// itself doesn't timeout waiting for a probing response. QUIC has a shorter idle timeout than TCP, so it doesn't rely on such probing to discover dead connections. If the peer fails to respond, the connection will idle timeout eventually. Thus, they are configured differently from :ref:`connection_keepalive `. message QuicKeepAliveSettings { - // The max interval for a connection to send keep-alive probing packets (with PING or PATH_RESPONSE). The value should be smaller than :ref:`connection idle_timeout ` to prevent idle timeout while not less than 1s to avoid throttling the connection or flooding the peer with probes. + // The max interval for a connection to send keep-alive probing packets (with ``PING`` or ``PATH_RESPONSE``). The value should be smaller than :ref:`connection idle_timeout ` to prevent idle timeout while not less than ``1s`` to avoid throttling the connection or flooding the peer with probes. // // If :ref:`initial_interval ` is absent or zero, a client connection will use this value to start probing. // @@ -54,20 +57,53 @@ message QuicKeepAliveSettings { } // QUIC protocol options which apply to both downstream and upstream connections. -// [#next-free-field: 10] +// [#next-free-field: 12] message QuicProtocolOptions { - // Maximum number of streams that the client can negotiate per connection. 100 + // Config for QUIC connection migration across network interfaces, i.e. cellular to WIFI, upon + // network change events from the platform, i.e. the current network gets + // disconnected, or upon the QUIC detecting a bad connection. After migration, the + // connection may be on a different network other than the default network + // picked by the platform. Both iOS and Android will use a default network to interact with the internet, usually prefer unmetered network (WIFI) + // over metered ones (cellular). And users can specify which network to be used as the default. A connection on non-default network is only allowed to + // serve new requests for a certain period of time before being drained, and + // meanwhile, QUIC will try to migrate to the default network if possible. + message ConnectionMigrationSettings { + // Config for options to migrate idle connections which aren't serving any requests. + message MigrateIdleConnectionSettings { + // If idle connections are allowed to be migrated, only migrate the connection + // if it hasn't been idle for longer than this idle period. Otherwise, the + // connection will be closed instead. + // Default to 30s. + google.protobuf.Duration max_idle_time_before_migration = 1 + [(validate.rules).duration = {gte {seconds: 1}}]; + } + + // Config whether and how to migrate idle connections. + // If absent, idle connections will not be migrated but be closed upon + // migration signals. + MigrateIdleConnectionSettings migrate_idle_connections = 1; + + // After migrating to a non-default network interface, the connection will + // only be allowed to stay on that network for up to this period of time before + // being drained unless it migrates to the default network or that network + // gets picked as the default by the device by then. + // Default to 128s. + google.protobuf.Duration max_time_on_non_default_network = 2 + [(validate.rules).duration = {gte {seconds: 1}}]; + } + + // Maximum number of streams that the client can negotiate per connection. ``100`` // if not specified. google.protobuf.UInt32Value max_concurrent_streams = 1 [(validate.rules).uint32 = {gte: 1}]; // `Initial stream-level flow-control receive window // `_ size. Valid values range from - // 1 to 16777216 (2^24, maximum supported by QUICHE) and defaults to 16777216 (16 * 1024 * 1024). + // ``1`` to ``16777216`` (``2^24``, maximum supported by QUICHE) and defaults to ``16777216`` (``16 * 1024 * 1024``). // // .. note:: // - // 16384 (2^14) is the minimum window size supported in Google QUIC. If configured smaller than it, we will use - // 16384 instead. QUICHE IETF Quic implementation supports 1 bytes window. We only support increasing the default + // ``16384`` (``2^14``) is the minimum window size supported in Google QUIC. If configured smaller than it, we will use + // ``16384`` instead. QUICHE IETF QUIC implementation supports ``1`` byte window. We only support increasing the default // window size now, so it's also the minimum. // // This field also acts as a soft limit on the number of bytes Envoy will buffer per-stream in the @@ -77,26 +113,26 @@ message QuicProtocolOptions { [(validate.rules).uint32 = {lte: 16777216 gte: 1}]; // Similar to ``initial_stream_window_size``, but for connection-level - // flow-control. Valid values rage from 1 to 25165824 (24MB, maximum supported by QUICHE) and defaults - // to 25165824 (24 * 1024 * 1024). + // flow-control. Valid values range from ``1`` to ``25165824`` (``24MB``, maximum supported by QUICHE) and defaults + // to ``25165824`` (``24 * 1024 * 1024``). // // .. note:: // - // 16384 (2^14) is the minimum window size supported in Google QUIC. We only support increasing the default + // ``16384`` (``2^14``) is the minimum window size supported in Google QUIC. We only support increasing the default // window size now, so it's also the minimum. // google.protobuf.UInt32Value initial_connection_window_size = 3 [(validate.rules).uint32 = {lte: 25165824 gte: 1}]; // The number of timeouts that can occur before port migration is triggered for QUIC clients. - // This defaults to 4. If set to 0, port migration will not occur on path degrading. - // Timeout here refers to QUIC internal path degrading timeout mechanism, such as PTO. + // This defaults to ``4``. If set to ``0``, port migration will not occur on path degrading. + // Timeout here refers to QUIC internal path degrading timeout mechanism, such as ``PTO``. // This has no effect on server sessions. google.protobuf.UInt32Value num_timeouts_to_trigger_port_migration = 4 [(validate.rules).uint32 = {lte: 5 gte: 0}]; - // Probes the peer at the configured interval to solicit traffic, i.e. ACK or PATH_RESPONSE, from the peer to push back connection idle timeout. - // If absent, use the default keepalive behavior of which a client connection sends PINGs every 15s, and a server connection doesn't do anything. + // Probes the peer at the configured interval to solicit traffic, i.e. ``ACK`` or ``PATH_RESPONSE``, from the peer to push back connection idle timeout. + // If absent, use the default keepalive behavior of which a client connection sends ``PING``s every ``15s``, and a server connection doesn't do anything. QuicKeepAliveSettings connection_keepalive = 5; // A comma-separated list of strings representing QUIC connection options defined in @@ -108,17 +144,35 @@ message QuicProtocolOptions { string client_connection_options = 7; // The duration that a QUIC connection stays idle before it closes itself. If this field is not present, QUICHE - // default 600s will be applied. + // default ``600s`` will be applied. // For internal corporate network, a long timeout is often fine. - // But for client facing network, 30s is usually a good choice. - google.protobuf.Duration idle_network_timeout = 8 [(validate.rules).duration = { - lte {seconds: 600} - gte {seconds: 1} - }]; + // But for client facing network, ``30s`` is usually a good choice. + // Do not add an upper bound here. A long idle timeout is useful for maintaining warm connections at non-front-line proxy for low QPS services. + google.protobuf.Duration idle_network_timeout = 8 + [(validate.rules).duration = {gte {seconds: 1}}]; // Maximum packet length for QUIC connections. It refers to the largest size of a QUIC packet that can be transmitted over the connection. // If not specified, one of the `default values in QUICHE `_ is used. google.protobuf.UInt64Value max_packet_length = 9; + + // A customized UDP socket and a QUIC packet writer using the socket for + // client connections. i.e. Mobile uses its own implementation to interact + // with platform socket APIs. + // If not present, the default platform-independent socket and writer will be used. + // [#extension-category: envoy.quic.client_packet_writer] + TypedExtensionConfig client_packet_writer = 10; + + // Enable QUIC `connection migration + // ` + // to a different network interface when the current network is degrading or + // has become bad. + // In order to use a different network interface other than the platform's default one, + // a customized :ref:`client_packet_writer ` needs to be configured to + // create UDP sockets on non-default networks. + // Only takes effect when runtime key ``envoy.reloadable_features.use_migration_in_quiche`` is true. + // If absent, the feature will be disabled. + // [#not-implemented-hide:] + ConnectionMigrationSettings connection_migration = 11; } message UpstreamHttpProtocolOptions { @@ -188,9 +242,9 @@ message AlternateProtocolsCacheOptions { // not the case. string name = 1 [(validate.rules).string = {min_len: 1}]; - // The maximum number of entries that the cache will hold. If not specified defaults to 1024. + // The maximum number of entries that the cache will hold. If not specified defaults to ``1024``. // - // .. note: + // .. note:: // // The implementation is approximate and enforced independently on each worker thread, thus // it is possible for the maximum entries in the cache to go slightly above the configured @@ -233,14 +287,14 @@ message HttpProtocolOptions { // Allow headers with underscores. This is the default behavior. ALLOW = 0; - // Reject client request. HTTP/1 requests are rejected with the 400 status. HTTP/2 requests - // end with the stream reset. The "httpN.requests_rejected_with_underscores_in_headers" counter + // Reject client request. HTTP/1 requests are rejected with ``HTTP 400`` status. HTTP/2 requests + // end with the stream reset. The ``httpN.requests_rejected_with_underscores_in_headers`` counter // is incremented for each rejected request. REJECT_REQUEST = 1; // Drop the client header with name containing underscores. The header is dropped before the filter chain is // invoked and as such filters will not see dropped headers. The - // "httpN.dropped_headers_with_underscores" is incremented for each dropped header. + // ``httpN.dropped_headers_with_underscores`` is incremented for each dropped header. DROP_HEADER = 2; } @@ -250,8 +304,12 @@ message HttpProtocolOptions { // downstream connection a drain sequence will occur prior to closing the connection, see // :ref:`drain_timeout // `. - // Note that request based timeouts mean that HTTP/2 PINGs will not keep the connection alive. - // If not specified, this defaults to 1 hour. To disable idle timeouts explicitly set this to 0. + // + // .. note:: + // + // Request based timeouts mean that HTTP/2 PINGs will not keep the connection alive. + // + // If not specified, this defaults to ``1 hour``. To disable idle timeouts explicitly set this to ``0``. // // .. warning:: // Disabling this timeout has a highly likelihood of yielding connection leaks due to lost TCP @@ -271,19 +329,19 @@ message HttpProtocolOptions { // The maximum number of headers (request headers if configured on HttpConnectionManager, // response headers when configured on a cluster). - // If unconfigured, the default maximum number of headers allowed is 100. + // If unconfigured, the default maximum number of headers allowed is ``100``. // The default value for requests can be overridden by setting runtime key ``envoy.reloadable_features.max_request_headers_count``. // The default value for responses can be overridden by setting runtime key ``envoy.reloadable_features.max_response_headers_count``. - // Downstream requests that exceed this limit will receive a 431 response for HTTP/1.x and cause a stream + // Downstream requests that exceed this limit will receive a ``HTTP 431`` response for HTTP/1.x and cause a stream // reset for HTTP/2. - // Upstream responses that exceed this limit will result in a 503 response. + // Upstream responses that exceed this limit will result in a ``HTTP 502`` response. google.protobuf.UInt32Value max_headers_count = 2 [(validate.rules).uint32 = {gte: 1}]; // The maximum size of response headers. - // If unconfigured, the default is 60 KiB, except for HTTP/1 response headers which have a default - // of 80KiB. + // If unconfigured, the default is ``60 KiB``, except for HTTP/1 response headers which have a default + // of ``80 KiB``. // The default value can be overridden by setting runtime key ``envoy.reloadable_features.max_response_headers_size_kb``. - // Responses that exceed this limit will result in a 503 response. + // Responses that exceed this limit will result in a ``HTTP 503`` response. // In Envoy, this setting is only valid when configured on an upstream cluster, not on the // :ref:`HTTP Connection Manager // `. @@ -292,8 +350,8 @@ message HttpProtocolOptions { // // Currently some protocol codecs impose limits on the maximum size of a single header. // - // * HTTP/2 (when using nghttp2) limits a single header to around 100kb. - // * HTTP/3 limits a single header to around 1024kb. + // * HTTP/2 (when using ``nghttp2``) limits a single header to around ``100kb``. + // * HTTP/3 limits a single header to around ``1024kb``. // google.protobuf.UInt32Value max_response_headers_kb = 7 [(validate.rules).uint32 = {lte: 8192 gt: 0}]; @@ -303,7 +361,7 @@ message HttpProtocolOptions { google.protobuf.Duration max_stream_duration = 4; // Action to take when a client request with a header name containing underscore characters is received. - // If this setting is not specified, the value defaults to ALLOW. + // If this setting is not specified, the value defaults to ``ALLOW``. // // .. note:: // @@ -317,7 +375,7 @@ message HttpProtocolOptions { // Optional maximum requests for both upstream and downstream connections. // If not specified, there is no limit. - // Setting this parameter to 1 will effectively disable keep alive. + // Setting this parameter to ``1`` will effectively disable keep alive. // For HTTP/2 and HTTP/3, due to concurrent stream processing, the limit is approximate. google.protobuf.UInt32Value max_requests_per_connection = 6; } @@ -342,9 +400,12 @@ message Http1ProtocolOptions { // Formats the header by proper casing words: the first character and any character following // a special character will be capitalized if it's an alpha character. For example, - // "content-type" becomes "Content-Type", and "foo$b#$are" becomes "Foo$B#$Are". - // Note that while this results in most headers following conventional casing, certain headers - // are not covered. For example, the "TE" header will be formatted as "Te". + // ``"content-type"`` becomes ``"Content-Type"``, and ``"foo$b#$are"`` becomes ``"Foo$B#$Are"``. + // + // .. note:: + // + // While this results in most headers following conventional casing, certain headers + // are not covered. For example, the ``"TE"`` header will be formatted as ``"Te"``. ProperCaseWords proper_case_words = 1; // Configuration for stateful formatter extensions that allow using received headers to @@ -360,7 +421,7 @@ message Http1ProtocolOptions { // ``http_proxy`` environment variable. google.protobuf.BoolValue allow_absolute_url = 1; - // Handle incoming HTTP/1.0 and HTTP 0.9 requests. + // Handle incoming HTTP/1.0 and HTTP/0.9 requests. // This is off by default, and not fully standards compliant. There is support for pre-HTTP/1.1 // style connect logic, dechunking, and handling lack of client host iff // ``default_host_for_http_10`` is configured. @@ -379,19 +440,20 @@ message Http1ProtocolOptions { // // .. attention:: // - // Note that this only happens when Envoy is chunk encoding which occurs when: + // This only happens when Envoy is chunk encoding which occurs when: // - The request is HTTP/1.1. - // - Is neither a HEAD only request nor a HTTP Upgrade. - // - Not a response to a HEAD request. - // - The content length header is not present. + // - Is neither a ``HEAD`` only request nor a HTTP Upgrade. + // - Not a response to a ``HEAD`` request. + // - The ``Content-Length`` header is not present. bool enable_trailers = 5; // Allows Envoy to process requests/responses with both ``Content-Length`` and ``Transfer-Encoding`` // headers set. By default such messages are rejected, but if option is enabled - Envoy will - // remove Content-Length header and process message. + // remove ``Content-Length`` header and process message. // See `RFC7230, sec. 3.3.3 `_ for details. // // .. attention:: + // // Enabling this option might lead to request smuggling vulnerability, especially if traffic // is proxied via multiple layers of proxies. // [#comment:TODO: This field is ignored when the @@ -420,7 +482,7 @@ message Http1ProtocolOptions { // envoy.reloadable_features.http1_use_balsa_parser. // See issue #21245. google.protobuf.BoolValue use_balsa_parser = 9 - [(xds.annotations.v3.field_status).work_in_progress = true]; + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; // [#not-implemented-hide:] Hiding so that field can be removed. // If true, and BalsaParser is used (either `use_balsa_parser` above is true, @@ -450,9 +512,12 @@ message KeepaliveSettings { google.protobuf.Duration interval = 1 [(validate.rules).duration = {gte {nanos: 1000000}}]; // How long to wait for a response to a keepalive PING. If a response is not received within this - // time period, the connection will be aborted. Note that in order to prevent the influence of - // Head-of-line (HOL) blocking the timeout period is extended when *any* frame is received on - // the connection, under the assumption that if a frame is received the connection is healthy. + // time period, the connection will be aborted. + // + // .. note:: + // + // In order to prevent the influence of Head-of-line (HOL) blocking the timeout period is extended when *any* frame is received on + // the connection, under the assumption that if a frame is received the connection is healthy. google.protobuf.Duration timeout = 2 [(validate.rules).duration = { required: true gte {nanos: 1000000} @@ -460,7 +525,7 @@ message KeepaliveSettings { // A random jitter amount as a percentage of interval that will be added to each interval. // A value of zero means there will be no jitter. - // The default value is 15%. + // The default value is ``15%``. type.v3.Percent interval_jitter = 3; // If the connection has been idle for this duration, send a HTTP/2 ping ahead @@ -474,7 +539,7 @@ message KeepaliveSettings { [(validate.rules).duration = {gte {nanos: 1000000}}]; } -// [#next-free-field: 18] +// [#next-free-field: 19] message Http2ProtocolOptions { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.core.Http2ProtocolOptions"; @@ -497,13 +562,13 @@ message Http2ProtocolOptions { // `Maximum table size `_ // (in octets) that the encoder is permitted to use for the dynamic HPACK table. Valid values - // range from 0 to 4294967295 (2^32 - 1) and defaults to 4096. 0 effectively disables header + // range from ``0`` to ``4294967295`` (``2^32 - 1``) and defaults to ``4096``. ``0`` effectively disables header // compression. google.protobuf.UInt32Value hpack_table_size = 1; // `Maximum concurrent streams `_ - // allowed for peer on one HTTP/2 connection. Valid values range from 1 to 2147483647 (2^31 - 1) - // and defaults to 2147483647. + // allowed for peer on one HTTP/2 connection. Valid values range from ``1`` to ``2147483647`` (``2^31 - 1``) + // and defaults to ``1024`` for safety and should be sufficient for most use cases. // // For upstream connections, this also limits how many streams Envoy will initiate concurrently // on a single connection. If the limit is reached, Envoy may queue requests or establish @@ -516,13 +581,13 @@ message Http2ProtocolOptions { [(validate.rules).uint32 = {lte: 2147483647 gte: 1}]; // `Initial stream-level flow-control window - // `_ size. Valid values range from 65535 - // (2^16 - 1, HTTP/2 default) to 2147483647 (2^31 - 1, HTTP/2 maximum) and defaults to 268435456 - // (256 * 1024 * 1024). + // `_ size. Valid values range from ``65535`` + // (``2^16 - 1``, HTTP/2 default) to ``2147483647`` (``2^31 - 1``, HTTP/2 maximum) and defaults to + // ``16MiB`` (``16 * 1024 * 1024``). // // .. note:: // - // 65535 is the initial window size from HTTP/2 spec. We only support increasing the default window size now, + // ``65535`` is the initial window size from HTTP/2 spec. We only support increasing the default window size now, // so it's also the minimum. // // This field also acts as a soft limit on the number of bytes Envoy will buffer per-stream in the @@ -532,7 +597,7 @@ message Http2ProtocolOptions { [(validate.rules).uint32 = {lte: 2147483647 gte: 65535}]; // Similar to ``initial_stream_window_size``, but for connection-level flow-control - // window. Currently, this has the same minimum/maximum/default as ``initial_stream_window_size``. + // window. The default is ``24MiB`` (``24 * 1024 * 1024``). google.protobuf.UInt32Value initial_connection_window_size = 4 [(validate.rules).uint32 = {lte: 2147483647 gte: 65535}]; @@ -550,51 +615,51 @@ message Http2ProtocolOptions { // Limit the number of pending outbound downstream frames of all types (frames that are waiting to // be written into the socket). Exceeding this limit triggers flood mitigation and connection is // terminated. The ``http2.outbound_flood`` stat tracks the number of terminated connections due - // to flood mitigation. The default limit is 10000. + // to flood mitigation. The default limit is ``10000``. google.protobuf.UInt32Value max_outbound_frames = 7 [(validate.rules).uint32 = {gte: 1}]; - // Limit the number of pending outbound downstream frames of types PING, SETTINGS and RST_STREAM, + // Limit the number of pending outbound downstream frames of types ``PING``, ``SETTINGS`` and ``RST_STREAM``, // preventing high memory utilization when receiving continuous stream of these frames. Exceeding // this limit triggers flood mitigation and connection is terminated. The // ``http2.outbound_control_flood`` stat tracks the number of terminated connections due to flood - // mitigation. The default limit is 1000. + // mitigation. The default limit is ``1000``. google.protobuf.UInt32Value max_outbound_control_frames = 8 [(validate.rules).uint32 = {gte: 1}]; - // Limit the number of consecutive inbound frames of types HEADERS, CONTINUATION and DATA with an + // Limit the number of consecutive inbound frames of types ``HEADERS``, ``CONTINUATION`` and ``DATA`` with an // empty payload and no end stream flag. Those frames have no legitimate use and are abusive, but - // might be a result of a broken HTTP/2 implementation. The `http2.inbound_empty_frames_flood`` + // might be a result of a broken HTTP/2 implementation. The ``http2.inbound_empty_frames_flood`` // stat tracks the number of connections terminated due to flood mitigation. - // Setting this to 0 will terminate connection upon receiving first frame with an empty payload - // and no end stream flag. The default limit is 1. + // Setting this to ``0`` will terminate connection upon receiving first frame with an empty payload + // and no end stream flag. The default limit is ``1``. google.protobuf.UInt32Value max_consecutive_inbound_frames_with_empty_payload = 9; - // Limit the number of inbound PRIORITY frames allowed per each opened stream. If the number - // of PRIORITY frames received over the lifetime of connection exceeds the value calculated + // Limit the number of inbound ``PRIORITY`` frames allowed per each opened stream. If the number + // of ``PRIORITY`` frames received over the lifetime of connection exceeds the value calculated // using this formula:: // // ``max_inbound_priority_frames_per_stream`` * (1 + ``opened_streams``) // // the connection is terminated. For downstream connections the ``opened_streams`` is incremented when // Envoy receives complete response headers from the upstream server. For upstream connection the - // ``opened_streams`` is incremented when Envoy send the HEADERS frame for a new stream. The + // ``opened_streams`` is incremented when Envoy sends the ``HEADERS`` frame for a new stream. The // ``http2.inbound_priority_frames_flood`` stat tracks - // the number of connections terminated due to flood mitigation. The default limit is 100. + // the number of connections terminated due to flood mitigation. The default limit is ``100``. google.protobuf.UInt32Value max_inbound_priority_frames_per_stream = 10; - // Limit the number of inbound WINDOW_UPDATE frames allowed per DATA frame sent. If the number - // of WINDOW_UPDATE frames received over the lifetime of connection exceeds the value calculated + // Limit the number of inbound ``WINDOW_UPDATE`` frames allowed per ``DATA`` frame sent. If the number + // of ``WINDOW_UPDATE`` frames received over the lifetime of connection exceeds the value calculated // using this formula:: // - // 5 + 2 * (``opened_streams`` + - // ``max_inbound_window_update_frames_per_data_frame_sent`` * ``outbound_data_frames``) + // ``5 + 2 * (opened_streams + + // max_inbound_window_update_frames_per_data_frame_sent * outbound_data_frames)`` // // the connection is terminated. For downstream connections the ``opened_streams`` is incremented when // Envoy receives complete response headers from the upstream server. For upstream connections the - // ``opened_streams`` is incremented when Envoy sends the HEADERS frame for a new stream. The + // ``opened_streams`` is incremented when Envoy sends the ``HEADERS`` frame for a new stream. The // ``http2.inbound_priority_frames_flood`` stat tracks the number of connections terminated due to - // flood mitigation. The default max_inbound_window_update_frames_per_data_frame_sent value is 10. - // Setting this to 1 should be enough to support HTTP/2 implementations with basic flow control, - // but more complex implementations that try to estimate available bandwidth require at least 2. + // flood mitigation. The default ``max_inbound_window_update_frames_per_data_frame_sent`` value is ``10``. + // Setting this to ``1`` should be enough to support HTTP/2 implementations with basic flow control, + // but more complex implementations that try to estimate available bandwidth require at least ``2``. google.protobuf.UInt32Value max_inbound_window_update_frames_per_data_frame_sent = 11 [(validate.rules).uint32 = {gte: 1}]; @@ -632,8 +697,10 @@ message Http2ProtocolOptions { // 2. SETTINGS_ENABLE_CONNECT_PROTOCOL (0x8) is only configurable through the named field // 'allow_connect'. // - // Note that custom parameters specified through this field can not also be set in the - // corresponding named parameters: + // .. note:: + // + // Custom parameters specified through this field can not also be set in the + // corresponding named parameters: // // .. code-block:: text // @@ -661,8 +728,14 @@ message Http2ProtocolOptions { google.protobuf.BoolValue use_oghttp2_codec = 16 [(xds.annotations.v3.field_status).work_in_progress = true]; - // Configure the maximum amount of metadata than can be handled per stream. Defaults to 1 MB. + // Configure the maximum amount of metadata than can be handled per stream. Defaults to ``1 MB``. google.protobuf.UInt64Value max_metadata_size = 17; + + // Controls whether to encode headers using huffman encoding. + // This can be useful in cases where the cpu spent encoding the headers isn't + // worth the network bandwidth saved e.g. for localhost. + // If unset, uses the data plane's default value. + google.protobuf.BoolValue enable_huffman_encoding = 18; } // [#not-implemented-hide:] @@ -674,7 +747,7 @@ message GrpcProtocolOptions { } // A message which allows using HTTP/3. -// [#next-free-field: 8] +// [#next-free-field: 9] message Http3ProtocolOptions { QuicProtocolOptions quic_protocol_options = 1; @@ -691,7 +764,10 @@ message Http3ProtocolOptions { // `_ // and settings `proposed for HTTP/3 // `_ - // Note that HTTP/3 CONNECT is not yet an RFC. + // + // .. note:: + // + // HTTP/3 CONNECT is not yet an RFC. bool allow_extended_connect = 5 [(xds.annotations.v3.field_status).work_in_progress = true]; // [#not-implemented-hide:] Hiding until Envoy has full metadata support. @@ -706,22 +782,26 @@ message Http3ProtocolOptions { // Still under implementation. DO NOT USE. // // Disables QPACK compression related features for HTTP/3 including: - // No huffman encoding, zero dynamic table capacity and no cookie crumbing. + // No huffman encoding, zero dynamic table capacity and no cookie crumbling. // This can be useful for trading off CPU vs bandwidth when an upstream HTTP/3 connection multiplexes multiple downstream connections. bool disable_qpack = 7; + + // Disables connection level flow control for HTTP/3 streams. This is useful in situations where the streams share the same connection + // but originate from different end-clients, so that each stream can make progress independently at non-front-line proxies. + bool disable_connection_flow_control_for_streams = 8; } // A message to control transformations to the :scheme header message SchemeHeaderTransformation { oneof transformation { // Overwrite any Scheme header with the contents of this string. - // If set, takes precedence over match_upstream. + // If set, takes precedence over ``match_upstream``. string scheme_to_overwrite = 1 [(validate.rules).string = {in: "http" in: "https"}]; } // Set the Scheme header to match the upstream transport protocol. For example, should a - // request be sent to the upstream over TLS, the scheme header will be set to "https". Should the - // request be sent over plaintext, the scheme header will be set to "http". - // If scheme_to_overwrite is set, this field is not used. + // request be sent to the upstream over TLS, the scheme header will be set to ``"https"``. Should the + // request be sent over plaintext, the scheme header will be set to ``"http"``. + // If ``scheme_to_overwrite`` is set, this field is not used. bool match_upstream = 2; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/proxy_protocol.proto b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/proxy_protocol.proto index 564e76cb1e5..2da5fe5fd4d 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/proxy_protocol.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/core/v3/proxy_protocol.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package envoy.config.core.v3; +import "envoy/config/core/v3/substitution_format_string.proto"; + import "udpa/annotations/status.proto"; import "validate/validate.proto"; @@ -37,8 +39,27 @@ message TlvEntry { // The type of the TLV. Must be a uint8 (0-255) as per the Proxy Protocol v2 specification. uint32 type = 1 [(validate.rules).uint32 = {lt: 256}]; - // The value of the TLV. Must be at least one byte long. - bytes value = 2 [(validate.rules).bytes = {min_len: 1}]; + // The static value of the TLV. + // Only one of ``value`` or ``format_string`` may be set. + bytes value = 2; + + // Uses the :ref:`format string ` to dynamically + // populate the TLV value from stream information. This allows dynamic values + // such as metadata, filter state, or other stream properties to be included in + // the TLV. + // + // For example: + // + // .. code-block:: yaml + // + // type: 0xF0 + // format_string: + // text_format_source: + // inline_string: "%DYNAMIC_METADATA(envoy.filters.network:key)%" + // + // The formatted string will be used directly as the TLV value. + // Only one of ``value`` or ``format_string`` may be set. + SubstitutionFormatString format_string = 3; } message ProxyProtocolConfig { @@ -81,6 +102,9 @@ message ProxyProtocolConfig { // at the transport socket level and override them at the host level. // - Any TLV defined in the ``pass_through_tlvs`` field will be overridden by either the host-level // or transport socket-level TLV. + // + // If there are multiple TLVs with the same type, only the TLVs from the highest precedence level + // will be used. repeated TlvEntry added_tlvs = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto index 894f68310a4..a149f6095c1 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/endpoint.proto @@ -113,8 +113,9 @@ message ClusterLoadAssignment { // to determine the health of the priority level, or in other words assume each host has a weight of 1 for // this calculation. // - // Note: this is not currently implemented for - // :ref:`locality weighted load balancing `. + // .. note:: + // This is not currently implemented for + // :ref:`locality weighted load balancing `. bool weighted_priority_health = 6; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto index 32bbfe2d3f6..6d12765cef5 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/endpoint/v3/load_report.proto @@ -38,7 +38,8 @@ message UpstreamLocalityStats { // locality. uint64 total_successful_requests = 2; - // The total number of unfinished requests + // The total number of unfinished requests. A request can be an HTTP request + // or a TCP connection for a TCP connection pool. uint64 total_requests_in_progress = 3; // The total number of requests that failed due to errors at the endpoint, @@ -47,7 +48,8 @@ message UpstreamLocalityStats { // The total number of requests that were issued by this Envoy since // the last report. This information is aggregated over all the - // upstream endpoints in the locality. + // upstream endpoints in the locality. A request can be an HTTP request + // or a TCP connection for a TCP connection pool. uint64 total_issued_requests = 8; // The total number of connections in an established state at the time of the diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener.proto b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener.proto index ff2f79d1137..54ef2cfed38 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener.proto @@ -15,7 +15,6 @@ import "envoy/config/listener/v3/udp_listener_config.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/wrappers.proto"; -import "xds/annotations/v3/status.proto"; import "xds/core/v3/collection_entry.proto"; import "xds/type/matcher/v3/matcher.proto"; @@ -46,6 +45,14 @@ message AdditionalAddress { // or an empty list of :ref:`socket_options `, // it means no socket option will apply. core.v3.SocketOptionsOverride socket_options = 2; + + // Configures TCP keepalive settings for the additional address. + // If not set, the listener :ref:`tcp_keepalive ` + // configuration is inherited. You can explicitly disable TCP keepalive for the additional address by setting any keepalive field + // (:ref:`keepalive_probes `, + // :ref:`keepalive_time `, or + // :ref:`keepalive_interval `) to ``0``. + core.v3.TcpKeepalive tcp_keepalive = 3; } // Listener list collections. Entries are ``Listener`` resources or references. @@ -54,7 +61,7 @@ message ListenerCollection { repeated xds.core.v3.CollectionEntry entries = 1; } -// [#next-free-field: 37] +// [#next-free-field: 38] message Listener { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.Listener"; @@ -141,6 +148,12 @@ message Listener { // that is governed by the bind rules of the OS. E.g., multiple listeners can listen on port 0 on // Linux as the actual port will be allocated by the OS. // Required unless ``api_listener`` or ``listener_specifier`` is populated. + // + // When the address contains a network namespace filepath (via + // :ref:`network_namespace_filepath `), + // Envoy automatically populates the filter state with key ``envoy.network.network_namespace`` + // when a connection is accepted. This provides read-only access to the network namespace for + // filters, access logs, and other components. core.v3.Address address = 2; // The additional addresses the listener should listen on. The addresses must be unique across all @@ -184,8 +197,7 @@ message Listener { // connections bound to the filter chain are not drained. If, however, the // filter chain is removed or structurally modified, then the drain for its // connections is initiated. - xds.type.matcher.v3.Matcher filter_chain_matcher = 32 - [(xds.annotations.v3.field_status).work_in_progress = true]; + xds.type.matcher.v3.Matcher filter_chain_matcher = 32; // If a connection is redirected using ``iptables``, the port on which the proxy // receives it might be different from the original destination address. When this flag is set to @@ -416,6 +428,12 @@ message Listener { // Whether the listener bypasses configured overload manager actions. bool bypass_overload_manager = 35; + + // If set, TCP keepalive settings are configured for the listener address and inherited by + // additional addresses. If not set, TCP keepalive settings are not configured for the + // listener address and additional addresses by default. See :ref:`tcp_keepalive ` + // to explicitly configure TCP keepalive settings for individual additional addresses. + core.v3.TcpKeepalive tcp_keepalive = 37; } // A placeholder proto so that users can explicitly configure the standard diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto index 33eb349fd06..16b43568f39 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/listener_components.proto @@ -233,7 +233,7 @@ message FilterChain { google.protobuf.BoolValue use_proxy_proto = 4 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; - // [#not-implemented-hide:] filter chain metadata. + // Filter chain metadata. core.v3.Metadata metadata = 5; // Optional custom transport socket implementation to use for downstream connections. @@ -250,9 +250,11 @@ message FilterChain { google.protobuf.Duration transport_socket_connect_timeout = 9; // The unique name (or empty) by which this filter chain is known. - // Note: :ref:`filter_chain_matcher - // ` - // requires that filter chains are uniquely named within a listener. + // + // .. note:: + // :ref:`filter_chain_matcher + // ` + // requires that filter chains are uniquely named within a listener. string name = 7; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/quic_config.proto b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/quic_config.proto index 6c0a5bd201f..c208a58f4a4 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/quic_config.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/listener/v3/quic_config.proto @@ -25,7 +25,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // [#protodoc-title: QUIC listener config] // Configuration specific to the UDP QUIC listener. -// [#next-free-field: 14] +// [#next-free-field: 15] message QuicProtocolOptions { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.listener.QuicProtocolOptions"; @@ -99,4 +99,10 @@ message QuicProtocolOptions { // QUIC layer by replying with an empty version negotiation packet to the // client. bool reject_new_connections = 13; + + // Maximum number of QUIC sessions to create per event loop. + // If not specified, the default value is 16. + // This is an equivalent of the TCP listener option + // max_connections_to_accept_per_socket_event. + google.protobuf.UInt32Value max_sessions_per_event_loop = 14 [(validate.rules).uint32 = {gt: 0}]; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto b/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto index e7d7f80d648..0fcf36c1c71 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/metrics/v3/stats.proto @@ -60,11 +60,6 @@ message StatsConfig { // `. They will be processed before // the custom tags. // - // .. note:: - // - // If any default tags are specified twice, the config will be considered - // invalid. - // // See :repo:`well_known_names.h ` for a list of the // default tags in Envoy. // @@ -298,10 +293,12 @@ message HistogramBucketSettings { // Each value is the upper bound of a bucket. Each bucket must be greater than 0 and unique. // The order of the buckets does not matter. repeated double buckets = 2 [(validate.rules).repeated = { - min_items: 1 unique: true items {double {gt: 0.0}} }]; + + // Initial number of bins for the ``circllhist`` thread local histogram per time series. Default value is 100. + google.protobuf.UInt32Value bins = 3 [(validate.rules).uint32 = {lte: 46082 gt: 0}]; } // Stats configuration proto schema for built-in ``envoy.stat_sinks.statsd`` sink. This sink does not support diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto b/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto index 1f267c1863d..b5bc2c4d830 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/overload/v3/overload.proto @@ -109,6 +109,13 @@ message ScaleTimersOverloadActionConfig { // :ref:`HttpConnectionManager.common_http_protocol_options.max_connection_duration // `. HTTP_DOWNSTREAM_CONNECTION_MAX = 4; + + // Adjusts the timeout for the downstream codec to flush an ended stream. + // This affects the value of :ref:`RouteAction.flush_timeout + // ` and + // :ref:`HttpConnectionManager.stream_flush_timeout + // ` + HTTP_DOWNSTREAM_STREAM_FLUSH = 5; } message ScaleTimer { @@ -134,9 +141,16 @@ message OverloadAction { option (udpa.annotations.versioning).previous_message_type = "envoy.config.overload.v2alpha.OverloadAction"; - // The name of the overload action. This is just a well-known string that listeners can - // use for registering callbacks. Custom overload actions should be named using reverse - // DNS to ensure uniqueness. + // The name of the overload action. This is just a well-known string that + // listeners can use for registering callbacks. + // Valid known overload actions include: + // - envoy.overload_actions.stop_accepting_requests + // - envoy.overload_actions.disable_http_keepalive + // - envoy.overload_actions.stop_accepting_connections + // - envoy.overload_actions.reject_incoming_connections + // - envoy.overload_actions.shrink_heap + // - envoy.overload_actions.reduce_timeouts + // - envoy.overload_actions.reset_high_memory_stream string name = 1 [(validate.rules).string = {min_len: 1}]; // A set of triggers for this action. The state of the action is the maximum @@ -148,7 +162,7 @@ message OverloadAction { // in this list. repeated Trigger triggers = 2 [(validate.rules).repeated = {min_items: 1}]; - // Configuration for the action being instantiated. + // Configuration for the action being instantiated if applicable. google.protobuf.Any typed_config = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/rbac/v3/rbac.proto b/xds/third_party/envoy/src/main/proto/envoy/config/rbac/v3/rbac.proto index cdb1267a2c9..ef153ad177b 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/rbac/v3/rbac.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/rbac/v3/rbac.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package envoy.config.rbac.v3; import "envoy/config/core/v3/address.proto"; +import "envoy/config/core/v3/cel.proto"; import "envoy/config/core/v3/extension.proto"; import "envoy/config/route/v3/route_components.proto"; import "envoy/type/matcher/v3/filter_state.proto"; @@ -173,6 +174,7 @@ message RBAC { // A policy matches if and only if at least one of its permissions match the // action taking place AND at least one of its principals match the downstream // AND the condition is true if specified. +// [#next-free-field: 6] message Policy { option (udpa.annotations.versioning).previous_message_type = "envoy.config.rbac.v2.Policy"; @@ -199,6 +201,12 @@ message Policy { // Only be used when condition is not used. google.api.expr.v1alpha1.CheckedExpr checked_condition = 4 [(udpa.annotations.field_migrate).oneof_promotion = "expression_specifier"]; + + // CEL expression configuration that modifies the evaluation behavior of the ``condition`` field. + // If specified, string conversion, concatenation, and manipulation functions may be enabled + // for the CEL expression. See :ref:`CelExpressionConfig ` + // for more details. + core.v3.CelExpressionConfig cel_config = 5; } // SourcedMetadata enables matching against metadata from different sources in the request processing diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route.proto b/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route.proto index c4d507d22b0..5bd909f34c3 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route.proto @@ -23,7 +23,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // * Routing :ref:`architecture overview ` // * HTTP :ref:`router filter ` -// [#next-free-field: 18] +// [#next-free-field: 19] message RouteConfiguration { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.RouteConfiguration"; @@ -129,10 +129,17 @@ message RouteConfiguration { // By default, port in :authority header (if any) is used in host matching. // With this option enabled, Envoy will ignore the port number in the :authority header (if any) when picking VirtualHost. - // NOTE: this option will not strip the port number (if any) contained in route config - // :ref:`envoy_v3_api_msg_config.route.v3.VirtualHost`.domains field. + // + // .. note:: + // This option will not strip the port number (if any) contained in route config + // :ref:`envoy_v3_api_msg_config.route.v3.VirtualHost`.domains field. bool ignore_port_in_host_matching = 14; + // Normally, virtual host matching is done using the :authority (or + // Host: in HTTP < 2) HTTP header. Setting this will instead, use a + // different HTTP header for this purpose. + string vhost_header = 18; + // Ignore path-parameters in path-matching. // Before RFC3986, URI were like(RFC1808): :///;?# // Envoy by default takes ":path" as ";". diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto b/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto index 292e5b93558..4587ef10487 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/route/v3/route_components.proto @@ -2,9 +2,11 @@ syntax = "proto3"; package envoy.config.route.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/proxy_protocol.proto"; +import "envoy/config/core/v3/substitution_format_string.proto"; import "envoy/type/matcher/v3/filter_state.proto"; import "envoy/type/matcher/v3/metadata.proto"; import "envoy/type/matcher/v3/regex.proto"; @@ -41,7 +43,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // host header. This allows a single listener to service multiple top level domain path trees. Once // a virtual host is selected based on the domain, the routes are processed in order to see which // upstream cluster to route to or whether to perform a redirect. -// [#next-free-field: 25] +// [#next-free-field: 26] message VirtualHost { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.VirtualHost"; @@ -78,7 +80,7 @@ message VirtualHost { // .. note:: // // The wildcard will not match the empty string. - // e.g. ``*-bar.foo.com`` will match ``baz-bar.foo.com`` but not ``-bar.foo.com``. + // For example, ``*-bar.foo.com`` will match ``baz-bar.foo.com`` but not ``-bar.foo.com``. // The longest wildcards match first. // Only a single virtual host in the entire route configuration can match on ``*``. A domain // must be unique across all virtual hosts or the config will fail to load. @@ -155,7 +157,7 @@ message VirtualHost { // This field can be used to provide virtual host level per filter config. The key should match the // :ref:`filter config name // `. - // See :ref:`Http filter route specific config ` + // See :ref:`HTTP filter route-specific config ` // for details. // [#comment: An entry's value may be wrapped in a // :ref:`FilterConfig` @@ -166,7 +168,10 @@ message VirtualHost { // ` header should be included // in the upstream request. Setting this option will cause it to override any existing header // value, so in the case of two Envoys on the request path with this option enabled, the upstream - // will see the attempt count as perceived by the second Envoy. Defaults to false. + // will see the attempt count as perceived by the second Envoy. + // + // Defaults to ``false``. + // // This header is unaffected by the // :ref:`suppress_envoy_headers // ` flag. @@ -178,7 +183,10 @@ message VirtualHost { // ` header should be included // in the downstream response. Setting this option will cause the router to override any existing header // value, so in the case of two Envoys on the request path with this option enabled, the downstream - // will see the attempt count as perceived by the Envoy closest upstream from itself. Defaults to false. + // will see the attempt count as perceived by the Envoy closest upstream from itself. + // + // Defaults to ``false``. + // // This header is unaffected by the // :ref:`suppress_envoy_headers // ` flag. @@ -186,29 +194,56 @@ message VirtualHost { // Indicates the retry policy for all routes in this virtual host. Note that setting a // route level entry will take precedence over this config and it'll be treated - // independently (e.g.: values are not inherited). + // independently (e.g., values are not inherited). RetryPolicy retry_policy = 16; // [#not-implemented-hide:] // Specifies the configuration for retry policy extension. Note that setting a route level entry - // will take precedence over this config and it'll be treated independently (e.g.: values are not + // will take precedence over this config and it'll be treated independently (e.g., values are not // inherited). :ref:`Retry policy ` should not be // set if this field is used. google.protobuf.Any retry_policy_typed_config = 20; // Indicates the hedge policy for all routes in this virtual host. Note that setting a // route level entry will take precedence over this config and it'll be treated - // independently (e.g.: values are not inherited). + // independently (e.g., values are not inherited). HedgePolicy hedge_policy = 17; // Decides whether to include the :ref:`x-envoy-is-timeout-retry ` - // request header in retries initiated by per try timeouts. + // request header in retries initiated by per-try timeouts. bool include_is_timeout_retry_header = 23; - // The maximum bytes which will be buffered for retries and shadowing. - // If set and a route-specific limit is not set, the bytes actually buffered will be the minimum - // value of this and the listener per_connection_buffer_limit_bytes. - google.protobuf.UInt32Value per_request_buffer_limit_bytes = 18; + // The maximum bytes which will be buffered for retries and shadowing. If set, the bytes actually buffered will be + // the minimum value of this and the listener ``per_connection_buffer_limit_bytes``. + // + // .. attention:: + // + // This field has been deprecated. Please use :ref:`request_body_buffer_limit + // ` instead. + // Only one of ``per_request_buffer_limit_bytes`` and ``request_body_buffer_limit`` could be set. + google.protobuf.UInt32Value per_request_buffer_limit_bytes = 18 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // The maximum bytes which will be buffered for request bodies to support large request body + // buffering beyond the ``per_connection_buffer_limit_bytes``. + // + // This limit is specifically for the request body buffering and allows buffering larger payloads while maintaining + // flow control. + // + // Buffer limit precedence (from highest to lowest priority): + // + // 1. If ``request_body_buffer_limit`` is set, then ``request_body_buffer_limit`` will be used. + // 2. If :ref:`per_request_buffer_limit_bytes ` + // is set but ``request_body_buffer_limit`` is not, then ``min(per_request_buffer_limit_bytes, per_connection_buffer_limit_bytes)`` + // will be used. + // 3. If neither is set, then ``per_connection_buffer_limit_bytes`` will be used. + // + // For flow control chunk sizes, ``min(per_connection_buffer_limit_bytes, 16KB)`` will be used. + // + // Only one of :ref:`per_request_buffer_limit_bytes ` + // and ``request_body_buffer_limit`` could be set. + google.protobuf.UInt64Value request_body_buffer_limit = 25 + [(validate.rules).message = {required: false}]; // Specify a set of default request mirroring policies for every route under this virtual host. // It takes precedence over the route config mirror policy entirely. @@ -244,7 +279,7 @@ message RouteList { // // Envoy supports routing on HTTP method via :ref:`header matching // `. -// [#next-free-field: 20] +// [#next-free-field: 21] message Route { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.Route"; @@ -297,7 +332,7 @@ message Route { // This field can be used to provide route specific per filter config. The key should match the // :ref:`filter config name // `. - // See :ref:`Http filter route specific config ` + // See :ref:`HTTP filter route-specific config ` // for details. // [#comment: An entry's value may be wrapped in a // :ref:`FilterConfig` @@ -341,7 +376,14 @@ message Route { // The maximum bytes which will be buffered for retries and shadowing. // If set, the bytes actually buffered will be the minimum value of this and the // listener per_connection_buffer_limit_bytes. - google.protobuf.UInt32Value per_request_buffer_limit_bytes = 16; + // + // .. attention:: + // + // This field has been deprecated. Please use :ref:`request_body_buffer_limit + // ` instead. + // Only one of ``per_request_buffer_limit_bytes`` and ``request_body_buffer_limit`` may be set. + google.protobuf.UInt32Value per_request_buffer_limit_bytes = 16 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; // The human readable prefix to use when emitting statistics for this endpoint. // The statistics are rooted at vhost..route.. @@ -355,8 +397,27 @@ message Route { // // We do not recommend setting up a stat prefix for // every application endpoint. This is both not easily maintainable and - // statistics use a non-trivial amount of memory(approximately 1KiB per route). + // statistics use a non-trivial amount of memory (approximately 1KiB per route). string stat_prefix = 19; + + // The maximum bytes which will be buffered for request bodies to support large request body + // buffering beyond the ``per_connection_buffer_limit_bytes``. + // + // This limit is specifically for the request body buffering and allows buffering larger payloads while maintaining + // flow control. + // + // Buffer limit precedence (from highest to lowest priority): + // + // 1. If ``request_body_buffer_limit`` is set: use ``request_body_buffer_limit`` + // 2. If :ref:`per_request_buffer_limit_bytes ` + // is set but ``request_body_buffer_limit`` is not: use ``min(per_request_buffer_limit_bytes, per_connection_buffer_limit_bytes)`` + // 3. If neither is set: use ``per_connection_buffer_limit_bytes`` + // + // For flow control chunk sizes, use ``min(per_connection_buffer_limit_bytes, 16KB)``. + // + // Only one of :ref:`per_request_buffer_limit_bytes ` + // and ``request_body_buffer_limit`` may be set. + google.protobuf.UInt64Value request_body_buffer_limit = 20; } // Compared to the :ref:`cluster ` field that specifies a @@ -365,6 +426,7 @@ message Route { // multiple upstream clusters along with weights that indicate the percentage of // traffic to be forwarded to each cluster. The router selects an upstream cluster based on the // weights. +// [#next-free-field: 6] message WeightedCluster { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.WeightedCluster"; @@ -452,7 +514,7 @@ message WeightedCluster { // This field can be used to provide weighted cluster specific per filter config. The key should match the // :ref:`filter config name // `. - // See :ref:`Http filter route specific config ` + // See :ref:`HTTP filter route-specific config ` // for details. // [#comment: An entry's value may be wrapped in a // :ref:`FilterConfig` @@ -495,6 +557,10 @@ message WeightedCluster { // the process for the consistency. And the value is a unsigned number between 0 and UINT64_MAX. string header_name = 4 [(validate.rules).string = {well_known_regex: HTTP_HEADER_NAME strict: false}]; + + // When set to true, the hash policies will be used to generate the random value for weighted cluster selection. + // This could ensure consistent cluster picking across multiple proxy levels for weighted traffic. + google.protobuf.BoolValue use_hash_policy = 5; } } @@ -513,7 +579,7 @@ message ClusterSpecifierPlugin { bool is_optional = 2; } -// [#next-free-field: 17] +// [#next-free-field: 18] message RouteMatch { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.RouteMatch"; @@ -571,7 +637,7 @@ message RouteMatch { // // [#next-major-version: In the v3 API we should redo how path specification works such // that we utilize StringMatcher, and additionally have consistent options around whether we - // strip query strings, do a case sensitive match, etc. In the interim it will be too disruptive + // strip query strings, do a case-sensitive match, etc. In the interim it will be too disruptive // to deprecate the existing options. We should even consider whether we want to do away with // path_specifier entirely and just rely on a set of header matchers which can already match // on :path, etc. The issue with that is it is unclear how to generically deal with query string @@ -603,7 +669,7 @@ message RouteMatch { core.v3.TypedExtensionConfig path_match_policy = 15; } - // Indicates that prefix/path matching should be case sensitive. The default + // Indicates that prefix/path matching should be case-sensitive. The default // is true. Ignored for safe_regex matching. google.protobuf.BoolValue case_sensitive = 4; @@ -643,14 +709,19 @@ message RouteMatch { // // If query parameters are used to pass request message fields when // `grpc_json_transcoder `_ - // is used, the transcoded message fields maybe different. The query parameters are - // url encoded, but the message fields are not. For example, if a query + // is used, the transcoded message fields may be different. The query parameters are + // URL-encoded, but the message fields are not. For example, if a query // parameter is "foo%20bar", the message field will be "foo bar". repeated QueryParameterMatcher query_parameters = 7; + // Specifies a set of cookies on which the route should match. The router parses the ``Cookie`` + // header and evaluates the named cookie against each matcher. If the number of specified cookie + // matchers is nonzero, they all must match for the route to be selected. + repeated CookieMatcher cookies = 17; + // If specified, only gRPC requests will be matched. The router will check - // that the content-type header has a application/grpc or one of the various - // application/grpc+ values. + // that the ``Content-Type`` header has ``application/grpc`` or one of the various + // ``application/grpc+`` values. GrpcRouteMatchOptions grpc = 8; // If specified, the client tls context will be matched against the defined @@ -736,11 +807,11 @@ message CorsPolicy { google.protobuf.BoolValue allow_private_network_access = 12; // Specifies if preflight requests not matching the configured allowed origin should be forwarded - // to the upstream. Default is true. + // to the upstream. Default is ``true``. google.protobuf.BoolValue forward_not_matching_preflights = 13; } -// [#next-free-field: 42] +// [#next-free-field: 46] message RouteAction { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.RouteAction"; @@ -779,8 +850,8 @@ message RouteAction { // // .. note:: // - // Shadowing doesn't support Http CONNECT and upgrades. - // [#next-free-field: 7] + // Shadowing doesn't support HTTP CONNECT and upgrades. + // [#next-free-field: 9] message RequestMirrorPolicy { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.RouteAction.RequestMirrorPolicy"; @@ -830,8 +901,24 @@ message RouteAction { // is disabled. google.protobuf.BoolValue trace_sampled = 4; - // Disables appending the ``-shadow`` suffix to the shadowed ``Host`` header. Defaults to ``false``. + // Disables appending the ``-shadow`` suffix to the shadowed ``Host`` header. + // + // Defaults to ``false``. bool disable_shadow_host_suffix_append = 6; + + // Specifies a list of header mutations that should be applied to each mirrored request. + // Header mutations are applied in the order they are specified. For more information, including + // details on header value syntax, see the documentation on :ref:`custom request headers + // `. + repeated common.mutation_rules.v3.HeaderMutation request_headers_mutations = 7 + [(validate.rules).repeated = {max_items: 1000}]; + + // Indicates that during mirroring, the host header will be swapped with this value. + // :ref:`disable_shadow_host_suffix_append + // ` + // is implicitly enabled if this field is set. + string host_rewrite_literal = 8 + [(validate.rules).string = {well_known_regex: HTTP_HEADER_VALUE strict: false}]; } // Specifies the route's hashing policy if the upstream cluster uses a hashing :ref:`load balancer @@ -993,13 +1080,15 @@ message RouteAction { bool allow_post = 2; } - // The case-insensitive name of this upgrade, e.g. "websocket". + // The case-insensitive name of this upgrade, for example, "websocket". // For each upgrade type present in upgrade_configs, requests with // Upgrade: [upgrade_type] will be proxied upstream. string upgrade_type = 1 [(validate.rules).string = {min_len: 1 well_known_regex: HTTP_HEADER_VALUE strict: false}]; - // Determines if upgrades are available on this route. Defaults to true. + // Determines if upgrades are available on this route. + // + // Defaults to ``true``. google.protobuf.BoolValue enabled = 2; // Configuration for sending data upstream as a raw data payload. This is used for @@ -1098,9 +1187,11 @@ message RouteAction { // place the original path before rewrite into the :ref:`x-envoy-original-path // ` header. // - // Only one of :ref:`regex_rewrite ` + // Only one of :ref:`regex_rewrite `, // :ref:`path_rewrite_policy `, - // or :ref:`prefix_rewrite ` may be specified. + // :ref:`path_rewrite `, + // or :ref:`prefix_rewrite ` + // may be specified. // // .. attention:: // @@ -1136,8 +1227,9 @@ message RouteAction { // ` header. // // Only one of :ref:`regex_rewrite `, - // :ref:`prefix_rewrite `, or - // :ref:`path_rewrite_policy `] + // :ref:`path_rewrite_policy `, + // :ref:`path_rewrite `, + // or :ref:`prefix_rewrite ` // may be specified. // // Examples using Google's `RE2 `_ engine: @@ -1161,6 +1253,33 @@ message RouteAction { // [#extension-category: envoy.path.rewrite] core.v3.TypedExtensionConfig path_rewrite_policy = 41; + // Rewrites the whole path (without query parameters) with the given path value. + // The router filter will + // place the original path before rewrite into the :ref:`x-envoy-original-path + // ` header. + // + // Only one of :ref:`regex_rewrite `, + // :ref:`path_rewrite_policy `, + // :ref:`path_rewrite `, + // or :ref:`prefix_rewrite ` + // may be specified. + // + // The :ref:`substitution format specifier ` could be applied here. + // For example, with the following config: + // + // .. code-block:: yaml + // + // path_rewrite: "/new_path_prefix%REQ(custom-path-header-name)%" + // + // Would rewrite the path to ``/new_path_prefix/some_value`` given the header + // ``custom-path-header-name: some_value``. If the header is not present, the path will be + // rewritten to ``/new_path_prefix``. + // + // + // If the final output of the path rewrite is empty, then the update will be ignored and the + // original path will be preserved. + string path_rewrite = 45; + // If one of the host rewrite specifiers is set and the // :ref:`suppress_envoy_headers // ` flag is not @@ -1219,6 +1338,25 @@ message RouteAction { // // Would rewrite the host header to ``envoyproxy.io`` given the path ``/envoyproxy.io/some/path``. type.matcher.v3.RegexMatchAndSubstitute host_rewrite_path_regex = 35; + + // Rewrites the host header with the value of this field. The router filter will + // place the original host header value before rewriting into the :ref:`x-envoy-original-host + // ` header. + // + // The :ref:`substitution format specifier ` could be applied here. + // For example, with the following config: + // + // .. code-block:: yaml + // + // host_rewrite: "prefix-%REQ(custom-host-header-name)%" + // + // Would rewrite the host header to ``prefix-some_value`` given the header + // ``custom-host-header-name: some_value``. If the header is not present, the host header will + // be rewritten to an value of ``prefix-``. + // + // If the final output of the host rewrite is empty, then the update will be ignored and the + // original host header will be preserved. + string host_rewrite = 44; } // If set, then a host rewrite action (one of @@ -1265,8 +1403,28 @@ message RouteAction { // If the :ref:`overload action ` "envoy.overload_actions.reduce_timeouts" // is configured, this timeout is scaled according to the value for // :ref:`HTTP_DOWNSTREAM_STREAM_IDLE `. + // + // This timeout may also be used in place of ``flush_timeout`` in very specific cases. See the + // documentation for ``flush_timeout`` for more details. google.protobuf.Duration idle_timeout = 24; + // Specifies the codec stream flush timeout for the route. + // + // If not specified, the first preference is the global :ref:`stream_flush_timeout + // `, + // but only if explicitly configured. + // + // If neither the explicit HCM-wide flush timeout nor this route-specific flush timeout is configured, + // the route's stream idle timeout is reused for this timeout. This is for + // backwards compatibility since both behaviors were historically controlled by the one timeout. + // + // If the route also does not have an idle timeout configured, the global :ref:`stream_idle_timeout + // `. used, again + // for backwards compatibility. That timeout defaults to 5 minutes. + // + // A value of 0 via any of the above paths will completely disable the timeout for a given route. + google.protobuf.Duration flush_timeout = 42; + // Specifies how to send request over TLS early data. // If absent, allows `safe HTTP requests `_ to be sent on early data. // [#extension-category: envoy.route.early_data_policy] @@ -1274,13 +1432,13 @@ message RouteAction { // Indicates that the route has a retry policy. Note that if this is set, // it'll take precedence over the virtual host level retry policy entirely - // (e.g.: policies are not merged, most internal one becomes the enforced policy). + // (e.g., policies are not merged, the most internal one becomes the enforced policy). RetryPolicy retry_policy = 9; // [#not-implemented-hide:] // Specifies the configuration for retry policy extension. Note that if this is set, it'll take - // precedence over the virtual host level retry policy entirely (e.g.: policies are not merged, - // most internal one becomes the enforced policy). :ref:`Retry policy ` + // precedence over the virtual host level retry policy entirely (e.g., policies are not merged, + // the most internal one becomes the enforced policy). :ref:`Retry policy ` // should not be set if this field is used. google.protobuf.Any retry_policy_typed_config = 33; @@ -1301,7 +1459,9 @@ message RouteAction { // :ref:`rate_limits ` are not applied to the // request. // - // This field is deprecated. Please use :ref:`vh_rate_limits ` + // .. attention:: + // + // This field is deprecated. Please use :ref:`vh_rate_limits ` google.protobuf.BoolValue include_vh_rate_limits = 14 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; @@ -1395,7 +1555,7 @@ message RouteAction { // Indicates that the route has a hedge policy. Note that if this is set, // it'll take precedence over the virtual host level hedge policy entirely - // (e.g.: policies are not merged, most internal one becomes the enforced policy). + // (e.g., policies are not merged, the most internal one becomes the enforced policy). HedgePolicy hedge_policy = 27; // Specifies the maximum stream duration for this route. @@ -1529,7 +1689,9 @@ message RetryPolicy { // Specifies the maximum back off interval that Envoy will allow. If a reset // header contains an interval longer than this then it will be discarded and - // the next header will be tried. Defaults to 300 seconds. + // the next header will be tried. + // + // Defaults to 300 seconds. google.protobuf.Duration max_interval = 2 [(validate.rules).duration = {gt {}}]; } @@ -1558,7 +1720,7 @@ message RetryPolicy { google.protobuf.Duration per_try_timeout = 3; // Specifies an upstream idle timeout per retry attempt (including the initial attempt). This - // parameter is optional and if absent there is no per try idle timeout. The semantics of the per + // parameter is optional and if absent there is no per-try idle timeout. The semantics of the per- // try idle timeout are similar to the // :ref:`route idle timeout ` and // :ref:`stream idle timeout @@ -1633,12 +1795,14 @@ message HedgePolicy { // Specifies the number of initial requests that should be sent upstream. // Must be at least 1. + // // Defaults to 1. // [#not-implemented-hide:] google.protobuf.UInt32Value initial_requests = 1 [(validate.rules).uint32 = {gte: 1}]; // Specifies a probability that an additional upstream request should be sent // on top of what is specified by initial_requests. + // // Defaults to 0. // [#not-implemented-hide:] type.v3.FractionalPercent additional_request_chance = 2; @@ -1648,14 +1812,16 @@ message HedgePolicy { // The first request to complete successfully will be the one returned to the caller. // // * At any time, a successful response (i.e. not triggering any of the retry-on conditions) would be returned to the client. - // * Before per-try timeout, an error response (per retry-on conditions) would be retried immediately or returned ot the client + // * Before per-try timeout, an error response (per retry-on conditions) would be retried immediately or returned to the client // if there are no more retries left. // * After per-try timeout, an error response would be discarded, as a retry in the form of a hedged request is already in progress. // - // Note: For this to have effect, you must have a :ref:`RetryPolicy ` that retries at least - // one error code and specifies a maximum number of retries. + // .. note:: + // + // For this to have effect, you must have a :ref:`RetryPolicy ` that retries at least + // one error code and specifies a maximum number of retries. // - // Defaults to false. + // Defaults to ``false``. bool hedge_on_per_try_timeout = 3; } @@ -1782,6 +1948,12 @@ message DirectResponseAction { // :ref:`envoy_v3_api_msg_config.route.v3.Route`, :ref:`envoy_v3_api_msg_config.route.v3.RouteConfiguration` or // :ref:`envoy_v3_api_msg_config.route.v3.VirtualHost`. core.v3.DataSource body = 2; + + // Specifies a format string for the response body. If present, the contents of + // ``body_format`` will be formatted and used as the response body, where the + // contents of ``body`` (may be empty) will be passed as the variable ``%LOCAL_REPLY_BODY%``. + // If neither are provided, no body is included in the generated response. + core.v3.SubstitutionFormatString body_format = 3; } // [#not-implemented-hide:] @@ -1801,10 +1973,11 @@ message Decorator { // ` header. string operation = 1 [(validate.rules).string = {min_len: 1}]; - // Whether the decorated details should be propagated to the other party. The default is true. + // Whether the decorated details should be propagated to the other party. The default is ``true``. google.protobuf.BoolValue propagate = 2; } +// [#next-free-field: 7] message Tracing { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.Tracing"; @@ -1840,6 +2013,34 @@ message Tracing { // each in the HTTP connection manager and the route level, the one configured here takes // priority. repeated type.tracing.v3.CustomTag custom_tags = 4; + + // The operation name of the span which will be used for tracing. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + // + // This field will take precedence over and make following settings ineffective: + // + // * :ref:`route decorator `. + // * :ref:`x-envoy-decorator-operation `. + // * :ref:`HCM tracing operation + // `. + string operation = 5; + + // The operation name of the upstream span which will be used for tracing. + // This only takes effect when ``spawn_upstream_span`` is set to true and the upstream + // span is created. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + // + // This field will take precedence over and make following settings ineffective: + // + // * :ref:`HCM tracing upstream operation + // ` + string upstream_operation = 6; } // A virtual cluster is a way of specifying a regex matching rule against @@ -1966,7 +2167,7 @@ message RateLimit { // the value of the descriptor entry for the descriptor_key. string query_parameter_name = 1 [(validate.rules).string = {min_len: 1}]; - // The key to use when creating the rate limit descriptor entry. his descriptor key will be used to identify the + // The key to use when creating the rate limit descriptor entry. This descriptor key will be used to identify the // rate limit rule in the rate limiting service. string descriptor_key = 2 [(validate.rules).string = {min_len: 1}]; @@ -2004,14 +2205,18 @@ message RateLimit { // ("masked_remote_address", "") message MaskedRemoteAddress { // Length of prefix mask len for IPv4 (e.g. 0, 32). + // // Defaults to 32 when unset. + // // For example, trusted address from x-forwarded-for is ``192.168.1.1``, // the descriptor entry is ("masked_remote_address", "192.168.1.1/32"); // if mask len is 24, the descriptor entry is ("masked_remote_address", "192.168.1.0/24"). google.protobuf.UInt32Value v4_prefix_mask_len = 1 [(validate.rules).uint32 = {lte: 32}]; // Length of prefix mask len for IPv6 (e.g. 0, 128). + // // Defaults to 128 when unset. + // // For example, trusted address from x-forwarded-for is ``2001:abcd:ef01:2345:6789:abcd:ef01:234``, // the descriptor entry is ("masked_remote_address", "2001:abcd:ef01:2345:6789:abcd:ef01:234/128"); // if mask len is 64, the descriptor entry is ("masked_remote_address", "2001:abcd:ef01:2345::/64"). @@ -2027,9 +2232,40 @@ message RateLimit { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.RateLimit.Action.GenericKey"; - // The value to use in the descriptor entry. + // Descriptor value of entry. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + // + // .. note:: + // + // Formatter parsing is controlled by the runtime feature flag + // ``envoy.reloadable_features.enable_formatter_for_ratelimit_action_descriptor_value`` + // (disabled by default). + // + // When enabled: The format string can contain multiple valid substitution + // fields. If multiple substitution fields are present, their results will be concatenated + // to form the final descriptor value. If it contains no substitution fields, the value + // will be used as is. If the final concatenated result is empty and ``default_value`` is set, + // the ``default_value`` will be used. If ``default_value`` is not set and the result is + // empty, this descriptor will be skipped and not included in the rate limit call. + // + // When disabled (default): The descriptor_value is used as a literal string without any formatter + // parsing or substitution. + // + // For example, ``static_value`` will be used as is since there are no substitution fields. + // ``%REQ(:method)%`` will be replaced with the HTTP method, and + // ``%REQ(:method)%%REQ(:path)%`` will be replaced with the concatenation of the HTTP method and path. + // ``%CEL(request.headers['user-id'])%`` will use CEL to extract the user ID from request headers. + // string descriptor_value = 1 [(validate.rules).string = {min_len: 1}]; + // An optional value to use if the final concatenated ``descriptor_value`` result is empty. + // Only applicable when formatter parsing is enabled by the runtime feature flag + // ``envoy.reloadable_features.enable_formatter_for_ratelimit_action_descriptor_value`` (disabled by default). + string default_value = 3; + // An optional key to use in the descriptor entry. If not set it defaults // to 'generic_key' as the descriptor key. string descriptor_key = 2; @@ -2040,16 +2276,51 @@ message RateLimit { // .. code-block:: cpp // // ("header_match", "") + // [#next-free-field: 6] message HeaderValueMatch { option (udpa.annotations.versioning).previous_message_type = "envoy.api.v2.route.RateLimit.Action.HeaderValueMatch"; - // The key to use in the descriptor entry. Defaults to ``header_match``. - string descriptor_key = 4; - - // The value to use in the descriptor entry. + // Descriptor value of entry. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + // + // .. note:: + // + // Formatter parsing is controlled by the runtime feature flag + // ``envoy.reloadable_features.enable_formatter_for_ratelimit_action_descriptor_value`` + // (disabled by default). + // + // When enabled: The format string can contain multiple valid substitution + // fields. If multiple substitution fields are present, their results will be concatenated + // to form the final descriptor value. If it contains no substitution fields, the value + // will be used as is. All substitution fields will be evaluated and their results + // concatenated. If the final concatenated result is empty and ``default_value`` is set, + // the ``default_value`` will be used. If ``default_value`` is not set and the result is + // empty, this descriptor will be skipped and not included in the rate limit call. + // + // When disabled (default): The descriptor_value is used as a literal string without any formatter + // parsing or substitution. + // + // For example, ``static_value`` will be used as is since there are no substitution fields. + // ``%REQ(:method)%`` will be replaced with the HTTP method, and + // ``%REQ(:method)%%REQ(:path)%`` will be replaced with the concatenation of the HTTP method and path. + // ``%CEL(request.headers['user-id'])%`` will use CEL to extract the user ID from request headers. + // string descriptor_value = 1 [(validate.rules).string = {min_len: 1}]; + // An optional value to use if the final concatenated ``descriptor_value`` result is empty. + // Only applicable when formatter parsing is enabled by the runtime feature flag + // ``envoy.reloadable_features.enable_formatter_for_ratelimit_action_descriptor_value`` (disabled by default). + string default_value = 5; + + // The key to use in the descriptor entry. + // + // Defaults to ``header_match``. + string descriptor_key = 4; + // If set to true, the action will append a descriptor entry when the // request matches the headers. If set to false, the action will append a // descriptor entry when the request does not match the headers. The @@ -2057,7 +2328,7 @@ message RateLimit { google.protobuf.BoolValue expect_match = 2; // Specifies a set of headers that the rate limit action should match - // on. The action will check the request’s headers against all the + // on. The action will check the request's headers against all the // specified headers in the config. A match will happen if all the // headers in the config are present in the request with the same values // (or based on presence if the value field is not in the config). @@ -2137,13 +2408,48 @@ message RateLimit { // .. code-block:: cpp // // ("query_match", "") + // [#next-free-field: 6] message QueryParameterValueMatch { - // The key to use in the descriptor entry. Defaults to ``query_match``. - string descriptor_key = 4; - - // The value to use in the descriptor entry. + // Descriptor value of entry. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + // + // .. note:: + // + // Formatter parsing is controlled by the runtime feature flag + // ``envoy.reloadable_features.enable_formatter_for_ratelimit_action_descriptor_value`` + // (disabled by default). + // + // When enabled: The format string can contain multiple valid substitution + // fields. If multiple substitution fields are present, their results will be concatenated + // to form the final descriptor value. If it contains no substitution fields, the value + // will be used as is. All substitution fields will be evaluated and their results + // concatenated. If the final concatenated result is empty and ``default_value`` is set, + // the ``default_value`` will be used. If ``default_value`` is not set and the result is + // empty, this descriptor will be skipped and not included in the rate limit call. + // + // When disabled (default): The descriptor_value is used as a literal string without any formatter + // parsing or substitution. + // + // For example, ``static_value`` will be used as is since there are no substitution fields. + // ``%REQ(:method)%`` will be replaced with the HTTP method, and + // ``%REQ(:method)%%REQ(:path)%`` will be replaced with the concatenation of the HTTP method and path. + // ``%CEL(request.headers['user-id'])%`` will use CEL to extract the user ID from request headers. + // string descriptor_value = 1 [(validate.rules).string = {min_len: 1}]; + // An optional value to use if the final concatenated ``descriptor_value`` result is empty. + // Only applicable when formatter parsing is enabled by the runtime feature flag + // ``envoy.reloadable_features.enable_formatter_for_ratelimit_action_descriptor_value`` (disabled by default). + string default_value = 5; + + // The key to use in the descriptor entry. + // + // Defaults to ``query_match``. + string descriptor_key = 4; + // If set to true, the action will append a descriptor entry when the // request matches the headers. If set to false, the action will append a // descriptor entry when the request does not match the headers. The @@ -2151,7 +2457,7 @@ message RateLimit { google.protobuf.BoolValue expect_match = 2; // Specifies a set of query parameters that the rate limit action should match - // on. The action will check the request’s query parameters against all the + // on. The action will check the request's query parameters against all the // specified query parameters in the config. A match will happen if all the // query parameters in the config are present in the request with the same values // (or based on presence if the value field is not in the config). @@ -2368,14 +2674,20 @@ message HeaderMatcher { // Specifies how the header match will be performed to route the request. oneof header_match_specifier { // If specified, header match will be performed based on the value of the header. - // This field is deprecated. Please use :ref:`string_match `. + // + // .. attention:: + // + // This field is deprecated. Please use :ref:`string_match `. string exact_match = 4 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; // If specified, this regex string is a regular expression rule which implies the entire request // header value must match the regex. The rule will not match if only a subsequence of the // request header value matches the regex. - // This field is deprecated. Please use :ref:`string_match `. + // + // .. attention:: + // + // This field is deprecated. Please use :ref:`string_match `. type.matcher.v3.RegexMatcher safe_regex_match = 11 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; @@ -2397,8 +2709,14 @@ message HeaderMatcher { bool present_match = 7; // If specified, header match will be performed based on the prefix of the header value. - // Note: empty prefix is not allowed, please use present_match instead. - // This field is deprecated. Please use :ref:`string_match `. + // + // .. note:: + // + // Empty prefix is not allowed. Please use ``present_match`` instead. + // + // .. attention:: + // + // This field is deprecated. Please use :ref:`string_match `. // // Examples: // @@ -2410,8 +2728,14 @@ message HeaderMatcher { ]; // If specified, header match will be performed based on the suffix of the header value. - // Note: empty suffix is not allowed, please use present_match instead. - // This field is deprecated. Please use :ref:`string_match `. + // + // .. note:: + // + // Empty suffix is not allowed. Please use ``present_match`` instead. + // + // .. attention:: + // + // This field is deprecated. Please use :ref:`string_match `. // // Examples: // @@ -2424,8 +2748,14 @@ message HeaderMatcher { // If specified, header match will be performed based on whether the header value contains // the given value or not. - // Note: empty contains match is not allowed, please use present_match instead. - // This field is deprecated. Please use :ref:`string_match `. + // + // .. note:: + // + // Empty contains match is not allowed. Please use ``present_match`` instead. + // + // .. attention:: + // + // This field is deprecated. Please use :ref:`string_match `. // // Examples: // @@ -2440,7 +2770,9 @@ message HeaderMatcher { type.matcher.v3.StringMatcher string_match = 13; } - // If specified, the match result will be inverted before checking. Defaults to false. + // If specified, the match result will be inverted before checking. + // + // Defaults to ``false``. // // Examples: // @@ -2449,7 +2781,9 @@ message HeaderMatcher { bool invert_match = 8; // If specified, for any header match rule, if the header match rule specified header - // does not exist, this header value will be treated as empty. Defaults to false. + // does not exist, this header value will be treated as empty. + // + // Defaults to ``false``. // // Examples: // @@ -2501,6 +2835,20 @@ message QueryParameterMatcher { } } +// Cookie matching inspects individual name/value pairs parsed from the ``Cookie`` header. +message CookieMatcher { + // Specifies the cookie name to evaluate. + string name = 1 [(validate.rules).string = {min_len: 1 max_bytes: 1024}]; + + // Match the cookie value using :ref:`StringMatcher + // ` semantics. + type.matcher.v3.StringMatcher string_match = 2 [(validate.rules).message = {required: true}]; + + // Invert the match result. If the cookie is not present, the match result is false, so + // ``invert_match`` will cause the matcher to succeed when the cookie is absent. + bool invert_match = 3; +} + // HTTP Internal Redirect :ref:`architecture overview `. // [#next-free-field: 6] message InternalRedirectPolicy { @@ -2526,7 +2874,7 @@ message InternalRedirectPolicy { repeated core.v3.TypedExtensionConfig predicates = 3; // Allow internal redirect to follow a target URI with a different scheme than the value of - // x-forwarded-proto. The default is false. + // x-forwarded-proto. The default is ``false``. bool allow_cross_scheme_redirect = 4; // Specifies a list of headers, by name, to copy from the internal redirect into the subsequent @@ -2566,6 +2914,5 @@ message FilterConfig { // initial route will not be added back to the filter chain because the filter chain is already // created and it is too late to change the chain. // - // This field only make sense for the downstream HTTP filters for now. bool disabled = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto index 59028326f22..5260d9bd6af 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/opentelemetry.proto @@ -6,6 +6,8 @@ import "envoy/config/core/v3/extension.proto"; import "envoy/config/core/v3/grpc_service.proto"; import "envoy/config/core/v3/http_service.proto"; +import "google/protobuf/wrappers.proto"; + import "udpa/annotations/migrate.proto"; import "udpa/annotations/status.proto"; @@ -19,7 +21,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // Configuration for the OpenTelemetry tracer. // [#extension: envoy.tracers.opentelemetry] -// [#next-free-field: 6] +// [#next-free-field: 7] message OpenTelemetryConfig { // The upstream gRPC cluster that will receive OTLP traces. // Note that the tracer drops traces if the server does not read data fast enough. @@ -57,4 +59,9 @@ message OpenTelemetryConfig { // See: `OpenTelemetry sampler specification `_ // [#extension-category: envoy.tracers.opentelemetry.samplers] core.v3.TypedExtensionConfig sampler = 5; + + // Envoy caches the span in memory when the OpenTelemetry backend service is temporarily unavailable. + // This field specifies the maximum number of spans that can be cached. If not specified, the + // default is 1024. + google.protobuf.UInt32Value max_cache_size = 6; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto index 2d8f3195c31..2364983efc5 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/config/trace/v3/zipkin.proto @@ -2,13 +2,14 @@ syntax = "proto3"; package envoy.config.trace.v3; +import "envoy/config/core/v3/http_service.proto"; + import "google/protobuf/wrappers.proto"; import "envoy/annotations/deprecation.proto"; import "udpa/annotations/migrate.proto"; import "udpa/annotations/status.proto"; import "udpa/annotations/versioning.proto"; -import "validate/validate.proto"; option java_package = "io.envoyproxy.envoy.config.trace.v3"; option java_outer_classname = "ZipkinProto"; @@ -21,10 +22,22 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // Configuration for the Zipkin tracer. // [#extension: envoy.tracers.zipkin] -// [#next-free-field: 8] +// [#next-free-field: 10] message ZipkinConfig { option (udpa.annotations.versioning).previous_message_type = "envoy.config.trace.v2.ZipkinConfig"; + // Available trace context options for handling different trace header formats. + enum TraceContextOption { + // Use B3 headers only (default behavior). + USE_B3 = 0; + + // Enable B3 and W3C dual header support: + // - For downstream: Extract from B3 headers first, fallback to W3C traceparent if B3 is unavailable. + // - For upstream: Inject both B3 and W3C traceparent headers. + // When this option is NOT set, only B3 headers are used for both extraction and injection. + USE_B3_WITH_W3C_PROPAGATION = 1; + } + // Available Zipkin collector endpoint versions. enum CollectorEndpointVersion { // Zipkin API v1, JSON over HTTP. @@ -48,11 +61,23 @@ message ZipkinConfig { } // The cluster manager cluster that hosts the Zipkin collectors. - string collector_cluster = 1 [(validate.rules).string = {min_len: 1}]; + // + // .. note:: + // This field will be deprecated in future releases in favor of + // :ref:`collector_service `. + // + // Either this field or ``collector_service`` must be specified. + string collector_cluster = 1; // The API endpoint of the Zipkin service where the spans will be sent. When // using a standard Zipkin installation. - string collector_endpoint = 2 [(validate.rules).string = {min_len: 1}]; + // + // .. note:: + // This field will be deprecated in future releases in favor of + // :ref:`collector_service `. + // + // Required when using ``collector_cluster``. + string collector_endpoint = 2; // Determines whether a 128bit trace id will be used when creating a new // trace instance. The default value is false, which will result in a 64 bit trace id being used. @@ -67,6 +92,10 @@ message ZipkinConfig { // Optional hostname to use when sending spans to the collector_cluster. Useful for collectors // that require a specific hostname. Defaults to :ref:`collector_cluster ` above. + // + // .. note:: + // This field will be deprecated in future releases in favor of + // :ref:`collector_service `. string collector_hostname = 6; // If this is set to true, then Envoy will be treated as an independent hop in trace chain. A complete span pair will be created for a single @@ -88,4 +117,60 @@ message ZipkinConfig { // Please use that ``spawn_upstream_span`` field to control the span creation. bool split_spans_for_request = 7 [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // Determines which trace context format to use for trace header extraction and propagation. + // This controls both downstream request header extraction and upstream request header injection. + // Here is the spec for W3C trace headers: https://www.w3.org/TR/trace-context/ + // The default value is USE_B3 to maintain backward compatibility. + TraceContextOption trace_context_option = 8; + + // HTTP service configuration for the Zipkin collector. + // When specified, this configuration takes precedence over the legacy fields: + // collector_cluster, collector_endpoint, and collector_hostname. + // This provides a complete HTTP service configuration including cluster, URI, timeout, and headers. + // If not specified, the legacy fields above will be used for backward compatibility. + // + // Required fields when using collector_service: + // + // * ``http_uri.cluster`` - Must be specified and non-empty + // * ``http_uri.uri`` - Must be specified and non-empty + // * ``http_uri.timeout`` - Optional + // + // Full URI Support with Automatic Parsing: + // + // The ``uri`` field supports both path-only and full URI formats: + // + // .. code-block:: yaml + // + // tracing: + // provider: + // name: envoy.tracers.zipkin + // typed_config: + // "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig + // collector_service: + // http_uri: + // # Full URI format - hostname and path are extracted automatically + // uri: "https://zipkin-collector.example.com/api/v2/spans" + // cluster: zipkin + // timeout: 5s + // request_headers_to_add: + // - header: + // key: "X-Custom-Token" + // value: "your-custom-token" + // - header: + // key: "X-Service-ID" + // value: "your-service-id" + // + // URI Parsing Behavior: + // + // * Full URI: ``"https://zipkin-collector.example.com/api/v2/spans"`` + // + // * Hostname: ``zipkin-collector.example.com`` (sets HTTP ``Host`` header) + // * Path: ``/api/v2/spans`` (sets HTTP request path) + // + // * Path only: ``"/api/v2/spans"`` + // + // * Hostname: Uses cluster name as fallback + // * Path: ``/api/v2/spans`` + core.v3.HttpService collector_service = 9; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/common/matching/v3/extension_matcher.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/common/matching/v3/extension_matcher.proto new file mode 100644 index 00000000000..817cd27a37a --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/common/matching/v3/extension_matcher.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; + +package envoy.extensions.common.matching.v3; + +import "envoy/config/common/matcher/v3/matcher.proto"; +import "envoy/config/core/v3/extension.proto"; + +import "xds/type/matcher/v3/matcher.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.common.matching.v3"; +option java_outer_classname = "ExtensionMatcherProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/common/matching/v3;matchingv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Extension matcher] + +// Wrapper around an existing extension that provides an associated matcher. This allows +// decorating an existing extension with a matcher, which can be used to match against +// relevant protocol data. +message ExtensionWithMatcher { + // The associated matcher. This is deprecated in favor of xds_matcher. + config.common.matcher.v3.Matcher matcher = 1 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // The associated matcher. + xds.type.matcher.v3.Matcher xds_matcher = 3; + + // The underlying extension config. + config.core.v3.TypedExtensionConfig extension_config = 2 + [(validate.rules).message = {required: true}]; +} + +// Extra settings on a per virtualhost/route/weighted-cluster level. +message ExtensionWithMatcherPerRoute { + // Matcher override. + xds.type.matcher.v3.Matcher xds_matcher = 1; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/composite/v3/composite.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/composite/v3/composite.proto new file mode 100644 index 00000000000..1ab6c5eb1ef --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/composite/v3/composite.proto @@ -0,0 +1,106 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.composite.v3; + +import "envoy/config/core/v3/base.proto"; +import "envoy/config/core/v3/config_source.proto"; +import "envoy/config/core/v3/extension.proto"; + +import "udpa/annotations/migrate.proto"; +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.composite.v3"; +option java_outer_classname = "CompositeProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/composite/v3;compositev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Composite] +// Composite Filter :ref:`configuration overview `. +// [#extension: envoy.filters.http.composite] + +// :ref:`Composite filter ` config. The composite filter config +// allows delegating filter handling to another filter as determined by matching on the request +// headers. This makes it possible to use different filters or filter configurations based on the +// incoming request. +// +// This is intended to be used with +// :ref:`ExtensionWithMatcher ` +// where a match tree is specified that indicates (via +// :ref:`ExecuteFilterAction `) +// which filter configuration to create and delegate to. +message Composite { + // Named filter chain definitions that can be referenced from + // :ref:`ExecuteFilterAction.filter_chain_name + // `. + // The filter chains are compiled at configuration time and can be referenced by name. + // This is useful when the same filter chain needs to be applied across many routes, + // as it avoids duplicating the filter chain configuration. + map named_filter_chains = 1; +} + +// A list of filter configurations to be called in order. Note that this can be used as the type +// inside of an ECDS :ref:`TypedExtensionConfig +// ` extension, which allows a chain of +// filters to be configured dynamically. In that case, the types of all filters in the chain must +// be present in the :ref:`ExtensionConfigSource.type_urls +// ` field. +message FilterChainConfiguration { + repeated config.core.v3.TypedExtensionConfig typed_config = 1; +} + +// Configuration for an extension configuration discovery service with name. +message DynamicConfig { + // The name of the extension configuration. It also serves as a resource name in ExtensionConfigDS. + // The resource type in the ``DiscoveryRequest`` will be :ref:`TypedExtensionConfig + // `. + string name = 1 [(validate.rules).string = {min_len: 1}]; + + // Configuration source specifier for an extension configuration discovery + // service. In case of a failure and without the default configuration, + // 500(Internal Server Error) will be returned. + config.core.v3.ExtensionConfigSource config_discovery = 2; +} + +// Composite match action (see :ref:`matching docs ` for more info on match actions). +// This specifies the filter configuration of the filter that the composite filter should delegate filter interactions to. +// [#next-free-field: 6] +message ExecuteFilterAction { + // Filter specific configuration which depends on the filter being + // instantiated. See the supported filters for further documentation. + // Only one of ``typed_config``, ``dynamic_config``, ``filter_chain``, or ``filter_chain_name`` + // can be set. + // [#extension-category: envoy.filters.http] + config.core.v3.TypedExtensionConfig typed_config = 1 + [(udpa.annotations.field_migrate).oneof_promotion = "config_type"]; + + // Dynamic configuration of filter obtained via extension configuration discovery service. + // Only one of ``typed_config``, ``dynamic_config``, ``filter_chain``, or ``filter_chain_name`` + // can be set. + DynamicConfig dynamic_config = 2 + [(udpa.annotations.field_migrate).oneof_promotion = "config_type"]; + + // An inlined list of filter configurations. The specified filters will be executed in order. + // Only one of ``typed_config``, ``dynamic_config``, ``filter_chain``, or ``filter_chain_name`` + // can be set. + FilterChainConfiguration filter_chain = 4; + + // The name of a filter chain defined in + // :ref:`Composite.named_filter_chains + // `. + // At runtime, if the named filter chain is not found in the Composite filter's configuration, + // no filter will be applied for this match (the action is silently skipped). + // Only one of ``typed_config``, ``dynamic_config``, ``filter_chain``, or ``filter_chain_name`` + // can be set. + string filter_chain_name = 5; + + // Probability of the action execution. If not specified, this is 100%. + // This allows sampling behavior for the configured actions. + // For example, if + // :ref:`default_value ` + // under the ``sample_percent`` is configured with 30%, a dice roll with that + // probability is done. The underline action will only be executed if the + // dice roll returns positive. Otherwise, the action is skipped. + config.core.v3.RuntimeFractionalPercent sample_percent = 3; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto new file mode 100644 index 00000000000..7f70b70013b --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto @@ -0,0 +1,602 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.ext_authz.v3; + +import "envoy/config/common/mutation_rules/v3/mutation_rules.proto"; +import "envoy/config/core/v3/base.proto"; +import "envoy/config/core/v3/config_source.proto"; +import "envoy/config/core/v3/grpc_service.proto"; +import "envoy/config/core/v3/http_uri.proto"; +import "envoy/type/matcher/v3/metadata.proto"; +import "envoy/type/matcher/v3/string.proto"; +import "envoy/type/v3/http_status.proto"; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/sensitive.proto"; +import "udpa/annotations/status.proto"; +import "udpa/annotations/versioning.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.ext_authz.v3"; +option java_outer_classname = "ExtAuthzProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_authz/v3;ext_authzv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: External Authorization] +// External Authorization :ref:`configuration overview `. +// [#extension: envoy.filters.http.ext_authz] + +// [#next-free-field: 32] +message ExtAuthz { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v3.ExtAuthz"; + + reserved 4; + + reserved "use_alpha"; + + // External authorization service configuration. + oneof services { + // gRPC service configuration (default timeout: 200ms). + config.core.v3.GrpcService grpc_service = 1; + + // HTTP service configuration (default timeout: 200ms). + HttpService http_service = 3; + } + + // API version for ext_authz transport protocol. This describes the ext_authz gRPC endpoint and + // version of messages used on the wire. + config.core.v3.ApiVersion transport_api_version = 12 + [(validate.rules).enum = {defined_only: true}]; + + // Changes the filter's behavior on errors: + // + // * When set to ``true``, the filter will ``accept`` the client request even if communication with + // the authorization service has failed, or if the authorization service has returned an HTTP 5xx + // error. + // + // * When set to ``false``, the filter will ``reject`` client requests and return ``Forbidden`` + // if communication with the authorization service has failed, or if the authorization service + // has returned an HTTP 5xx error. + // + // Errors can always be tracked in the :ref:`stats `. + // + // Defaults to ``false``. + bool failure_mode_allow = 2; + + // When ``failure_mode_allow`` and ``failure_mode_allow_header_add`` are both set to ``true``, + // ``x-envoy-auth-failure-mode-allowed: true`` will be added to request headers if the communication + // with the authorization service has failed, or if the authorization service has returned a + // HTTP 5xx error. + bool failure_mode_allow_header_add = 19; + + // Enables the filter to buffer the client request body and send it within the authorization request. + // The ``x-envoy-auth-partial-body: false|true`` metadata header will be added to the authorization + // request indicating whether the body data is partial. + BufferSettings with_request_body = 5; + + // Clears the route cache in order to allow the external authorization service to correctly affect + // routing decisions. The filter clears all cached routes when all of the following holds: + // + // * This field is set to ``true``. + // * The status returned from the authorization service is an HTTP 200 or gRPC 0. + // * At least one ``authorization response header`` is added to the client request, or is used to + // alter another client request header. + // + // Defaults to ``false``. + bool clear_route_cache = 6; + + // Sets the HTTP status that is returned to the client when the authorization server returns an error + // or cannot be reached. + // + // The default status is ``HTTP 403 Forbidden``. + type.v3.HttpStatus status_on_error = 7; + + // When set to ``true``, the filter will check the :ref:`ext_authz response + // ` for invalid header and + // query parameter mutations. If the response is invalid, the filter will send a local reply + // to the downstream request with status ``HTTP 500 Internal Server Error``. + // + // .. note:: + // Both ``headers_to_remove`` and ``query_parameters_to_remove`` are validated, but invalid elements in + // those fields should not affect any headers and thus will not cause the filter to send a local reply. + // + // When set to ``false``, any invalid mutations will be visible to the rest of Envoy and may cause + // unexpected behavior. + // + // If you are using ext_authz with an untrusted ext_authz server, you should set this to ``true``. + // + // Defaults to ``false``. + bool validate_mutations = 24; + + // Specifies a list of metadata namespaces whose values, if present, will be passed to the + // ext_authz service. The :ref:`filter_metadata ` + // is passed as an opaque ``protobuf::Struct``. + // + // .. note:: + // This field applies exclusively to the gRPC ext_authz service and has no effect on the HTTP service. + // + // For example, if the ``jwt_authn`` filter is used and :ref:`payload_in_metadata + // ` is set, + // then the following will pass the jwt payload to the authorization server. + // + // .. code-block:: yaml + // + // metadata_context_namespaces: + // - envoy.filters.http.jwt_authn + // + repeated string metadata_context_namespaces = 8; + + // Specifies a list of metadata namespaces whose values, if present, will be passed to the + // ext_authz service. :ref:`typed_filter_metadata ` + // is passed as a ``protobuf::Any``. + // + // .. note:: + // This field applies exclusively to the gRPC ext_authz service and has no effect on the HTTP service. + // + // This works similarly to ``metadata_context_namespaces`` but allows Envoy and the ext_authz server to share + // the protobuf message definition in order to perform safe parsing. + // + repeated string typed_metadata_context_namespaces = 16; + + // Specifies a list of route metadata namespaces whose values, if present, will be passed to the + // ext_authz service at :ref:`route_metadata_context ` in + // :ref:`CheckRequest `. + // :ref:`filter_metadata ` is passed as an opaque ``protobuf::Struct``. + repeated string route_metadata_context_namespaces = 21; + + // Specifies a list of route metadata namespaces whose values, if present, will be passed to the + // ext_authz service at :ref:`route_metadata_context ` in + // :ref:`CheckRequest `. + // :ref:`typed_filter_metadata ` is passed as a ``protobuf::Any``. + repeated string route_typed_metadata_context_namespaces = 22; + + // Specifies if the filter is enabled. + // + // If :ref:`runtime_key ` is specified, + // Envoy will lookup the runtime key to get the percentage of requests to filter. + // + // If this field is not specified, the filter will be enabled for all requests. + config.core.v3.RuntimeFractionalPercent filter_enabled = 9; + + // Specifies if the filter is enabled with metadata matcher. + // If this field is not specified, the filter will be enabled for all requests. + // + // .. note:: + // + // This field is only evaluated if the filter is instantiated. If the filter is marked with + // ``disabled: true`` in the :ref:`HttpFilter + // ` + // configuration or in per-route configuration via :ref:`ExtAuthzPerRoute + // `, + // the filter will not be instantiated and this field will have no effect. + // + // .. tip:: + // + // For dynamic filter activation based on metadata (such as metadata set by a preceding + // filter), consider using :ref:`ExtensionWithMatcher + // ` instead. This + // provides a more flexible matching framework that can evaluate conditions before filter + // instantiation. See the :ref:`ext_authz filter documentation + // ` for examples. + type.matcher.v3.MetadataMatcher filter_enabled_metadata = 14; + + // Specifies whether to deny the requests when the filter is disabled. + // If :ref:`runtime_key ` is specified, + // Envoy will lookup the runtime key to determine whether to deny requests for filter-protected paths + // when the filter is disabled. If the filter is disabled in ``typed_per_filter_config`` for the path, + // requests will not be denied. + // + // If this field is not specified, all requests will be allowed when disabled. + // + // If a request is denied due to this setting, the response code in :ref:`status_on_error + // ` will + // be returned. + config.core.v3.RuntimeFeatureFlag deny_at_disable = 11; + + // Specifies if the peer certificate is sent to the external service. + // + // When this field is ``true``, Envoy will include the peer X.509 certificate, if available, in the + // :ref:`certificate`. + bool include_peer_certificate = 10; + + // Optional additional prefix to use when emitting statistics. This allows distinguishing + // emitted statistics between configured ``ext_authz`` filters in an HTTP filter chain. For example: + // + // .. code-block:: yaml + // + // http_filters: + // - name: envoy.filters.http.ext_authz + // typed_config: + // "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + // stat_prefix: waf # This emits ext_authz.waf.ok, ext_authz.waf.denied, etc. + // - name: envoy.filters.http.ext_authz + // typed_config: + // "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + // stat_prefix: blocker # This emits ext_authz.blocker.ok, ext_authz.blocker.denied, etc. + // + string stat_prefix = 13; + + // Optional labels that will be passed to :ref:`labels` in + // :ref:`destination`. + // The labels will be read from :ref:`metadata` with the specified key. + string bootstrap_metadata_labels_key = 15; + + // Check request to authorization server will include the client request headers that have a correspondent match + // in the list. If this option isn't specified, then + // all client request headers are included in the check request to a gRPC authorization server, whereas no client request headers + // (besides the ones allowed by default - see note below) are included in the check request to an HTTP authorization server. + // This inconsistency between gRPC and HTTP servers is to maintain backwards compatibility with legacy behavior. + // + // .. note:: + // + // For requests to an HTTP authorization server: in addition to the user's supplied matchers, ``Host``, ``Method``, ``Path``, + // ``Content-Length``, and ``Authorization`` are **additionally included** in the list. + // + // .. note:: + // + // For requests to an HTTP authorization server: the value of ``Content-Length`` will be set to ``0`` and the request to the + // authorization server will not have a message body. However, the check request can include the buffered + // client request body (controlled by :ref:`with_request_body + // ` setting); + // consequently, the value of ``Content-Length`` in the authorization request reflects the size of its payload. + // + // .. note:: + // + // This can be overridden by the field ``disallowed_headers`` below. That is, if a header + // matches for both ``allowed_headers`` and ``disallowed_headers``, the header will NOT be sent. + type.matcher.v3.ListStringMatcher allowed_headers = 17; + + // If set, specifically disallow any header in this list to be forwarded to the external + // authentication server. This overrides the above ``allowed_headers`` if a header matches both. + type.matcher.v3.ListStringMatcher disallowed_headers = 25; + + // Specifies if the TLS session level details like SNI are sent to the external service. + // + // When this field is ``true``, Envoy will include the SNI name used for TLSClientHello, if available, in the + // :ref:`tls_session`. + bool include_tls_session = 18; + + // Whether to increment cluster statistics (e.g. cluster..upstream_rq_*) on authorization failure. + // Defaults to ``true``. + google.protobuf.BoolValue charge_cluster_response_stats = 20; + + // Whether to encode the raw headers (i.e., unsanitized values and unconcatenated multi-line headers) + // in the authorization request. Works with both HTTP and gRPC clients. + // + // When this is set to ``true``, header values are not sanitized. Headers with the same key will also + // not be combined into a single, comma-separated header. + // Requests to gRPC services will populate the field + // :ref:`header_map`. + // Requests to HTTP services will be constructed with the unsanitized header values and preserved + // multi-line headers with the same key. + // + // If this field is set to ``false``, header values will be sanitized, with any non-UTF-8-compliant + // bytes replaced with ``'!'``. Headers with the same key will have their values concatenated into a + // single comma-separated header value. + // Requests to gRPC services will populate the field + // :ref:`headers`. + // Requests to HTTP services will have their header values sanitized and will not preserve + // multi-line headers with the same key. + // + // It is recommended to set this to ``true`` unless you rely on the previous behavior. + // + // It is set to ``false`` by default for backwards compatibility. + bool encode_raw_headers = 23; + + // Rules for what modifications an ext_authz server may make to the request headers before + // continuing decoding or forwarding upstream. + // + // If set, enables header mutation checking against the configured rules. Note that + // :ref:`HeaderMutationRules ` + // has defaults that change ext_authz behavior. Also note that if this field is set, + // ext_authz can no longer append to ``:``-prefixed headers. + // + // If unset, header mutation rule checking is completely disabled. + // + // Regardless of what is configured here, ext_authz cannot remove ``:``-prefixed headers. + // + // This field and ``validate_mutations`` have different use cases. ``validate_mutations`` enables + // correctness checks for all header and query parameter mutations (for example, invalid characters). + // This field allows the filter to reject mutations to specific headers. + config.common.mutation_rules.v3.HeaderMutationRules decoder_header_mutation_rules = 26; + + // Enable or disable ingestion of dynamic metadata from the ext_authz service. + // + // If ``false``, the filter will ignore dynamic metadata injected by the ext_authz service. If the + // ext_authz service tries injecting dynamic metadata, the filter will log, increment the + // ``ignored_dynamic_metadata`` stat, then continue handling the response. + // + // If ``true``, the filter will ingest dynamic metadata entries as normal. + // + // If unset, defaults to ``true``. + google.protobuf.BoolValue enable_dynamic_metadata_ingestion = 27; + + // 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_authz filter + // name. + google.protobuf.Struct filter_metadata = 28; + + // When set to ``true``, the filter will emit per-stream stats for access logging. The filter state + // key will be the same as the filter name. + // + // If using Envoy gRPC, emits latency, bytes sent / received, upstream info, and upstream cluster + // info. If not using Envoy gRPC, emits only latency. + // + // .. note:: + // Stats are ONLY added to filter state if a check request is actually made to an ext_authz service. + // + // If this is ``false`` the filter will not emit stats, but filter_metadata will still be respected if + // it has a value. + // + // Field ``latency_us`` is exposed for CEL and logging when using gRPC or HTTP service. + // Fields ``bytesSent`` and ``bytesReceived`` are exposed for CEL and logging only when using gRPC service. + bool emit_filter_state_stats = 29; + + // Sets the maximum size (in bytes) of the response body that the filter will send downstream + // when a request is denied by the external authorization service. + // + // If the authorization server returns a response body larger than this configured limit, + // the body will be truncated to ``max_denied_response_body_bytes`` before being sent to the + // downstream client. + // + // If this field is not set or is set to 0, no truncation will occur, and the entire + // denied response body will be forwarded. + uint32 max_denied_response_body_bytes = 30; + + // When set to ``true``, the filter will enforce the response header map's count and size limits + // by sending a local reply when those limits are violated. + // + // When set to ``false``, the filter will ignore the response header map's limits and add / set + // all response headers as specified by the external authorization service. + // + // Recommendation: enable if the external authorization service is not trusted. Otherwise, leave + // it ``false``. + // + // Defaults to ``false``. + bool enforce_response_header_limits = 31; +} + +// Configuration for buffering the request data. +message BufferSettings { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.BufferSettings"; + + // Sets the maximum size of a message body that the filter will hold in memory. Envoy will return + // ``HTTP 413`` and will *not* initiate the authorization process when the buffer reaches the size + // set in this field. + // + // .. note:: + // This setting will have precedence over :ref:`failure_mode_allow + // `. + uint32 max_request_bytes = 1 [(validate.rules).uint32 = {gt: 0}]; + + // When this field is ``true``, Envoy will buffer the message until ``max_request_bytes`` is reached. + // The authorization request will be dispatched and no 413 HTTP error will be returned by the + // filter. + // + // Defaults to ``false``. + bool allow_partial_message = 2; + + // If ``true``, the body sent to the external authorization service is set as raw bytes and populates + // :ref:`raw_body` + // in the HTTP request attribute context. Otherwise, :ref:`body + // ` will be populated + // with a UTF-8 string request body. + // + // This field only affects configurations using a :ref:`grpc_service + // `. In configurations that use + // an :ref:`http_service `, this + // has no effect. + // + // Defaults to ``false``. + bool pack_as_bytes = 3; +} + +// HttpService is used for raw HTTP communication between the filter and the authorization service. +// When configured, the filter will parse the client request and use these attributes to call the +// authorization server. Depending on the response, the filter may reject or accept the client +// request. +// +// .. note:: +// In any of these events, metadata can be added, removed or overridden by the filter: +// +// On authorization request, a list of allowed request headers may be supplied. See +// :ref:`allowed_headers +// ` +// for details. Additional headers metadata may be added to the authorization request. See +// :ref:`headers_to_add +// ` for +// details. +// +// On authorization response status ``HTTP 200 OK``, the filter will allow traffic to the upstream and +// additional headers metadata may be added to the original client request. See +// :ref:`allowed_upstream_headers +// ` +// for details. Additionally, the filter may add additional headers to the client's response. See +// :ref:`allowed_client_headers_on_success +// ` +// for details. +// +// On other authorization response statuses, the filter will not allow traffic. Additional headers +// metadata as well as body may be added to the client's response. See :ref:`allowed_client_headers +// ` +// for details. +// [#next-free-field: 10] +message HttpService { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.HttpService"; + + reserved 3, 4, 5, 6; + + // Sets the HTTP server URI which the authorization requests must be sent to. + config.core.v3.HttpUri server_uri = 1; + + // Sets a prefix to the value of authorization request header ``Path``. + string path_prefix = 2; + + // Settings used for controlling authorization request metadata. + AuthorizationRequest authorization_request = 7; + + // Settings used for controlling authorization response metadata. + AuthorizationResponse authorization_response = 8; + + // Optional retry policy for requests to the authorization server. + // If not set, no retries will be performed. + // + // .. note:: + // When this field is set, the ``ext_authz`` filter will buffer the request body for retry purposes. + config.core.v3.RetryPolicy retry_policy = 9; +} + +message AuthorizationRequest { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.AuthorizationRequest"; + + // Authorization request includes the client request headers that have a corresponding match + // in the list. + // This field has been deprecated in favor of :ref:`allowed_headers + // `. + // + // .. note:: + // + // In addition to the user's supplied matchers, ``Host``, ``Method``, ``Path``, + // ``Content-Length``, and ``Authorization`` are **automatically included** in the list. + // + // .. note:: + // + // By default, the ``Content-Length`` header is set to ``0`` and the request to the authorization + // service has no message body. However, the authorization request *may* include the buffered + // client request body (controlled by :ref:`with_request_body + // ` + // setting); hence the value of its ``Content-Length`` reflects the size of its payload. + // + type.matcher.v3.ListStringMatcher allowed_headers = 1 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // Sets a list of headers that will be included in the request to the authorization service. + // + // .. note:: + // Client request headers with the same key will be overridden. + repeated config.core.v3.HeaderValue headers_to_add = 2; +} + +// [#next-free-field: 6] +message AuthorizationResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.AuthorizationResponse"; + + // When this list is set, authorization + // response headers that have a correspondent match will be added to the original client request. + // + // .. note:: + // Existing headers will be overridden. + type.matcher.v3.ListStringMatcher allowed_upstream_headers = 1; + + // When this list is set, authorization + // response headers that have a correspondent match will be added to the original client request. + // + // .. note:: + // Existing headers will be appended. + type.matcher.v3.ListStringMatcher allowed_upstream_headers_to_append = 3; + + // When this list is set, authorization + // response headers that have a correspondent match will be added to the client's response. + // When a header is included in this list, ``Path``, ``Status``, ``Content-Length``, ``WWW-Authenticate`` and + // ``Location`` are automatically added. + // + // .. note:: + // When this list is *not* set, all the authorization response headers, except + // ``Authority (Host)``, will be in the response to the client. + type.matcher.v3.ListStringMatcher allowed_client_headers = 2; + + // When this list is set, authorization + // response headers that have a correspondent match will be added to the client's response when + // the authorization response itself is successful, i.e. not failed or denied. When this list is + // *not* set, no additional headers will be added to the client's response on success. + type.matcher.v3.ListStringMatcher allowed_client_headers_on_success = 4; + + // When this list is set, authorization + // response headers that have a correspondent match will be emitted as dynamic metadata to be consumed + // by the next filter. This metadata lives in a namespace specified by the canonical name of extension filter + // that requires it: + // + // - :ref:`envoy.filters.http.ext_authz ` for HTTP filter. + // - :ref:`envoy.filters.network.ext_authz ` for network filter. + type.matcher.v3.ListStringMatcher dynamic_metadata_from_headers = 5; +} + +// Extra settings on a per virtualhost/route/weighted-cluster level. +message ExtAuthzPerRoute { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.ExtAuthzPerRoute"; + + oneof override { + option (validate.required) = true; + + // Disable the ext auth filter for this particular vhost or route. + // If disabled is specified in multiple per-filter-configs, the most specific one will be used. + // If the filter is disabled by default and this is set to ``false``, the filter will be enabled + // for this vhost or route. + bool disabled = 1; + + // Check request settings for this route. + CheckSettings check_settings = 2 [(validate.rules).message = {required: true}]; + } +} + +// Extra settings for the check request. +// [#next-free-field: 6] +message CheckSettings { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.ext_authz.v2.CheckSettings"; + + // Context extensions to set on the CheckRequest's + // :ref:`AttributeContext.context_extensions` + // + // You can use this to provide extra context for the external authorization server on specific + // virtual hosts/routes. For example, adding a context extension on the virtual host level can + // give the ext-authz server information on what virtual host is used without needing to parse the + // host header. If CheckSettings is specified in multiple per-filter-configs, they will be merged + // in order, and the result will be used. + // + // Merge semantics for this field are such that keys from more specific configs override. + // + // .. note:: + // These settings are only applied to a filter configured with a + // :ref:`grpc_service`. + map context_extensions = 1 [(udpa.annotations.sensitive) = true]; + + // When set to ``true``, disable the configured :ref:`with_request_body + // ` for a specific route. + // + // Only one of ``disable_request_body_buffering`` and + // :ref:`with_request_body ` + // may be specified. + bool disable_request_body_buffering = 2; + + // Enable or override request body buffering, which is configured using the + // :ref:`with_request_body ` + // option for a specific route. + // + // Only one of ``with_request_body`` and + // :ref:`disable_request_body_buffering ` + // may be specified. + BufferSettings with_request_body = 3; + + // Override the external authorization service for this route. + // This allows different routes to use different external authorization service backends + // and service types (gRPC or HTTP). If specified, this overrides the filter-level service + // configuration regardless of the original service type. + oneof service_override { + // Override with a gRPC service configuration. + config.core.v3.GrpcService grpc_service = 4; + + // Override with an HTTP service configuration. + HttpService http_service = 5; + } +} 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/extensions/filters/http/rbac/v3/rbac.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/rbac/v3/rbac.proto index 6efd47ac58b..a37efe157db 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/rbac/v3/rbac.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/rbac/v3/rbac.proto @@ -4,7 +4,6 @@ package envoy.extensions.filters.http.rbac.v3; import "envoy/config/rbac/v3/rbac.proto"; -import "xds/annotations/v3/status.proto"; import "xds/type/matcher/v3/matcher.proto"; import "udpa/annotations/migrate.proto"; @@ -64,10 +63,8 @@ message RBAC { // If absent, no shadow matcher will be applied. // Match tree for testing RBAC rules through stats and logs without enforcing them. // If absent, no shadow matching occurs. - xds.type.matcher.v3.Matcher shadow_matcher = 5 [ - (udpa.annotations.field_migrate).oneof_promotion = "shadow_rules_specifier", - (xds.annotations.v3.field_status).work_in_progress = true - ]; + xds.type.matcher.v3.Matcher shadow_matcher = 5 + [(udpa.annotations.field_migrate).oneof_promotion = "shadow_rules_specifier"]; // If specified, shadow rules will emit stats with the given prefix. // This is useful for distinguishing metrics when multiple RBAC filters use shadow rules. diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/router/v3/router.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/router/v3/router.proto index d3996a96798..7da658bcb33 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/router/v3/router.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/http/router/v3/router.proto @@ -23,7 +23,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // Router :ref:`configuration overview `. // [#extension: envoy.filters.http.router] -// [#next-free-field: 10] +// [#next-free-field: 11] message Router { option (udpa.annotations.versioning).previous_message_type = "envoy.config.filter.http.router.v2.Router"; @@ -134,4 +134,10 @@ message Router { // upstream HTTP filters will count as a final response if hedging is configured. // [#extension-category: envoy.filters.http.upstream] repeated network.http_connection_manager.v3.HttpFilter upstream_http_filters = 8; + + // If set to true, Envoy will reject ``CONNECT`` requests that send data before + // receiving a ``200`` response from the upstream. This early data behavior + // is common for latency reduction but can cause issues with some upstreams. + // Defaults to false to allow early data and be compatible with common behavior. + google.protobuf.BoolValue reject_connect_request_early_data = 10; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto index e0282af86e6..9d8cf8bf4fd 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto @@ -20,6 +20,8 @@ import "google/protobuf/any.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/wrappers.proto"; +import "xds/type/matcher/v3/matcher.proto"; + import "envoy/annotations/deprecation.proto"; import "udpa/annotations/migrate.proto"; import "udpa/annotations/security.proto"; @@ -37,7 +39,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // HTTP connection manager :ref:`configuration overview `. // [#extension: envoy.filters.network.http_connection_manager] -// [#next-free-field: 59] +// [#next-free-field: 61] message HttpConnectionManager { option (udpa.annotations.versioning).previous_message_type = "envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager"; @@ -139,11 +141,13 @@ message HttpConnectionManager { UNESCAPE_AND_FORWARD = 4; } - // [#next-free-field: 11] + // [#next-free-field: 13] message Tracing { option (udpa.annotations.versioning).previous_message_type = "envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager.Tracing"; + // This OperationName makes no sense and is unnecessary in the current tracing API. + // [#not-implemented-hide:] enum OperationName { // The HTTP listener is used for ingress/incoming requests. INGRESS = 0; @@ -217,6 +221,28 @@ message HttpConnectionManager { // // The default value is false for now for backward compatibility. google.protobuf.BoolValue spawn_upstream_span = 10; + + // The operation name of the span which will be used for tracing. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + // + // This field will take precedence over and make following settings ineffective: + // + // * :ref:`route decorator ` and + // * :ref:`x-envoy-decorator-operation ` + // header will be ignored. + string operation = 11; + + // The operation name of the upstream span which will be used for tracing. + // This only takes effect when ``spawn_upstream_span`` is set to true and the upstream + // span is created. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + string upstream_operation = 12; } message InternalAddressConfig { @@ -263,6 +289,15 @@ message HttpConnectionManager { bool uri = 5; } + // The configuration for forwarding client cert details. + message ForwardClientCertConfig { + // How to handle the XFCC header. + ForwardClientCertDetails forward_client_cert_details = 1; + + // How to set the current client cert details. + SetCurrentClientCertDetails set_current_client_cert_details = 2; + } + // The configuration for HTTP upgrades. // For each upgrade type desired, an UpgradeConfig must be added. // @@ -527,16 +562,6 @@ message HttpConnectionManager { // is terminated with a 408 Request Timeout error code if no upstream response // header has been received, otherwise a stream reset occurs. // - // This timeout also specifies the amount of time that Envoy will wait for the peer to open enough - // window to write any remaining stream data once the entirety of stream data (local end stream is - // true) has been buffered pending available window. In other words, this timeout defends against - // a peer that does not release enough window to completely write the stream, even though all - // data has been proxied within available flow control windows. If the timeout is hit in this - // case, the :ref:`tx_flush_timeout ` counter will be - // incremented. Note that :ref:`max_stream_duration - // ` does not apply to - // this corner case. - // // If the :ref:`overload action ` "envoy.overload_actions.reduce_timeouts" // is configured, this timeout is scaled according to the value for // :ref:`HTTP_DOWNSTREAM_STREAM_IDLE `. @@ -549,9 +574,29 @@ message HttpConnectionManager { // // A value of 0 will completely disable the connection manager stream idle // timeout, although per-route idle timeout overrides will continue to apply. + // + // This timeout is also used as the default value for :ref:`stream_flush_timeout + // `. google.protobuf.Duration stream_idle_timeout = 24 [(udpa.annotations.security).configure_for_untrusted_downstream = true]; + // The stream flush timeout for connections managed by the connection manager. + // + // If not specified, the value of stream_idle_timeout is used. This is for backwards compatibility + // since this was the original behavior. In essence this timeout is an override for the + // stream_idle_timeout that applies specifically to the end of stream flush case. + // + // This timeout specifies the amount of time that Envoy will wait for the peer to open enough + // window to write any remaining stream data once the entirety of stream data (local end stream is + // true) has been buffered pending available window. In other words, this timeout defends against + // a peer that does not release enough window to completely write the stream, even though all + // data has been proxied within available flow control windows. If the timeout is hit in this + // case, the :ref:`tx_flush_timeout ` counter will be + // incremented. Note that :ref:`max_stream_duration + // ` does not apply to + // this corner case. + google.protobuf.Duration stream_flush_timeout = 59; + // The amount of time that Envoy will wait for the entire request to be received. // The timer is activated when the request is initiated, and is disarmed when the last byte of the // request is sent upstream (i.e. all decoding filters have processed the request), OR when the @@ -774,6 +819,53 @@ message HttpConnectionManager { // value. SetCurrentClientCertDetails set_current_client_cert_details = 17; + // The matcher for forwarding client cert details. This allows per-request configuration + // of forward client cert behavior based on request properties. If a matcher is configured + // and matches a request, the matched action's forward client cert config will be used. + // If the matcher is not configured or doesn't match, the static + // :ref:`forward_client_cert_details + // ` + // and + // :ref:`set_current_client_cert_details + // ` + // config will be used as fallback. + // + // Example: If the x-forwarded-client-cert header contains "trusted-client", use APPEND_FORWARD, + // otherwise use SANITIZE_SET: + // + // .. code-block:: yaml + // + // forward_client_cert_matcher: + // matcher_list: + // matchers: + // - predicate: + // single_predicate: + // input: + // name: envoy.matching.inputs.request_headers + // typed_config: + // "@type": type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput + // header_name: "x-forwarded-client-cert" + // value_match: + // string_match: + // contains: "trusted-client" + // on_match: + // action: + // name: forward_client_cert + // typed_config: + // "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager.ForwardClientCertConfig + // forward_client_cert_details: APPEND_FORWARD + // set_current_client_cert_details: + // uri: true + // on_no_match: + // action: + // name: forward_client_cert + // typed_config: + // "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager.ForwardClientCertConfig + // forward_client_cert_details: SANITIZE_SET + // set_current_client_cert_details: + // uri: true + xds.type.matcher.v3.Matcher forward_client_cert_matcher = 60; + // If proxy_100_continue is true, Envoy will proxy incoming "Expect: // 100-continue" headers upstream, and forward "100 Continue" responses // downstream. If this is false or not set, Envoy will instead strip the @@ -1036,7 +1128,7 @@ message Rds { "envoy.config.filter.network.http_connection_manager.v2.Rds"; // Configuration source specifier for RDS. - config.core.v3.ConfigSource config_source = 1 [(validate.rules).message = {required: true}]; + config.core.v3.ConfigSource config_source = 1; // The name of the route configuration. This name will be passed to the RDS // API. This allows an Envoy configuration with multiple HTTP listeners (and diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto new file mode 100644 index 00000000000..45ee3839e6f --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/call_credentials/access_token/v3/access_token_credentials.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.call_credentials.access_token.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.call_credentials.access_token.v3"; +option java_outer_classname = "AccessTokenCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/call_credentials/access_token/v3;access_tokenv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Access Token Credentials] + +// [#not-implemented-hide:] +message AccessTokenCredentials { + // The access token. + string token = 1; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto new file mode 100644 index 00000000000..77c3af41fdd --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/google_default/v3/google_default_credentials.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.google_default.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.google_default.v3"; +option java_outer_classname = "GoogleDefaultCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/google_default/v3;google_defaultv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Google Default Credentials] + +// [#not-implemented-hide:] +message GoogleDefaultCredentials { +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto new file mode 100644 index 00000000000..70d58451e2d --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/insecure/v3/insecure_credentials.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.insecure.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.insecure.v3"; +option java_outer_classname = "InsecureCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/insecure/v3;insecurev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Insecure Credentials] + +// [#not-implemented-hide:] +message InsecureCredentials { +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto new file mode 100644 index 00000000000..00514a0e847 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/local/v3/local_credentials.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.local.v3; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.local.v3"; +option java_outer_classname = "LocalCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/local/v3;localv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC Local Credentials] + +// [#not-implemented-hide:] +message LocalCredentials { +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto new file mode 100644 index 00000000000..f64c16bb684 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/tls/v3/tls_credentials.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.tls.v3; + +import "envoy/extensions/transport_sockets/tls/v3/tls.proto"; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.tls.v3"; +option java_outer_classname = "TlsCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/tls/v3;tlsv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC TLS Credentials] + +// [#not-implemented-hide:] +message TlsCredentials { + // The certificate provider instance for the root cert. Must be set. + transport_sockets.tls.v3.CommonTlsContext.CertificateProviderInstance root_certificate_provider = + 1; + + // The certificate provider instance for the identity cert. Optional; + // if unset, no identity certificate will be sent to the server. + transport_sockets.tls.v3.CommonTlsContext.CertificateProviderInstance + identity_certificate_provider = 2; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto new file mode 100644 index 00000000000..ba8d471dd49 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/grpc_service/channel_credentials/xds/v3/xds_credentials.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package envoy.extensions.grpc_service.channel_credentials.xds.v3; + +import "google/protobuf/any.proto"; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.grpc_service.channel_credentials.xds.v3"; +option java_outer_classname = "XdsCredentialsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/grpc_service/channel_credentials/xds/v3;xdsv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: gRPC xDS Credentials] + +// [#not-implemented-hide:] +message XdsCredentials { + // Fallback credentials. Required. + google.protobuf.Any fallback_credentials = 1; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/client_side_weighted_round_robin/v3/client_side_weighted_round_robin.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/client_side_weighted_round_robin/v3/client_side_weighted_round_robin.proto index f913cb6a257..c55d30b89e0 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/client_side_weighted_round_robin/v3/client_side_weighted_round_robin.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/client_side_weighted_round_robin/v3/client_side_weighted_round_robin.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package envoy.extensions.load_balancing_policies.client_side_weighted_round_robin.v3; +import "envoy/extensions/load_balancing_policies/common/v3/common.proto"; + import "google/protobuf/duration.proto"; import "google/protobuf/wrappers.proto"; @@ -42,7 +44,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // See the :ref:`load balancing architecture // overview` for more information. // -// [#next-free-field: 8] +// [#next-free-field: 9] message ClientSideWeightedRoundRobin { // Whether to enable out-of-band utilization reporting collection from // the endpoints. By default, per-request utilization reporting is used. @@ -82,4 +84,8 @@ message ClientSideWeightedRoundRobin { // For map fields in the ORCA proto, the string will be of the form ``.``. For example, the string ``named_metrics.foo`` will mean to look for the key ``foo`` in the ORCA :ref:`named_metrics ` field. // If none of the specified metrics are present in the load report, then :ref:`cpu_utilization ` is used instead. repeated string metric_names_for_computing_utilization = 7; + + // Configuration for slow start mode. + // If this configuration is not set, slow start will not be not enabled. + common.v3.SlowStartConfig slow_start_config = 8; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto index 7868fb02b1a..22faf11b9c5 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/common/v3/common.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package envoy.extensions.load_balancing_policies.common.v3; import "envoy/config/core/v3/base.proto"; +import "envoy/config/route/v3/route_components.proto"; import "envoy/type/v3/percent.proto"; import "google/protobuf/duration.proto"; @@ -23,8 +24,17 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; message LocalityLbConfig { // Configuration for :ref:`zone aware routing // `. - // [#next-free-field: 6] + // [#next-free-field: 7] message ZoneAwareLbConfig { + // Basis for computing per-locality percentages in zone-aware routing. + enum LocalityBasis { + // Use the number of healthy hosts in each locality. + HEALTHY_HOSTS_NUM = 0; + + // Use the weights of healthy hosts in each locality. + HEALTHY_HOSTS_WEIGHT = 1; + } + // Configures Envoy to always route requests to the local zone regardless of the // upstream zone structure. In Envoy's default configuration, traffic is distributed proportionally // across all upstream hosts while trying to maximize local routing when possible. The approach @@ -66,6 +76,12 @@ message LocalityLbConfig { [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; ForceLocalZone force_local_zone = 5; + + // Determines how locality percentages are computed: + // - HEALTHY_HOSTS_NUM: proportional to the count of healthy hosts. + // - HEALTHY_HOSTS_WEIGHT: proportional to the weights of healthy hosts. + // Default value is HEALTHY_HOSTS_NUM if unset. + LocalityBasis locality_basis = 6; } // Configuration for :ref:`locality weighted load balancing @@ -136,4 +152,10 @@ message ConsistentHashingLbConfig { // This is an O(N) algorithm, unlike other load balancers. Using a lower ``hash_balance_factor`` results in more hosts // being probed, so use a higher value if you require better performance. google.protobuf.UInt32Value hash_balance_factor = 2 [(validate.rules).uint32 = {gte: 100}]; + + // Specifies a list of hash policies to use for ring hash load balancing. If ``hash_policy`` is + // set, then + // :ref:`route level hash policy ` + // will be ignored. + repeated config.route.v3.RouteAction.HashPolicy hash_policy = 3; } diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/wrr_locality/v3/wrr_locality.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/wrr_locality/v3/wrr_locality.proto index ab8367a401a..e2e4ade8236 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/wrr_locality/v3/wrr_locality.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/load_balancing_policies/wrr_locality/v3/wrr_locality.proto @@ -14,7 +14,7 @@ option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/loa option (udpa.annotations.file_status).package_version_status = ACTIVE; // [#protodoc-title: Weighted Round Robin Locality-Picking Load Balancing Policy] -// [#not-implemented-hide:] +// [#extension: envoy.load_balancing_policies.wrr_locality] // Configuration for the wrr_locality LB policy. See the :ref:`load balancing architecture overview // ` for more information. diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto index b292b18c45d..d656c66b5d0 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto @@ -300,6 +300,7 @@ message CommonTlsContext { // Select TLS certificate based on TLS client hello. // If empty, defaults to native TLS certificate selection behavior: // DNS SANs or Subject Common Name in TLS certificates is extracted as server name pattern to match SNI. + // [#extension-category: envoy.tls.certificate_selectors] config.core.v3.TypedExtensionConfig custom_tls_certificate_selector = 16; // Certificate provider for fetching TLS certificates. diff --git a/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto new file mode 100644 index 00000000000..2c4fbb4b73e --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/attribute_context.proto @@ -0,0 +1,222 @@ +syntax = "proto3"; + +package envoy.service.auth.v3; + +import "envoy/config/core/v3/address.proto"; +import "envoy/config/core/v3/base.proto"; + +import "google/protobuf/timestamp.proto"; + +import "udpa/annotations/migrate.proto"; +import "udpa/annotations/status.proto"; +import "udpa/annotations/versioning.proto"; + +option java_package = "io.envoyproxy.envoy.service.auth.v3"; +option java_outer_classname = "AttributeContextProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3;authv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Attribute context] + +// See :ref:`network filter configuration overview ` +// and :ref:`HTTP filter configuration overview `. + +// An attribute is a piece of metadata that describes an activity on a network. +// For example, the size of an HTTP request, or the status code of an HTTP response. +// +// Each attribute has a type and a name, which is logically defined as a proto message field +// of the ``AttributeContext``. The ``AttributeContext`` is a collection of individual attributes +// supported by Envoy authorization system. +// [#comment: The following items are left out of this proto +// Request.Auth field for JWTs +// Request.Api for api management +// Origin peer that originated the request +// Caching Protocol +// request_context return values to inject back into the filter chain +// peer.claims -- from X.509 extensions +// Configuration +// - field mask to send +// - which return values from request_context are copied back +// - which return values are copied into request_headers] +// [#next-free-field: 14] +message AttributeContext { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext"; + + // This message defines attributes for a node that handles a network request. + // The node can be either a service or an application that sends, forwards, + // or receives the request. Service peers should fill in the ``service``, + // ``principal``, and ``labels`` as appropriate. + // [#next-free-field: 6] + message Peer { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext.Peer"; + + // The address of the peer, this is typically the IP address. + // It can also be UDS path, or others. + config.core.v3.Address address = 1; + + // The canonical service name of the peer. + // It should be set to :ref:`the HTTP x-envoy-downstream-service-cluster + // ` + // If a more trusted source of the service name is available through mTLS/secure naming, it + // should be used. + string service = 2; + + // The labels associated with the peer. + // These could be pod labels for Kubernetes or tags for VMs. + // The source of the labels could be an X.509 certificate or other configuration. + map labels = 3; + + // The authenticated identity of this peer. + // For example, the identity associated with the workload such as a service account. + // If an X.509 certificate is used to assert the identity this field should be sourced from + // ``URI Subject Alternative Names``, ``DNS Subject Alternate Names`` or ``Subject`` in that order. + // The primary identity should be the principal. The principal format is issuer specific. + // + // Examples: + // + // - SPIFFE format is ``spiffe://trust-domain/path``. + // - Google account format is ``https://accounts.google.com/{userid}``. + string principal = 4; + + // The X.509 certificate used to authenticate the identify of this peer. + // When present, the certificate contents are encoded in URL and PEM format. + string certificate = 5; + } + + // Represents a network request, such as an HTTP request. + message Request { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext.Request"; + + // The timestamp when the proxy receives the first byte of the request. + google.protobuf.Timestamp time = 1; + + // Represents an HTTP request or an HTTP-like request. + HttpRequest http = 2; + } + + // This message defines attributes for an HTTP request. + // HTTP/1.x, HTTP/2, gRPC are all considered as HTTP requests. + // [#next-free-field: 14] + message HttpRequest { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.AttributeContext.HttpRequest"; + + // The unique ID for a request, which can be propagated to downstream + // systems. The ID should have low probability of collision + // within a single day for a specific service. + // For HTTP requests, it should be X-Request-ID or equivalent. + string id = 1; + + // The HTTP request method, such as ``GET``, ``POST``. + string method = 2; + + // The HTTP request headers. If multiple headers share the same key, they + // must be merged according to the HTTP spec. All header keys must be + // lower-cased, because HTTP header keys are case-insensitive. + // Header value is encoded as UTF-8 string. Non-UTF-8 characters will be replaced by "!". + // This field will not be set if + // :ref:`encode_raw_headers ` + // is set to true. + map headers = 3 + [(udpa.annotations.field_migrate).oneof_promotion = "headers_type"]; + + // A list of the raw HTTP request headers. This is used instead of + // :ref:`headers ` when + // :ref:`encode_raw_headers ` + // is set to true. + // + // Note that this is not actually a map type. ``header_map`` contains a single repeated field + // ``headers``. + // + // Here, only the ``key`` and ``raw_value`` fields will be populated for each HeaderValue, and + // that is only when + // :ref:`encode_raw_headers ` + // is set to true. + // + // Also, unlike the + // :ref:`headers ` + // field, headers with the same key are not combined into a single comma separated header. + config.core.v3.HeaderMap header_map = 13 + [(udpa.annotations.field_migrate).oneof_promotion = "headers_type"]; + + // The request target, as it appears in the first line of the HTTP request. This includes + // the URL path and query-string. No decoding is performed. + string path = 4; + + // The HTTP request ``Host`` or ``:authority`` header value. + string host = 5; + + // The HTTP URL scheme, such as ``http`` and ``https``. + string scheme = 6; + + // This field is always empty, and exists for compatibility reasons. The HTTP URL query is + // included in ``path`` field. + string query = 7; + + // This field is always empty, and exists for compatibility reasons. The URL fragment is + // not submitted as part of HTTP requests; it is unknowable. + string fragment = 8; + + // The HTTP request size in bytes. If unknown, it must be -1. + int64 size = 9; + + // The network protocol used with the request, such as "HTTP/1.0", "HTTP/1.1", or "HTTP/2". + // + // See :repo:`headers.h:ProtocolStrings ` for a list of all + // possible values. + string protocol = 10; + + // The HTTP request body. + string body = 11; + + // The HTTP request body in bytes. This is used instead of + // :ref:`body ` when + // :ref:`pack_as_bytes ` + // is set to true. + bytes raw_body = 12; + } + + // This message defines attributes for the underlying TLS session. + message TLSSession { + // SNI used for TLS session. + string sni = 1; + } + + // The source of a network activity, such as starting a TCP connection. + // In a multi hop network activity, the source represents the sender of the + // last hop. + Peer source = 1; + + // The destination of a network activity, such as accepting a TCP connection. + // In a multi hop network activity, the destination represents the receiver of + // the last hop. + Peer destination = 2; + + // Represents a network request, such as an HTTP request. + Request request = 4; + + // This is analogous to http_request.headers, however these contents will not be sent to the + // upstream server. Context_extensions provide an extension mechanism for sending additional + // information to the auth server without modifying the proto definition. It maps to the + // internal opaque context in the filter chain. + map context_extensions = 10; + + // Dynamic metadata associated with the request. + config.core.v3.Metadata metadata_context = 11; + + // Metadata associated with the selected route. + config.core.v3.Metadata route_metadata_context = 13; + + // TLS session details of the underlying connection. + // This is not populated by default and will be populated only if the ext_authz filter has + // been specifically configured to include this information. + // For HTTP ext_authz, that requires :ref:`include_tls_session ` + // to be set to true. + // For network ext_authz, that requires :ref:`include_tls_session ` + // to be set to true. + TLSSession tls_session = 12; +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto new file mode 100644 index 00000000000..520a4ff4f31 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/service/auth/v3/external_auth.proto @@ -0,0 +1,157 @@ +syntax = "proto3"; + +package envoy.service.auth.v3; + +import "envoy/config/core/v3/base.proto"; +import "envoy/service/auth/v3/attribute_context.proto"; +import "envoy/type/v3/http_status.proto"; + +import "google/protobuf/struct.proto"; +import "google/rpc/status.proto"; + +import "envoy/annotations/deprecation.proto"; +import "udpa/annotations/status.proto"; +import "udpa/annotations/versioning.proto"; + +option java_package = "io.envoyproxy.envoy.service.auth.v3"; +option java_outer_classname = "ExternalAuthProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3;authv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Authorization service] + +// The authorization service request messages used by external authorization :ref:`network filter +// ` and :ref:`HTTP filter `. + +// A generic interface for performing authorization check on incoming +// requests to a networked service. +service Authorization { + // Performs authorization check based on the attributes associated with the + // incoming request, and returns status `OK` or not `OK`. + rpc Check(CheckRequest) returns (CheckResponse) { + } +} + +message CheckRequest { + option (udpa.annotations.versioning).previous_message_type = "envoy.service.auth.v2.CheckRequest"; + + // The request attributes. + AttributeContext attributes = 1; +} + +// HTTP attributes for a denied response. +message DeniedHttpResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.DeniedHttpResponse"; + + // This field allows the authorization service to send an HTTP response status code to the + // downstream client. If not set, Envoy sends ``403 Forbidden`` HTTP status code by default. + type.v3.HttpStatus status = 1; + + // This field allows the authorization service to send HTTP response headers + // to the downstream client. Note that the :ref:`append field in HeaderValueOption ` defaults to + // false when used in this message. + repeated config.core.v3.HeaderValueOption headers = 2; + + // This field allows the authorization service to send a response body data + // to the downstream client. + string body = 3; +} + +// HTTP attributes for an OK response. +// [#next-free-field: 9] +message OkHttpResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.OkHttpResponse"; + + // HTTP entity headers in addition to the original request headers. This allows the authorization + // service to append, to add or to override headers from the original request before + // dispatching it to the upstream. Note that the :ref:`append field in HeaderValueOption ` defaults to + // false when used in this message. By setting the ``append`` field to ``true``, + // the filter will append the correspondent header value to the matched request header. + // By leaving ``append`` as false, the filter will either add a new header, or override an existing + // one if there is a match. + repeated config.core.v3.HeaderValueOption headers = 2; + + // HTTP entity headers to remove from the original request before dispatching + // it to the upstream. This allows the authorization service to act on auth + // related headers (like ``Authorization``), process them, and consume them. + // Under this model, the upstream will either receive the request (if it's + // authorized) or not receive it (if it's not), but will not see headers + // containing authorization credentials. + // + // Pseudo headers (such as ``:authority``, ``:method``, ``:path`` etc), as well as + // the header ``Host``, may not be removed as that would make the request + // malformed. If mentioned in ``headers_to_remove`` these special headers will + // be ignored. + // + // When using the HTTP service this must instead be set by the HTTP + // authorization service as a comma separated list like so: + // ``x-envoy-auth-headers-to-remove: one-auth-header, another-auth-header``. + repeated string headers_to_remove = 5; + + // This field has been deprecated in favor of :ref:`CheckResponse.dynamic_metadata + // `. Until it is removed, + // setting this field overrides :ref:`CheckResponse.dynamic_metadata + // `. + google.protobuf.Struct dynamic_metadata = 3 + [deprecated = true, (envoy.annotations.deprecated_at_minor_version) = "3.0"]; + + // This field allows the authorization service to send HTTP response headers + // to the downstream client on success. Note that the :ref:`append field in HeaderValueOption ` + // defaults to false when used in this message. + repeated config.core.v3.HeaderValueOption response_headers_to_add = 6; + + // This field allows the authorization service to set (and overwrite) query + // string parameters on the original request before it is sent upstream. + repeated config.core.v3.QueryParameter query_parameters_to_set = 7; + + // This field allows the authorization service to specify which query parameters + // should be removed from the original request before it is sent upstream. Each + // element in this list is a case-sensitive query parameter name to be removed. + repeated string query_parameters_to_remove = 8; +} + +// Intended for gRPC and Network Authorization servers ``only``. +// [#next-free-field: 6] +message CheckResponse { + option (udpa.annotations.versioning).previous_message_type = + "envoy.service.auth.v2.CheckResponse"; + + // Status ``OK`` allows the request. Any other status indicates the request should be denied, and + // for HTTP filter, if not overridden by :ref:`denied HTTP response status ` + // Envoy sends ``403 Forbidden`` HTTP status code by default. + google.rpc.Status status = 1; + + // An message that contains HTTP response attributes. This message is + // used when the authorization service needs to send custom responses to the + // downstream client or, to modify/add request headers being dispatched to the upstream. + oneof http_response { + // Supplies http attributes for a denied response. + DeniedHttpResponse denied_response = 2; + + // Supplies http attributes for an ok response. + OkHttpResponse ok_response = 3; + + // Supplies http attributes for an error response. This is used when the authorization + // service encounters an internal error and wants to return custom headers and body to the + // downstream client. When ``error_response`` is set, the ext_authz filter increments the + // ``ext_authz_error`` stat and respects the :ref:`failure_mode_allow + // ` + // configuration. The HTTP status code, headers, and body are taken from the + // :ref:`DeniedHttpResponse ` message. + // If the status field is not set, Envoy sends the status code configured via + // :ref:`status_on_error `, + // which defaults to ``403 Forbidden``. + DeniedHttpResponse error_response = 5; + } + + // Optional response metadata that will be emitted as dynamic metadata to be consumed by the next + // filter. This metadata lives in a namespace specified by the canonical name of extension filter + // that requires it: + // + // - :ref:`envoy.filters.http.ext_authz ` for HTTP filter. + // - :ref:`envoy.filters.network.ext_authz ` for network filter. + google.protobuf.Struct dynamic_metadata = 4; +} 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]; + } +} diff --git a/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto b/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto index d773c6057fc..8d65c457ccc 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/type/matcher/v3/value.proto @@ -17,7 +17,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // [#protodoc-title: Value matcher] -// Specifies the way to match a ProtobufWkt::Value. Primitive values and ListValue are supported. +// Specifies the way to match a Protobuf::Value. Primitive values and ListValue are supported. // StructValue is not supported and is always not matched. // [#next-free-field: 8] message ValueMatcher { diff --git a/xds/third_party/envoy/src/main/proto/envoy/type/tracing/v3/custom_tag.proto b/xds/third_party/envoy/src/main/proto/envoy/type/tracing/v3/custom_tag.proto index feb57e8eb66..cdb42a43507 100644 --- a/xds/third_party/envoy/src/main/proto/envoy/type/tracing/v3/custom_tag.proto +++ b/xds/third_party/envoy/src/main/proto/envoy/type/tracing/v3/custom_tag.proto @@ -17,7 +17,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // [#protodoc-title: Custom Tag] // Describes custom tags for the active span. -// [#next-free-field: 6] +// [#next-free-field: 7] message CustomTag { option (udpa.annotations.versioning).previous_message_type = "envoy.type.tracing.v2.CustomTag"; @@ -98,5 +98,12 @@ message CustomTag { // A custom tag to obtain tag value from the metadata. Metadata metadata = 5; + + // Custom tag value. + // + // The same :ref:`format specifier ` as used for + // :ref:`HTTP access logging ` applies here, however + // unknown specifier values are replaced with the empty string instead of ``-``. + string value = 6; } }