Skip to content

Commit 38735ac

Browse files
committed
feat(moonzero): real HTTP-over-TCP consul socket client with a live-consul CI job.
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent e7f41c7 commit 38735ac

6 files changed

Lines changed: 501 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,47 @@ jobs:
8787
MOON_REDIS_TEST: "1"
8888
MOON_REDIS_HOST: "127.0.0.1"
8989
run: moon test --target native
90+
91+
# consul's agent needs `agent -dev` arguments, so it is started with docker run
92+
# rather than a service container; the native discov tests register a service with a
93+
# TTL check, pass it, resolve the healthy set, and deregister, gated on
94+
# MOON_CONSUL_TEST.
95+
discovery-consul:
96+
name: discovery drivers (native, live consul)
97+
runs-on: ubuntu-latest
98+
permissions:
99+
contents: read
100+
steps:
101+
- uses: actions/checkout@v4
102+
with:
103+
persist-credentials: false
104+
105+
- name: install
106+
run: |
107+
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
108+
echo "$HOME/.moon/bin" >> $GITHUB_PATH
109+
110+
- name: post install
111+
run: |
112+
moon version --all
113+
moon update
114+
115+
- name: start consul (dev agent)
116+
run: |
117+
docker run -d --name consul -p 8500:8500 hashicorp/consul:1.19 agent -dev -client 0.0.0.0
118+
119+
- name: wait for consul
120+
run: |
121+
for i in $(seq 1 30); do
122+
if curl -sf http://127.0.0.1:8500/v1/status/leader | grep -qE '[0-9]'; then
123+
echo "consul is up"; break
124+
fi
125+
echo "waiting for consul... ($i)"; sleep 2
126+
done
127+
curl -sf http://127.0.0.1:8500/v1/agent/self | head -c 200
128+
129+
- name: discovery round trip against live consul
130+
env:
131+
MOON_CONSUL_TEST: "1"
132+
MOON_CONSUL_HOST: "127.0.0.1"
133+
run: moon test --target native

consul.mbt

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ pub fn ConsulResponse::new(status : Int, body : Bytes) -> ConsulResponse {
2525
{ status, body }
2626
}
2727

