async-grpc-xds 0.2.0 → 0.4.0

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.
@@ -1,294 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- require "async/grpc/xds/client"
7
- require "async/grpc/xds/ads_stream"
8
- require "async/grpc/service"
9
- require "sus/fixtures/async"
10
- require "async/http/endpoint"
11
- require_relative "../../../../../fixtures/async/grpc/test_interface"
12
- require "json"
13
- require "net/http"
14
- require "set"
15
- require "uri"
16
-
17
- describe Async::GRPC::XDS::Client do
18
- include Sus::Fixtures::Async::ReactorContext
19
-
20
- let(:xds_server_uri) {ENV["XDS_SERVER_URI"] || "xds-control-plane:18000"}
21
- let(:xds_admin_uri) {ENV["XDS_ADMIN_URI"] || "http://xds-control-plane:18001"}
22
- let(:service_name) {"myservice"}
23
-
24
- let(:bootstrap) do
25
- {
26
- xds_servers: [
27
- {
28
- server_uri: xds_server_uri,
29
- channel_creds: [{type: "insecure"}]
30
- }
31
- ],
32
- node: {
33
- id: "test-client-#{Process.pid}",
34
- cluster: "test"
35
- }
36
- }
37
- end
38
-
39
- let(:client) {subject.new(service_name, bootstrap: bootstrap)}
40
-
41
- def backend_id(response)
42
- response.value[/Response from ([^:]+)/, 1]
43
- end
44
-
45
- def call_backend_ids(stub, count: 6)
46
- count.times.map do
47
- request = Protocol::GRPC::Fixtures::TestMessage.new(value: "test")
48
- backend_id(stub.unary_call(request))
49
- end
50
- end
51
-
52
- def post_admin(path, payload = {})
53
- uri = URI.join("#{xds_admin_uri}/", path)
54
- request = Net::HTTP::Post.new(uri)
55
- request["Content-Type"] = "application/json"
56
- request.body = JSON.dump(payload)
57
-
58
- response = Net::HTTP.start(uri.host, uri.port) do |http|
59
- http.request(request)
60
- end
61
-
62
- unless response.is_a?(Net::HTTPSuccess)
63
- raise "Admin request failed: #{response.code} #{response.body}"
64
- end
65
-
66
- JSON.parse(response.body)
67
- end
68
-
69
- def set_control_plane_endpoints(*endpoints)
70
- post_admin("endpoints", endpoints: endpoints)
71
- end
72
-
73
- def reset_control_plane_streams
74
- post_admin("reset-streams")
75
- end
76
-
77
- def restore_control_plane_endpoints
78
- set_control_plane_endpoints(
79
- "backend-1:50051",
80
- "backend-2:50052",
81
- "backend-3:50053"
82
- )
83
- end
84
-
85
- def eventually(timeout: 15, interval: 0.1)
86
- deadline = Time.now + timeout
87
- last_error = nil
88
-
89
- while Time.now < deadline
90
- begin
91
- result = yield
92
- return result if result
93
- rescue => error
94
- last_error = error
95
- end
96
-
97
- sleep(interval)
98
- end
99
-
100
- raise last_error if last_error
101
- raise "Timed out waiting for condition"
102
- end
103
-
104
- it "can stream updates" do
105
- skip "Requires xDS control plane (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
106
-
107
- received = []
108
- delegate = Object.new
109
- delegate.define_singleton_method(:discovery_response){|response, _stream| received << response}
110
-
111
- endpoint = Async::HTTP::Endpoint.parse(
112
- "http://#{xds_server_uri}",
113
- protocol: Async::HTTP::Protocol::HTTP2
114
- )
115
- http_client = Async::HTTP::Client.new(endpoint)
116
- grpc_client = Async::GRPC::Client.new(http_client)
117
- node = Envoy::Config::Core::V3::Node.new(id: "test-#{Process.pid}", cluster: "test")
118
-
119
- initial = Envoy::Service::Discovery::V3::DiscoveryRequest.new(
120
- type_url: "type.googleapis.com/envoy.config.cluster.v3.Cluster",
121
- resource_names: [service_name],
122
- node: node
123
- )
124
- stream = Async::GRPC::XDS::ADSStream.new(grpc_client, node, delegate: delegate)
125
-
126
- stream_task = Async{stream.run(initial: initial)}
127
- deadline = Time.now + 10
128
- while received.empty? && Time.now < deadline
129
- sleep(0.1)
130
- end
131
- stream_task.stop
132
-
133
- expect(received.size).to be >= 1
134
- end
135
-
136
- it "can resolve endpoints" do
137
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
138
-
139
- endpoints = client.resolve_endpoints
140
-
141
- expect(endpoints.size).to be >= 1
142
- end
143
-
144
- it "can make RPC calls through xDS" do
145
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
146
-
147
- stub = client.stub(Async::GRPC::Fixtures::TestInterface, service_name)
148
-
149
- request = Protocol::GRPC::Fixtures::TestMessage.new(value: "test")
150
- response = stub.unary_call(request)
151
-
152
- expect(response).to be_a(Protocol::GRPC::Fixtures::TestMessage)
153
- expect(response.value).to be(:include?, "test")
154
- end
155
-
156
- it "load balances across multiple endpoints" do
157
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
158
-
159
- stub = client.stub(Async::GRPC::Fixtures::TestInterface, service_name)
160
- endpoints_used = Set.new
161
-
162
- 10.times do
163
- request = Protocol::GRPC::Fixtures::TestMessage.new(value: "test")
164
- response = stub.unary_call(request)
165
- endpoints_used << response.value
166
- end
167
-
168
- expect(endpoints_used.size).to be >= 1
169
- end
170
-
171
- it "handles bootstrap configuration errors" do
172
- expect{subject.new(service_name, bootstrap: {invalid: "config" })}.to raise_exception(Async::GRPC::XDS::Client::ConfigurationError)
173
- end
174
-
175
- it "handles no endpoints available" do
176
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
177
-
178
- invalid_client = subject.new("nonexistent-service", bootstrap: bootstrap)
179
-
180
- expect{invalid_client.resolve_endpoints}.to raise_exception(Async::GRPC::XDS::Client::NoEndpointsError)
181
- end
182
-
183
- it "evicts resolved promises to prevent unbounded growth" do
184
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
185
-
186
- xds_client = subject.new(service_name, bootstrap: bootstrap)
187
- xds_client.resolve_endpoints
188
-
189
- context = xds_client.instance_variable_get(:@context)
190
- # Resolved promises are evicted immediately; hashes stay bounded
191
- expect(context.instance_variable_get(:@cluster_promises)).to be(:empty?)
192
- expect(context.instance_variable_get(:@endpoint_promises)).to be(:empty?)
193
- end
194
-
195
- it "clears promise caches on close to prevent memory growth" do
196
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
197
-
198
- xds_client = subject.new(service_name, bootstrap: bootstrap)
199
- xds_client.resolve_endpoints
200
-
201
- context = xds_client.instance_variable_get(:@context)
202
- xds_client.close
203
-
204
- # Close clears any remaining promises (e.g. unresolved for nonexistent services)
205
- expect(context.instance_variable_get(:@cluster_promises)).to be(:empty?)
206
- expect(context.instance_variable_get(:@endpoint_promises)).to be(:empty?)
207
- end
208
-
209
- it "applies endpoint updates from the control plane" do
210
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
211
-
212
- begin
213
- set_control_plane_endpoints("backend-1:50051")
214
-
215
- xds_client = subject.new(service_name, bootstrap: bootstrap)
216
- stub = xds_client.stub(Async::GRPC::Fixtures::TestInterface, service_name)
217
-
218
- eventually do
219
- ids = call_backend_ids(stub, count: 3)
220
- ids.all?{|id| id == "backend-1"}
221
- end
222
-
223
- set_control_plane_endpoints("backend-1:50051", "backend-2:50052")
224
-
225
- eventually do
226
- ids = call_backend_ids(stub, count: 6)
227
- ids.include?("backend-2")
228
- end
229
- ensure
230
- xds_client&.close
231
- restore_control_plane_endpoints
232
- end
233
- end
234
-
235
- it "removes and recovers endpoints from control plane updates" do
236
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
237
-
238
- begin
239
- set_control_plane_endpoints("backend-1:50051", "backend-2:50052")
240
-
241
- xds_client = subject.new(service_name, bootstrap: bootstrap)
242
- stub = xds_client.stub(Async::GRPC::Fixtures::TestInterface, service_name)
243
-
244
- eventually do
245
- ids = call_backend_ids(stub, count: 6)
246
- ids.include?("backend-1") && ids.include?("backend-2")
247
- end
248
-
249
- set_control_plane_endpoints("backend-2:50052")
250
-
251
- eventually do
252
- ids = call_backend_ids(stub, count: 4)
253
- ids.all?{|id| id == "backend-2"}
254
- end
255
-
256
- set_control_plane_endpoints("backend-1:50051", "backend-2:50052")
257
-
258
- eventually do
259
- ids = call_backend_ids(stub, count: 6)
260
- ids.include?("backend-1")
261
- end
262
- ensure
263
- xds_client&.close
264
- restore_control_plane_endpoints
265
- end
266
- end
267
-
268
- it "reconnects after the ADS stream is reset" do
269
- skip "Requires docker compose environment (XDS_SERVER_URI)" unless ENV["XDS_SERVER_URI"]
270
-
271
- begin
272
- set_control_plane_endpoints("backend-1:50051")
273
-
274
- xds_client = subject.new(service_name, bootstrap: bootstrap)
275
- stub = xds_client.stub(Async::GRPC::Fixtures::TestInterface, service_name)
276
-
277
- eventually do
278
- ids = call_backend_ids(stub, count: 3)
279
- ids.all?{|id| id == "backend-1"}
280
- end
281
-
282
- reset_control_plane_streams
283
- set_control_plane_endpoints("backend-2:50052")
284
-
285
- eventually(timeout: 20) do
286
- ids = call_backend_ids(stub, count: 4)
287
- ids.all?{|id| id == "backend-2"}
288
- end
289
- ensure
290
- xds_client&.close
291
- restore_control_plane_endpoints
292
- end
293
- end
294
- end
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- require "async/grpc/xds/control_plane"
7
- require "async/grpc/xds/client"
8
- require "async/grpc/xds/server"
9
- require "async/http/endpoint"
10
- require "sus/fixtures/async"
11
- require "socket"
12
-
13
- describe Async::GRPC::XDS::ControlPlane do
14
- include Sus::Fixtures::Async::ReactorContext
15
-
16
- let(:control_plane) {subject.new}
17
-
18
- it "publishes cluster resources" do
19
- control_plane.update_cluster("myservice")
20
-
21
- response = control_plane.response(
22
- Async::GRPC::XDS::ControlPlane::CLUSTER_TYPE,
23
- ["myservice"]
24
- )
25
-
26
- expect(response.version_info).to be == "1"
27
- expect(response.resources.size).to be == 1
28
- expect(response.resources.first.type_url).to be == Async::GRPC::XDS::ControlPlane::CLUSTER_TYPE
29
- end
30
-
31
- it "publishes endpoint resources" do
32
- control_plane.update_endpoints(
33
- "myservice",
34
- [
35
- {addresses: [{address: "127.0.0.1", port: 50051}], healthy: true},
36
- {addresses: [{address: "127.0.0.2", port: 50052}], healthy: false}
37
- ]
38
- )
39
-
40
- response = control_plane.response(
41
- Async::GRPC::XDS::ControlPlane::ENDPOINT_TYPE,
42
- ["myservice"]
43
- )
44
-
45
- expect(response.version_info).to be == "1"
46
- expect(response.resources.size).to be == 1
47
- expect(response.resources.first.type_url).to be == Async::GRPC::XDS::ControlPlane::ENDPOINT_TYPE
48
- end
49
-
50
- it "increments resource versions" do
51
- control_plane.update_endpoints("myservice", [{addresses: [{address: "127.0.0.1", port: 50051}], healthy: true}])
52
- control_plane.update_endpoints("myservice", [{addresses: [{address: "127.0.0.2", port: 50052}], healthy: true}])
53
-
54
- expect(control_plane.version(Async::GRPC::XDS::ControlPlane::ENDPOINT_TYPE)).to be == "2"
55
- end
56
-
57
- it "serves resources over ADS" do
58
- control_plane.update_cluster("myservice")
59
- control_plane.update_endpoints("myservice", [{addresses: [{address: "127.0.0.1", port: 50051}], healthy: true}])
60
-
61
- port = available_port
62
- endpoint = Async::HTTP::Endpoint.parse(
63
- "http://127.0.0.1:#{port}",
64
- protocol: Async::HTTP::Protocol::HTTP2
65
- )
66
- server = Async::GRPC::XDS::Server.new(control_plane)
67
- server_task = Async{server.run(endpoint)}
68
-
69
- client = Async::GRPC::XDS::Client.new("myservice", bootstrap: {
70
- xds_servers: [
71
- {
72
- server_uri: "127.0.0.1:#{port}",
73
- channel_creds: [{type: "insecure"}]
74
- }
75
- ],
76
- node: {id: "test-#{Process.pid}", cluster: "test"}
77
- })
78
-
79
- endpoints = client.resolve_endpoints
80
-
81
- expect(endpoints.map(&:authority)).to be == ["127.0.0.1:50051"]
82
- ensure
83
- client&.close
84
- server_task&.stop
85
- end
86
-
87
- def available_port
88
- server = TCPServer.new("127.0.0.1", 0)
89
- server.addr[1]
90
- ensure
91
- server&.close
92
- end
93
- end