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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/fixtures/async/grpc/test_interface.rb +79 -0
- data/fixtures/async/grpc/test_message.rb +56 -0
- data/lib/async/grpc/xds/ads_stream.rb +70 -0
- data/lib/async/grpc/xds/client.rb +255 -0
- data/lib/async/grpc/xds/context.rb +201 -0
- data/lib/async/grpc/xds/control_plane.rb +143 -0
- data/lib/async/grpc/xds/discovery_client.rb +356 -0
- data/lib/async/grpc/xds/health_checker.rb +88 -0
- data/lib/async/grpc/xds/load_balancer.rb +196 -0
- data/lib/async/grpc/xds/resource_builder.rb +138 -0
- data/lib/async/grpc/xds/resource_cache.rb +55 -0
- data/lib/async/grpc/xds/resources.rb +270 -0
- data/lib/async/grpc/xds/server.rb +34 -0
- data/lib/async/grpc/xds/service.rb +117 -0
- data/lib/async/grpc/xds/version.rb +12 -0
- data/lib/async/grpc/xds.rb +42 -0
- data/lib/envoy/annotations/deprecation_pb.rb +19 -0
- data/lib/envoy/config/cluster/v3/circuit_breaker_pb.rb +31 -0
- data/lib/envoy/config/cluster/v3/cluster_pb.rb +80 -0
- data/lib/envoy/config/cluster/v3/filter_pb.rb +28 -0
- data/lib/envoy/config/cluster/v3/outlier_detection_pb.rb +29 -0
- data/lib/envoy/config/core/v3/address_pb.rb +38 -0
- data/lib/envoy/config/core/v3/backoff_pb.rb +27 -0
- data/lib/envoy/config/core/v3/base_pb.rb +68 -0
- data/lib/envoy/config/core/v3/cel_pb.rb +24 -0
- data/lib/envoy/config/core/v3/config_source_pb.rb +42 -0
- data/lib/envoy/config/core/v3/event_service_config_pb.rb +27 -0
- data/lib/envoy/config/core/v3/extension_pb.rb +26 -0
- data/lib/envoy/config/core/v3/grpc_method_list_pb.rb +27 -0
- data/lib/envoy/config/core/v3/grpc_service_pb.rb +45 -0
- data/lib/envoy/config/core/v3/health_check_pb.rb +47 -0
- data/lib/envoy/config/core/v3/http_service_pb.rb +27 -0
- data/lib/envoy/config/core/v3/http_uri_pb.rb +27 -0
- data/lib/envoy/config/core/v3/protocol_pb.rb +51 -0
- data/lib/envoy/config/core/v3/proxy_protocol_pb.rb +31 -0
- data/lib/envoy/config/core/v3/resolver_pb.rb +27 -0
- data/lib/envoy/config/core/v3/socket_cmsg_headers_pb.rb +25 -0
- data/lib/envoy/config/core/v3/socket_option_pb.rb +31 -0
- data/lib/envoy/config/core/v3/substitution_format_string_pb.rb +30 -0
- data/lib/envoy/config/core/v3/udp_socket_config_pb.rb +26 -0
- data/lib/envoy/config/endpoint/v3/endpoint_components_pb.rb +40 -0
- data/lib/envoy/config/endpoint/v3/endpoint_pb.rb +32 -0
- data/lib/envoy/config/endpoint/v3/load_report_pb.rb +36 -0
- data/lib/envoy/service/discovery/v3/ads_pb.rb +26 -0
- data/lib/envoy/service/discovery/v3/aggregated_discovery_service.rb +64 -0
- data/lib/envoy/service/discovery/v3/discovery_pb.rb +42 -0
- data/lib/envoy/type/matcher/v3/address_pb.rb +25 -0
- data/lib/envoy/type/matcher/v3/filter_state_pb.rb +27 -0
- data/lib/envoy/type/matcher/v3/http_inputs_pb.rb +29 -0
- data/lib/envoy/type/matcher/v3/metadata_pb.rb +28 -0
- data/lib/envoy/type/matcher/v3/node_pb.rb +27 -0
- data/lib/envoy/type/matcher/v3/number_pb.rb +27 -0
- data/lib/envoy/type/matcher/v3/path_pb.rb +27 -0
- data/lib/envoy/type/matcher/v3/regex_pb.rb +30 -0
- data/lib/envoy/type/matcher/v3/status_code_input_pb.rb +25 -0
- data/lib/envoy/type/matcher/v3/string_pb.rb +29 -0
- data/lib/envoy/type/matcher/v3/struct_pb.rb +28 -0
- data/lib/envoy/type/matcher/v3/value_pb.rb +31 -0
- data/lib/envoy/type/metadata/v3/metadata_pb.rb +32 -0
- data/lib/envoy/type/v3/hash_policy_pb.rb +26 -0
- data/lib/envoy/type/v3/http_pb.rb +22 -0
- data/lib/envoy/type/v3/http_status_pb.rb +25 -0
- data/lib/envoy/type/v3/percent_pb.rb +26 -0
- data/lib/envoy/type/v3/range_pb.rb +25 -0
- data/lib/envoy/type/v3/ratelimit_strategy_pb.rb +28 -0
- data/lib/envoy/type/v3/ratelimit_unit_pb.rb +22 -0
- data/lib/envoy/type/v3/semantic_version_pb.rb +23 -0
- data/lib/envoy/type/v3/token_bucket_pb.rb +26 -0
- data/lib/envoy.rb +83 -0
- data/lib/google/protobuf/any_pb.rb +18 -0
- data/lib/google/protobuf/duration_pb.rb +18 -0
- data/lib/google/protobuf/empty_pb.rb +18 -0
- data/lib/google/protobuf/struct_pb.rb +21 -0
- data/lib/google/protobuf/timestamp_pb.rb +18 -0
- data/lib/google/protobuf/wrappers_pb.rb +26 -0
- data/lib/google/rpc/status_pb.rb +20 -0
- data/lib/udpa/annotations/migrate_pb.rb +22 -0
- data/lib/udpa/annotations/security_pb.rb +23 -0
- data/lib/udpa/annotations/sensitive_pb.rb +19 -0
- data/lib/udpa/annotations/status_pb.rb +21 -0
- data/lib/udpa/annotations/versioning_pb.rb +20 -0
- data/lib/validate/validate_pb.rb +43 -0
- data/lib/xds/annotations/v3/status_pb.rb +26 -0
- data/lib/xds/core/v3/authority_pb.rb +23 -0
- data/lib/xds/core/v3/cidr_pb.rb +24 -0
- data/lib/xds/core/v3/collection_entry_pb.rb +26 -0
- data/lib/xds/core/v3/context_params_pb.rb +22 -0
- data/lib/xds/core/v3/extension_pb.rb +23 -0
- data/lib/xds/core/v3/resource_locator_pb.rb +26 -0
- data/lib/xds/core/v3/resource_name_pb.rb +24 -0
- data/lib/xds/core/v3/resource_pb.rb +24 -0
- data/lib/xds/type/matcher/v3/domain_pb.rb +27 -0
- data/lib/xds/type/matcher/v3/http_inputs_pb.rb +22 -0
- data/lib/xds/type/matcher/v3/ip_pb.rb +28 -0
- data/lib/xds/type/matcher/v3/matcher_pb.rb +34 -0
- data/lib/xds/type/matcher/v3/range_pb.rb +31 -0
- data/lib/xds/type/matcher/v3/regex_pb.rb +25 -0
- data/lib/xds/type/matcher/v3/string_pb.rb +27 -0
- data/license.md +21 -0
- data/plan.md +156 -0
- data/proto/envoy/annotations/deprecation.proto +34 -0
- data/proto/envoy/annotations/resource.proto +19 -0
- data/proto/envoy/config/README.md +3 -0
- data/proto/envoy/config/cluster/v3/BUILD +18 -0
- data/proto/envoy/config/cluster/v3/circuit_breaker.proto +121 -0
- data/proto/envoy/config/cluster/v3/cluster.proto +1407 -0
- data/proto/envoy/config/cluster/v3/filter.proto +40 -0
- data/proto/envoy/config/cluster/v3/outlier_detection.proto +180 -0
- data/proto/envoy/config/core/v3/BUILD +16 -0
- data/proto/envoy/config/core/v3/address.proto +214 -0
- data/proto/envoy/config/core/v3/backoff.proto +37 -0
- data/proto/envoy/config/core/v3/base.proto +662 -0
- data/proto/envoy/config/core/v3/cel.proto +63 -0
- data/proto/envoy/config/core/v3/config_source.proto +283 -0
- data/proto/envoy/config/core/v3/event_service_config.proto +29 -0
- data/proto/envoy/config/core/v3/extension.proto +32 -0
- data/proto/envoy/config/core/v3/grpc_method_list.proto +33 -0
- data/proto/envoy/config/core/v3/grpc_service.proto +355 -0
- data/proto/envoy/config/core/v3/health_check.proto +443 -0
- data/proto/envoy/config/core/v3/http_service.proto +35 -0
- data/proto/envoy/config/core/v3/http_uri.proto +58 -0
- data/proto/envoy/config/core/v3/protocol.proto +807 -0
- data/proto/envoy/config/core/v3/proxy_protocol.proto +114 -0
- data/proto/envoy/config/core/v3/resolver.proto +36 -0
- data/proto/envoy/config/core/v3/socket_cmsg_headers.proto +28 -0
- data/proto/envoy/config/core/v3/socket_option.proto +108 -0
- data/proto/envoy/config/core/v3/substitution_format_string.proto +136 -0
- data/proto/envoy/config/core/v3/udp_socket_config.proto +32 -0
- data/proto/envoy/config/endpoint/v3/BUILD +16 -0
- data/proto/envoy/config/endpoint/v3/endpoint.proto +137 -0
- data/proto/envoy/config/endpoint/v3/endpoint_components.proto +229 -0
- data/proto/envoy/config/endpoint/v3/load_report.proto +220 -0
- data/proto/envoy/config/listener/v3/BUILD +18 -0
- data/proto/envoy/config/listener/v3/api_listener.proto +34 -0
- data/proto/envoy/config/listener/v3/listener.proto +455 -0
- data/proto/envoy/config/listener/v3/listener_components.proto +353 -0
- data/proto/envoy/config/listener/v3/quic_config.proto +108 -0
- data/proto/envoy/config/listener/v3/udp_listener_config.proto +52 -0
- data/proto/envoy/config/route/v3/BUILD +19 -0
- data/proto/envoy/config/route/v3/route.proto +172 -0
- data/proto/envoy/config/route/v3/route_components.proto +2918 -0
- data/proto/envoy/config/route/v3/scoped_route.proto +133 -0
- data/proto/envoy/extensions/transport_sockets/tls/v3/BUILD +14 -0
- data/proto/envoy/extensions/transport_sockets/tls/v3/cert.proto +12 -0
- data/proto/envoy/extensions/transport_sockets/tls/v3/common.proto +597 -0
- data/proto/envoy/extensions/transport_sockets/tls/v3/secret.proto +61 -0
- data/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto +366 -0
- data/proto/envoy/extensions/transport_sockets/tls/v3/tls_spiffe_validator_config.proto +67 -0
- data/proto/envoy/service/README.md +3 -0
- data/proto/envoy/service/discovery/v3/BUILD +13 -0
- data/proto/envoy/service/discovery/v3/ads.proto +44 -0
- data/proto/envoy/service/discovery/v3/discovery.proto +443 -0
- data/proto/envoy/type/BUILD +9 -0
- data/proto/envoy/type/hash_policy.proto +28 -0
- data/proto/envoy/type/http.proto +24 -0
- data/proto/envoy/type/http_status.proto +140 -0
- data/proto/envoy/type/matcher/v3/address.proto +22 -0
- data/proto/envoy/type/matcher/v3/filter_state.proto +33 -0
- data/proto/envoy/type/matcher/v3/http_inputs.proto +71 -0
- data/proto/envoy/type/matcher/v3/metadata.proto +110 -0
- data/proto/envoy/type/matcher/v3/node.proto +29 -0
- data/proto/envoy/type/matcher/v3/number.proto +33 -0
- data/proto/envoy/type/matcher/v3/path.proto +31 -0
- data/proto/envoy/type/matcher/v3/regex.proto +97 -0
- data/proto/envoy/type/matcher/v3/status_code_input.proto +23 -0
- data/proto/envoy/type/matcher/v3/string.proto +94 -0
- data/proto/envoy/type/matcher/v3/struct.proto +91 -0
- data/proto/envoy/type/matcher/v3/value.proto +80 -0
- data/proto/envoy/type/metadata/v3/metadata.proto +117 -0
- data/proto/envoy/type/percent.proto +52 -0
- data/proto/envoy/type/range.proto +43 -0
- data/proto/envoy/type/semantic_version.proto +24 -0
- data/proto/envoy/type/token_bucket.proto +36 -0
- data/proto/envoy/type/v3/BUILD +12 -0
- data/proto/envoy/type/v3/hash_policy.proto +43 -0
- data/proto/envoy/type/v3/http.proto +24 -0
- data/proto/envoy/type/v3/http_status.proto +199 -0
- data/proto/envoy/type/v3/percent.proto +57 -0
- data/proto/envoy/type/v3/range.proto +50 -0
- data/proto/envoy/type/v3/ratelimit_strategy.proto +79 -0
- data/proto/envoy/type/v3/ratelimit_unit.proto +37 -0
- data/proto/envoy/type/v3/semantic_version.proto +27 -0
- data/proto/envoy/type/v3/token_bucket.proto +39 -0
- data/proto/google/protobuf/any.proto +162 -0
- data/proto/google/protobuf/duration.proto +115 -0
- data/proto/google/protobuf/empty.proto +51 -0
- data/proto/google/protobuf/struct.proto +95 -0
- data/proto/google/protobuf/timestamp.proto +145 -0
- data/proto/google/protobuf/wrappers.proto +157 -0
- data/proto/google/rpc/status.proto +47 -0
- data/proto/readme.md +70 -0
- data/proto/udpa/annotations/migrate.proto +49 -0
- data/proto/udpa/annotations/security.proto +31 -0
- data/proto/udpa/annotations/sensitive.proto +14 -0
- data/proto/udpa/annotations/status.proto +34 -0
- data/proto/udpa/annotations/versioning.proto +17 -0
- data/proto/validate/validate.proto +862 -0
- data/proto/xds/annotations/v3/migrate.proto +46 -0
- data/proto/xds/annotations/v3/security.proto +30 -0
- data/proto/xds/annotations/v3/sensitive.proto +16 -0
- data/proto/xds/annotations/v3/status.proto +59 -0
- data/proto/xds/annotations/v3/versioning.proto +20 -0
- data/proto/xds/core/v3/authority.proto +22 -0
- data/proto/xds/core/v3/cidr.proto +25 -0
- data/proto/xds/core/v3/collection_entry.proto +55 -0
- data/proto/xds/core/v3/context_params.proto +23 -0
- data/proto/xds/core/v3/extension.proto +26 -0
- data/proto/xds/core/v3/resource.proto +29 -0
- data/proto/xds/core/v3/resource_locator.proto +118 -0
- data/proto/xds/core/v3/resource_name.proto +42 -0
- data/proto/xds/type/matcher/v3/cel.proto +37 -0
- data/proto/xds/type/matcher/v3/domain.proto +46 -0
- data/proto/xds/type/matcher/v3/http_inputs.proto +23 -0
- data/proto/xds/type/matcher/v3/ip.proto +53 -0
- data/proto/xds/type/matcher/v3/matcher.proto +144 -0
- data/proto/xds/type/matcher/v3/range.proto +69 -0
- data/proto/xds/type/matcher/v3/regex.proto +46 -0
- data/proto/xds/type/matcher/v3/string.proto +71 -0
- data/proto/xds/type/v3/cel.proto +77 -0
- data/proto/xds/type/v3/range.proto +40 -0
- data/proto/xds/type/v3/typed_struct.proto +44 -0
- data/readme.md +37 -0
- data/releases.md +5 -0
- data/xds/Dockerfile.backend +24 -0
- data/xds/Dockerfile.control-plane +22 -0
- data/xds/backend_server.rb +68 -0
- data/xds/docker-compose.yaml +89 -0
- data/xds/go.mod +22 -0
- data/xds/go.sum +82 -0
- data/xds/readme.md +122 -0
- data/xds/test/async/grpc/xds/client.rb +294 -0
- data/xds/test/async/grpc/xds/control_plane.rb +94 -0
- data/xds/test_server.go +355 -0
- data/xds/update_protos.sh +123 -0
- data.tar.gz.sig +0 -0
- metadata +386 -0
- 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
|