async-grpc-xds 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/fixtures/async/grpc/test_interface.rb +79 -0
  4. data/fixtures/async/grpc/test_message.rb +56 -0
  5. data/lib/async/grpc/xds/ads_stream.rb +70 -0
  6. data/lib/async/grpc/xds/client.rb +255 -0
  7. data/lib/async/grpc/xds/context.rb +201 -0
  8. data/lib/async/grpc/xds/control_plane.rb +143 -0
  9. data/lib/async/grpc/xds/discovery_client.rb +356 -0
  10. data/lib/async/grpc/xds/health_checker.rb +88 -0
  11. data/lib/async/grpc/xds/load_balancer.rb +196 -0
  12. data/lib/async/grpc/xds/resource_builder.rb +138 -0
  13. data/lib/async/grpc/xds/resource_cache.rb +55 -0
  14. data/lib/async/grpc/xds/resources.rb +270 -0
  15. data/lib/async/grpc/xds/server.rb +34 -0
  16. data/lib/async/grpc/xds/service.rb +117 -0
  17. data/lib/async/grpc/xds/version.rb +12 -0
  18. data/lib/async/grpc/xds.rb +42 -0
  19. data/lib/envoy/annotations/deprecation_pb.rb +19 -0
  20. data/lib/envoy/config/cluster/v3/circuit_breaker_pb.rb +31 -0
  21. data/lib/envoy/config/cluster/v3/cluster_pb.rb +80 -0
  22. data/lib/envoy/config/cluster/v3/filter_pb.rb +28 -0
  23. data/lib/envoy/config/cluster/v3/outlier_detection_pb.rb +29 -0
  24. data/lib/envoy/config/core/v3/address_pb.rb +38 -0
  25. data/lib/envoy/config/core/v3/backoff_pb.rb +27 -0
  26. data/lib/envoy/config/core/v3/base_pb.rb +68 -0
  27. data/lib/envoy/config/core/v3/cel_pb.rb +24 -0
  28. data/lib/envoy/config/core/v3/config_source_pb.rb +42 -0
  29. data/lib/envoy/config/core/v3/event_service_config_pb.rb +27 -0
  30. data/lib/envoy/config/core/v3/extension_pb.rb +26 -0
  31. data/lib/envoy/config/core/v3/grpc_method_list_pb.rb +27 -0
  32. data/lib/envoy/config/core/v3/grpc_service_pb.rb +45 -0
  33. data/lib/envoy/config/core/v3/health_check_pb.rb +47 -0
  34. data/lib/envoy/config/core/v3/http_service_pb.rb +27 -0
  35. data/lib/envoy/config/core/v3/http_uri_pb.rb +27 -0
  36. data/lib/envoy/config/core/v3/protocol_pb.rb +51 -0
  37. data/lib/envoy/config/core/v3/proxy_protocol_pb.rb +31 -0
  38. data/lib/envoy/config/core/v3/resolver_pb.rb +27 -0
  39. data/lib/envoy/config/core/v3/socket_cmsg_headers_pb.rb +25 -0
  40. data/lib/envoy/config/core/v3/socket_option_pb.rb +31 -0
  41. data/lib/envoy/config/core/v3/substitution_format_string_pb.rb +30 -0
  42. data/lib/envoy/config/core/v3/udp_socket_config_pb.rb +26 -0
  43. data/lib/envoy/config/endpoint/v3/endpoint_components_pb.rb +40 -0
  44. data/lib/envoy/config/endpoint/v3/endpoint_pb.rb +32 -0
  45. data/lib/envoy/config/endpoint/v3/load_report_pb.rb +36 -0
  46. data/lib/envoy/service/discovery/v3/ads_pb.rb +26 -0
  47. data/lib/envoy/service/discovery/v3/aggregated_discovery_service.rb +64 -0
  48. data/lib/envoy/service/discovery/v3/discovery_pb.rb +42 -0
  49. data/lib/envoy/type/matcher/v3/address_pb.rb +25 -0
  50. data/lib/envoy/type/matcher/v3/filter_state_pb.rb +27 -0
  51. data/lib/envoy/type/matcher/v3/http_inputs_pb.rb +29 -0
  52. data/lib/envoy/type/matcher/v3/metadata_pb.rb +28 -0
  53. data/lib/envoy/type/matcher/v3/node_pb.rb +27 -0
  54. data/lib/envoy/type/matcher/v3/number_pb.rb +27 -0
  55. data/lib/envoy/type/matcher/v3/path_pb.rb +27 -0
  56. data/lib/envoy/type/matcher/v3/regex_pb.rb +30 -0
  57. data/lib/envoy/type/matcher/v3/status_code_input_pb.rb +25 -0
  58. data/lib/envoy/type/matcher/v3/string_pb.rb +29 -0
  59. data/lib/envoy/type/matcher/v3/struct_pb.rb +28 -0
  60. data/lib/envoy/type/matcher/v3/value_pb.rb +31 -0
  61. data/lib/envoy/type/metadata/v3/metadata_pb.rb +32 -0
  62. data/lib/envoy/type/v3/hash_policy_pb.rb +26 -0
  63. data/lib/envoy/type/v3/http_pb.rb +22 -0
  64. data/lib/envoy/type/v3/http_status_pb.rb +25 -0
  65. data/lib/envoy/type/v3/percent_pb.rb +26 -0
  66. data/lib/envoy/type/v3/range_pb.rb +25 -0
  67. data/lib/envoy/type/v3/ratelimit_strategy_pb.rb +28 -0
  68. data/lib/envoy/type/v3/ratelimit_unit_pb.rb +22 -0
  69. data/lib/envoy/type/v3/semantic_version_pb.rb +23 -0
  70. data/lib/envoy/type/v3/token_bucket_pb.rb +26 -0
  71. data/lib/envoy.rb +83 -0
  72. data/lib/google/protobuf/any_pb.rb +18 -0
  73. data/lib/google/protobuf/duration_pb.rb +18 -0
  74. data/lib/google/protobuf/empty_pb.rb +18 -0
  75. data/lib/google/protobuf/struct_pb.rb +21 -0
  76. data/lib/google/protobuf/timestamp_pb.rb +18 -0
  77. data/lib/google/protobuf/wrappers_pb.rb +26 -0
  78. data/lib/google/rpc/status_pb.rb +20 -0
  79. data/lib/udpa/annotations/migrate_pb.rb +22 -0
  80. data/lib/udpa/annotations/security_pb.rb +23 -0
  81. data/lib/udpa/annotations/sensitive_pb.rb +19 -0
  82. data/lib/udpa/annotations/status_pb.rb +21 -0
  83. data/lib/udpa/annotations/versioning_pb.rb +20 -0
  84. data/lib/validate/validate_pb.rb +43 -0
  85. data/lib/xds/annotations/v3/status_pb.rb +26 -0
  86. data/lib/xds/core/v3/authority_pb.rb +23 -0
  87. data/lib/xds/core/v3/cidr_pb.rb +24 -0
  88. data/lib/xds/core/v3/collection_entry_pb.rb +26 -0
  89. data/lib/xds/core/v3/context_params_pb.rb +22 -0
  90. data/lib/xds/core/v3/extension_pb.rb +23 -0
  91. data/lib/xds/core/v3/resource_locator_pb.rb +26 -0
  92. data/lib/xds/core/v3/resource_name_pb.rb +24 -0
  93. data/lib/xds/core/v3/resource_pb.rb +24 -0
  94. data/lib/xds/type/matcher/v3/domain_pb.rb +27 -0
  95. data/lib/xds/type/matcher/v3/http_inputs_pb.rb +22 -0
  96. data/lib/xds/type/matcher/v3/ip_pb.rb +28 -0
  97. data/lib/xds/type/matcher/v3/matcher_pb.rb +34 -0
  98. data/lib/xds/type/matcher/v3/range_pb.rb +31 -0
  99. data/lib/xds/type/matcher/v3/regex_pb.rb +25 -0
  100. data/lib/xds/type/matcher/v3/string_pb.rb +27 -0
  101. data/license.md +21 -0
  102. data/plan.md +156 -0
  103. data/proto/envoy/annotations/deprecation.proto +34 -0
  104. data/proto/envoy/annotations/resource.proto +19 -0
  105. data/proto/envoy/config/README.md +3 -0
  106. data/proto/envoy/config/cluster/v3/BUILD +18 -0
  107. data/proto/envoy/config/cluster/v3/circuit_breaker.proto +121 -0
  108. data/proto/envoy/config/cluster/v3/cluster.proto +1407 -0
  109. data/proto/envoy/config/cluster/v3/filter.proto +40 -0
  110. data/proto/envoy/config/cluster/v3/outlier_detection.proto +180 -0
  111. data/proto/envoy/config/core/v3/BUILD +16 -0
  112. data/proto/envoy/config/core/v3/address.proto +214 -0
  113. data/proto/envoy/config/core/v3/backoff.proto +37 -0
  114. data/proto/envoy/config/core/v3/base.proto +662 -0
  115. data/proto/envoy/config/core/v3/cel.proto +63 -0
  116. data/proto/envoy/config/core/v3/config_source.proto +283 -0
  117. data/proto/envoy/config/core/v3/event_service_config.proto +29 -0
  118. data/proto/envoy/config/core/v3/extension.proto +32 -0
  119. data/proto/envoy/config/core/v3/grpc_method_list.proto +33 -0
  120. data/proto/envoy/config/core/v3/grpc_service.proto +355 -0
  121. data/proto/envoy/config/core/v3/health_check.proto +443 -0
  122. data/proto/envoy/config/core/v3/http_service.proto +35 -0
  123. data/proto/envoy/config/core/v3/http_uri.proto +58 -0
  124. data/proto/envoy/config/core/v3/protocol.proto +807 -0
  125. data/proto/envoy/config/core/v3/proxy_protocol.proto +114 -0
  126. data/proto/envoy/config/core/v3/resolver.proto +36 -0
  127. data/proto/envoy/config/core/v3/socket_cmsg_headers.proto +28 -0
  128. data/proto/envoy/config/core/v3/socket_option.proto +108 -0
  129. data/proto/envoy/config/core/v3/substitution_format_string.proto +136 -0
  130. data/proto/envoy/config/core/v3/udp_socket_config.proto +32 -0
  131. data/proto/envoy/config/endpoint/v3/BUILD +16 -0
  132. data/proto/envoy/config/endpoint/v3/endpoint.proto +137 -0
  133. data/proto/envoy/config/endpoint/v3/endpoint_components.proto +229 -0
  134. data/proto/envoy/config/endpoint/v3/load_report.proto +220 -0
  135. data/proto/envoy/config/listener/v3/BUILD +18 -0
  136. data/proto/envoy/config/listener/v3/api_listener.proto +34 -0
  137. data/proto/envoy/config/listener/v3/listener.proto +455 -0
  138. data/proto/envoy/config/listener/v3/listener_components.proto +353 -0
  139. data/proto/envoy/config/listener/v3/quic_config.proto +108 -0
  140. data/proto/envoy/config/listener/v3/udp_listener_config.proto +52 -0
  141. data/proto/envoy/config/route/v3/BUILD +19 -0
  142. data/proto/envoy/config/route/v3/route.proto +172 -0
  143. data/proto/envoy/config/route/v3/route_components.proto +2918 -0
  144. data/proto/envoy/config/route/v3/scoped_route.proto +133 -0
  145. data/proto/envoy/extensions/transport_sockets/tls/v3/BUILD +14 -0
  146. data/proto/envoy/extensions/transport_sockets/tls/v3/cert.proto +12 -0
  147. data/proto/envoy/extensions/transport_sockets/tls/v3/common.proto +597 -0
  148. data/proto/envoy/extensions/transport_sockets/tls/v3/secret.proto +61 -0
  149. data/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto +366 -0
  150. data/proto/envoy/extensions/transport_sockets/tls/v3/tls_spiffe_validator_config.proto +67 -0
  151. data/proto/envoy/service/README.md +3 -0
  152. data/proto/envoy/service/discovery/v3/BUILD +13 -0
  153. data/proto/envoy/service/discovery/v3/ads.proto +44 -0
  154. data/proto/envoy/service/discovery/v3/discovery.proto +443 -0
  155. data/proto/envoy/type/BUILD +9 -0
  156. data/proto/envoy/type/hash_policy.proto +28 -0
  157. data/proto/envoy/type/http.proto +24 -0
  158. data/proto/envoy/type/http_status.proto +140 -0
  159. data/proto/envoy/type/matcher/v3/address.proto +22 -0
  160. data/proto/envoy/type/matcher/v3/filter_state.proto +33 -0
  161. data/proto/envoy/type/matcher/v3/http_inputs.proto +71 -0
  162. data/proto/envoy/type/matcher/v3/metadata.proto +110 -0
  163. data/proto/envoy/type/matcher/v3/node.proto +29 -0
  164. data/proto/envoy/type/matcher/v3/number.proto +33 -0
  165. data/proto/envoy/type/matcher/v3/path.proto +31 -0
  166. data/proto/envoy/type/matcher/v3/regex.proto +97 -0
  167. data/proto/envoy/type/matcher/v3/status_code_input.proto +23 -0
  168. data/proto/envoy/type/matcher/v3/string.proto +94 -0
  169. data/proto/envoy/type/matcher/v3/struct.proto +91 -0
  170. data/proto/envoy/type/matcher/v3/value.proto +80 -0
  171. data/proto/envoy/type/metadata/v3/metadata.proto +117 -0
  172. data/proto/envoy/type/percent.proto +52 -0
  173. data/proto/envoy/type/range.proto +43 -0
  174. data/proto/envoy/type/semantic_version.proto +24 -0
  175. data/proto/envoy/type/token_bucket.proto +36 -0
  176. data/proto/envoy/type/v3/BUILD +12 -0
  177. data/proto/envoy/type/v3/hash_policy.proto +43 -0
  178. data/proto/envoy/type/v3/http.proto +24 -0
  179. data/proto/envoy/type/v3/http_status.proto +199 -0
  180. data/proto/envoy/type/v3/percent.proto +57 -0
  181. data/proto/envoy/type/v3/range.proto +50 -0
  182. data/proto/envoy/type/v3/ratelimit_strategy.proto +79 -0
  183. data/proto/envoy/type/v3/ratelimit_unit.proto +37 -0
  184. data/proto/envoy/type/v3/semantic_version.proto +27 -0
  185. data/proto/envoy/type/v3/token_bucket.proto +39 -0
  186. data/proto/google/protobuf/any.proto +162 -0
  187. data/proto/google/protobuf/duration.proto +115 -0
  188. data/proto/google/protobuf/empty.proto +51 -0
  189. data/proto/google/protobuf/struct.proto +95 -0
  190. data/proto/google/protobuf/timestamp.proto +145 -0
  191. data/proto/google/protobuf/wrappers.proto +157 -0
  192. data/proto/google/rpc/status.proto +47 -0
  193. data/proto/readme.md +70 -0
  194. data/proto/udpa/annotations/migrate.proto +49 -0
  195. data/proto/udpa/annotations/security.proto +31 -0
  196. data/proto/udpa/annotations/sensitive.proto +14 -0
  197. data/proto/udpa/annotations/status.proto +34 -0
  198. data/proto/udpa/annotations/versioning.proto +17 -0
  199. data/proto/validate/validate.proto +862 -0
  200. data/proto/xds/annotations/v3/migrate.proto +46 -0
  201. data/proto/xds/annotations/v3/security.proto +30 -0
  202. data/proto/xds/annotations/v3/sensitive.proto +16 -0
  203. data/proto/xds/annotations/v3/status.proto +59 -0
  204. data/proto/xds/annotations/v3/versioning.proto +20 -0
  205. data/proto/xds/core/v3/authority.proto +22 -0
  206. data/proto/xds/core/v3/cidr.proto +25 -0
  207. data/proto/xds/core/v3/collection_entry.proto +55 -0
  208. data/proto/xds/core/v3/context_params.proto +23 -0
  209. data/proto/xds/core/v3/extension.proto +26 -0
  210. data/proto/xds/core/v3/resource.proto +29 -0
  211. data/proto/xds/core/v3/resource_locator.proto +118 -0
  212. data/proto/xds/core/v3/resource_name.proto +42 -0
  213. data/proto/xds/type/matcher/v3/cel.proto +37 -0
  214. data/proto/xds/type/matcher/v3/domain.proto +46 -0
  215. data/proto/xds/type/matcher/v3/http_inputs.proto +23 -0
  216. data/proto/xds/type/matcher/v3/ip.proto +53 -0
  217. data/proto/xds/type/matcher/v3/matcher.proto +144 -0
  218. data/proto/xds/type/matcher/v3/range.proto +69 -0
  219. data/proto/xds/type/matcher/v3/regex.proto +46 -0
  220. data/proto/xds/type/matcher/v3/string.proto +71 -0
  221. data/proto/xds/type/v3/cel.proto +77 -0
  222. data/proto/xds/type/v3/range.proto +40 -0
  223. data/proto/xds/type/v3/typed_struct.proto +44 -0
  224. data/readme.md +37 -0
  225. data/releases.md +5 -0
  226. data/xds/Dockerfile.backend +24 -0
  227. data/xds/Dockerfile.control-plane +22 -0
  228. data/xds/backend_server.rb +68 -0
  229. data/xds/docker-compose.yaml +89 -0
  230. data/xds/go.mod +22 -0
  231. data/xds/go.sum +82 -0
  232. data/xds/readme.md +122 -0
  233. data/xds/test/async/grpc/xds/client.rb +294 -0
  234. data/xds/test/async/grpc/xds/control_plane.rb +94 -0
  235. data/xds/test_server.go +355 -0
  236. data/xds/update_protos.sh +123 -0
  237. data.tar.gz.sig +0 -0
  238. metadata +386 -0
  239. metadata.gz.sig +2 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fba6859fd43ebb2de75d228d4511125d1c7b36f08321fdda99eabf573fea900a
