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
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "set"
7
+ require "securerandom"
8
+
9
+ require "async/queue"
10
+
11
+ require "envoy/config/core/v3/base_pb"
12
+ require "envoy/service/discovery/v3/discovery_pb"
13
+
14
+ require_relative "resource_builder"
15
+
16
+ module Async
17
+ module GRPC
18
+ module XDS
19
+ # Maintains xDS resource snapshots and notifies ADS streams when resources change.
20
+ class ControlPlane
21
+ CLUSTER_TYPE = ResourceBuilder::CLUSTER_TYPE
22
+ ENDPOINT_TYPE = ResourceBuilder::ENDPOINT_TYPE
23
+
24
+ def initialize(identifier: "async-grpc-xds")
25
+ @identifier = identifier
26
+ @resources = Hash.new{|hash, type_url| hash[type_url] = {}}
27
+ @versions = Hash.new(0)
28
+ @streams = Set.new.compare_by_identity
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ attr :identifier
33
+
34
+ def update_cluster(name, resource = nil, **options)
35
+ resource ||= ResourceBuilder.cluster(name, **options)
36
+ update_resource(CLUSTER_TYPE, name.to_s, resource)
37
+ end
38
+
39
+ def update_endpoints(cluster_name, endpoints)
40
+ update_resource(
41
+ ENDPOINT_TYPE,
42
+ cluster_name.to_s,
43
+ ResourceBuilder.cluster_load_assignment(cluster_name, endpoints)
44
+ )
45
+ end
46
+
47
+ def remove_cluster(name)
48
+ remove_resource(CLUSTER_TYPE, name.to_s)
49
+ end
50
+
51
+ def remove_endpoints(cluster_name)
52
+ remove_resource(ENDPOINT_TYPE, cluster_name.to_s)
53
+ end
54
+
55
+ def update_resource(type_url, name, resource)
56
+ notify = false
57
+
58
+ @mutex.synchronize do
59
+ @resources[type_url][name] = resource
60
+ @versions[type_url] += 1
61
+ notify = true
62
+ end
63
+
64
+ notify_streams(type_url) if notify
65
+ end
66
+
67
+ def remove_resource(type_url, name)
68
+ notify = false
69
+
70
+ @mutex.synchronize do
71
+ if @resources[type_url].delete(name)
72
+ @versions[type_url] += 1
73
+ notify = true
74
+ end
75
+ end
76
+
77
+ notify_streams(type_url) if notify
78
+ end
79
+
80
+ def resource_names(type_url)
81
+ @mutex.synchronize do
82
+ @resources[type_url].keys
83
+ end
84
+ end
85
+
86
+ def resources(type_url, names = nil)
87
+ @mutex.synchronize do
88
+ resources = @resources[type_url]
89
+
90
+ if names && names.any?
91
+ names.filter_map{|name| resources[name]}
92
+ else
93
+ resources.values
94
+ end
95
+ end
96
+ end
97
+
98
+ def version(type_url)
99
+ @mutex.synchronize do
100
+ @versions[type_url].to_s
101
+ end
102
+ end
103
+
104
+ def response(type_url, names = nil)
105
+ resources = self.resources(type_url, names)
106
+ version = self.version(type_url)
107
+
108
+ Envoy::Service::Discovery::V3::DiscoveryResponse.new(
109
+ version_info: version,
110
+ resources: resources.map{|resource| ResourceBuilder.pack(resource)},
111
+ type_url: type_url,
112
+ nonce: "#{type_url}:#{version}:#{SecureRandom.hex(8)}",
113
+ control_plane: Envoy::Config::Core::V3::ControlPlane.new(identifier: @identifier)
114
+ )
115
+ end
116
+
117
+ def register_stream(stream)
118
+ @mutex.synchronize do
119
+ @streams.add(stream)
120
+ end
121
+ end
122
+
123
+ def remove_stream(stream)
124
+ @mutex.synchronize do
125
+ @streams.delete(stream)
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def notify_streams(type_url)
132
+ streams = @mutex.synchronize{@streams.to_a}
133
+
134
+ streams.each do |stream|
135
+ stream.changed(type_url)
136
+ rescue => error
137
+ Console.warn(self, "Failed to notify xDS stream.", stream: stream, exception: error)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,356 @@
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 "async/grpc/client"
10
+ require "async/grpc/xds/ads_stream"
11
+ require "securerandom"
12
+ require "envoy/service/discovery/v3/aggregated_discovery_service"
13
+ require "envoy/service/discovery/v3/discovery_pb"
14
+ require "envoy/config/core/v3/base_pb"
15
+ require "envoy/config/cluster/v3/cluster_pb"
16
+ require "envoy/config/endpoint/v3/endpoint_pb"
17
+ require "google/protobuf/any_pb"
18
+
19
+ module Async
20
+ module GRPC
21
+ module XDS
22
+ # Client for xDS APIs (ADS or individual APIs)
23
+ # Implements Aggregated Discovery Service (ADS) protocol
24
+ # Acts as delegate for ADSStream, receiving discovery_response events
25
+ class DiscoveryClient
26
+ include ADSStream::Delegate
27
+ # xDS API type URLs (v3 API)
28
+ LISTENER_TYPE = "type.googleapis.com/envoy.config.listener.v3.Listener"
29
+ ROUTE_TYPE = "type.googleapis.com/envoy.config.route.v3.RouteConfiguration"
30
+ CLUSTER_TYPE = "type.googleapis.com/envoy.config.cluster.v3.Cluster"
31
+ ENDPOINT_TYPE = "type.googleapis.com/envoy.config.endpoint.v3.ClusterLoadAssignment"
32
+ SECRET_TYPE = "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret"
33
+
34
+ # Initialize xDS discovery client
35
+ # @parameter server_config [Hash] xDS server configuration from bootstrap
36
+ # @parameter node [Hash] Node information (id, cluster, metadata, locality)
37
+ def initialize(server_config, node: nil)
38
+ @server_uri = server_config[:server_uri]
39
+ @channel_creds = server_config[:channel_creds]
40
+ @server_features = server_config[:server_features] || []
41
+ @node_info = node || build_node_info
42
+ @node = build_node_proto(@node_info)
43
+ @grpc_client = nil
44
+ @versions = {} # Track version_info per type_url
45
+ @nonces = {} # Track nonces per type_url
46
+ @mutex = Mutex.new
47
+ @subscriptions = {} # Track subscriptions by type_url
48
+ @stream_task = nil
49
+ @ads_stream = nil # ADSStream instance when connected (owns stream state)
50
+ @stream_ready_promise = nil # Resolved when stream_opened runs
51
+ end
52
+
53
+ # Subscribe to resource type using ADS
54
+ # (Aggregated Discovery Service - single stream for all types)
55
+ # @parameter type_url [String] Resource type URL
56
+ # @parameter resource_names [Array<String>] Resources to subscribe to
57
+ # @yields [Array] Updated resources (as protobuf objects)
58
+ # @returns [Async::Task] Subscription task
59
+ def subscribe(type_url, resource_names, &block)
60
+ # Store subscription callback
61
+ @mutex.synchronize do
62
+ @subscriptions[type_url] = {
63
+ resource_names: resource_names,
64
+ callback: block
65
+ }
66
+ end
67
+
68
+ # Ensure ADS stream is running
69
+ ensure_stream_running
70
+
71
+ # Wait for stream to be ready (event-driven, no polling)
72
+ promise = @stream_ready_promise
73
+ if promise && !promise.completed?
74
+ begin
75
+ promise.wait(timeout: 5)
76
+ rescue Async::TimeoutError
77
+ # Stream didn't open in time; send_discovery_request will no-op if @ads_stream is nil
78
+ end
79
+ end
80
+
81
+ send_discovery_request(type_url, resource_names) if @ads_stream
82
+
83
+ # Return the stream task (already running)
84
+ @stream_task
85
+ end
86
+
87
+ # Close xDS discovery client
88
+ def close
89
+ @mutex.synchronize do
90
+ @stream_task&.stop
91
+ @grpc_client&.close
92
+ @grpc_client = nil
93
+ @subscriptions.clear
94
+ @stream_task = nil
95
+ @ads_stream = nil
96
+ @stream_ready_promise = nil
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def ensure_stream_running
103
+ @mutex.synchronize do
104
+ return if @stream_task&.running?
105
+
106
+ @stream_ready_promise = Async::Promise.new
107
+ @stream_task = Async do |task|
108
+ backoff = 5
109
+ loop do
110
+ begin
111
+ create_and_run_ads_stream(task)
112
+ rescue Async::Stop
113
+ raise
114
+ rescue => error
115
+ Console.error(self, error)
116
+ end
117
+
118
+ @mutex.synchronize do
119
+ @grpc_client&.close
120
+ @grpc_client = nil
121
+ @ads_stream = nil
122
+ @stream_ready_promise = Async::Promise.new
123
+ end
124
+
125
+ sleep(backoff)
126
+ backoff = [backoff * 2, 60].min
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def create_and_run_ads_stream(task)
133
+ # Create gRPC client
134
+ server_uri = @server_uri
135
+ unless server_uri.match?(/^https?:\/\//)
136
+ use_insecure = @channel_creds&.any?{|cred| cred[:type] == "insecure"}
137
+ scheme = use_insecure ? "http" : "https"
138
+ server_uri = "#{scheme}://#{server_uri}"
139
+ end
140
+ Console.debug(self, "Connecting to xDS server:", server_uri: server_uri)
141
+ endpoint = Async::HTTP::Endpoint.parse(server_uri, protocol: Async::HTTP::Protocol::HTTP2)
142
+ http_client = Async::HTTP::Client.new(endpoint)
143
+ grpc_client = Async::GRPC::Client.new(http_client)
144
+
145
+ @mutex.synchronize{@grpc_client = grpc_client}
146
+
147
+ # ADSStream owns the stream; we act as delegate receiving discovery_response events
148
+ ads_stream = ADSStream.new(grpc_client, @node, delegate: self)
149
+ ads_stream.run(initial: build_initial_requests)
150
+ end
151
+
152
+ # ADSStream::Delegate interface - must be public for ADSStream to call
153
+ public
154
+
155
+ def stream_opened(stream)
156
+ @mutex.synchronize{@ads_stream = stream}
157
+ @stream_ready_promise&.resolve(stream)
158
+ end
159
+
160
+ def stream_closed(stream)
161
+ @mutex.synchronize{@ads_stream = nil}
162
+ end
163
+
164
+ def discovery_response(response, stream)
165
+ process_response(response, stream)
166
+ end
167
+
168
+ private
169
+
170
+ def send_discovery_request(type_url, resource_names)
171
+ @mutex.synchronize do
172
+ stream = @ads_stream
173
+ return unless stream
174
+
175
+ request = Envoy::Service::Discovery::V3::DiscoveryRequest.new(
176
+ version_info: @versions[type_url] || "",
177
+ node: @node,
178
+ resource_names: resource_names,
179
+ type_url: type_url,
180
+ response_nonce: @nonces[type_url] || ""
181
+ )
182
+ stream.send(request)
183
+ end
184
+ rescue => error
185
+ Console.error(self, error)
186
+ raise
187
+ end
188
+
189
+ def build_initial_requests
190
+ # Build discovery requests for all active subscriptions.
191
+ # If no subscriptions exist, return minimal request with node info so the server
192
+ # receives data and responds (avoids deadlock when server waits for first message).
193
+ subscriptions_copy = nil
194
+ @mutex.synchronize do
195
+ subscriptions_copy = @subscriptions.dup
196
+ end
197
+
198
+ if subscriptions_copy.empty?
199
+ Console.info(self){"Building initial DiscoveryRequest (no subscriptions yet)"}
200
+ [Envoy::Service::Discovery::V3::DiscoveryRequest.new(node: @node)]
201
+ else
202
+ Console.info(self){"Building #{subscriptions_copy.size} subscription requests"}
203
+ subscriptions_copy.map do |type_url, subscription|
204
+ Envoy::Service::Discovery::V3::DiscoveryRequest.new(
205
+ version_info: @versions[type_url] || "",
206
+ node: @node,
207
+ resource_names: subscription[:resource_names],
208
+ type_url: type_url,
209
+ response_nonce: @nonces[type_url] || ""
210
+ )
211
+ end
212
+ end
213
+ end
214
+
215
+ def process_response(response, stream)
216
+ type_url = response.type_url
217
+ Console.debug(self, "Processing response:", type_url: type_url)
218
+
219
+ callback = nil
220
+ resources = nil
221
+ resource_names = nil
222
+
223
+ @mutex.synchronize do
224
+ subscription = @subscriptions[type_url]
225
+ unless subscription
226
+ Console.warn(self, "No subscription found!", type_url: type_url)
227
+ return
228
+ end
229
+
230
+ # Update version and nonce
231
+ @versions[type_url] = response.version_info
232
+ @nonces[type_url] = response.nonce
233
+
234
+ # Deserialize resources (skip failed; callback receives only valid resources)
235
+ resources = response.resources.filter_map do |any_resource|
236
+ deserialize_resource(any_resource, type_url)
237
+ end
238
+
239
+ # Capture for use outside mutex (avoid deadlock)
240
+ callback = subscription[:callback]
241
+ resource_names = subscription[:resource_names]
242
+ end
243
+
244
+ # Call callback outside mutex
245
+ if callback
246
+ callback.call(resources)
247
+ else
248
+ Console.warn(self, "No callback found!", type_url: type_url)
249
+ end
250
+
251
+ # Send ACK (acknowledge receipt)
252
+ @mutex.synchronize do
253
+ send_ack(type_url, resource_names, stream)
254
+ end
255
+ end
256
+
257
+ def send_ack(type_url, resource_names, stream)
258
+ request = Envoy::Service::Discovery::V3::DiscoveryRequest.new(
259
+ version_info: @versions[type_url] || "",
260
+ node: @node,
261
+ resource_names: resource_names,
262
+ type_url: type_url,
263
+ response_nonce: @nonces[type_url] || ""
264
+ )
265
+ stream.send(request)
266
+ rescue => error
267
+ Console.warn(self, "Failed to send ACK: #{error.message}")
268
+ end
269
+
270
+ def deserialize_resource(any_resource, type_url)
271
+ # Deserialize google.protobuf.Any to appropriate resource type
272
+ # Based on type_url, decode the value to the correct protobuf message
273
+ case type_url
274
+ when CLUSTER_TYPE
275
+ # Decode Cluster from Any.value
276
+ begin
277
+ cluster_proto = Envoy::Config::Cluster::V3::Cluster.decode(any_resource.value)
278
+ Resources::Cluster.from_proto(cluster_proto)
279
+ rescue => error
280
+ Console.warn(self, "Failed to deserialize Cluster: #{error.message}")
281
+ nil
282
+ end
283
+ when ENDPOINT_TYPE
284
+ # Decode ClusterLoadAssignment from Any.value
285
+ begin
286
+ endpoint_proto = Envoy::Config::Endpoint::V3::ClusterLoadAssignment.decode(any_resource.value)
287
+ Resources::ClusterLoadAssignment.from_proto(endpoint_proto)
288
+ rescue => error
289
+ Console.warn(self, "Failed to deserialize ClusterLoadAssignment: #{error.message}")
290
+ nil
291
+ end
292
+ else
293
+ # For other types, return raw protobuf for now
294
+ any_resource
295
+ end
296
+ end
297
+
298
+ def build_node_proto(node_info)
299
+ # Build envoy.config.core.v3.Node protobuf
300
+ Envoy::Config::Core::V3::Node.new(
301
+ id: node_info[:id] || generate_node_id,
302
+ cluster: node_info[:cluster] || ENV["XDS_CLUSTER"] || "default",
303
+ metadata: build_metadata_struct(node_info[:metadata] || {}),
304
+ locality: node_info[:locality] ? build_locality_proto(node_info[:locality]) : nil
305
+ )
306
+ end
307
+
308
+ def build_metadata_struct(metadata_hash)
309
+ # Convert hash to google.protobuf.Struct
310
+ return nil if metadata_hash.empty?
311
+
312
+ fields = {}
313
+ metadata_hash.each do |key, value|
314
+ fields[key.to_s] = case value
315
+ when String
316
+ Google::Protobuf::Value.new(string_value: value)
317
+ when Numeric
318
+ Google::Protobuf::Value.new(number_value: value.to_f)
319
+ when TrueClass, FalseClass
320
+ Google::Protobuf::Value.new(bool_value: value)
321
+ else
322
+ Google::Protobuf::Value.new(string_value: value.to_s)
323
+ end
324
+ end
325
+
326
+ Google::Protobuf::Struct.new(fields: fields)
327
+ end
328
+
329
+ def build_locality_proto(locality_hash)
330
+ # Build envoy.config.core.v3.Locality protobuf
331
+ Envoy::Config::Core::V3::Locality.new(
332
+ region: locality_hash[:region] || "",
333
+ zone: locality_hash[:zone] || "",
334
+ sub_zone: locality_hash[:sub_zone] || ""
335
+ )
336
+ end
337
+
338
+ def build_node_info
339
+ # Build node identification for xDS server
340
+ # Based on envoy.config.core.v3.Node
341
+ {
342
+ id: generate_node_id,
343
+ cluster: ENV["XDS_CLUSTER"] || "default",
344
+ metadata: {},
345
+ locality: nil
346
+ }
347
+ end
348
+
349
+ def generate_node_id
350
+ # Generate unique node ID
351
+ "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(4)}"
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,88 @@
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
+
11
+ module Async
12
+ module GRPC
13
+ module XDS
14
+ # Performs health checks on endpoints. Called by LoadBalancer's loop.
15
+ # Runs within the caller's reactor; does not spawn tasks or reactors.
16
+ # Only HTTP health checks are supported; gRPC health checks return :unknown.
17
+ class HealthChecker
18
+ # Initialize health checker
19
+ # @parameter health_checks [Array<Hash>] Health check configurations from cluster
20
+ def initialize(health_checks)
21
+ @health_checks = health_checks
22
+ @endpoints = []
23
+ @cache = {}
24
+ end
25
+
26
+ # Update endpoints (cleans cache for removed endpoints)
27
+ # @parameter endpoints [Array<Async::HTTP::Endpoint>] Current endpoints
28
+ def update_endpoints(endpoints)
29
+ removed = @endpoints - endpoints
30
+ removed.each{|endpoint| @cache.delete(endpoint)}
31
+ @endpoints = endpoints
32
+ end
33
+
34
+ # Check health of endpoint. Runs in caller's reactor.
35
+ # @parameter endpoint [Async::HTTP::Endpoint] Endpoint to check
36
+ # @returns [Symbol] :healthy, :unhealthy, or :unknown
37
+ def check(endpoint)
38
+ if cached = @cache[endpoint]
39
+ return cached[:status] if Time.now - cached[:time] < 5
40
+ end
41
+
42
+ status = perform_check(endpoint)
43
+ @cache[endpoint] = {status: status, time: Time.now}
44
+ status
45
+ end
46
+
47
+ # Close health checker
48
+ def close
49
+ @cache.clear
50
+ end
51
+
52
+ private
53
+
54
+ def perform_check(endpoint)
55
+ health_check = @health_checks.first
56
+ return :unknown unless health_check
57
+
58
+ case health_check[:type]
59
+ when :HTTP, "HTTP"
60
+ check_http_health(endpoint, health_check)
61
+ when :gRPC, "gRPC"
62
+ check_grpc_health(endpoint, health_check)
63
+ else
64
+ :unknown
65
+ end
66
+ rescue => error
67
+ Console.warn(self, "Health check failed for #{endpoint}: #{error.message}")
68
+ :unhealthy
69
+ end
70
+
71
+ def check_http_health(endpoint, health_check)
72
+ path = health_check[:path] || "/health"
73
+ http_client = Async::HTTP::Client.new(endpoint)
74
+ request = Protocol::HTTP::Request["GET", path]
75
+ response = http_client.call(request)
76
+ response.status == 200 ? :healthy : :unhealthy
77
+ ensure
78
+ http_client&.close
79
+ end
80
+
81
+ def check_grpc_health(endpoint, health_check)
82
+ # gRPC health checks (grpc.health.v1.Health) not implemented
83
+ :unknown
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end