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,196 @@
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/endpoint"
8
+ require_relative "health_checker"
9
+ require_relative "resources"
10
+
11
+ module Async
12
+ module GRPC
13
+ module XDS
14
+ # Client-side load balancing with health checking.
15
+ # RING_HASH and MAGLEV fall back to round-robin (require request context to hash).
16
+ class LoadBalancer
17
+ # Load balancing policies.
18
+ ROUND_ROBIN = :round_robin
19
+ LEAST_REQUEST = :least_request
20
+ RANDOM = :random
21
+ RING_HASH = :ring_hash
22
+ MAGLEV = :maglev
23
+
24
+ # Initialize load balancer
25
+ # @parameter cluster [Resources::Cluster] Cluster configuration
26
+ # @parameter endpoints [Array<Async::HTTP::Endpoint>] Initial endpoints
27
+ def initialize(cluster, endpoints)
28
+ @cluster = cluster
29
+ @endpoints = endpoints
30
+ @policy = parse_policy(cluster.load_balancer_policy)
31
+ @health_status = {} # Track health per endpoint
32
+ @health_checker = HealthChecker.new(cluster.health_checks)
33
+ @current_index = 0
34
+ @in_flight_requests = {} # Track in-flight requests per endpoint
35
+ @health_check_task = nil # Transient task for health check loop
36
+
37
+ # Initialize health status
38
+ @endpoints.each do |endpoint|
39
+ @health_status[endpoint] = :unknown
40
+ end
41
+
42
+ # Start health checking if configured
43
+ start_health_checks if cluster.health_checks.any?
44
+ end
45
+
46
+ # Get healthy endpoints
47
+ # @returns [Array<Async::HTTP::Endpoint>] Healthy endpoints
48
+ def healthy_endpoints
49
+ @endpoints.select{|endpoint| healthy?(endpoint)}
50
+ end
51
+
52
+ # Pick next endpoint using load balancing policy
53
+ # @returns [Async::HTTP::Endpoint, nil] Selected endpoint
54
+ def pick
55
+ healthy = healthy_endpoints
56
+ return nil if healthy.empty?
57
+
58
+ case @policy
59
+ when ROUND_ROBIN
60
+ pick_round_robin(healthy)
61
+ when LEAST_REQUEST
62
+ pick_least_request(healthy)
63
+ when RANDOM
64
+ pick_random(healthy)
65
+ when RING_HASH
66
+ pick_ring_hash(healthy)
67
+ when MAGLEV
68
+ pick_maglev(healthy)
69
+ else
70
+ healthy.first
71
+ end
72
+ end
73
+
74
+ # Update endpoints from EDS
75
+ # @parameter endpoints [Array<Async::HTTP::Endpoint>] New endpoints
76
+ def update_endpoints(endpoints)
77
+ old_endpoints = @endpoints
78
+ @endpoints = endpoints
79
+
80
+ # Update health checker
81
+ @health_checker.update_endpoints(endpoints)
82
+
83
+ # Initialize health status for new endpoints
84
+ endpoints.each do |endpoint|
85
+ @health_status[endpoint] ||= :unknown
86
+ end
87
+
88
+ # Remove state for old endpoints
89
+ (old_endpoints - endpoints).each do |endpoint|
90
+ @health_status.delete(endpoint)
91
+ @in_flight_requests.delete(endpoint)
92
+ end
93
+ end
94
+
95
+ # Record that a request has started for the given endpoint.
96
+ # Used by LEAST_REQUEST policy. Call from Client when a call begins.
97
+ # @parameter endpoint [Async::HTTP::Endpoint] The endpoint handling the request
98
+ def record_request_start(endpoint)
99
+ @in_flight_requests[endpoint] ||= 0
100
+ @in_flight_requests[endpoint] += 1
101
+ end
102
+
103
+ # Record that a request has finished for the given endpoint.
104
+ # Must be called in ensure to decrement even on error/retry.
105
+ # @parameter endpoint [Async::HTTP::Endpoint] The endpoint that handled the request
106
+ def record_request_end(endpoint)
107
+ return unless endpoint
108
+ current = @in_flight_requests[endpoint]
109
+ return unless current && current > 0
110
+ @in_flight_requests[endpoint] = current - 1
111
+ @in_flight_requests.delete(endpoint) if @in_flight_requests[endpoint] == 0
112
+ end
113
+
114
+ # Mark endpoint as unhealthy (e.g. after connection failure).
115
+ # Health checker may restore it on next successful check.
116
+ # @parameter endpoint [Async::HTTP::Endpoint] The endpoint to mark unhealthy
117
+ def mark_unhealthy(endpoint)
118
+ @health_status[endpoint] = :unhealthy
119
+ end
120
+
121
+ # Close load balancer
122
+ def close
123
+ if health_check_task = @health_check_task
124
+ @health_check_task = nil
125
+ health_check_task.stop
126
+ end
127
+
128
+ @health_checker.close
129
+ end
130
+
131
+ private
132
+
133
+ def healthy?(endpoint)
134
+ status = @health_status[endpoint]
135
+ status == :healthy || status == :unknown
136
+ end
137
+
138
+ def pick_round_robin(endpoints)
139
+ @current_index = (@current_index + 1) % endpoints.size
140
+ endpoints[@current_index]
141
+ end
142
+
143
+ def pick_least_request(endpoints)
144
+ # Track in-flight requests and pick endpoint with fewest
145
+ endpoints.min_by{|endpoint| @in_flight_requests[endpoint] || 0}
146
+ end
147
+
148
+ def pick_random(endpoints)
149
+ endpoints.sample
150
+ end
151
+
152
+ def pick_ring_hash(endpoints)
153
+ pick_round_robin(endpoints) # Fallback; requires request context for consistent hashing
154
+ end
155
+
156
+ def pick_maglev(endpoints)
157
+ pick_round_robin(endpoints) # Fallback; requires request context for Maglev hashing
158
+ end
159
+
160
+ def parse_policy(load_balancer_policy)
161
+ # Parse cluster load balancer policy to our constants
162
+ case load_balancer_policy
163
+ when :ROUND_ROBIN, "ROUND_ROBIN"
164
+ ROUND_ROBIN
165
+ when :LEAST_REQUEST, "LEAST_REQUEST"
166
+ LEAST_REQUEST
167
+ when :RANDOM, "RANDOM"
168
+ RANDOM
169
+ when :RING_HASH, "RING_HASH"
170
+ RING_HASH
171
+ when :MAGLEV, "MAGLEV"
172
+ MAGLEV
173
+ else
174
+ ROUND_ROBIN # Default
175
+ end
176
+ end
177
+
178
+ def start_health_checks
179
+ return unless @cluster.health_checks.any?
180
+
181
+ @health_check_task = Async(transient: true) do
182
+ loop do
183
+ @endpoints.each do |endpoint|
184
+ @health_status[endpoint] = @health_checker.check(endpoint)
185
+ end
186
+
187
+ # Sleep for health check interval
188
+ interval = @cluster.health_checks.first[:interval] || 30
189
+ sleep(interval)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "google/protobuf/any_pb"
7
+ require "google/protobuf/duration_pb"
8
+
9
+ require "envoy/config/cluster/v3/cluster_pb"
10
+ require "envoy/config/core/v3/address_pb"
11
+ require "envoy/config/core/v3/config_source_pb"
12
+ require "envoy/config/core/v3/health_check_pb"
13
+ require "envoy/config/core/v3/protocol_pb"
14
+ require "envoy/config/endpoint/v3/endpoint_pb"
15
+ require "envoy/config/endpoint/v3/endpoint_components_pb"
16
+
17
+ module Async
18
+ module GRPC
19
+ module XDS
20
+ # Builds Envoy xDS resource protobufs.
21
+ module ResourceBuilder
22
+ TYPE_URL_PREFIX = "type.googleapis.com"
23
+
24
+ CLUSTER_TYPE = "#{TYPE_URL_PREFIX}/envoy.config.cluster.v3.Cluster"
25
+ ENDPOINT_TYPE = "#{TYPE_URL_PREFIX}/envoy.config.endpoint.v3.ClusterLoadAssignment"
26
+
27
+ def self.pack(resource)
28
+ Google::Protobuf::Any.new(
29
+ type_url: "#{TYPE_URL_PREFIX}/#{resource.class.descriptor.name}",
30
+ value: resource.to_proto
31
+ )
32
+ end
33
+
34
+ def self.cluster(name, service_name: name, load_balancer_policy: :round_robin, connect_timeout: 5)
35
+ Envoy::Config::Cluster::V3::Cluster.new(
36
+ name: name.to_s,
37
+ type: Envoy::Config::Cluster::V3::Cluster::DiscoveryType::EDS,
38
+ eds_cluster_config: Envoy::Config::Cluster::V3::Cluster::EdsClusterConfig.new(
39
+ service_name: service_name.to_s,
40
+ eds_config: Envoy::Config::Core::V3::ConfigSource.new(
41
+ ads: Envoy::Config::Core::V3::AggregatedConfigSource.new
42
+ )
43
+ ),
44
+ connect_timeout: duration(connect_timeout),
45
+ lb_policy: load_balancer_policy_value(load_balancer_policy),
46
+ http2_protocol_options: Envoy::Config::Core::V3::Http2ProtocolOptions.new
47
+ )
48
+ end
49
+
50
+ def self.cluster_load_assignment(cluster_name, endpoints)
51
+ Envoy::Config::Endpoint::V3::ClusterLoadAssignment.new(
52
+ cluster_name: cluster_name.to_s,
53
+ endpoints: [
54
+ Envoy::Config::Endpoint::V3::LocalityLbEndpoints.new(
55
+ lb_endpoints: endpoints.map{|endpoint| load_balancer_endpoint(endpoint)}
56
+ )
57
+ ]
58
+ )
59
+ end
60
+
61
+ def self.load_balancer_endpoint(endpoint)
62
+ endpoint = normalize_endpoint(endpoint)
63
+
64
+ Envoy::Config::Endpoint::V3::LbEndpoint.new(
65
+ endpoint: Envoy::Config::Endpoint::V3::Endpoint.new(
66
+ address: Envoy::Config::Core::V3::Address.new(
67
+ socket_address: Envoy::Config::Core::V3::SocketAddress.new(
68
+ protocol: Envoy::Config::Core::V3::SocketAddress::Protocol::TCP,
69
+ address: endpoint[:address],
70
+ port_value: endpoint[:port]
71
+ )
72
+ ),
73
+ hostname: endpoint[:hostname].to_s
74
+ ),
75
+ health_status: health_status_value(endpoint.fetch(:healthy, true))
76
+ )
77
+ end
78
+
79
+ def self.normalize_endpoint(endpoint)
80
+ case endpoint
81
+ when Hash
82
+ {
83
+ address: endpoint.fetch(:address){endpoint.fetch("address")},
84
+ port: endpoint.fetch(:port){endpoint.fetch("port")}.to_i,
85
+ hostname: endpoint[:hostname] || endpoint["hostname"],
86
+ healthy: endpoint.key?(:healthy) ? endpoint[:healthy] : endpoint.fetch("healthy", true)
87
+ }
88
+ else
89
+ if endpoint.respond_to?(:address) && endpoint.respond_to?(:port)
90
+ {
91
+ address: endpoint.address,
92
+ port: endpoint.port.to_i,
93
+ hostname: endpoint.respond_to?(:hostname) ? endpoint.hostname : nil,
94
+ healthy: endpoint.respond_to?(:healthy?) ? endpoint.healthy? : true
95
+ }
96
+ else
97
+ raise ArgumentError, "Invalid endpoint: #{endpoint.inspect}"
98
+ end
99
+ end
100
+ end
101
+
102
+ def self.duration(seconds)
103
+ whole_seconds = seconds.to_i
104
+ nanos = ((seconds.to_f - whole_seconds) * 1_000_000_000).to_i
105
+
106
+ Google::Protobuf::Duration.new(seconds: whole_seconds, nanos: nanos)
107
+ end
108
+
109
+ def self.load_balancer_policy_value(load_balancer_policy)
110
+ case load_balancer_policy
111
+ when :round_robin, :ROUND_ROBIN, "round_robin", "ROUND_ROBIN"
112
+ Envoy::Config::Cluster::V3::Cluster::LbPolicy::ROUND_ROBIN
113
+ when :least_request, :LEAST_REQUEST, "least_request", "LEAST_REQUEST"
114
+ Envoy::Config::Cluster::V3::Cluster::LbPolicy::LEAST_REQUEST
115
+ when :random, :RANDOM, "random", "RANDOM"
116
+ Envoy::Config::Cluster::V3::Cluster::LbPolicy::RANDOM
117
+ else
118
+ load_balancer_policy
119
+ end
120
+ end
121
+
122
+ def self.health_status_value(healthy)
123
+ case healthy
124
+ when :healthy, :HEALTHY, "healthy", "HEALTHY", true
125
+ Envoy::Config::Core::V3::HealthStatus::HEALTHY
126
+ when :unhealthy, :UNHEALTHY, "unhealthy", "UNHEALTHY", false
127
+ Envoy::Config::Core::V3::HealthStatus::UNHEALTHY
128
+ when :degraded, :DEGRADED, "degraded", "DEGRADED"
129
+ Envoy::Config::Core::V3::HealthStatus::DEGRADED
130
+ else
131
+ Envoy::Config::Core::V3::HealthStatus::UNKNOWN
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ module GRPC
8
+ module XDS
9
+ # Caches discovered xDS resources
10
+ # Thread-safe cache for clusters and endpoints
11
+ class ResourceCache
12
+ def initialize
13
+ @clusters = {}
14
+ @endpoints = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Get cluster by name
19
+ # @parameter name [String] Cluster name
20
+ # @returns [Resources::Cluster, nil] Cached cluster or nil
21
+ def get_cluster(name)
22
+ @mutex.synchronize{@clusters[name]}
23
+ end
24
+
25
+ # Update cluster in cache
26
+ # @parameter cluster [Resources::Cluster] Cluster to cache
27
+ def update_cluster(cluster)
28
+ @mutex.synchronize{@clusters[cluster.name] = cluster}
29
+ end
30
+
31
+ # Get endpoints for cluster
32
+ # @parameter cluster_name [String] Cluster name
33
+ # @returns [Array<Async::HTTP::Endpoint>, nil] Cached endpoints or nil
34
+ def get_endpoints(cluster_name)
35
+ @mutex.synchronize{@endpoints[cluster_name]}
36
+ end
37
+
38
+ # Update endpoints for cluster
39
+ # @parameter cluster_name [String] Cluster name
40
+ # @parameter endpoints [Array<Async::HTTP::Endpoint>] Endpoints to cache
41
+ def update_endpoints(cluster_name, endpoints)
42
+ @mutex.synchronize{@endpoints[cluster_name] = endpoints}
43
+ end
44
+
45
+ # Clear all cached resources
46
+ def clear
47
+ @mutex.synchronize do
48
+ @clusters.clear
49
+ @endpoints.clear
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ module GRPC
8
+ module XDS
9
+ module Resources
10
+ # Represents a discovered cluster
11
+ # Based on envoy.config.cluster.v3.Cluster
12
+ class Cluster
13
+ attr_reader :name, :type, :load_balancer_policy, :health_checks, :circuit_breakers, :eds_cluster_config
14
+
15
+ # Initialize cluster from protobuf or hash
16
+ # @parameter data [Object, Hash] Cluster protobuf or hash representation
17
+ def initialize(data)
18
+ if data.is_a?(Hash)
19
+ @name = data[:name]
20
+ @type = parse_type(data[:type])
21
+ @load_balancer_policy = parse_load_balancer_policy(data[:load_balancer_policy] || data[:lb_policy])
22
+ @health_checks = parse_health_checks(data[:health_checks] || [])
23
+ @circuit_breakers = data[:circuit_breakers]
24
+ @eds_cluster_config = data[:eds_cluster_config]
25
+ else
26
+ # Assume protobuf object
27
+ @name = data.name
28
+ @type = parse_type(data.type)
29
+ @load_balancer_policy = parse_load_balancer_policy(data.lb_policy)
30
+ @health_checks = parse_health_checks(data.health_checks || [])
31
+ @circuit_breakers = data.circuit_breakers
32
+ @eds_cluster_config = data.eds_cluster_config
33
+ end
34
+ end
35
+
36
+ # Create Cluster from protobuf message
37
+ # @parameter proto [Envoy::Config::Cluster::V3::Cluster] Protobuf cluster
38
+ # @returns [Cluster] Cluster instance
39
+ def self.from_proto(proto)
40
+ new(proto)
41
+ end
42
+
43
+ def eds_cluster?
44
+ @type == :EDS
45
+ end
46
+
47
+ private
48
+
49
+ def parse_type(type)
50
+ # Handle protobuf enum values (integers) or symbols/strings
51
+ case type
52
+ when :EDS, "EDS", "envoy.config.cluster.v3.Cluster.EDS", 3
53
+ :EDS
54
+ when :STATIC, "STATIC", "envoy.config.cluster.v3.Cluster.STATIC", 0
55
+ :STATIC
56
+ when :LOGICAL_DNS, "LOGICAL_DNS", "envoy.config.cluster.v3.Cluster.LOGICAL_DNS", 2
57
+ :LOGICAL_DNS
58
+ when :STRICT_DNS, "STRICT_DNS", "envoy.config.cluster.v3.Cluster.STRICT_DNS", 1
59
+ :STRICT_DNS
60
+ else
61
+ # Default to EDS for unknown types
62
+ :EDS
63
+ end
64
+ end
65
+
66
+ def parse_load_balancer_policy(policy)
67
+ # Handle protobuf enum values (integers) or symbols/strings
68
+ case policy
69
+ when :ROUND_ROBIN, "ROUND_ROBIN", "envoy.config.cluster.v3.Cluster.ROUND_ROBIN", 0
70
+ :ROUND_ROBIN
71
+ when :LEAST_REQUEST, "LEAST_REQUEST", "envoy.config.cluster.v3.Cluster.LEAST_REQUEST", 1
72
+ :LEAST_REQUEST
73
+ when :RANDOM, "RANDOM", "envoy.config.cluster.v3.Cluster.RANDOM", 3
74
+ :RANDOM
75
+ when :RING_HASH, "RING_HASH", "envoy.config.cluster.v3.Cluster.RING_HASH", 2
76
+ :RING_HASH
77
+ when :MAGLEV, "MAGLEV", "envoy.config.cluster.v3.Cluster.MAGLEV", 5
78
+ :MAGLEV
79
+ else
80
+ # Default to ROUND_ROBIN
81
+ :ROUND_ROBIN
82
+ end
83
+ end
84
+
85
+ def parse_health_checks(checks)
86
+ Array(checks).map do |check|
87
+ if check.is_a?(Hash)
88
+ {
89
+ type: parse_health_check_type(check[:health_checker] || check[:type] || :HTTP),
90
+ timeout: parse_duration(check[:timeout]),
91
+ interval: parse_duration(check[:interval] || 30),
92
+ path: extract_http_path(check)
93
+ }
94
+ else
95
+ # Protobuf HealthCheck object
96
+ {
97
+ type: parse_health_check_type(check.health_checker),
98
+ timeout: parse_duration(check.timeout),
99
+ interval: parse_duration(check.interval),
100
+ path: extract_http_path_from_proto(check)
101
+ }
102
+ end
103
+ end
104
+ end
105
+
106
+ def parse_health_check_type(checker)
107
+ # Handle protobuf HealthCheck.health_checker oneof
108
+ # checker is the health_checker field from HealthCheck protobuf
109
+ return :HTTP if checker.nil?
110
+
111
+ case checker
112
+ when :http_health_check
113
+ :HTTP
114
+ when :grpc_health_check
115
+ :gRPC
116
+ when :tcp_health_check
117
+ :TCP
118
+ when Hash
119
+ checker_type = checker[:type]
120
+ case checker_type
121
+ when :HTTP, "HTTP", "envoy.config.core.v3.HealthCheck.HttpHealthCheck"
122
+ :HTTP
123
+ when :gRPC, "gRPC", "envoy.config.core.v3.HealthCheck.GrpcHealthCheck"
124
+ :gRPC
125
+ when :TCP, "TCP", "envoy.config.core.v3.HealthCheck.TcpHealthCheck"
126
+ :TCP
127
+ else
128
+ :HTTP # Default
129
+ end
130
+ else
131
+ # Protobuf HealthCheck object - check which oneof field is set
132
+ # The health_checker is a oneof, so we check which field is populated
133
+ if checker.respond_to?(:http_health_check) && checker.http_health_check
134
+ :HTTP
135
+ elsif checker.respond_to?(:grpc_health_check) && checker.grpc_health_check
136
+ :gRPC
137
+ elsif checker.respond_to?(:tcp_health_check) && checker.tcp_health_check
138
+ :TCP
139
+ else
140
+ :HTTP # Default
141
+ end
142
+ end
143
+ end
144
+
145
+ def parse_duration(duration)
146
+ # Convert protobuf Duration to seconds (float)
147
+ return duration if duration.is_a?(Numeric)
148
+ return nil unless duration
149
+
150
+ # If it's a protobuf Duration, convert to seconds
151
+ if duration.respond_to?(:seconds) && duration.respond_to?(:nanos)
152
+ duration.seconds + (duration.nanos.to_f / 1_000_000_000)
153
+ else
154
+ duration.to_f
155
+ end
156
+ end
157
+
158
+ def extract_http_path(check)
159
+ # Extract HTTP path from hash
160
+ return nil unless check.is_a?(Hash)
161
+
162
+ http_check = check[:http_health_check] || {}
163
+ http_check[:path] || "/health"
164
+ end
165
+
166
+ def extract_http_path_from_proto(check)
167
+ # Extract HTTP path from protobuf HealthCheck
168
+ return nil unless check.respond_to?(:http_health_check)
169
+
170
+ http_check = check.http_health_check
171
+ return nil unless http_check
172
+
173
+ http_check.path || "/health"
174
+ end
175
+ end
176
+
177
+ # Represents endpoint assignment (ClusterLoadAssignment)
178
+ # Based on envoy.config.endpoint.v3.ClusterLoadAssignment
179
+ class ClusterLoadAssignment
180
+ attr_reader :cluster_name, :endpoints
181
+
182
+ # Initialize from protobuf or hash
183
+ # @parameter data [Object, Hash] ClusterLoadAssignment protobuf or hash
184
+ def initialize(data)
185
+ if data.is_a?(Hash)
186
+ @cluster_name = data[:cluster_name]
187
+ @endpoints = parse_endpoints(data[:endpoints] || [])
188
+ else
189
+ @cluster_name = data.cluster_name
190
+ @endpoints = parse_endpoints(data.endpoints || [])
191
+ end
192
+ end
193
+
194
+ # Create ClusterLoadAssignment from protobuf message
195
+ # @parameter proto [Envoy::Config::Endpoint::V3::ClusterLoadAssignment] Protobuf assignment
196
+ # @returns [ClusterLoadAssignment] Assignment instance
197
+ def self.from_proto(proto)
198
+ new(proto)
199
+ end
200
+
201
+ private
202
+
203
+ def parse_endpoints(endpoints_data)
204
+ Array(endpoints_data).flat_map do |locality_endpoints|
205
+ load_balancer_endpoints = locality_endpoints.is_a?(Hash) ?
206
+ (locality_endpoints[:lb_endpoints] || []) :
207
+ (locality_endpoints.lb_endpoints || [])
208
+
209
+ load_balancer_endpoints.map{|load_balancer_endpoint| Endpoint.new(load_balancer_endpoint)}
210
+ end
211
+ end
212
+ end
213
+
214
+ # Represents a single endpoint
215
+ # Based on envoy.config.endpoint.v3.LbEndpoint
216
+ class Endpoint
217
+ attr_reader :address, :port, :health_status, :metadata
218
+
219
+ def initialize(load_balancer_endpoint)
220
+ if load_balancer_endpoint.is_a?(Hash)
221
+ endpoint_data = load_balancer_endpoint[:endpoint] || {}
222
+ address_data = endpoint_data[:address] || {}
223
+ socket_address = address_data[:socket_address] || {}
224
+
225
+ @address = socket_address[:address] || "localhost"
226
+ @port = socket_address[:port_value] || 50051
227
+ @health_status = parse_health_status(load_balancer_endpoint[:health_status])
228
+ @metadata = load_balancer_endpoint[:metadata] || {}
229
+ else
230
+ socket_address = load_balancer_endpoint.endpoint.address.socket_address
231
+ @address = socket_address.address
232
+ @port = socket_address.port_value
233
+ @health_status = parse_health_status(load_balancer_endpoint.health_status)
234
+ @metadata = load_balancer_endpoint.metadata || {}
235
+ end
236
+ end
237
+
238
+ def healthy?
239
+ @health_status == :HEALTHY || @health_status == :UNKNOWN
240
+ end
241
+
242
+ def uri
243
+ # Use http for insecure/docker environments (gRPC h2c)
244
+ scheme = ENV["XDS_ENDPOINT_SCHEME"] || "http"
245
+ "#{scheme}://#{@address}:#{@port}"
246
+ end
247
+
248
+ private
249
+
250
+ def parse_health_status(status)
251
+ # Handle protobuf enum values (integers) or symbols/strings
252
+ # HealthStatus is defined in envoy.config.endpoint.v3.Endpoint
253
+ case status
254
+ when :HEALTHY, "HEALTHY", 0
255
+ :HEALTHY
256
+ when :UNHEALTHY, "UNHEALTHY", 1
257
+ :UNHEALTHY
258
+ when :DEGRADED, "DEGRADED", 2
259
+ :DEGRADED
260
+ when :UNKNOWN, "UNKNOWN", 3, nil
261
+ :UNKNOWN
262
+ else
263
+ :UNKNOWN
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async/grpc/dispatcher"
7
+ require "async/http/server"
8
+
9
+ require_relative "control_plane"
10
+ require_relative "service"
11
+
12
+ module Async
13
+ module GRPC
14
+ module XDS
15
+ # Convenience wrapper for serving an xDS control plane over gRPC.
16
+ class Server
17
+ def initialize(control_plane = ControlPlane.new, **options)
18
+ @control_plane = control_plane
19
+ @dispatcher = Async::GRPC::Dispatcher.new
20
+ @dispatcher.register(Service.new(@control_plane))
21
+ @options = options
22
+ end
23
+
24
+ attr :control_plane
25
+ attr :dispatcher
26
+
27
+ def run(endpoint, **options)
28
+ server = Async::HTTP::Server.new(@dispatcher, endpoint, **@options, **options)
29
+ server.run
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end