4
+ data.tar.gz: 2bca6eb3ec749a434703f0296f6a12c14e6d75f5518930618355e3abd8b37d83
5
+ SHA512:
6
+ metadata.gz: 78f056024e2e5c0cfbf1e1ca67acf9edd9b6ca357659911cbd733eeb2fde2ff0d73c169724a2caebd1ea1860bfaa09ce07784d950a5e9908c9c1a07c2021e81d
7
+ data.tar.gz: 4e9a3490cfb4e8918994301ef300a2780929dffd31dec5278bf690f976e37681e9476f58899fc668f9799d3d9398fbc740654d42599a7ee8b01c265a47a70e7b
checksums.yaml.gz.sig ADDED
Binary file
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/grpc/interface"
7
+ require_relative "test_message"
8
+
9
+ module Async
10
+ module GRPC
11
+ module Fixtures
12
+ # Test interface for unit tests
13
+ # RPC names use PascalCase to match .proto files
14
+ class TestInterface < Protocol::GRPC::Interface
15
+ rpc :UnaryCall, request_class: Protocol::GRPC::Fixtures::TestMessage,
16
+ response_class: Protocol::GRPC::Fixtures::TestMessage, streaming: :unary
17
+ rpc :ServerStreamingCall, request_class: Protocol::GRPC::Fixtures::TestMessage,
18
+ response_class: Protocol::GRPC::Fixtures::TestMessage, streaming: :server_streaming
19
+ rpc :SayHello, request_class: Protocol::GRPC::Fixtures::TestMessage,
20
+ response_class: Protocol::GRPC::Fixtures::TestMessage, streaming: :unary
21
+ rpc :BidirectionalCall, request_class: Protocol::GRPC::Fixtures::TestMessage,
22
+ response_class: Protocol::GRPC::Fixtures::TestMessage, streaming: :bidirectional
23
+ rpc :ClientStreamingCall, request_class: Protocol::GRPC::Fixtures::TestMessage,
24
+ response_class: Protocol::GRPC::Fixtures::TestMessage, streaming: :client_streaming
25
+ rpc :SlowCall
26
+ end
27
+
28
+ # Test service implementation
29
+ # Method names use snake_case (Ruby convention)
30
+ class TestService < Async::GRPC::Service
31
+ def unary_call(input, output, _call)
32
+ request = input.read
33
+ response = Protocol::GRPC::Fixtures::TestMessage.new(value: "Response: #{request.value}")
34
+ output.write(response)
35
+ end
36
+
37
+ def server_streaming_call(input, output, _call)
38
+ request = input.read
39
+ 3.times do |i|
40
+ response = Protocol::GRPC::Fixtures::TestMessage.new(value: "Response #{i}: #{request.value}")
41
+ output.write(response)
42
+ end
43
+ end
44
+
45
+ def say_hello(input, output, _call)
46
+ request = input.read
47
+ response = Protocol::GRPC::Fixtures::TestMessage.new(value: "Hello, #{request.value}!")
48
+ output.write(response)
49
+ end
50
+
51
+ def bidirectional_call(input, output, _call)
52
+ # Read all input messages and echo them back with a prefix
53
+ input.each do |request|
54
+ response = Protocol::GRPC::Fixtures::TestMessage.new(value: "Echo: #{request.value}")
55
+ output.write(response)
56
+ end
57
+ output.close_write
58
+ end
59
+
60
+ def client_streaming_call(input, output, _call)
61
+ # Read all input messages and return a summary
62
+ values = []
63
+ input.each do |request|
64
+ values << request.value
65
+ end
66
+ response = Protocol::GRPC::Fixtures::TestMessage.new(value: "Received: #{values.join(', ')}")
67
+ output.write(response)
68
+ end
69
+
70
+ def slow_call(input, output, _call)
71
+ request = input.read
72
+ sleep 1 # Simulate a slow operation
73
+ response = Protocol::GRPC::Fixtures::TestMessage.new(value: "Slow response: #{request.value}")
74
+ output.write(response)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module GRPC
8
+ module Fixtures
9
+ # Simple test message for unit tests.
10
+ # Provides a basic protobuf-like message structure.
11
+ class TestMessage
12
+ # @attribute [String | Nil] The message value.
13
+ attr_accessor :value
14
+
15
+ # Initialize a new test message.
16
+ # @parameter value [String | Nil] The message value
17
+ def initialize(value: nil)
18
+ @value = value
19
+ end
20
+
21
+ # Serialize the message to a binary string.
22
+ # @returns [String] Binary representation of the message
23
+ def to_proto
24
+ # Simple serialization: length-prefixed value
25
+ value_data = (@value || "").dup.force_encoding(Encoding::BINARY)
26
+ [value_data.bytesize].pack("N") + value_data
27
+ end
28
+
29
+ alias encode to_proto
30
+
31
+ # Deserialize a binary string into a message.
32
+ # @parameter data [String] Binary representation of the message
33
+ # @returns [TestMessage] Deserialized message instance
34
+ def self.decode(data)
35
+ # Simple binary format: first 4 bytes are length, rest is value
36
+ length = data[0...4].unpack1("N")
37
+ value = data[4...(4 + length)].dup.force_encoding(Encoding::UTF_8)
38
+ new(value: value)
39
+ end
40
+
41
+ # Check equality with another message.
42
+ # @parameter other [Object] The object to compare
43
+ # @returns [Boolean] `true` if values are equal
44
+ def ==(other)
45
+ other.is_a?(TestMessage) && @value == other.value
46
+ end
47
+
48
+ # Get a string representation of the message.
49
+ # @returns [String] String representation
50
+ def inspect
51
+ "#<#{self.class.name} value=#{@value.inspect}>"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async"
7
+ require "async/grpc/client"
8
+ require "envoy/service/discovery/v3/aggregated_discovery_service"
9
+ require "envoy/service/discovery/v3/discovery_pb"
10
+ require "envoy/config/core/v3/base_pb"
11
+
12
+ module Async
13
+ module GRPC
14
+ module XDS
15
+ # Encapsulates a single ADS (Aggregated Discovery Service) bidirectional stream.
16
+ # Owns the stream lifecycle and delegates events to a delegate object.
17
+ class ADSStream
18
+ # Interface for ADSStream delegates. Implement these methods to receive stream events.
19
+ module Delegate
20
+ # Called when a DiscoveryResponse is received from the server.
21
+ # @parameter response [Envoy::Service::Discovery::V3::DiscoveryResponse] The discovery response
22
+ # @parameter stream [ADSStream] The stream instance; use stream.send(request) to send ACKs or new requests
23
+ def discovery_response(response, stream)
24
+ end
25
+ end
26
+
27
+ def initialize(client, node, delegate:)
28
+ @client = client
29
+ @node = node
30
+ @delegate = delegate
31
+ @body = nil
32
+ end
33
+
34
+ # Send a DiscoveryRequest on the stream. Call from within discovery_response to send ACKs.
35
+ # @parameter request [Envoy::Service::Discovery::V3::DiscoveryRequest] The request to send
36
+ def send(request)
37
+ @body&.write(request)
38
+ end
39
+
40
+ # Run the ADS stream. Blocks until the stream completes or errors.
41
+ # @parameter initial [Object | Array | Nil] Initial message(s) to send (defaults to node-only request if nil/empty)
42
+ def run(initial: nil)
43
+ service = Envoy::Service::Discovery::V3::AggregatedDiscoveryService.new(
44
+ "envoy.service.discovery.v3.AggregatedDiscoveryService"
45
+ )
46
+
47
+ initial = Array(initial).any? ? initial : [Envoy::Service::Discovery::V3::DiscoveryRequest.new(node: @node)]
48
+
49
+ @client.invoke(service, :StreamAggregatedResources, nil, initial: initial) do |body, readable_body|
50
+ @body = body
51
+ @delegate.stream_opened(self) if @delegate.respond_to?(:stream_opened)
52
+
53
+ begin
54
+ readable_body.each do |response|
55
+ @delegate.discovery_response(response, self)
56
+ end
57
+ ensure
58
+ @delegate.stream_closed(self) if @delegate.respond_to?(:stream_closed)
59
+ @body = nil
60
+ end
61
+ end
62
+ rescue => error
63
+ @delegate.stream_error(self, error) if @delegate.respond_to?(:stream_error)
64
+ Console.error(self, "Failed while streaming updates!", exception: error)
65
+ raise
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async"
7
+ require "async/http/client"
8
+ require "async/http/endpoint"
9
+ require "protocol/http"
10
+ require "protocol/grpc"
11
+ require "async/grpc/client"
12
+ require "async/grpc/stub"
13
+ require_relative "context"
14
+ require_relative "load_balancer"
15
+
16
+ module Async
17
+ module GRPC
18
+ module XDS
19
+ # Wrapper client for xDS-enabled gRPC connections
20
+ # Follows the same pattern as Async::Redis::SentinelClient and ClusterClient
21
+ class Client < Protocol::HTTP::Middleware
22
+ # Raised when xDS configuration cannot be loaded
23
+ ConfigurationError = Context::ConfigurationError
24
+
25
+ # Raised when no endpoints are available
26
+ class NoEndpointsError < StandardError
27
+ end
28
+
29
+ # Raised when cluster configuration cannot be reloaded
30
+ ReloadError = Context::ReloadError
31
+
32
+ # Create a new xDS client
33
+ # @parameter service_name [String] Target service name (e.g., "myservice")
34
+ # @parameter bootstrap [Hash, String, nil] Bootstrap config (hash, file path, or nil for default)
35
+ # @parameter headers [Protocol::HTTP::Headers] Default headers
36
+ # @parameter options [Hash] Additional options passed to underlying clients
37
+ def initialize(service_name, bootstrap: nil, headers: Protocol::HTTP::Headers.new, node: nil, **options)
38
+ @service_name = service_name
39
+ @bootstrap = load_bootstrap(bootstrap)
40
+ @headers = headers
41
+ @options = options
42
+
43
+ @context = Context.new(@bootstrap, node: node || @bootstrap[:node])
44
+ @load_balancer = nil
45
+ @clients = {} # Cache clients per endpoint (like ClusterClient caches node.client)
46
+ @mutex = Mutex.new
47
+ end
48
+
49
+ # Resolve endpoints lazily (like SentinelClient.resolve_address)
50
+ # @returns [Array<Async::HTTP::Endpoint>] Available endpoints
51
+ def resolve_endpoints
52
+ @mutex.synchronize do
53
+ unless @load_balancer
54
+ # Discover cluster via CDS
55
+ cluster = @context.discover_cluster(@service_name)
56
+
57
+ # Discover endpoints via EDS
58
+ endpoints = @context.discover_endpoints(cluster)
59
+
60
+ raise NoEndpointsError, "No endpoints discovered for #{@service_name}" if endpoints.empty?
61
+
62
+ # Create load balancer
63
+ @load_balancer = LoadBalancer.new(cluster, endpoints)
64
+
65
+ # Set load balancer reference in context for endpoint updates
66
+ @context.load_balancer = @load_balancer
67
+ end
68
+
69
+ @load_balancer.healthy_endpoints
70
+ end
71
+ rescue Context::ReloadError => error
72
+ raise NoEndpointsError, "No endpoints discovered for #{@service_name}", cause: error
73
+ end
74
+
75
+ # Get a client for making calls (like ClusterClient.client_for)
76
+ # Resolves endpoints lazily and picks one via load balancer
77
+ # @returns [Array(Async::GRPC::Client, Async::HTTP::Endpoint)] Client and endpoint for request tracking
78
+ def client_for_call
79
+ endpoints = resolve_endpoints
80
+ raise NoEndpointsError, "No endpoints available for #{@service_name}" if endpoints.empty?
81
+
82
+ # Pick endpoint via load balancer
83
+ endpoint = @load_balancer.pick
84
+ raise NoEndpointsError, "No healthy endpoints available" unless endpoint
85
+
86
+ # Cache client per endpoint (like ClusterClient caches node.client)
87
+ client = @clients[endpoint] ||= begin
88
+ http_client = Async::HTTP::Client.new(endpoint, **@options)
89
+ Async::GRPC::Client.new(http_client, headers: @headers)
90
+ end
91
+ [client, endpoint]
92
+ end
93
+
94
+ # Implement Protocol::HTTP::Middleware interface
95
+ # This allows XDS::Client to be used anywhere Async::GRPC::Client is used
96
+ # @parameter request [Protocol::HTTP::Request] The HTTP request
97
+ # @returns [Protocol::HTTP::Response] The HTTP response
98
+ def call(request, attempts: 3)
99
+ client, endpoint = client_for_call
100
+ @load_balancer.record_request_start(endpoint)
101
+ begin
102
+ client.call(request)
103
+ rescue Protocol::GRPC::Error => error
104
+ # Handle endpoint changes (like ClusterClient handles MOVED/ASK)
105
+ if error.status_code == Protocol::GRPC::Status::UNAVAILABLE
106
+ Console.warn(self, error)
107
+
108
+ # Invalidate cache, reload configuration
109
+ invalidate_cache!
110
+
111
+ attempts -= 1
112
+ retry if attempts > 0
113
+ end
114
+
115
+ raise
116
+ rescue => error
117
+ # Network errors might indicate endpoint failure
118
+ Console.warn(self, error)
119
+
120
+ # Invalidate this specific endpoint
121
+ invalidate_endpoint(client)
122
+
123
+ attempts -= 1
124
+ retry if attempts > 0
125
+
126
+ raise
127
+ end
128
+ ensure
129
+ @load_balancer&.record_request_end(endpoint)
130
+ end
131
+
132
+ # Create a stub for the given interface.
133
+ # Same API as Async::GRPC::Client - load balancing happens per RPC call.
134
+ # @parameter interface_class [Class] Interface class (subclass of Protocol::GRPC::Interface)
135
+ # @parameter service_name [String] Service name (e.g., "hello.Greeter")
136
+ # @returns [Async::GRPC::Stub] Stub object with methods for each RPC
137
+ def stub(interface_class, service_name)
138
+ interface = interface_class.new(service_name)
139
+ Stub.new(self, interface)
140
+ end
141
+
142
+ # Invoke an RPC (called by Stub). Load balances per call.
143
+ # @parameter service [Protocol::GRPC::Interface] Interface instance
144
+ # @parameter method [Symbol, String] Method name
145
+ # @parameter request [Object | Nil] Request message
146
+ # @parameter metadata [Hash] Custom metadata headers
147
+ # @parameter timeout [Numeric | Nil] Optional timeout in seconds
148
+ # @parameter encoding [String | Nil] Optional compression encoding
149
+ # @parameter initial [Object | Array] Optional initial message(s) for bidirectional streaming
150
+ # @yields {|input, output| ...} Block for streaming calls
151
+ # @returns [Object | Protocol::GRPC::Body::ReadableBody] Response message or readable body
152
+ def invoke(service, method, request = nil, metadata: {}, timeout: nil, encoding: nil, initial: nil, attempts: 3, &block)
153
+ client, endpoint = client_for_call
154
+ @load_balancer.record_request_start(endpoint)
155
+ begin
156
+ client.invoke(service, method, request, metadata: metadata, timeout: timeout, encoding: encoding, initial: initial, &block)
157
+ rescue Protocol::GRPC::Error => error
158
+ if error.status_code == Protocol::GRPC::Status::UNAVAILABLE
159
+ Console.warn(self, error)
160
+ invalidate_cache!
161
+ attempts -= 1
162
+ retry if attempts > 0
163
+ end
164
+ raise
165
+ rescue => error
166
+ Console.warn(self, error)
167
+ invalidate_endpoint(client)
168
+ attempts -= 1
169
+ retry if attempts > 0
170
+ raise
171
+ end
172
+ ensure
173
+ @load_balancer&.record_request_end(endpoint)
174
+ end
175
+
176
+ # Close xDS client and all connections
177
+ def close
178
+ @clients.each_value(&:close)
179
+ @clients.clear
180
+ @context.close
181
+ @load_balancer&.close
182
+ end
183
+
184
+ private
185
+
186
+ def load_bootstrap(bootstrap)
187
+ case bootstrap
188
+ when Hash
189
+ normalize_keys(bootstrap)
190
+ when String
191
+ load_bootstrap_file(bootstrap)
192
+ when nil
193
+ load_default_bootstrap
194
+ else
195
+ raise ArgumentError, "Invalid bootstrap: #{bootstrap.inspect}"
196
+ end
197
+ end
198
+
199
+ def load_bootstrap_file(path)
200
+ raise ConfigurationError, "Bootstrap file not found: #{path}" unless File.exist?(path)
201
+
202
+ require "json"
203
+ normalize_keys(JSON.parse(File.read(path), symbolize_names: true))
204
+ rescue JSON::ParserError => error
205
+ raise ConfigurationError, "Invalid bootstrap JSON: #{error.message}"
206
+ end
207
+
208
+ def normalize_keys(object)
209
+ case object
210
+ when Hash
211
+ object.each_with_object({}) do |(key, value), result|
212
+ result[key.to_sym] = normalize_keys(value)
213
+ end
214
+ when Array
215
+ object.map{|value| normalize_keys(value)}
216
+ else
217
+ object
218
+ end
219
+ end
220
+
221
+ def load_default_bootstrap
222
+ # Try environment variable first
223
+ if path = ENV["GRPC_XDS_BOOTSTRAP"]
224
+ return load_bootstrap_file(path)
225
+ end
226
+
227
+ # Try default location
228
+ default_path = File.expand_path("~/.config/grpc/bootstrap.json")
229
+ if File.exist?(default_path)
230
+ return load_bootstrap_file(default_path)
231
+ end
232
+
233
+ raise ConfigurationError, "No bootstrap configuration found"
234
+ end
235
+
236
+ def invalidate_cache!
237
+ @mutex.synchronize do
238
+ @clients.each_value(&:close)
239
+ @clients.clear
240
+ @load_balancer = nil
241
+ end
242
+ end
243
+
244
+ def invalidate_endpoint(client)
245
+ @mutex.synchronize do
246
+ endpoint = @clients.key(client)
247
+ @load_balancer&.mark_unhealthy(endpoint) if endpoint
248
+ @clients.delete_if{|_, cached_client| cached_client == client}
249
+ client.close
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async"
7
+ require_relative "discovery_client"
8
+ require_relative "resource_cache"
9
+ require_relative "resources"
10
+
11
+ module Async
12
+ module GRPC
13
+ module XDS
14
+ # Manages xDS subscriptions and maintains discovered resource state
15
+ class Context
16
+ # Raised when configuration is invalid
17
+ class ConfigurationError < StandardError
18
+ end
19
+
20
+ # Raised when cluster configuration cannot be reloaded
21
+ class ReloadError < StandardError
22
+ end
23
+
24
+ # Initialize xDS context
25
+ # @parameter bootstrap [Hash] Bootstrap configuration
26
+ # @parameter node [Hash] Node information (id, cluster, metadata, locality)
27
+ def initialize(bootstrap, node: nil)
28
+ @bootstrap = bootstrap
29
+ xds_server = bootstrap[:xds_servers]&.first
30
+ raise ConfigurationError, "No xds_servers in bootstrap" unless xds_server
31
+
32
+ @discovery_client = DiscoveryClient.new(xds_server, node: node)
33
+ @cache = ResourceCache.new
34
+ @subscriptions = {} # Track active subscriptions
35
+ @load_balancer = nil # Will be set by Client
36
+ @mutex = Mutex.new
37
+ @cluster_promises = {} # service_name -> Async::Promise (level-triggered: resolved value persists)
38
+ @endpoint_promises = {} # cluster_name -> Async::Promise
39
+ end
40
+
41
+ # Set load balancer reference (called by Client)
42
+ # @parameter load_balancer [LoadBalancer] Load balancer instance
43
+ def load_balancer=(load_balancer)
44
+ @load_balancer = load_balancer
45
+ end
46
+
47
+ # Discover cluster for service (like ClusterClient.reload_cluster!)
48
+ # @parameter service_name [String] Service to discover
49
+ # @returns [Resources::Cluster] Cluster configuration
50
+ def discover_cluster(service_name)
51
+ @mutex.synchronize do
52
+ # Check cache first
53
+ if cluster = @cache.get_cluster(service_name)
54
+ return cluster
55
+ end
56
+
57
+ # Subscribe to CDS if not already subscribed
58
+ unless @subscriptions[:cds]
59
+ @subscriptions[:cds] = subscribe_cds(service_name)
60
+ end
61
+
62
+ # Subscribe to EDS for same name up front (EDS clusters use service name as cluster name)
63
+ # This avoids 10s delay between CDS and EDS - both requests go out together
64
+ subscription_key = :"eds_#{service_name}"
65
+ unless @subscriptions[subscription_key]
66
+ @subscriptions[subscription_key] = subscribe_eds(service_name)
67
+ end
68
+ end
69
+ return @cache.get_cluster(service_name) if @cache.get_cluster(service_name)
70
+
71
+ # Wait for cluster (CDS response)
72
+ cluster = wait_for_cluster(service_name, timeout: 10)
73
+ raise ReloadError, "Failed to discover cluster: #{service_name}" unless cluster
74
+ cluster
75
+ end
76
+
77
+ # Discover endpoints for cluster (like ClusterClient discovers nodes)
78
+ # @parameter cluster [Resources::Cluster] Cluster configuration
79
+ # @returns [Array<Async::HTTP::Endpoint>] Discovered endpoints
80
+ def discover_endpoints(cluster)
81
+ cluster_name = cluster.name
82
+ @mutex.synchronize do
83
+ # Check cache first
84
+ if endpoints = @cache.get_endpoints(cluster_name)
85
+ return endpoints
86
+ end
87
+
88
+ # Subscribe to EDS if not already subscribed
89
+ subscription_key = :"eds_#{cluster_name}"
90
+ unless @subscriptions[subscription_key]
91
+ @subscriptions[subscription_key] = subscribe_eds(cluster_name)
92
+ end
93
+ end
94
+ return @cache.get_endpoints(cluster_name) if @cache.get_endpoints(cluster_name)
95
+
96
+ # Wait outside mutex so EDS callback can run and update cache
97
+ endpoints = wait_for_endpoints(cluster_name, timeout: 10)
98
+ raise ReloadError, "Failed to discover endpoints for cluster: #{cluster_name}" unless endpoints
99
+ endpoints
100
+ end
101
+
102
+ # Subscribe to CDS (Cluster Discovery Service)
103
+ # @parameter service_name [String] Service name
104
+ # @returns [Async::Task] Subscription task
105
+ def subscribe_cds(service_name)
106
+ @discovery_client.subscribe(
107
+ DiscoveryClient::CLUSTER_TYPE,
108
+ [service_name]
109
+ ) do |resources|
110
+ resources.each do |resource|
111
+ cluster = resource.is_a?(Resources::Cluster) ? resource : Resources::Cluster.from_proto(resource)
112
+ @cache.update_cluster(cluster)
113
+ resolve_cluster_promise(cluster.name, cluster)
114
+ end
115
+ end
116
+ end
117
+
118
+ # Subscribe to EDS (Endpoint Discovery Service)
119
+ # @parameter cluster_name [String] Cluster name
120
+ # @returns [Async::Task] Subscription task
121
+ def subscribe_eds(cluster_name)
122
+ @discovery_client.subscribe(
123
+ DiscoveryClient::ENDPOINT_TYPE,
124
+ [cluster_name]
125
+ ) do |resources|
126
+ resources.each do |resource|
127
+ assignment = resource.is_a?(Resources::ClusterLoadAssignment) ? resource : Resources::ClusterLoadAssignment.from_proto(resource)
128
+ endpoints = assignment.endpoints.select(&:healthy?).map do |endpoint|
129
+ Async::HTTP::Endpoint.parse(endpoint.uri, protocol: Async::HTTP::Protocol::HTTP2)
130
+ end
131
+ @cache.update_endpoints(cluster_name, endpoints)
132
+ resolve_endpoint_promise(cluster_name, endpoints) unless endpoints.empty?
133
+ @load_balancer&.update_endpoints(endpoints)
134
+ end
135
+ end
136
+ end
137
+
138
+ # Close all subscriptions
139
+ def close
140
+ @mutex.synchronize do
141
+ @subscriptions.each_value do |task|
142
+ task.stop if task.respond_to?(:stop)
143
+ end
144
+ @subscriptions.clear
145
+ @cluster_promises.clear
146
+ @endpoint_promises.clear
147
+ end
148
+ @discovery_client.close
149
+ end
150
+
151
+ private
152
+
153
+ def wait_for_cluster(service_name, timeout:)
154
+ promise = cluster_promise_for(service_name)
155
+ return promise.value if promise.completed?
156
+
157
+ begin
158
+ promise.wait(timeout: timeout)
159
+ promise.completed? ? promise.value : nil
160
+ rescue Async::TimeoutError
161
+ nil
162
+ end
163
+ end
164
+
165
+ def wait_for_endpoints(cluster_name, timeout:)
166
+ promise = endpoint_promise_for(cluster_name)
167
+ return promise.value if promise.completed?
168
+
169
+ begin
170
+ promise.wait(timeout: timeout)
171
+ promise.completed? ? promise.value : nil
172
+ rescue Async::TimeoutError
173
+ nil
174
+ end
175
+ end
176
+
177
+ def cluster_promise_for(service_name)
178
+ @mutex.synchronize do
179
+ @cluster_promises[service_name] ||= Async::Promise.new
180
+ end
181
+ end
182
+
183
+ def endpoint_promise_for(cluster_name)
184
+ @mutex.synchronize do
185
+ @endpoint_promises[cluster_name] ||= Async::Promise.new
186
+ end
187
+ end
188
+
189
+ def resolve_cluster_promise(service_name, cluster)
190
+ cluster_promise_for(service_name).resolve(cluster)
191
+ @mutex.synchronize{@cluster_promises.delete(service_name)}
192
+ end
193
+
194
+ def resolve_endpoint_promise(cluster_name, endpoints)
195
+ endpoint_promise_for(cluster_name).resolve(endpoints)
196
+ @mutex.synchronize{@endpoint_promises.delete(cluster_name)}
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end