From 9acf8c5d797838a7d7886be749da81e9fd619a26 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 23 Jul 2026 21:17:40 +1200 Subject: [PATCH 1/9] Register Falcon cluster endpoints with Envoy --- context/getting-started.md | 27 +++- control-plane/backend.rb | 7 +- control-plane/readme.md | 2 +- examples/falcon/falcon.rb | 63 ++++++++ guides/getting-started/readme.md | 27 +++- lib/async/service/supervisor/envoy.rb | 1 + .../service/supervisor/envoy/delegate.rb | 18 +-- .../service/supervisor/envoy/endpoint.rb | 101 ++++++------ lib/async/service/supervisor/envoy/monitor.rb | 41 +++-- .../service/supervisor/envoy/supervised.rb | 62 ++++++++ readme.md | 2 +- releases.md | 6 + .../async/service/supervisor/envoy/monitor.rb | 147 +++++++++++------- .../service/supervisor/envoy/supervised.rb | 46 ++++++ 14 files changed, 402 insertions(+), 148 deletions(-) create mode 100755 examples/falcon/falcon.rb create mode 100644 lib/async/service/supervisor/envoy/supervised.rb create mode 100644 test/async/service/supervisor/envoy/supervised.rb diff --git a/context/getting-started.md b/context/getting-started.md index af25279..3f4e545 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, + protocol: :http1, + 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, protocol, 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..d434c79 100644 --- a/control-plane/backend.rb +++ b/control-plane/backend.rb @@ -45,10 +45,11 @@ 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, + protocol: :http1, + addresses: [{address: backend_address, port: backend_port}], } } ) diff --git a/control-plane/readme.md b/control-plane/readme.md index e489cb1..5d80b3a 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, protocol, 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..3f4e545 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, + protocol: :http1, + 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, protocol, 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..1c0d9a8 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"] 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..7211eb8 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -3,92 +3,87 @@ # 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)) + attributes = value.each_with_object({}) do |(key, item), result| + result[key.to_sym] = item + end + + new(**attributes) 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) + value = value.each_with_object({}) do |(key, item), result| + result[key.to_sym] = item + end + + 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 + # Initialize an endpoint. + # @parameter name [String] The upstream cluster name. + # @parameter scheme [String | Symbol] The upstream application scheme. + # @parameter protocol [String | Symbol] The upstream HTTP protocol. + # @parameter addresses [Array(Hash)] The grouped concrete addresses. + # @parameter healthy [Boolean] Whether the endpoint is healthy. + def initialize(name:, scheme:, protocol:, addresses:, healthy: true) + @name = name.to_s + @scheme = scheme.to_sym + @protocol = protocol.to_sym + @addresses = addresses.map{|value| self.class.normalize_address(value)}.freeze + raise ArgumentError, "An endpoint requires at least one address!" if @addresses.empty? + @healthy = healthy 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 [Symbol] The upstream application scheme. + attr :scheme - # @attribute [Integer] The endpoint port. - attr :port - - # @attribute [String | Nil] The optional endpoint hostname. - attr :hostname - - # @attribute [String | Symbol | Nil] The optional endpoint protocol. + # @attribute [Symbol] The upstream HTTP 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. + # @returns [Boolean] Whether the endpoint is healthy. def healthy? @healthy end - # Convert the endpoint to a hash suitable for the xDS control plane. + # Convert the endpoint to an xDS control-plane hash. # @returns [Hash] The endpoint attributes. def to_h - { - name: @name, - address: @address, - port: @port, - hostname: @hostname, - protocol: @protocol, - healthy: @healthy - }.compact + {addresses: @addresses, healthy: @healthy} end end end diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index f8c900b..668afc4 100644 --- a/lib/async/service/supervisor/envoy/monitor.rb +++ b/lib/async/service/supervisor/envoy/monitor.rb @@ -106,17 +106,18 @@ 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, + scheme: endpoint.scheme, + protocol: endpoint.protocol, endpoint: Endpoint.new( name: endpoint.name, - address: endpoint.address, - port: endpoint.port, - hostname: endpoint.hostname, + scheme: endpoint.scheme, protocol: endpoint.protocol, + addresses: endpoint.addresses, healthy: @delegate.healthy?(supervisor_controller, endpoint) ) } @@ -129,11 +130,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 +147,30 @@ 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| + end + end + + def build_clusters(records_by_cluster = build_records_by_cluster) + records_by_cluster.transform_values do |records| records.map{|record| record[:endpoint].to_h} end end + + def cluster_configuration(records) + schemes = records.filter_map{|record| record[:scheme]}.uniq + protocols = records.filter_map{|record| record[:protocol]}.map(&:to_sym).uniq + + raise ArgumentError, "Envoy cluster contains incompatible schemes: #{schemes.inspect}" if schemes.size > 1 + raise ArgumentError, "Envoy cluster contains incompatible protocols: #{protocols.inspect}" if protocols.size > 1 + raise ArgumentError, "HTTPS upstream endpoints are not yet supported!" if schemes.first == :https + + {protocol: protocols.first || :http2} + 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..b2b03c5 --- /dev/null +++ b/lib/async/service/supervisor/envoy/supervised.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/http/protocol/http2" +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:) + state = supervisor_worker_state.merge(endpoint: envoy_endpoint(listener)) + prepare!(instance, state: state) + 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, + protocol: envoy_protocol(listener.protocol), + addresses: listener.addresses.filter_map{|address| envoy_address(address)}, + } + end + + # Convert an application protocol implementation to an Envoy upstream protocol. + # @parameter protocol [Object] The application protocol implementation. + # @returns [Symbol] The Envoy upstream protocol. + def envoy_protocol(protocol) + if protocol.equal?(Async::HTTP::Protocol::HTTP2) + :http2 + else + :http1 + end + end + + # Convert a concrete bound address to serializable supervisor state. + # @parameter address [Addrinfo] The bound address. + # @returns [Hash | Nil] The serialized address, or nil if unsupported. + def envoy_address(address) + if address.ip? + {address: address.ip_address, port: address.ip_port} + elsif address.unix? + {path: address.unix_path} + 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..530b5ea 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 for each endpoint's HTTP protocol. + ## 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..21e8a69 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", protocol: "http2", 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", + protocol: "http1", + 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", + protocol: "http2", + 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", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]} }) monitor.register(controller) @@ -61,38 +102,30 @@ 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", protocol: "http2", 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", protocol: "http2", 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} + "endpoint" => { + "name" => "myservice", + "scheme" => "http", + "protocol" => "http2", + "addresses" => [{"address" => "127.0.0.1", "port" => 50051}], + } })) expect(monitor.as_json).to be == { clusters: { "myservice" => [ { - address: "127.0.0.1", - port: 50051, + addresses: [{address: "127.0.0.1", port: 50051}], healthy: true } ] @@ -103,8 +136,8 @@ def endpoint_assignment(cluster) 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", protocol: "http1", addresses: [{address: "127.0.0.1", port: 50050}]}, + {name: "api-http2", scheme: "http", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]} ] })) @@ -112,19 +145,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 +161,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", protocol: "http2", 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", protocol: "http2", addresses: [{address: "127.0.0.2", port: 50052}]} monitor.run_once assignment = endpoint_assignment("myservice") @@ -159,8 +185,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", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]}, healthy: false }) @@ -184,8 +209,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", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]}, healthy: true }) @@ -205,13 +229,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", + protocol: "http1", + addresses: [{address: "127.0.0.1", port: 50051}] ) ] end @@ -233,8 +259,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 +269,24 @@ 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", protocol: "http2", addresses: [{path: "/tmp/api.ipc"}]} ) + expect(endpoint.name).to be == "api" + expect(endpoint.scheme).to be == :http + expect(endpoint.protocol).to be == :http2 expect(endpoint.to_h).to be == { - name: "api", - address: "127.0.0.1", - port: 50051, - protocol: "http2", - healthy: true + addresses: [{path: "/tmp/api.ipc"}], + healthy: true, } 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", protocol: "http2", 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 +295,18 @@ def healthy?(supervisor_controller, endpoint) end.to raise_exception(ArgumentError) end + it "rejects incompatible endpoint protocols in one cluster" do + monitor.register(Controller.new(1, { + endpoint: {name: "myservice", scheme: "http", protocol: "http1", addresses: [{path: "/tmp/one.ipc"}]} + })) + + expect do + monitor.register(Controller.new(2, { + endpoint: {name: "myservice", scheme: "http", protocol: "http2", addresses: [{path: "/tmp/two.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..f859740 --- /dev/null +++ b/test/async/service/supervisor/envoy/supervised.rb @@ -0,0 +1,46 @@ +# 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, :protocol, :addresses).new( + "hello", + "http", + Async::HTTP::Protocol::HTTP2, + [Addrinfo.tcp("127.0.0.1", 9292), Addrinfo.unix("/tmp/hello.ipc")] + ) + state = { + name: "hello", + endpoint: { + name: "hello", + scheme: "http", + protocol: :http2, + 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 +end From 6d9afe51380d4bb6f0ff7a8af84e5f8c308fbdc4 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 11:20:07 +1200 Subject: [PATCH 2/9] Supply only Envoy endpoint state --- lib/async/service/supervisor/envoy/supervised.rb | 3 +-- test/async/service/supervisor/envoy/supervised.rb | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/async/service/supervisor/envoy/supervised.rb b/lib/async/service/supervisor/envoy/supervised.rb index b2b03c5..fd0e241 100644 --- a/lib/async/service/supervisor/envoy/supervised.rb +++ b/lib/async/service/supervisor/envoy/supervised.rb @@ -18,8 +18,7 @@ module Supervised # @parameter instance [Async::Container::Instance] The container instance. # @parameter listener [Object] The bound listener descriptor. def prepare_worker!(instance, listener:) - state = supervisor_worker_state.merge(endpoint: envoy_endpoint(listener)) - prepare!(instance, state: state) + prepare!(instance, state: {endpoint: envoy_endpoint(listener)}) end # Convert a bound listener into serialized Envoy endpoint state. diff --git a/test/async/service/supervisor/envoy/supervised.rb b/test/async/service/supervisor/envoy/supervised.rb index f859740..f085888 100644 --- a/test/async/service/supervisor/envoy/supervised.rb +++ b/test/async/service/supervisor/envoy/supervised.rb @@ -27,7 +27,6 @@ def prepare!(...) [Addrinfo.tcp("127.0.0.1", 9292), Addrinfo.unix("/tmp/hello.ipc")] ) state = { - name: "hello", endpoint: { name: "hello", scheme: "http", From 8b65346ea44259553fc0125a455f9e743bd1d9ae Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 12:19:37 +1200 Subject: [PATCH 3/9] Preserve endpoint protocol capabilities. --- async-service-supervisor-envoy.gemspec | 2 +- context/getting-started.md | 4 +- control-plane/backend.rb | 3 +- guides/getting-started/readme.md | 4 +- .../service/supervisor/envoy/endpoint.rb | 11 +-- lib/async/service/supervisor/envoy/monitor.rb | 24 +++++-- .../service/supervisor/envoy/supervised.rb | 14 +--- releases.md | 2 +- .../async/service/supervisor/envoy/monitor.rb | 69 +++++++++++++------ .../service/supervisor/envoy/supervised.rb | 6 +- 10 files changed, 85 insertions(+), 54 deletions(-) diff --git a/async-service-supervisor-envoy.gemspec b/async-service-supervisor-envoy.gemspec index faee9d7..9700a0c 100644 --- a/async-service-supervisor-envoy.gemspec +++ b/async-service-supervisor-envoy.gemspec @@ -27,5 +27,5 @@ Gem::Specification.new do |spec| spec.add_dependency "async", "~> 2.38" spec.add_dependency "async-grpc-xds" 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 3f4e545..35e94f4 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -30,7 +30,7 @@ state = { endpoint: { name: "myservice", scheme: :http, - protocol: :http1, + protocols: ["http/1.1"], addresses: [ {address: "127.0.0.1", port: 50051}, {path: "/run/myservice/worker.ipc"} @@ -50,7 +50,7 @@ service "application" do end ``` -Falcon describes its bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, protocol, and every concrete IP or Unix socket address. Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. +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 d434c79..29c67b6 100644 --- a/control-plane/backend.rb +++ b/control-plane/backend.rb @@ -53,8 +53,7 @@ } } ) - + worker.run server.run end - \ No newline at end of file diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 3f4e545..35e94f4 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -30,7 +30,7 @@ state = { endpoint: { name: "myservice", scheme: :http, - protocol: :http1, + protocols: ["http/1.1"], addresses: [ {address: "127.0.0.1", port: 50051}, {path: "/run/myservice/worker.ipc"} @@ -50,7 +50,7 @@ service "application" do end ``` -Falcon describes its bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, protocol, and every concrete IP or Unix socket address. Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. +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/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 7211eb8..fe738c8 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -49,13 +49,14 @@ def self.normalize_address(value) # Initialize an endpoint. # @parameter name [String] The upstream cluster name. # @parameter scheme [String | Symbol] The upstream application scheme. - # @parameter protocol [String | Symbol] The upstream HTTP protocol. + # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. # @parameter addresses [Array(Hash)] The grouped concrete addresses. # @parameter healthy [Boolean] Whether the endpoint is healthy. - def initialize(name:, scheme:, protocol:, addresses:, healthy: true) + def initialize(name:, scheme:, protocols:, addresses:, healthy: true) @name = name.to_s @scheme = scheme.to_sym - @protocol = protocol.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? @@ -68,8 +69,8 @@ def initialize(name:, scheme:, protocol:, addresses:, healthy: true) # @attribute [Symbol] The upstream application scheme. attr :scheme - # @attribute [Symbol] The upstream HTTP protocol. - attr :protocol + # @attribute [Array(String)] The supported upstream HTTP protocol names. + attr :protocols # @attribute [Array(Hash)] The grouped concrete addresses. attr :addresses diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index 668afc4..b394b8e 100644 --- a/lib/async/service/supervisor/envoy/monitor.rb +++ b/lib/async/service/supervisor/envoy/monitor.rb @@ -112,11 +112,11 @@ def build_record(supervisor_controller, endpoint) controller: supervisor_controller, cluster: cluster.to_s, scheme: endpoint.scheme, - protocol: endpoint.protocol, + protocols: endpoint.protocols, endpoint: Endpoint.new( name: endpoint.name, scheme: endpoint.scheme, - protocol: endpoint.protocol, + protocols: endpoint.protocols, addresses: endpoint.addresses, healthy: @delegate.healthy?(supervisor_controller, endpoint) ) @@ -163,13 +163,27 @@ def build_clusters(records_by_cluster = build_records_by_cluster) def cluster_configuration(records) schemes = records.filter_map{|record| record[:scheme]}.uniq - protocols = records.filter_map{|record| record[:protocol]}.map(&:to_sym).uniq + protocols = records.map{|record| record[: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 incompatible protocols: #{protocols.inspect}" if protocols.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: protocols.first || :http2} + {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 diff --git a/lib/async/service/supervisor/envoy/supervised.rb b/lib/async/service/supervisor/envoy/supervised.rb index fd0e241..7179f6f 100644 --- a/lib/async/service/supervisor/envoy/supervised.rb +++ b/lib/async/service/supervisor/envoy/supervised.rb @@ -3,7 +3,6 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "async/http/protocol/http2" require "async/service/supervisor/supervised" module Async @@ -28,22 +27,11 @@ def envoy_endpoint(listener) { name: listener.name, scheme: listener.scheme, - protocol: envoy_protocol(listener.protocol), + protocols: listener.protocols, addresses: listener.addresses.filter_map{|address| envoy_address(address)}, } end - # Convert an application protocol implementation to an Envoy upstream protocol. - # @parameter protocol [Object] The application protocol implementation. - # @returns [Symbol] The Envoy upstream protocol. - def envoy_protocol(protocol) - if protocol.equal?(Async::HTTP::Protocol::HTTP2) - :http2 - else - :http1 - end - end - # Convert a concrete bound address to serializable supervisor state. # @parameter address [Addrinfo] The bound address. # @returns [Hash | Nil] The serialized address, or nil if unsupported. diff --git a/releases.md b/releases.md index 530b5ea..8ddd3bc 100644 --- a/releases.md +++ b/releases.md @@ -4,7 +4,7 @@ - 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 for each endpoint's HTTP protocol. + - Configure generated clusters from each endpoint's supported HTTP protocol names. ## v0.1.0 diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index 21e8a69..63998a3 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -23,7 +23,7 @@ def endpoint_assignment(cluster) it "publishes registered endpoints" do controller = Controller.new(1, { - endpoint: {name: "myservice", scheme: "http", protocol: "http2", addresses: [{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) @@ -41,7 +41,7 @@ def endpoint_assignment(cluster) endpoint: { name: "myservice", scheme: "http", - protocol: "http1", + protocols: ["http/1.1"], addresses: [{path: "/tmp/falcon.ipc"}], } }) @@ -62,7 +62,7 @@ def endpoint_assignment(cluster) endpoint: { name: "myservice", scheme: "http", - protocol: "http2", + protocols: ["h2"], addresses: [ {address: "127.0.0.1", port: 9292}, {path: "/tmp/falcon.ipc"}, @@ -89,7 +89,7 @@ def endpoint_assignment(cluster) it "removes disconnected workers from endpoints" do controller = Controller.new(1, { - endpoint: {name: "myservice", scheme: "http", protocol: "http2", addresses: [{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) @@ -102,10 +102,10 @@ def endpoint_assignment(cluster) it "groups workers by service name" do monitor.register(Controller.new(1, { - endpoint: {name: "service-a", scheme: "http", protocol: "http2", addresses: [{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, { - endpoint: {name: "service-b", scheme: "http", protocol: "http2", addresses: [{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") @@ -116,7 +116,7 @@ def endpoint_assignment(cluster) "endpoint" => { "name" => "myservice", "scheme" => "http", - "protocol" => "http2", + "protocols" => ["h2"], "addresses" => [{"address" => "127.0.0.1", "port" => 50051}], } })) @@ -136,8 +136,8 @@ def endpoint_assignment(cluster) it "publishes multiple endpoints from one worker" do monitor.register(Controller.new(1, { endpoints: [ - {name: "api-http1", scheme: "http", protocol: "http1", addresses: [{address: "127.0.0.1", port: 50050}]}, - {name: "api-http2", scheme: "http", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]} + {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}]} ] })) @@ -161,12 +161,12 @@ def endpoint_assignment(cluster) it "updates published endpoints from controller state" do controller = Controller.new(1, { - endpoint: {name: "myservice", scheme: "http", protocol: "http2", addresses: [{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] = {name: "myservice", scheme: "http", protocol: "http2", addresses: [{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") @@ -185,7 +185,7 @@ def healthy?(supervisor_controller, endpoint) monitor = subject.new(delegate: delegate) controller = Controller.new(1, { - endpoint: {name: "myservice", scheme: "http", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]}, + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]}, healthy: false }) @@ -209,7 +209,7 @@ def healthy?(supervisor_controller, endpoint) end.new controller = Controller.new(1, { - endpoint: {name: "myservice", scheme: "http", protocol: "http2", addresses: [{address: "127.0.0.1", port: 50051}]}, + endpoint: {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]}, healthy: true }) @@ -236,7 +236,7 @@ def endpoint_list(supervisor_controller) Async::Service::Supervisor::Envoy::Endpoint.new( name: "ignored", scheme: "http", - protocol: "http1", + protocols: ["http/1.1"], addresses: [{address: "127.0.0.1", port: 50051}] ) ] @@ -269,12 +269,13 @@ def healthy?(supervisor_controller, endpoint) it "wraps endpoint values" do endpoint = Async::Service::Supervisor::Envoy::Endpoint.wrap( - {name: "api", scheme: "http", protocol: "http2", addresses: [{path: "/tmp/api.ipc"}]} + {name: "api", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/api.ipc"}]} ) expect(endpoint.name).to be == "api" expect(endpoint.scheme).to be == :http - expect(endpoint.protocol).to be == :http2 + expect(endpoint.protocols).to be == ["h2"] + expect(endpoint.protocols.frozen?).to be == true expect(endpoint.to_h).to be == { addresses: [{path: "/tmp/api.ipc"}], healthy: true, @@ -283,7 +284,7 @@ def healthy?(supervisor_controller, endpoint) it "returns endpoint instances unchanged" do endpoint = Async::Service::Supervisor::Envoy::Endpoint.new( - name: "api", scheme: "http", protocol: "http2", addresses: [{path: "/tmp/api.ipc"}] + name: "api", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/api.ipc"}] ) expect(Async::Service::Supervisor::Envoy::Endpoint.wrap(endpoint)).to be == endpoint @@ -295,14 +296,42 @@ def healthy?(supervisor_controller, endpoint) end.to raise_exception(ArgumentError) end - it "rejects incompatible endpoint protocols in one cluster" do + 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 "selects the preferred common endpoint protocol" do monitor.register(Controller.new(1, { - endpoint: {name: "myservice", scheme: "http", protocol: "http1", addresses: [{path: "/tmp/one.ipc"}]} + 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", protocol: "http2", addresses: [{path: "/tmp/two.ipc"}]} + 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 diff --git a/test/async/service/supervisor/envoy/supervised.rb b/test/async/service/supervisor/envoy/supervised.rb index f085888..ba2a47a 100644 --- a/test/async/service/supervisor/envoy/supervised.rb +++ b/test/async/service/supervisor/envoy/supervised.rb @@ -20,17 +20,17 @@ def prepare!(...) instance = Object.new evaluator.define_singleton_method(:prepare!) do |*_arguments, **_options| end - listener = Struct.new(:name, :scheme, :protocol, :addresses).new( + listener = Struct.new(:name, :scheme, :protocols, :addresses).new( "hello", "http", - Async::HTTP::Protocol::HTTP2, + ["h2", "http/1.1"], [Addrinfo.tcp("127.0.0.1", 9292), Addrinfo.unix("/tmp/hello.ipc")] ) state = { endpoint: { name: "hello", scheme: "http", - protocol: :http2, + protocols: ["h2", "http/1.1"], addresses: [ {address: "127.0.0.1", port: 9292}, {path: "/tmp/hello.ipc"}, From 3f3fa9c908fc87ae6d992a58369c0a4526241433 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 13:43:09 +1200 Subject: [PATCH 4/9] Update released xDS integration --- async-service-supervisor-envoy.gemspec | 2 +- control-plane/backend.rb | 2 +- control-plane/readme.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/async-service-supervisor-envoy.gemspec b/async-service-supervisor-envoy.gemspec index 9700a0c..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", "~> 0.18" end diff --git a/control-plane/backend.rb b/control-plane/backend.rb index 29c67b6..ad8bab1 100644 --- a/control-plane/backend.rb +++ b/control-plane/backend.rb @@ -48,7 +48,7 @@ endpoint: { name: service_name, scheme: :http, - protocol: :http1, + protocols: [Async::HTTP::Protocol::HTTP1.name], addresses: [{address: backend_address, port: backend_port}], } } diff --git a/control-plane/readme.md b/control-plane/readme.md index 5d80b3a..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 its name, scheme, protocol, and concrete addresses. +- 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. From f4489985ad022a77849eca5d6e86c9efc34a6e8c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 13:45:48 +1200 Subject: [PATCH 5/9] Cover endpoint state validation --- test/async/service/supervisor/envoy/monitor.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index 63998a3..33c44e9 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -276,6 +276,7 @@ def healthy?(supervisor_controller, endpoint) expect(endpoint.scheme).to be == :http expect(endpoint.protocols).to be == ["h2"] expect(endpoint.protocols.frozen?).to be == true + expect(endpoint.healthy?).to be == true expect(endpoint.to_h).to be == { addresses: [{path: "/tmp/api.ipc"}], healthy: true, @@ -304,6 +305,14 @@ def healthy?(supervisor_controller, endpoint) 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"}]} From 007d1f6ce82798d4ba7649bd1dee199a6cb6a829 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 13:48:38 +1200 Subject: [PATCH 6/9] Use HTTP protocol capability names --- control-plane/backend.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-plane/backend.rb b/control-plane/backend.rb index ad8bab1..af84c76 100644 --- a/control-plane/backend.rb +++ b/control-plane/backend.rb @@ -48,7 +48,7 @@ endpoint: { name: service_name, scheme: :http, - protocols: [Async::HTTP::Protocol::HTTP1.name], + protocols: Async::HTTP::Protocol::HTTP1.names, addresses: [{address: backend_address, port: backend_port}], } } From bfccc449845b647f2de68ec4b91abd0e12acf3ef Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 13:57:51 +1200 Subject: [PATCH 7/9] Simplify endpoint state handling --- .../service/supervisor/envoy/delegate.rb | 2 +- .../service/supervisor/envoy/endpoint.rb | 26 ++--------------- lib/async/service/supervisor/envoy/monitor.rb | 20 +++++-------- .../service/supervisor/envoy/supervised.rb | 7 +++-- .../async/service/supervisor/envoy/monitor.rb | 28 +------------------ .../service/supervisor/envoy/supervised.rb | 10 +++++++ 6 files changed, 26 insertions(+), 67 deletions(-) diff --git a/lib/async/service/supervisor/envoy/delegate.rb b/lib/async/service/supervisor/envoy/delegate.rb index 1c0d9a8..d0725a2 100644 --- a/lib/async/service/supervisor/envoy/delegate.rb +++ b/lib/async/service/supervisor/envoy/delegate.rb @@ -17,7 +17,7 @@ class Delegate def endpoints(supervisor_controller) state = supervisor_controller.state - state[:endpoints] || state["endpoints"] || state[:endpoint] || state["endpoint"] + state[:endpoints] || state[:endpoint] end # Convert serialized state into Envoy endpoint values. diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index fe738c8..accf1b1 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -17,11 +17,7 @@ def self.wrap(value) when self value when Hash - attributes = value.each_with_object({}) do |(key, item), result| - result[key.to_sym] = item - end - - new(**attributes) + new(**value) else raise ArgumentError, "Invalid Envoy endpoint: #{value.inspect}" end @@ -31,10 +27,6 @@ def self.wrap(value) # @parameter value [Hash] The address value. # @returns [Hash] A normalized IP or Unix address. def self.normalize_address(value) - value = value.each_with_object({}) do |(key, item), result| - result[key.to_sym] = item - end - if path = value[:path] raise ArgumentError, "A Unix endpoint cannot specify an IP address or port!" if value[:address] || value[:port] @@ -51,16 +43,13 @@ def self.normalize_address(value) # @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. - # @parameter healthy [Boolean] Whether the endpoint is healthy. - def initialize(name:, scheme:, protocols:, addresses:, healthy: true) + 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? - - @healthy = healthy end # @attribute [String] The upstream cluster name. @@ -75,17 +64,6 @@ def initialize(name:, scheme:, protocols:, addresses:, healthy: true) # @attribute [Array(Hash)] The grouped concrete addresses. attr :addresses - # Whether the endpoint is healthy. - # @returns [Boolean] Whether the endpoint is healthy. - def healthy? - @healthy - end - - # Convert the endpoint to an xDS control-plane hash. - # @returns [Hash] The endpoint attributes. - def to_h - {addresses: @addresses, healthy: @healthy} - end end end end diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index b394b8e..48f2e5b 100644 --- a/lib/async/service/supervisor/envoy/monitor.rb +++ b/lib/async/service/supervisor/envoy/monitor.rb @@ -109,17 +109,9 @@ def build_record(supervisor_controller, endpoint) return unless cluster { - controller: supervisor_controller, cluster: cluster.to_s, - scheme: endpoint.scheme, - protocols: endpoint.protocols, - endpoint: Endpoint.new( - name: endpoint.name, - scheme: endpoint.scheme, - protocols: endpoint.protocols, - addresses: endpoint.addresses, - healthy: @delegate.healthy?(supervisor_controller, endpoint) - ) + endpoint: endpoint, + healthy: @delegate.healthy?(supervisor_controller, endpoint), } end @@ -157,13 +149,15 @@ def build_records_by_cluster def build_clusters(records_by_cluster = build_records_by_cluster) records_by_cluster.transform_values do |records| - records.map{|record| record[:endpoint].to_h} + records.map do |record| + {addresses: record[:endpoint].addresses, healthy: record[:healthy]} + end end end def cluster_configuration(records) - schemes = records.filter_map{|record| record[:scheme]}.uniq - protocols = records.map{|record| record[:protocols]} + 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 diff --git a/lib/async/service/supervisor/envoy/supervised.rb b/lib/async/service/supervisor/envoy/supervised.rb index 7179f6f..e1f6693 100644 --- a/lib/async/service/supervisor/envoy/supervised.rb +++ b/lib/async/service/supervisor/envoy/supervised.rb @@ -28,18 +28,21 @@ def envoy_endpoint(listener) name: listener.name, scheme: listener.scheme, protocols: listener.protocols, - addresses: listener.addresses.filter_map{|address| envoy_address(address)}, + 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 | Nil] The serialized address, or nil if unsupported. + # @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 diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index 33c44e9..f5e3f00 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -111,28 +111,6 @@ def endpoint_assignment(cluster) expect(monitor.as_json[:clusters]).to have_keys("service-a", "service-b") end - it "accepts string keyed endpoint state" do - monitor.register(Controller.new(1, { - "endpoint" => { - "name" => "myservice", - "scheme" => "http", - "protocols" => ["h2"], - "addresses" => [{"address" => "127.0.0.1", "port" => 50051}], - } - })) - - expect(monitor.as_json).to be == { - clusters: { - "myservice" => [ - { - addresses: [{address: "127.0.0.1", port: 50051}], - healthy: true - } - ] - } - } - end - it "publishes multiple endpoints from one worker" do monitor.register(Controller.new(1, { endpoints: [ @@ -276,11 +254,7 @@ def healthy?(supervisor_controller, endpoint) expect(endpoint.scheme).to be == :http expect(endpoint.protocols).to be == ["h2"] expect(endpoint.protocols.frozen?).to be == true - expect(endpoint.healthy?).to be == true - expect(endpoint.to_h).to be == { - addresses: [{path: "/tmp/api.ipc"}], - healthy: true, - } + expect(endpoint.addresses).to be == [{path: "/tmp/api.ipc"}] end it "returns endpoint instances unchanged" do diff --git a/test/async/service/supervisor/envoy/supervised.rb b/test/async/service/supervisor/envoy/supervised.rb index ba2a47a..e3c3ad8 100644 --- a/test/async/service/supervisor/envoy/supervised.rb +++ b/test/async/service/supervisor/envoy/supervised.rb @@ -42,4 +42,14 @@ def prepare!(...) 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 From cd24abcd2686570d8aeb6281ba9b825da7297abc Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 14:01:02 +1200 Subject: [PATCH 8/9] Consolidate namespace documentation --- lib/async/service/supervisor/envoy/monitor.rb | 3 --- lib/async/service/supervisor/envoy/version.rb | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index 48f2e5b..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 diff --git a/lib/async/service/supervisor/envoy/version.rb b/lib/async/service/supervisor/envoy/version.rb index 1ef3e98..143495a 100644 --- a/lib/async/service/supervisor/envoy/version.rb +++ b/lib/async/service/supervisor/envoy/version.rb @@ -9,6 +9,7 @@ module Async module Service # @namespace module Supervisor + # @namespace # Provides Envoy integration for supervisor-managed services. module Envoy # The current version of the gem. From 67eb5534897c00111edb56cc83ea9aaf55724a4c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 14:02:05 +1200 Subject: [PATCH 9/9] Remove redundant namespace annotation --- lib/async/service/supervisor/envoy/version.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/async/service/supervisor/envoy/version.rb b/lib/async/service/supervisor/envoy/version.rb index 143495a..1ef3e98 100644 --- a/lib/async/service/supervisor/envoy/version.rb +++ b/lib/async/service/supervisor/envoy/version.rb @@ -9,7 +9,6 @@ module Async module Service # @namespace module Supervisor - # @namespace # Provides Envoy integration for supervisor-managed services. module Envoy # The current version of the gem.