28+
///|
29+
/// The HTTP status code.
30+
pub fn ConsulResponse::status(self : ConsulResponse) -> Int {
31+
self.status
32+
}
33+
34+
///|
35+
/// The raw response body bytes.
36+
pub fn ConsulResponse::body(self : ConsulResponse) -> Bytes {
37+
self.body
38+
}
39+
2840
///|
2941
/// A transport to a consul agent: it performs one HTTP request (`method` and `path`,
3042
/// with `body` for writes) and returns the response. Implementations own the medium —
@@ -90,6 +102,24 @@ pub fn ConsulClient::register_service(
90102
port : Int,
91103
ttl_secs : Int,
92104
) -> Unit raise ConsulError {
105+
let _ = self.call(
106+
"PUT",
107+
"/v1/agent/service/register",
108+
consul_register_body(id, name, address, port, ttl_secs),
109+
)
110+
}
111+
112+
///|
113+
/// The JSON body of a `service/register` request: the instance's id, name, address, and
114+
/// port, plus a TTL check that consul deregisters `ttl*3` seconds after it stops
115+
/// passing. Exposed so the native HTTP-socket path builds the exact same body.
116+
pub fn consul_register_body(
117+
id : String,
118+
name : String,
119+
address : String,
120+
port : Int,
121+
ttl_secs : Int,
122+
) -> Bytes {
93123
let check : Map[String, Json] = Map([
94124
("TTL", (ttl_secs.to_string() + "s").to_json()),
95125
(
@@ -104,11 +134,7 @@ pub fn ConsulClient::register_service(
104134
("Port", port.to_json()),
105135
("Check", check.to_json()),
106136
])
107-
let _ = self.call(
108-
"PUT",
109-
"/v1/agent/service/register",
110-
@utf8.encode(body.to_json().stringify()),
111-
)
137+
@utf8.encode(body.to_json().stringify())
112138
}
113139

114140
///|
@@ -177,6 +203,13 @@ pub fn ConsulClient::agent_services(
177203
out
178204
}
179205

206+
///|
207+
/// Parse a consul `health/service` JSON body into endpoints — exposed so the native
208+
/// HTTP-socket path decodes a real agent's response exactly as the client does.
209+
pub fn consul_parse_health(body : Bytes) -> Array[Endpoint] raise ConsulError {
210+
parse_health_response(body)
211+
}
212+
180213
///|
181214
/// Parse a consul `health/service` JSON array into endpoints.
182215
fn parse_health_response(body : Bytes) -> Array[Endpoint] raise ConsulError {

discov/consul_socket.mbt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// HTTP over a real TCP socket: a consul agent transport the discovery flow drives
2+
// against a live consul. moonzero's root `ConsulHttp` is synchronous (so the driver and
3+
// its mock run on every backend), but socket I/O is async, so the real transport is a
4+
// separate native-only path here — it reuses the root's pure HTTP/1 codec
5+
// (`http1_request` to frame the request, `http1_parse_response` to parse the reply) and
6+
// only adds the async connect/send/receive. Native-only, because `moonbitlang/async`
7+
// has no JS/Wasm backend.
8+
9+
///|
10+
/// A consul agent reachable over HTTP at `host:port`. Each request opens a fresh
11+
/// connection (HTTP/1.0 with `Connection: close`), the simplest correct model for the
12+
/// agent's short JSON round trips.
13+
pub struct ConsulSocket {
14+
host : String
15+
port : Int
16+
}
17+
18+
///|
19+
/// A transport to the consul agent at `host:port` (default port 8500).
20+
pub fn ConsulSocket::new(host : String, port? : Int = 8500) -> ConsulSocket {
21+
{ host, port }
22+
}
23+
24+
///|
25+
/// Perform one HTTP request against the agent and return its status and body.
26+
pub async fn ConsulSocket::request(
27+
self : ConsulSocket,
28+
verb : String,
29+
path : String,
30+
body : Bytes,
31+
) -> @moonzero.ConsulResponse {
32+
let addr = @socket.Addr::resolve(self.host, port=self.port)
33+
let tcp = @socket.Tcp::connect(addr)
34+
let content_type = if body.length() > 0 { "application/json" } else { "" }
35+
let host_header = self.host + ":" + self.port.to_string()
36+
let request = @moonzero.http1_request(
37+
verb,
38+
path,
39+
host_header,
40+
body,
41+
content_type~,
42+
)
43+
let response = try {
44+
(tcp : &@io.Writer).write(request)
45+
let raw = (tcp : &@io.Reader).read_all()
46+
let parsed = @moonzero.http1_parse_response(raw.binary())
47+
@moonzero.ConsulResponse::new(parsed.status(), parsed.body())
48+
} catch {
49+
e => {
50+
tcp.close()
51+
raise e
52+
}
53+
}
54+
tcp.close()
55+
response
56+
}

discov/consul_socket_test.mbt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Integration test against a REAL consul agent. Gated on `MOON_CONSUL_TEST`, so it is
2+
// skipped in the normal suite and runs only in the CI job that stands up a consul
3+
// container. It drives the exact discovery wire — register a service with a TTL check,
4+
// pass the check, resolve the healthy set, deregister — over the real HTTP socket,
5+
// reusing the root's request-body builder and health parser, so a green run proves the
6+
// HTTP/1 codec and the discovery flow against a live consul.
7+
8+
///|
9+
/// The consul host for the integration test: `MOON_CONSUL_HOST`, or localhost.
10+
fn consul_test_host() -> String {
11+
match @env.get_env_var("MOON_CONSUL_HOST") {
12+
Some(h) => h
13+
None => "127.0.0.1"
14+
}
15+
}
16+
17+
///|
18+
/// Register `endpoint` for `service` and pass its TTL check so it is healthy at once.
19+
async fn consul_put_healthy(
20+
sock : ConsulSocket,
21+
service : String,
22+
id : String,
23+
host : String,
24+
port : Int,
25+
) -> Unit {
26+
let ok = sock.request(
27+
"PUT",
28+
"/v1/agent/service/register",
29+
@moonzero.consul_register_body(id, service, host, port, 30),
30+
)
31+
assert_eq(ok.status(), 200)
32+
let passed = sock.request("PUT", "/v1/agent/check/pass/service:" + id, b"")
33+
assert_eq(passed.status(), 200)
34+
}
35+
36+
///|
37+
async test "integration: consul discovery register/resolve/deregister over a real consul" {
38+
guard @env.get_env_var("MOON_CONSUL_TEST") is Some(_) else { return }
39+
let sock = ConsulSocket::new(consul_test_host())
40+
let id1 = "greeter-10.0.0.1-9090"
41+
let id2 = "greeter-10.0.0.2-9090"
42+
// Clean any leftover from a prior run.
43+
let _ = sock.request("PUT", "/v1/agent/service/deregister/" + id1, b"")
44+
let _ = sock.request("PUT", "/v1/agent/service/deregister/" + id2, b"")
45+
// Register two healthy instances.
46+
consul_put_healthy(sock, "greeter", id1, "10.0.0.1", 9090)
47+
consul_put_healthy(sock, "greeter", id2, "10.0.0.2", 9090)
48+
// Resolve the healthy set.
49+
let resp = sock.request("GET", "/v1/health/service/greeter?passing=true", b"")
50+
assert_eq(resp.status(), 200)
51+
let eps = @moonzero.consul_parse_health(resp.body())
52+
assert_eq(eps.length(), 2)
53+
let addrs = eps.map(e => e.address())
54+
assert_eq(addrs.contains("10.0.0.1:9090"), true)
55+
assert_eq(addrs.contains("10.0.0.2:9090"), true)
56+
// Deregister both; the healthy set is then empty.
57+
let _ = sock.request("PUT", "/v1/agent/service/deregister/" + id1, b"")
58+
let _ = sock.request("PUT", "/v1/agent/service/deregister/" + id2, b"")
59+
let after = sock.request(
60+
"GET", "/v1/health/service/greeter?passing=true", b"",
61+
)
62+
assert_eq(@moonzero.consul_parse_health(after.body()).length(), 0)
63+
}

0 commit comments

Comments
 (0)