Skip to content

Commit 69941d5

Browse files
feat: Basic CI/CD for elixir (#497)
* [elixir] Basic CI/CD for elixir * plain install approach * use now allowlisted action
1 parent 2009592 commit 69941d5

5 files changed

Lines changed: 180 additions & 155 deletions

File tree

.github/workflows/build_and_test_cpp.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ on:
3131
- 'website/**'
3232
- '**/*.md'
3333
- 'bindings/python/**'
34+
- 'bindings/elixir/**'
3435
workflow_dispatch:
3536

3637
concurrency:
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
name: Elixir Build and Tests
19+
20+
on:
21+
push:
22+
branches:
23+
- main
24+
paths-ignore:
25+
- 'website/**'
26+
- '**/*.md'
27+
pull_request:
28+
branches:
29+
- main
30+
paths-ignore:
31+
- 'website/**'
32+
- '**/*.md'
33+
- 'bindings/cpp/**'
34+
- 'bindings/python/**'
35+
workflow_dispatch:
36+
37+
concurrency:
38+
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
39+
cancel-in-progress: true
40+
41+
jobs:
42+
build-and-test:
43+
timeout-minutes: 60
44+
runs-on: ubuntu-latest
45+
env:
46+
OTP_VERSION: "28.0.2"
47+
ELIXIR_VERSION: "1.19.5"
48+
FLUSS_TEST_CLUSTER_BIN: ${{ github.workspace }}/target/debug/fluss-test-cluster
49+
MIX_ENV: test
50+
steps:
51+
- uses: actions/checkout@v6
52+
53+
- name: Set up BEAM
54+
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
55+
with:
56+
otp-version: ${{ env.OTP_VERSION }}
57+
elixir-version: ${{ env.ELIXIR_VERSION }}
58+
59+
- name: Install protoc
60+
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
61+
62+
- name: Rust Cache
63+
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
64+
65+
- name: Cache Mix deps and build
66+
uses: actions/cache@v4
67+
with:
68+
path: |
69+
bindings/elixir/deps
70+
bindings/elixir/_build
71+
key: ${{ runner.os }}-mix-otp${{ env.OTP_VERSION }}-elixir${{ env.ELIXIR_VERSION }}-${{ hashFiles('bindings/elixir/mix.lock') }}
72+
restore-keys: |
73+
${{ runner.os }}-mix-otp${{ env.OTP_VERSION }}-elixir${{ env.ELIXIR_VERSION }}-
74+
75+
- name: Build fluss-test-cluster binary
76+
run: cargo build -p fluss-test-cluster
77+
78+
- name: Fetch Elixir deps
79+
working-directory: bindings/elixir
80+
run: mix deps.get
81+
82+
- name: Check formatting
83+
working-directory: bindings/elixir
84+
run: mix format --check-formatted
85+
86+
- name: Compile (warnings as errors)
87+
working-directory: bindings/elixir
88+
run: mix compile --warnings-as-errors
89+
90+
- name: Credo
91+
working-directory: bindings/elixir
92+
run: mix credo
93+
94+
- name: Run unit tests
95+
working-directory: bindings/elixir
96+
run: mix test
97+
98+
- name: Run integration tests
99+
working-directory: bindings/elixir
100+
run: mix test --include integration --only integration
101+
env:
102+
RUST_LOG: DEBUG
103+
RUST_BACKTRACE: full

.github/workflows/build_and_test_python.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ on:
3131
- 'website/**'
3232
- '**/*.md'
3333
- 'bindings/cpp/**'
34+
- 'bindings/elixir/**'
3435
workflow_dispatch:
3536

3637
concurrency:

.github/workflows/build_and_test_rust.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ on:
3232
- '**/*.md'
3333
- 'bindings/python/**'
3434
- 'bindings/cpp/**'
35+
- 'bindings/elixir/**'
3536
workflow_dispatch:
3637

3738
concurrency:

bindings/elixir/test/support/cluster.ex

Lines changed: 74 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,11 @@
1818
defmodule Fluss.Test.Cluster do
1919
@moduledoc false
2020

21-
@fluss_image "apache/fluss"
22-
@fluss_version "0.9.0-incubating"
21+
# Shells out to the `fluss-test-cluster` CLI (from `crates/fluss-test-cluster`),
22+
# the same binary used by the Python and C++ integration tests.
2323

24-
@network_name "fluss-elixir-test-network"
25-
@zookeeper_name "zookeeper-elixir-test"
26-
@coordinator_name "coordinator-server-elixir-test"
27-
@tablet_server_name "tablet-server-elixir-test"
28-
29-
# Same fixed ports used by Python/C++ integration tests.
30-
@coordinator_sasl_port 9123
31-
@coordinator_plain_port 9223
32-
@tablet_sasl_port 9124
33-
@tablet_plain_port 9224
34-
35-
def bootstrap_servers, do: "127.0.0.1:#{@coordinator_plain_port}"
24+
@cluster_name "shared-test"
25+
@cluster_json_prefix "CLUSTER_JSON: "
3626

3727
def ensure_started do
3828
case System.get_env("FLUSS_BOOTSTRAP_SERVERS") do
@@ -42,170 +32,99 @@ defmodule Fluss.Test.Cluster do
4232
end
4333

4434
def stop do
45-
for name <- [@tablet_server_name, @coordinator_name, @zookeeper_name] do
46-
System.cmd("docker", ["rm", "-f", name], stderr_to_stdout: true)
47-
end
35+
if System.get_env("FLUSS_BOOTSTRAP_SERVERS") do
36+
:ok
37+
else
38+
case find_cli_binary() do
39+
{:ok, cli} ->
40+
System.cmd(cli, ["stop", "--name", @cluster_name], stderr_to_stdout: true)
41+
:ok
4842

49-
System.cmd("docker", ["network", "rm", @network_name], stderr_to_stdout: true)
50-
:ok
43+
{:error, _} ->
44+
:ok
45+
end
46+
end
5147
end
5248

5349
defp start_cluster do
54-
if port_open?(@coordinator_plain_port) do
55-
IO.puts("Reusing existing Fluss cluster on port #{@coordinator_plain_port}")
56-
{:ok, bootstrap_servers()}
50+
with {:ok, cli} <- find_cli_binary(),
51+
{output, 0} <-
52+
System.cmd(cli, ["start", "--sasl", "--name", @cluster_name], stderr_to_stdout: true),
53+
{:ok, bootstrap} <- parse_cluster_json(output) do
54+
{:ok, bootstrap}
5755
else
58-
do_start_cluster()
56+
{output, code} when is_binary(output) ->
57+
{:error, "fluss-test-cluster start failed (exit #{code}):\n#{output}"}
58+
59+
{:error, _} = err ->
60+
err
5961
end
6062
end
6163

62-
defp do_start_cluster do
63-
IO.puts("Starting Fluss cluster via Docker...")
64+
defp find_cli_binary do
65+
case System.get_env("FLUSS_TEST_CLUSTER_BIN") do
66+
bin when is_binary(bin) and bin != "" ->
67+
if File.regular?(bin),
68+
do: {:ok, bin},
69+
else: {:error, "FLUSS_TEST_CLUSTER_BIN=#{bin} does not exist"}
6470

65-
# Remove any leftover containers from previous runs
66-
for name <- [@tablet_server_name, @coordinator_name, @zookeeper_name] do
67-
System.cmd("docker", ["rm", "-f", name], stderr_to_stdout: true)
68-
end
69-
70-
System.cmd("docker", ["network", "create", @network_name], stderr_to_stdout: true)
71-
72-
sasl_jaas =
73-
~s(org.apache.fluss.security.auth.sasl.plain.PlainLoginModule required user_admin="admin-secret" user_alice="alice-secret";)
74-
75-
coordinator_props =
76-
Enum.join(
77-
[
78-
"zookeeper.address: #{@zookeeper_name}:2181",
79-
"bind.listeners: INTERNAL://#{@coordinator_name}:0, CLIENT://#{@coordinator_name}:9123, PLAIN_CLIENT://#{@coordinator_name}:9223",
80-
"advertised.listeners: CLIENT://localhost:#{@coordinator_sasl_port}, PLAIN_CLIENT://localhost:#{@coordinator_plain_port}",
81-
"internal.listener.name: INTERNAL",
82-
"security.protocol.map: CLIENT:sasl",
83-
"security.sasl.enabled.mechanisms: plain",
84-
"security.sasl.plain.jaas.config: #{sasl_jaas}",
85-
"netty.server.num-network-threads: 1",
86-
"netty.server.num-worker-threads: 3"
87-
],
88-
"\n"
89-
)
90-
91-
tablet_props =
92-
Enum.join(
93-
[
94-
"zookeeper.address: #{@zookeeper_name}:2181",
95-
"bind.listeners: INTERNAL://#{@tablet_server_name}:0, CLIENT://#{@tablet_server_name}:9123, PLAIN_CLIENT://#{@tablet_server_name}:9223",
96-
"advertised.listeners: CLIENT://localhost:#{@tablet_sasl_port}, PLAIN_CLIENT://localhost:#{@tablet_plain_port}",
97-
"internal.listener.name: INTERNAL",
98-
"security.protocol.map: CLIENT:sasl",
99-
"security.sasl.enabled.mechanisms: plain",
100-
"security.sasl.plain.jaas.config: #{sasl_jaas}",
101-
"tablet-server.id: 0",
102-
"netty.server.num-network-threads: 1",
103-
"netty.server.num-worker-threads: 3"
104-
],
105-
"\n"
106-
)
107-
108-
docker_run([
109-
"--name",
110-
@zookeeper_name,
111-
"--network",
112-
@network_name,
113-
"-d",
114-
"zookeeper:3.9.2"
115-
])
116-
117-
docker_run([
118-
"--name",
119-
@coordinator_name,
120-
"--network",
121-
@network_name,
122-
"-p",
123-
"#{@coordinator_sasl_port}:9123",
124-
"-p",
125-
"#{@coordinator_plain_port}:9223",
126-
"-e",
127-
"FLUSS_PROPERTIES=#{coordinator_props}",
128-
"-d",
129-
"#{@fluss_image}:#{@fluss_version}",
130-
"coordinatorServer"
131-
])
132-
133-
docker_run([
134-
"--name",
135-
@tablet_server_name,
136-
"--network",
137-
@network_name,
138-
"-p",
139-
"#{@tablet_sasl_port}:9123",
140-
"-p",
141-
"#{@tablet_plain_port}:9223",
142-
"-e",
143-
"FLUSS_PROPERTIES=#{tablet_props}",
144-
"-d",
145-
"#{@fluss_image}:#{@fluss_version}",
146-
"tabletServer"
147-
])
148-
149-
all_ports = [@coordinator_plain_port, @tablet_plain_port]
150-
151-
if wait_for_ports(all_ports, 90) do
152-
IO.puts("Fluss cluster started successfully.")
153-
{:ok, bootstrap_servers()}
154-
else
155-
{:error, "Cluster ports did not become ready within timeout"}
71+
_ ->
72+
locate_via_cargo()
15673
end
15774
end
15875

159-
defp docker_run(args) do
160-
{output, code} = System.cmd("docker", ["run" | args], stderr_to_stdout: true)
76+
defp locate_via_cargo do
77+
case System.cmd("cargo", ["locate-project", "--workspace", "--message-format", "plain"],
78+
stderr_to_stdout: true
79+
) do
80+
{output, 0} ->
81+
output |> String.trim() |> Path.dirname() |> find_binary_in_target()
16182

162-
if code != 0 do
163-
IO.puts("Docker run warning (code #{code}): #{output}")
83+
{output, code} ->
84+
{:error, "cargo locate-project failed (exit #{code}): #{output}"}
16485
end
16586
end
16687

167-
defp wait_for_ports(ports, timeout_s) do
168-
deadline = System.monotonic_time(:second) + timeout_s
88+
defp find_binary_in_target(root) do
89+
Enum.find_value(
90+
["debug", "release"],
91+
{:error, "fluss-test-cluster binary not found. Run: cargo build -p fluss-test-cluster"},
92+
&check_binary(root, &1)
93+
)
94+
end
16995

170-
Enum.all?(ports, fn port ->
171-
remaining = deadline - System.monotonic_time(:second)
172-
remaining > 0 and wait_for_port(port, remaining)
173-
end)
96+
defp check_binary(root, profile) do
97+
path = Path.join([root, "target", profile, "fluss-test-cluster"])
98+
if File.regular?(path), do: {:ok, path}, else: nil
17499
end
175100

176-
defp wait_for_port(port, timeout_s) do
177-
deadline = System.monotonic_time(:second) + timeout_s
101+
defp parse_cluster_json(output) do
102+
output
103+
|> String.split("\n", trim: true)
104+
|> Enum.find_value(
105+
{:error, "No #{@cluster_json_prefix} token in output:\n#{output}"},
106+
&extract_bootstrap/1
107+
)
108+
end
178109

179-
Stream.repeatedly(fn ->
180-
case :gen_tcp.connect(~c"localhost", port, [], 1000) do
181-
{:ok, socket} ->
182-
:gen_tcp.close(socket)
183-
:ok
110+
defp extract_bootstrap(line) do
111+
case String.split(line, @cluster_json_prefix, parts: 2) do
112+
[_, json] ->
113+
case decode_bootstrap(json) do
114+
{:ok, bootstrap} -> {:ok, bootstrap}
115+
_ -> nil
116+
end
184117

185-
{:error, _} ->
186-
Process.sleep(1000)
187-
:retry
188-
end
189-
end)
190-
|> Enum.reduce_while(false, fn
191-
:ok, _acc ->
192-
{:halt, true}
193-
194-
:retry, _acc ->
195-
if System.monotonic_time(:second) >= deadline,
196-
do: {:halt, false},
197-
else: {:cont, false}
198-
end)
118+
_ ->
119+
nil
120+
end
199121
end
200122

201-
defp port_open?(port) do
202-
case :gen_tcp.connect(~c"localhost", port, [], 1000) do
203-
{:ok, socket} ->
204-
:gen_tcp.close(socket)
205-
true
206-
207-
{:error, _} ->
208-
false
123+
# Minimal JSON extractor for `bootstrap_servers`: avoids adding a JSON dep just for tests.
124+
defp decode_bootstrap(json) do
125+
case Regex.run(~r/"bootstrap_servers"\s*:\s*"([^"]+)"/, json) do
126+
[_, servers] -> {:ok, servers}
127+
_ -> {:error, "no bootstrap_servers in: #{json}"}
209128
end
210129
end
211130
end

0 commit comments

Comments
 (0)