diff --git a/async-service-supervisor-envoy.gemspec b/async-service-supervisor-envoy.gemspec index faee9d7..c0a9251 100644 --- a/async-service-supervisor-envoy.gemspec +++ b/async-service-supervisor-envoy.gemspec @@ -25,7 +25,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "async", "~> 2.38" - spec.add_dependency "async-grpc-xds" + spec.add_dependency "async-grpc-xds", "~> 0.1" spec.add_dependency "async-http" - spec.add_dependency "async-service-supervisor" + spec.add_dependency "async-service-supervisor", "~> 0.18" end diff --git a/context/getting-started.md b/context/getting-started.md index af25279..35e94f4 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -21,21 +21,36 @@ The gem depends on `async-service-supervisor` and `async-grpc-xds`. The monitor runs an xDS control plane endpoint. Envoy connects to it using ADS and receives CDS/EDS updates derived from supervisor worker state. -## Worker State +## Endpoint State -Workers are published when they register with `state[:endpoint]`: +Workers are published when they register concrete endpoint state: ``` ruby state = { - name: "myservice", endpoint: { - address: "127.0.0.1", - port: 50051 + name: "myservice", + scheme: :http, + protocols: ["http/1.1"], + addresses: [ + {address: "127.0.0.1", port: 50051}, + {path: "/run/myservice/worker.ipc"} + ] } } ``` -Workers without `state[:endpoint]` are ignored by the Envoy monitor. +Workers without endpoint state are ignored by the Envoy monitor. + +Falcon cluster workers can register their concrete post-bind listener automatically: + +``` ruby +service "application" do + include Falcon::Environment::Cluster + include Async::Service::Supervisor::Envoy::Supervised +end +``` + +Falcon describes its bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, supported protocols, and every concrete IP or Unix socket address. Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. ## Monitor Usage diff --git a/control-plane/backend.rb b/control-plane/backend.rb index 2be4e1a..af84c76 100644 --- a/control-plane/backend.rb +++ b/control-plane/backend.rb @@ -45,15 +45,15 @@ worker = Async::Service::Supervisor::Worker.new( endpoint: supervisor_endpoint, state: { - name: service_name, endpoint: { - address: backend_address, - port: backend_port + name: service_name, + scheme: :http, + protocols: Async::HTTP::Protocol::HTTP1.names, + addresses: [{address: backend_address, port: backend_port}], } } ) - + worker.run server.run end - \ No newline at end of file diff --git a/control-plane/readme.md b/control-plane/readme.md index e489cb1..8a3c72c 100644 --- a/control-plane/readme.md +++ b/control-plane/readme.md @@ -3,7 +3,7 @@ This scenario exercises the intended Envoy control plane topology: - Falcon workers register with `Async::Service::Supervisor::Worker`. -- Each worker publishes `state[:endpoint]` with an `address` and `port`. +- Each worker publishes `state[:endpoint]` with its name, scheme, supported protocols, and concrete addresses. - `Async::Service::Supervisor::Envoy::Monitor` maps supervisor state into xDS endpoint resources. - Envoy connects to the supervisor's xDS server and subscribes to endpoint updates. - Envoy routes HTTP traffic to the supervised Falcon workers. diff --git a/examples/falcon/falcon.rb b/examples/falcon/falcon.rb new file mode 100755 index 0000000..0c8bd90 --- /dev/null +++ b/examples/falcon/falcon.rb @@ -0,0 +1,63 @@ +#!/usr/bin/env async-service +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/service/supervisor" +require "async/service/supervisor/envoy" +require "falcon/environment/cluster" +require "fileutils" +require "io/endpoint/unix_endpoint" + +service_name = ENV.fetch("SERVICE_NAME", "hello-world") +xds_bind = ENV.fetch("XDS_BIND", "http://127.0.0.1:18000") +socket_directory = File.expand_path(ENV.fetch("SOCKET_DIRECTORY", "sockets"), __dir__) +FileUtils.mkdir_p(socket_directory) + +service service_name do + include Falcon::Environment::Cluster + include Async::Service::Supervisor::Envoy::Supervised + + count 2 + + endpoint do + worker_id = "#{Process.pid}-#{Thread.current.object_id}" + transport = IO::Endpoint.unix(File.join(socket_directory, "#{worker_id}.ipc")) + + Async::HTTP::Endpoint.parse( + "http://localhost", + transport, + protocol: Async::HTTP::Protocol::HTTP1, + ) + end + + middleware do + rack_application = proc do |env| + body = "Hello World\n" + + [ + 200, + { + "content-type" => "text/plain", + "content-length" => body.bytesize.to_s + }, + [body] + ] + end + + Falcon::Server.middleware(rack_application, cache: false) + end +end + +service "supervisor" do + include Async::Service::Supervisor::Environment + + monitors do + [ + Async::Service::Supervisor::Envoy::Monitor.new( + bind: xds_bind + ) + ] + end +end diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index af25279..35e94f4 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -21,21 +21,36 @@ The gem depends on `async-service-supervisor` and `async-grpc-xds`. The monitor runs an xDS control plane endpoint. Envoy connects to it using ADS and receives CDS/EDS updates derived from supervisor worker state. -## Worker State +## Endpoint State -Workers are published when they register with `state[:endpoint]`: +Workers are published when they register concrete endpoint state: ``` ruby state = { - name: "myservice", endpoint: { - address: "127.0.0.1", - port: 50051 + name: "myservice", + scheme: :http, + protocols: ["http/1.1"], + addresses: [ + {address: "127.0.0.1", port: 50051}, + {path: "/run/myservice/worker.ipc"} + ] } } ``` -Workers without `state[:endpoint]` are ignored by the Envoy monitor. +Workers without endpoint state are ignored by the Envoy monitor. + +Falcon cluster workers can register their concrete post-bind listener automatically: + +``` ruby +service "application" do + include Falcon::Environment::Cluster + include Async::Service::Supervisor::Envoy::Supervised +end +``` + +Falcon describes its bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, supported protocols, and every concrete IP or Unix socket address. Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. ## Monitor Usage diff --git a/lib/async/service/supervisor/envoy.rb b/lib/async/service/supervisor/envoy.rb index e4ccbfb..a3c6ed7 100644 --- a/lib/async/service/supervisor/envoy.rb +++ b/lib/async/service/supervisor/envoy.rb @@ -7,3 +7,4 @@ require_relative "envoy/delegate" require_relative "envoy/endpoint" require_relative "envoy/monitor" +require_relative "envoy/supervised" diff --git a/lib/async/service/supervisor/envoy/delegate.rb b/lib/async/service/supervisor/envoy/delegate.rb index 6b936fa..d0725a2 100644 --- a/lib/async/service/supervisor/envoy/delegate.rb +++ b/lib/async/service/supervisor/envoy/delegate.rb @@ -5,28 +5,24 @@ require_relative "endpoint" -# @namespace module Async - # @namespace module Service - # @namespace module Supervisor - # Provides Envoy integration for supervisor-managed services. module Envoy - # Maps supervisor controller state into Envoy endpoint records. + # Maps supervisor state into Envoy upstream endpoints. class Delegate - # Extract endpoint state from the supervisor controller. + # Extract serialized endpoints from a supervisor controller. # @parameter supervisor_controller [Object] The supervisor controller describing the worker. - # @returns [Endpoint | Array(Endpoint) | Hash | Array(Hash) | Nil] The endpoint state to publish. + # @returns [Hash | Array(Hash) | Nil] The serialized endpoint state. def endpoints(supervisor_controller) state = supervisor_controller.state - state[:endpoints] || state["endpoints"] || state[:endpoint] || state["endpoint"] + state[:endpoints] || state[:endpoint] end - # Convert endpoint state into endpoint values. + # Convert serialized state into Envoy endpoint values. # @parameter supervisor_controller [Object] The supervisor controller describing the worker. - # @returns [Array(Endpoint)] The endpoints to publish. + # @returns [Array(Endpoint)] The upstream endpoints to publish. def endpoint_list(supervisor_controller) case endpoints = self.endpoints(supervisor_controller) when nil @@ -43,9 +39,7 @@ def endpoint_list(supervisor_controller) # @parameter endpoint [Endpoint] The endpoint being published. # @returns [String | Nil] The cluster name, or nil to skip the endpoint. def cluster(supervisor_controller, endpoint) - state = supervisor_controller.state - - endpoint.name || state[:name] || state["name"] + endpoint.name end # Determine whether an endpoint should be published as healthy. diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 7028bd1..accf1b1 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -3,93 +3,67 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -# @namespace module Async - # @namespace module Service - # @namespace module Supervisor - # Provides Envoy integration for supervisor-managed services. module Envoy - # Represents an endpoint published to Envoy EDS. + # Represents one upstream endpoint published to Envoy EDS. class Endpoint - # Wrap an endpoint-like value. - # @parameter value [Endpoint | Hash | Object | Nil] The endpoint value to wrap. - # @returns [Endpoint | Nil] The wrapped endpoint, or `nil` if no endpoint was supplied. - # @raises [ArgumentError] If the value cannot be converted to an endpoint. + # Wrap serialized endpoint state. + # @parameter value [Endpoint | Hash] The value to wrap. + # @returns [Endpoint] The endpoint value. def self.wrap(value) case value - when nil - nil when self value when Hash - new(**symbolize_keys(value)) + new(**value) else raise ArgumentError, "Invalid Envoy endpoint: #{value.inspect}" end end - # Convert hash keys to symbols. - # @parameter hash [Hash] The hash to convert. - # @returns [Hash] A copy of the hash with symbol keys. - def self.symbolize_keys(hash) - hash.each_with_object({}) do |(key, value), result| - result[key.to_sym] = value + # Normalize a concrete endpoint address. + # @parameter value [Hash] The address value. + # @returns [Hash] A normalized IP or Unix address. + def self.normalize_address(value) + if path = value[:path] + raise ArgumentError, "A Unix endpoint cannot specify an IP address or port!" if value[:address] || value[:port] + + {path: path.to_s}.freeze + elsif value[:address] && value[:port] + {address: value[:address].to_s, port: Integer(value[:port])}.freeze + else + raise ArgumentError, "An endpoint address requires either path, or address and port: #{value.inspect}" end end - private_class_method :symbolize_keys - - # Initialize the endpoint. - # @parameter name [String | Nil] The optional endpoint name. - # @parameter address [String] The endpoint IP address or hostname. - # @parameter port [Integer] The endpoint port. - # @parameter hostname [String | Nil] The optional endpoint hostname. - # @parameter protocol [String | Symbol | Nil] The optional endpoint protocol. - # @parameter healthy [Boolean] Whether the endpoint should be published as healthy. - def initialize(address:, port:, name: nil, hostname: nil, protocol: nil, healthy: true) - @name = name - @address = address - @port = port.to_i - @hostname = hostname - @protocol = protocol - @healthy = healthy + # Initialize an endpoint. + # @parameter name [String] The upstream cluster name. + # @parameter scheme [String | Symbol] The upstream application scheme. + # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. + # @parameter addresses [Array(Hash)] The grouped concrete addresses. + def initialize(name:, scheme:, protocols:, addresses:) + @name = name.to_s + @scheme = scheme.to_sym + @protocols = protocols.map(&:to_s).uniq.freeze + raise ArgumentError, "An endpoint requires at least one protocol!" if @protocols.empty? + @addresses = addresses.map{|value| self.class.normalize_address(value)}.freeze + raise ArgumentError, "An endpoint requires at least one address!" if @addresses.empty? end - # @attribute [String | Nil] The optional endpoint name. + # @attribute [String] The upstream cluster name. attr :name - # @attribute [String] The endpoint IP address or hostname. - attr :address - - # @attribute [Integer] The endpoint port. - attr :port + # @attribute [Symbol] The upstream application scheme. + attr :scheme - # @attribute [String | Nil] The optional endpoint hostname. - attr :hostname + # @attribute [Array(String)] The supported upstream HTTP protocol names. + attr :protocols - # @attribute [String | Symbol | Nil] The optional endpoint protocol. - attr :protocol + # @attribute [Array(Hash)] The grouped concrete addresses. + attr :addresses - # Whether the endpoint is healthy. - # @returns [Boolean] Returns `true` when the endpoint should be published as healthy. - def healthy? - @healthy - end - - # Convert the endpoint to a hash suitable for the xDS control plane. - # @returns [Hash] The endpoint attributes. - def to_h - { - name: @name, - address: @address, - port: @port, - hostname: @hostname, - protocol: @protocol, - healthy: @healthy - }.compact - end end end end diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index f8c900b..fed8cc0 100644 --- a/lib/async/service/supervisor/envoy/monitor.rb +++ b/lib/async/service/supervisor/envoy/monitor.rb @@ -11,11 +11,8 @@ require_relative "delegate" require_relative "endpoint" -# @namespace module Async - # @namespace module Service - # @namespace module Supervisor # Provides Envoy integration for supervisor-managed services. module Envoy @@ -106,19 +103,12 @@ def run_once def build_record(supervisor_controller, endpoint) cluster = @delegate.cluster(supervisor_controller, endpoint) - return unless cluster && endpoint + return unless cluster { - controller: supervisor_controller, cluster: cluster.to_s, - endpoint: Endpoint.new( - name: endpoint.name, - address: endpoint.address, - port: endpoint.port, - hostname: endpoint.hostname, - protocol: endpoint.protocol, - healthy: @delegate.healthy?(supervisor_controller, endpoint) - ) + endpoint: endpoint, + healthy: @delegate.healthy?(supervisor_controller, endpoint), } end @@ -129,11 +119,16 @@ def build_records(supervisor_controller) end def reconcile - clusters = build_clusters + records_by_cluster = build_records_by_cluster + clusters = build_clusters(records_by_cluster) - clusters.each_key do |cluster| - @control_plane.update_cluster(cluster) unless @published_clusters.key?(cluster) - @published_clusters[cluster] = true + records_by_cluster.each do |cluster, records| + configuration = cluster_configuration(records) + + unless @published_clusters[cluster] == configuration + @control_plane.update_cluster(cluster, **configuration) + @published_clusters[cluster] = configuration + end end (@published_clusters.keys | clusters.keys).each do |cluster| @@ -141,15 +136,46 @@ def reconcile end end - def build_clusters + def build_records_by_cluster @controllers.each_value.flat_map do |controller| build_records(controller) end.group_by do |record| record[:cluster] - end.transform_values do |records| - records.map{|record| record[:endpoint].to_h} end end + + def build_clusters(records_by_cluster = build_records_by_cluster) + records_by_cluster.transform_values do |records| + records.map do |record| + {addresses: record[:endpoint].addresses, healthy: record[:healthy]} + end + end + end + + def cluster_configuration(records) + schemes = records.map{|record| record[:endpoint].scheme}.uniq + protocols = records.map{|record| record[:endpoint].protocols} + common_protocols = protocols.reduce{|common, names| common & names} + + raise ArgumentError, "Envoy cluster contains incompatible schemes: #{schemes.inspect}" if schemes.size > 1 + raise ArgumentError, "Envoy cluster contains no common protocols: #{protocols.inspect}" if common_protocols.empty? + raise ArgumentError, "HTTPS upstream endpoints are not yet supported!" if schemes.first == :https + + {protocol: envoy_protocol(common_protocols)} + end + + def envoy_protocol(protocols) + protocols.each do |protocol| + case protocol + when "h2" + return :http2 + when "http/1.1", "http/1.0" + return :http1 + end + end + + raise ArgumentError, "Envoy cluster contains no supported protocols: #{protocols.inspect}" + end end end end diff --git a/lib/async/service/supervisor/envoy/supervised.rb b/lib/async/service/supervisor/envoy/supervised.rb new file mode 100644 index 0000000..e1f6693 --- /dev/null +++ b/lib/async/service/supervisor/envoy/supervised.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/service/supervisor/supervised" + +module Async + module Service + module Supervisor + module Envoy + # Registers post-bind cluster listeners with the supervisor. + module Supervised + include Async::Service::Supervisor::Supervised + + # Prepare and register a worker after its listener has been bound. + # @parameter instance [Async::Container::Instance] The container instance. + # @parameter listener [Object] The bound listener descriptor. + def prepare_worker!(instance, listener:) + prepare!(instance, state: {endpoint: envoy_endpoint(listener)}) + end + + # Convert a bound listener into serialized Envoy endpoint state. + # @parameter listener [Object] The bound listener descriptor. + # @returns [Hash] The serialized endpoint. + def envoy_endpoint(listener) + { + name: listener.name, + scheme: listener.scheme, + protocols: listener.protocols, + addresses: listener.addresses.map{|address| envoy_address(address)}, + } + end + + # Convert a concrete bound address to serializable supervisor state. + # @parameter address [Addrinfo] The bound address. + # @returns [Hash] The serialized address. + # @raises [ArgumentError] If the address family is unsupported. + def envoy_address(address) + if address.ip? + {address: address.ip_address, port: address.ip_port} + elsif address.unix? + {path: address.unix_path} + else + raise ArgumentError, "Unsupported listener address: #{address.inspect}" + end + end + end + end + end + end +end diff --git a/readme.md b/readme.md index ab314c8..fe99e15 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ Provides an Envoy xDS monitor for `async-service-supervisor`. - **xDS control plane** - Runs an ADS server backed by `async-grpc-xds`. - **Supervisor integration** - Registers and removes endpoints from supervisor worker lifecycle events. - **Multiple clusters** - Groups workers by `state[:name]` by default. - - **Endpoint contract** - Publishes workers with `state[:endpoint]` and ignores workers without endpoints. + - **Endpoint contract** - Converts concrete post-bind worker listeners into Envoy upstream endpoints, including grouped IP or Unix socket addresses. - **Delegate mapping** - Uses a delegate object for endpoint selection, cluster grouping, and health without active probing. ## Usage diff --git a/releases.md b/releases.md index 61abf35..8ddd3bc 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,11 @@ # Releases +## Unreleased + + - Register concrete Falcon cluster listeners as Envoy upstream endpoint state after binding. + - Publish grouped IP and Unix-domain-socket endpoint addresses to Envoy. + - Configure generated clusters from each endpoint's supported HTTP protocol names. + ## v0.1.0 - Initial release. diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index 4d949e0..f5e3f00 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -21,10 +21,9 @@ def endpoint_assignment(cluster) Envoy::Config::Endpoint::V3::ClusterLoadAssignment.decode(response.resources.first.value) end - it "publishes registered workers as endpoints" do + it "publishes registered endpoints" do controller = Controller.new(1, { - name: "myservice", - endpoint: {address: "127.0.0.1", port: 50051} + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} }) monitor.register(controller) @@ -37,6 +36,49 @@ def endpoint_assignment(cluster) expect(load_balancer_endpoint.endpoint.address.socket_address.port_value).to be == 50051 end + it "publishes a supervised Unix endpoint" do + controller = Controller.new(1, { + endpoint: { + name: "myservice", + scheme: "http", + protocols: ["http/1.1"], + addresses: [{path: "/tmp/falcon.ipc"}], + } + }) + + monitor.register(controller) + + assignment = endpoint_assignment("myservice") + load_balancer_endpoint = assignment.endpoints.first.lb_endpoints.first + + expect(load_balancer_endpoint.endpoint.address.pipe.path).to be == "/tmp/falcon.ipc" + + cluster = control_plane.resources(Async::GRPC::XDS::ControlPlane::CLUSTER_TYPE).first + expect(cluster.http2_protocol_options).to be_nil + end + + it "keeps one endpoint's addresses grouped" do + controller = Controller.new(1, { + endpoint: { + name: "myservice", + scheme: "http", + protocols: ["h2"], + addresses: [ + {address: "127.0.0.1", port: 9292}, + {path: "/tmp/falcon.ipc"}, + ], + } + }) + + monitor.register(controller) + + assignment = endpoint_assignment("myservice") + endpoints = assignment.endpoints.first.lb_endpoints + + expect(endpoints.size).to be == 1 + expect(endpoints.first.endpoint.additional_addresses.first.address.pipe.path).to be == "/tmp/falcon.ipc" + end + it "ignores workers without endpoints" do controller = Controller.new(1, {name: "myservice"}) @@ -47,8 +89,7 @@ def endpoint_assignment(cluster) it "removes disconnected workers from endpoints" do controller = Controller.new(1, { - name: "myservice", - endpoint: {address: "127.0.0.1", port: 50051} + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} }) monitor.register(controller) @@ -61,50 +102,20 @@ def endpoint_assignment(cluster) it "groups workers by service name" do monitor.register(Controller.new(1, { - name: "service-a", - endpoint: {address: "127.0.0.1", port: 50051} + endpoint: {name: "service-a", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} })) monitor.register(Controller.new(2, { - name: "service-b", - endpoint: {address: "127.0.0.2", port: 50052} + endpoint: {name: "service-b", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.2", port: 50052}]} })) expect(monitor.as_json[:clusters]).to have_keys("service-a", "service-b") end - it "uses endpoint names as the default cluster name" do - monitor.register(Controller.new(1, { - name: "worker", - endpoint: {name: "api-http2", address: "127.0.0.1", port: 50051} - })) - - expect(monitor.as_json[:clusters]).to have_keys("api-http2") - end - - it "accepts string keyed endpoint state" do - monitor.register(Controller.new(1, { - "name" => "myservice", - "endpoint" => {"address" => "127.0.0.1", "port" => 50051} - })) - - expect(monitor.as_json).to be == { - clusters: { - "myservice" => [ - { - address: "127.0.0.1", - port: 50051, - healthy: true - } - ] - } - } - end - it "publishes multiple endpoints from one worker" do monitor.register(Controller.new(1, { endpoints: [ - {name: "api-http1", address: "127.0.0.1", port: 50050, protocol: "http1"}, - {name: "api-http2", address: "127.0.0.1", port: 50051, protocol: "http2"} + {name: "api-http1", scheme: "http", protocols: ["http/1.1"], addresses: [{address: "127.0.0.1", port: 50050}]}, + {name: "api-http2", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} ] })) @@ -112,19 +123,13 @@ def endpoint_assignment(cluster) clusters: { "api-http1" => [ { - name: "api-http1", - address: "127.0.0.1", - port: 50050, - protocol: "http1", + addresses: [{address: "127.0.0.1", port: 50050}], healthy: true } ], "api-http2" => [ { - name: "api-http2", - address: "127.0.0.1", - port: 50051, - protocol: "http2", + addresses: [{address: "127.0.0.1", port: 50051}], healthy: true } ] @@ -134,13 +139,12 @@ def endpoint_assignment(cluster) it "updates published endpoints from controller state" do controller = Controller.new(1, { - name: "myservice", - endpoint: {address: "127.0.0.1", port: 50051} + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} }) monitor.register(controller) - controller.state[:endpoint] = {address: "127.0.0.2", port: 50052} + controller.state[:endpoint] = {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.2", port: 50052}]} monitor.run_once assignment = endpoint_assignment("myservice") @@ -159,8 +163,7 @@ def healthy?(supervisor_controller, endpoint) monitor = subject.new(delegate: delegate) controller = Controller.new(1, { - name: "myservice", - endpoint: {address: "127.0.0.1", port: 50051}, + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]}, healthy: false }) @@ -184,8 +187,7 @@ def healthy?(supervisor_controller, endpoint) end.new controller = Controller.new(1, { - name: "myservice", - endpoint: {address: "127.0.0.1", port: 50051}, + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]}, healthy: true }) @@ -205,13 +207,15 @@ def healthy?(supervisor_controller, endpoint) expect(load_balancer_endpoint.health_status).to be == :UNHEALTHY end - it "uses the delegate to customize cluster and endpoints" do + it "uses the delegate to customize cluster and health" do delegate = Class.new(Async::Service::Supervisor::Envoy::Delegate) do def endpoint_list(supervisor_controller) [ - Async::Service::Supervisor::Envoy::Endpoint.wrap( - address: "127.0.0.1", - port: 50051 + Async::Service::Supervisor::Envoy::Endpoint.new( + name: "ignored", + scheme: "http", + protocols: ["http/1.1"], + addresses: [{address: "127.0.0.1", port: 50051}] ) ] end @@ -233,8 +237,7 @@ def healthy?(supervisor_controller, endpoint) clusters: { "myservice" => [ { - address: "127.0.0.1", - port: 50051, + addresses: [{address: "127.0.0.1", port: 50051}], healthy: false } ] @@ -244,26 +247,22 @@ def healthy?(supervisor_controller, endpoint) it "wraps endpoint values" do endpoint = Async::Service::Supervisor::Envoy::Endpoint.wrap( - {name: "api", address: "127.0.0.1", port: 50051, protocol: "http2"} + {name: "api", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/api.ipc"}]} ) - expect(endpoint.to_h).to be == { - name: "api", - address: "127.0.0.1", - port: 50051, - protocol: "http2", - healthy: true - } + expect(endpoint.name).to be == "api" + expect(endpoint.scheme).to be == :http + expect(endpoint.protocols).to be == ["h2"] + expect(endpoint.protocols.frozen?).to be == true + expect(endpoint.addresses).to be == [{path: "/tmp/api.ipc"}] end - it "wraps nil endpoints" do - expect(Async::Service::Supervisor::Envoy::Endpoint.wrap(nil)).to be_nil - end it "returns endpoint instances unchanged" do - endpoint = Async::Service::Supervisor::Envoy::Endpoint.new(address: "127.0.0.1", port: 50051) + endpoint = Async::Service::Supervisor::Envoy::Endpoint.new( + name: "api", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/api.ipc"}] + ) expect(Async::Service::Supervisor::Envoy::Endpoint.wrap(endpoint)).to be == endpoint - expect(endpoint).to be(:healthy?) end it "rejects invalid endpoint objects" do @@ -272,6 +271,54 @@ def healthy?(supervisor_controller, endpoint) end.to raise_exception(ArgumentError) end + it "rejects endpoints without protocols" do + expect do + Async::Service::Supervisor::Envoy::Endpoint.new( + name: "api", scheme: "http", protocols: [], addresses: [{path: "/tmp/api.ipc"}] + ) + end.to raise_exception(ArgumentError) + end + + it "rejects invalid endpoint addresses" do + expect do + Async::Service::Supervisor::Envoy::Endpoint.new( + name: "api", scheme: "http", protocols: ["h2"], addresses: [{}] + ) + end.to raise_exception(ArgumentError) + end + + it "selects the preferred common endpoint protocol" do + monitor.register(Controller.new(1, { + endpoint: {name: "myservice", scheme: "http", protocols: ["h2", "http/1.1"], addresses: [{path: "/tmp/one.ipc"}]} + })) + monitor.register(Controller.new(2, { + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/two.ipc"}]} + })) + + cluster = control_plane.resources(Async::GRPC::XDS::ControlPlane::CLUSTER_TYPE).first + expect(cluster.http2_protocol_options).not.to be_nil + end + + it "rejects endpoints without a common protocol in one cluster" do + monitor.register(Controller.new(1, { + endpoint: {name: "myservice", scheme: "http", protocols: ["http/1.1"], addresses: [{path: "/tmp/one.ipc"}]} + })) + + expect do + monitor.register(Controller.new(2, { + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/two.ipc"}]} + })) + end.to raise_exception(ArgumentError) + end + + it "rejects unsupported endpoint protocols" do + expect do + monitor.register(Controller.new(1, { + endpoint: {name: "myservice", scheme: "http", protocols: ["unsupported"], addresses: [{path: "/tmp/one.ipc"}]} + })) + end.to raise_exception(ArgumentError) + end + it "runs an xDS server when bound" do parent = Class.new do def initialize diff --git a/test/async/service/supervisor/envoy/supervised.rb b/test/async/service/supervisor/envoy/supervised.rb new file mode 100644 index 0000000..e3c3ad8 --- /dev/null +++ b/test/async/service/supervisor/envoy/supervised.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/service/environment" +require "async/service/supervisor/envoy/supervised" + +describe Async::Service::Supervisor::Envoy::Supervised do + let(:evaluator) do + base = Module.new do + def prepare!(...) + end + end + + Async::Service::Environment.build(base, subject, name: "hello", root: Dir.pwd).evaluator + end + + it "registers concrete endpoint state during worker preparation" do + instance = Object.new + evaluator.define_singleton_method(:prepare!) do |*_arguments, **_options| + end + listener = Struct.new(:name, :scheme, :protocols, :addresses).new( + "hello", + "http", + ["h2", "http/1.1"], + [Addrinfo.tcp("127.0.0.1", 9292), Addrinfo.unix("/tmp/hello.ipc")] + ) + state = { + endpoint: { + name: "hello", + scheme: "http", + protocols: ["h2", "http/1.1"], + addresses: [ + {address: "127.0.0.1", port: 9292}, + {path: "/tmp/hello.ipc"}, + ], + } + } + + expect(evaluator).to receive(:prepare!).with(instance, state: state) + + evaluator.prepare_worker!(instance, listener: listener) + end + + it "rejects unsupported listener addresses" do + address = Object.new + address.define_singleton_method(:ip?){false} + address.define_singleton_method(:unix?){false} + + expect do + evaluator.envoy_address(address) + end.to raise_exception(ArgumentError) + end +end