Skip to content
Merged
4 changes: 2 additions & 2 deletions async-service-supervisor-envoy.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 21 additions & 6 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions control-plane/backend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 1 addition & 1 deletion control-plane/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions examples/falcon/falcon.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 21 additions & 6 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/async/service/supervisor/envoy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
require_relative "envoy/delegate"
require_relative "envoy/endpoint"
require_relative "envoy/monitor"
require_relative "envoy/supervised"
20 changes: 7 additions & 13 deletions lib/async/service/supervisor/envoy/delegate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
98 changes: 36 additions & 62 deletions lib/async/service/supervisor/envoy/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading