Skip to content

Commit 205276a

Browse files
committed
feat(moonzero): consul-backed discovery driver over the agent HTTP API.
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent 6f5cf5c commit 205276a

3 files changed

Lines changed: 498 additions & 0 deletions

File tree

consul.mbt

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// A consul client over consul's HTTP API: a `ConsulHttp` carries a request to a consul
2+
// agent and hands back the status and body, and `ConsulClient` wraps it with the agent
3+
// operations the discovery driver needs (register a service with a TTL check, pass the
4+
// check to keep it alive, deregister it, and read a service's healthy instances). The
5+
// transport is an interface, so the same client drives a real consul over an HTTP
6+
// socket (the native `discov` driver) or an in-process fake in a test; the request
7+
// shaping and JSON parsing here run on every backend.
8+
9+
///|
10+
/// A consul API call that failed — a transport error, or a non-2xx agent response.
11+
pub suberror ConsulError {
12+
ConsulError(String)
13+
}
14+
15+
///|
16+
/// A consul agent's HTTP response: the status code and the raw body bytes.
17+
pub struct ConsulResponse {
18+
status : Int
19+
body : Bytes
20+
}
21+
22+
///|
23+
/// A consul response.
24+
pub fn ConsulResponse::new(status : Int, body : Bytes) -> ConsulResponse {
25+
{ status, body }
26+
}
27+
28+
///|
29+
/// A transport to a consul agent: it performs one HTTP request (`method` and `path`,
30+
/// with `body` for writes) and returns the response. Implementations own the medium —
31+
/// a real HTTP socket, or an in-memory fake.
32+
pub trait ConsulHttp {
33+
fn request(Self, String, String, Bytes) -> ConsulResponse raise
34+
}
35+
36+
///|
37+
/// A consul client over a `ConsulHttp`, exposing the agent operations discovery uses.
38+
pub struct ConsulClient {
39+
http : &ConsulHttp
40+
}
41+
42+
///|
43+
/// A client over `http`.
44+
pub fn ConsulClient::new(http : &ConsulHttp) -> ConsulClient {
45+
{ http, }
46+
}
47+
48+
///|
49+
/// Perform a request and require a 2xx status, mapping a transport failure or a non-2xx
50+
/// response to `ConsulError`.
51+
fn ConsulClient::call(
52+
self : ConsulClient,
53+
verb : String,
54+
path : String,
55+
body : Bytes,
56+
) -> ConsulResponse raise ConsulError {
57+
let resp = self.http.request(verb, path, body) catch {
58+
e => raise ConsulError("consul transport error: " + e.to_string())
59+
}
60+
if resp.status < 200 || resp.status >= 300 {
61+
raise ConsulError(
62+
verb +
63+
" " +
64+
path +
65+
" -> HTTP " +
66+
resp.status.to_string() +
67+
": " +
68+
@utf8.decode_lossy(resp.body[:]),
69+
)
70+
}
71+
resp
72+
}
73+
74+
///|
75+
/// The check id consul assigns an inline service TTL check: `service:<service-id>`.
76+
pub fn consul_check_id(service_id : String) -> String {
77+
"service:" + service_id
78+
}
79+
80+
///|
81+
/// `PUT /v1/agent/service/register`: register an instance under `name` at
82+
/// `address:port` with an id, held alive by a TTL check that consul deregisters
83+
/// `ttl*3` seconds after it stops passing. Pass the check with `check_pass` before each
84+
/// TTL lapses to stay healthy.
85+
pub fn ConsulClient::register_service(
86+
self : ConsulClient,
87+
id : String,
88+
name : String,
89+
address : String,
90+
port : Int,
91+
ttl_secs : Int,
92+
) -> Unit raise ConsulError {
93+
let check : Map[String, Json] = Map([
94+
("TTL", (ttl_secs.to_string() + "s").to_json()),
95+
(
96+
"DeregisterCriticalServiceAfter",
97+
((ttl_secs * 3).to_string() + "s").to_json(),
98+
),
99+
])
100+
let body : Map[String, Json] = Map([
101+
("ID", id.to_json()),
102+
("Name", name.to_json()),
103+
("Address", address.to_json()),
104+
("Port", port.to_json()),
105+
("Check", check.to_json()),
106+
])
107+
let _ = self.call(
108+
"PUT",
109+
"/v1/agent/service/register",
110+
@utf8.encode(body.to_json().stringify()),
111+
)
112+
}
113+
114+
///|
115+
/// `PUT /v1/agent/check/pass/service:<id>`: mark an instance's TTL check passing, the
116+
/// keep-alive that renews its lease.
117+
pub fn ConsulClient::check_pass(
118+
self : ConsulClient,
119+
service_id : String,
120+
) -> Unit raise ConsulError {
121+
let _ = self.call(
122+
"PUT",
123+
"/v1/agent/check/pass/" + consul_check_id(service_id),
124+
b"",
125+
)
126+
}
127+
128+
///|
129+
/// `PUT /v1/agent/service/deregister/<id>`: deregister one instance.
130+
pub fn ConsulClient::deregister_service(
131+
self : ConsulClient,
132+
id : String,
133+
) -> Unit raise ConsulError {
134+
let _ = self.call("PUT", "/v1/agent/service/deregister/" + id, b"")
135+
}
136+
137+
///|
138+
/// `GET /v1/health/service/<name>?passing=true`: the healthy instances of `name`, each
139+
/// as its `Service.Address:Service.Port` endpoint (falling back to `Node.Address` when
140+
/// the service advertises no address of its own, as consul's clients do).
141+
pub fn ConsulClient::health_service(
142+
self : ConsulClient,
143+
name : String,
144+
) -> Array[Endpoint] raise ConsulError {
145+
let resp = self.call(
146+
"GET",
147+
"/v1/health/service/" + name + "?passing=true",
148+
b"",
149+
)
150+
parse_health_response(resp.body)
151+
}
152+
153+
///|
154+
/// `GET /v1/agent/services`: every service instance registered on this agent, as
155+
/// `(instance-id, service-name)` pairs — the list a by-name deregister filters to find
156+
/// the ids to drop.
157+
pub fn ConsulClient::agent_services(
158+
self : ConsulClient,
159+
) -> Array[(String, String)] raise ConsulError {
160+
let resp = self.call("GET", "/v1/agent/services", b"")
161+
let text = @utf8.decode_lossy(resp.body[:])
162+
let json = @json.parse(text) catch {
163+
e => raise ConsulError("invalid consul JSON: " + e.to_string())
164+
}
165+
let obj = match json {
166+
Object(o) => o
167+
_ => raise ConsulError("consul agent/services is not a JSON object")
168+
}
169+
let out : Array[(String, String)] = []
170+
for _id, svc in obj {
171+
let id = consul_str_field(svc, "ID")
172+
let name = consul_str_field(svc, "Service")
173+
if id != "" {
174+
out.push((id, name))
175+
}
176+
}
177+
out
178+
}
179+
180+
///|
181+
/// Parse a consul `health/service` JSON array into endpoints.
182+
fn parse_health_response(body : Bytes) -> Array[Endpoint] raise ConsulError {
183+
let text = @utf8.decode_lossy(body[:])
184+
let json = @json.parse(text) catch {
185+
e => raise ConsulError("invalid consul JSON: " + e.to_string())
186+
}
187+
let entries = match json {
188+
Array(a) => a
189+
_ => raise ConsulError("consul health response is not a JSON array")
190+
}
191+
let out : Array[Endpoint] = []
192+
for entry in entries {
193+
let service = match json_field(entry, "Service") {
194+
Some(s) => s
195+
None => continue
196+
}
197+
let node_addr = match json_field(entry, "Node") {
198+
Some(n) => consul_str_field(n, "Address")
199+
None => ""
200+
}
201+
let svc_addr = consul_str_field(service, "Address")
202+
let address = if svc_addr == "" { node_addr } else { svc_addr }
203+
let port = consul_int_field(service, "Port")
204+
if address != "" && port > 0 {
205+
out.push(Endpoint::new(address, port))
206+
}
207+
}
208+
out
209+
}
210+
211+
///|
212+
/// The value of `key` in a JSON object, or `None` if `obj` is not an object or lacks
213+
/// the key.
214+
fn json_field(obj : Json, key : String) -> Json? {
215+
if obj is Object(m) {
216+
m.get(key)
217+
} else {
218+
None
219+
}
220+
}
221+
222+
///|
223+
/// The string value of `key` in a JSON object, or `""`.
224+
fn consul_str_field(obj : Json, key : String) -> String {
225+
match json_field(obj, key) {
226+
Some(String(s)) => s
227+
_ => ""
228+
}
229+
}
230+
231+
///|
232+
/// The integer value of a JSON number `key`, truncated toward zero, or `0`.
233+
fn consul_int_field(obj : Json, key : String) -> Int {
234+
match json_field(obj, key) {
235+
Some(Number(n, ..)) => n.to_int()
236+
_ => 0
237+
}
238+
}

consul_discovery.mbt

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// The consul-backed discovery driver: the same register -> resolve -> deregister flow
2+
// as the etcd and redis drivers, expressed over consul's agent API. An instance
3+
// registers as a consul service with a TTL check (the lease), a keep-alive passes that
4+
// check, and a client resolves a service to its healthy instances. This is a third
5+
// `Resolve` for the balancer and load-balanced channel, chosen by swapping the driver.
6+
7+
///|
8+
/// A consul-backed service registry / resolver over a `ConsulClient`. Each instance is
9+
/// a consul service named `service`, uniquely identified per agent by its address.
10+
pub struct ConsulDiscovery {
11+
client : ConsulClient
12+
}
13+
14+
///|
15+
/// A consul discovery over `client`.
16+
pub fn ConsulDiscovery::new(client : ConsulClient) -> ConsulDiscovery {
17+
{ client, }
18+
}
19+
20+
///|
21+
/// The per-agent-unique instance id for `endpoint` of `service`:
22+
/// `<service>-<host>-<port>`.
23+
fn consul_instance_id(service : String, endpoint : Endpoint) -> String {
24+
service + "-" + endpoint.host + "-" + endpoint.port.to_string()
25+
}
26+
27+
///|
28+
/// Register `endpoint` for `service` with a `ttl`-second TTL check and immediately pass
29+
/// the check so the instance is healthy at once (a fresh TTL check starts critical).
30+
/// Returns the instance id; renew it with `keepalive` before the TTL lapses.
31+
pub fn ConsulDiscovery::register(
32+
self : ConsulDiscovery,
33+
service : String,
34+
endpoint : Endpoint,
35+
ttl? : Int = 10,
36+
) -> String raise {
37+
let id = consul_instance_id(service, endpoint)
38+
self.client.register_service(id, service, endpoint.host, endpoint.port, ttl)
39+
self.client.check_pass(id)
40+
id
41+
}
42+
43+
///|
44+
/// Refresh an instance's lease by passing its TTL check.
45+
pub fn ConsulDiscovery::keepalive(
46+
self : ConsulDiscovery,
47+
service : String,
48+
endpoint : Endpoint,
49+
) -> Unit raise {
50+
self.client.check_pass(consul_instance_id(service, endpoint))
51+
}
52+
53+
///|
54+
/// Resolve `service` to its healthy endpoints.
55+
pub fn ConsulDiscovery::resolve(
56+
self : ConsulDiscovery,
57+
service : String,
58+
) -> Array[Endpoint] raise {
59+
self.client.health_service(service)
60+
}
61+
62+
///|
63+
/// Deregister one instance of `service`.
64+
pub fn ConsulDiscovery::deregister_instance(
65+
self : ConsulDiscovery,
66+
service : String,
67+
endpoint : Endpoint,
68+
) -> Unit raise {
69+
self.client.deregister_service(consul_instance_id(service, endpoint))
70+
}
71+
72+
///|
73+
/// Deregister every instance of `service` registered on this agent.
74+
pub fn ConsulDiscovery::deregister(
75+
self : ConsulDiscovery,
76+
service : String,
77+
) -> Unit raise {
78+
for pair in self.client.agent_services() {
79+
let (id, name) = pair
80+
if name == service {
81+
self.client.deregister_service(id)
82+
}
83+
}
84+
}
85+
86+
///|
87+
/// This consul discovery as a `Resolve` interface value, so the balancer and the
88+
/// load-balanced channel run against consul unchanged. A resolve error surfaces as an
89+
/// empty endpoint set, matching the other drivers.
90+
pub fn ConsulDiscovery::resolver(self : ConsulDiscovery) -> Resolve {
91+
service => self.resolve(service) catch { _ => [] }
92+
}

0 commit comments

Comments
 (0)