Skip to content

Commit 03a6dfe

Browse files
initial commit
1 parent 6908954 commit 03a6dfe

File tree

10 files changed

+370
-0
lines changed

10 files changed

+370
-0
lines changed

.github/workflows/test.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- main
8+
pull_request:
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: erlef/setup-beam@v1
16+
with:
17+
otp-version: "26.0.2"
18+
gleam-version: "1.4.1"
19+
rebar3-version: "3"
20+
# elixir-version: "1.15.4"
21+
- run: gleam deps download
22+
- run: gleam test
23+
- run: gleam format --check src test

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*.beam
2+
*.ez
3+
/build
4+
erl_crash.dump

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# barnacle
2+
3+
[![Package Version](https://img.shields.io/hexpm/v/barnacle)](https://hex.pm/packages/barnacle)
4+
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/barnacle/)
5+
6+
```sh
7+
gleam add barnacle@1
8+
```
9+
```gleam
10+
import barnacle
11+
12+
pub fn main() {
13+
// TODO: An example of the project in use
14+
}
15+
```
16+
17+
Further documentation can be found at <https://hexdocs.pm/barnacle>.
18+
19+
## Development
20+
21+
```sh
22+
gleam run # Run the project
23+
gleam test # Run the tests
24+
```

gleam.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name = "barnacle"
2+
version = "1.0.0"
3+
4+
# Fill out these fields if you intend to generate HTML documentation or publish
5+
# your project to the Hex package manager.
6+
#
7+
# description = ""
8+
# licences = ["Apache-2.0"]
9+
# repository = { type = "github", user = "", repo = "" }
10+
# links = [{ title = "Website", href = "" }]
11+
#
12+
# For a full reference of all the available options, you can have a look at
13+
# https://gleam.run/writing-gleam/gleam-toml/.
14+
15+
[dependencies]
16+
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
17+
gleam_erlang = ">= 0.27.0 and < 1.0.0"
18+
gleam_otp = ">= 0.12.1 and < 1.0.0"
19+
20+
[dev-dependencies]
21+
gleeunit = ">= 1.0.0 and < 2.0.0"

manifest.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file was generated by Gleam
2+
# You typically do not need to edit this file
3+
4+
packages = [
5+
{ name = "gleam_erlang", version = "0.27.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "DE468F676D71B313C6C8C5334425CFCF827837333F8AB47B64D8A6D7AA40185D" },
6+
{ name = "gleam_otp", version = "0.12.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BFACC1513410DF5A1617169A9CD7EA334973AC71D860A17574BA7B2EADD89A6F" },
7+
{ name = "gleam_stdlib", version = "0.40.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86606B75A600BBD05E539EB59FABC6E307EEEA7B1E5865AFB6D980A93BCB2181" },
8+
{ name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
9+
]
10+
11+
[requirements]
12+
gleam_erlang = { version = ">= 0.27.0 and < 1.0.0" }
13+
gleam_otp = { version = ">= 0.12.1 and < 1.0.0" }
14+
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
15+
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }

src/barnacle.gleam

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import barnacle/internal/local_epmd
2+
import gleam/erlang/atom
3+
import gleam/erlang/node
4+
import gleam/erlang/process.{type Subject}
5+
import gleam/function
6+
import gleam/list
7+
import gleam/option.{type Option, None, Some}
8+
import gleam/otp/actor
9+
import gleam/otp/supervisor
10+
import gleam/result
11+
import gleam/set
12+
import gleam/string
13+
14+
pub opaque type Barnacle(error) {
15+
Barnacle(
16+
discover_nodes: fn() -> Result(List(atom.Atom), error),
17+
poll_interval: Int,
18+
)
19+
}
20+
21+
fn default_barnacle() -> Barnacle(a) {
22+
Barnacle(discover_nodes: fn() { Ok([]) }, poll_interval: 5000)
23+
}
24+
25+
pub fn local_epmd() -> Barnacle(Nil) {
26+
Barnacle(..default_barnacle(), discover_nodes: local_epmd.discover_nodes)
27+
}
28+
29+
pub fn with_poll_interval(
30+
barnacle: Barnacle(error),
31+
poll_interval: Int,
32+
) -> Barnacle(error) {
33+
Barnacle(..barnacle, poll_interval: poll_interval)
34+
}
35+
36+
fn spec(
37+
barnacle: Barnacle(error),
38+
parent: Option(Subject(Subject(Message(error)))),
39+
) {
40+
actor.Spec(init_timeout: 10_000, loop: handle_message, init: fn() {
41+
case refresh_nodes(barnacle) {
42+
Ok(_) -> {
43+
let self = process.new_subject()
44+
let selector =
45+
process.new_selector()
46+
|> process.selecting(self, function.identity)
47+
48+
option.map(parent, process.send(_, self))
49+
50+
let timer =
51+
process.send_after(self, barnacle.poll_interval, Refresh(None))
52+
actor.Ready(
53+
selector: selector,
54+
state: State(self:, barnacle:, timer: Some(timer)),
55+
)
56+
}
57+
Error(err) ->
58+
actor.Failed(case err {
59+
ConnectError(_) -> "Failed to connect to nodes"
60+
DisconnectError(_) -> "Failed to disconnect from nodes"
61+
StrategyError(err) ->
62+
"Failed to discover nodes: " <> string.inspect(err)
63+
})
64+
}
65+
})
66+
}
67+
68+
pub fn start(
69+
barnacle: Barnacle(error),
70+
parent: Option(Subject(Subject(Message(error)))),
71+
) {
72+
barnacle
73+
|> spec(parent)
74+
|> actor.start_spec
75+
}
76+
77+
pub fn child_spec(
78+
barnacle: Barnacle(error),
79+
parent: Option(Subject(Subject(Message(error)))),
80+
) {
81+
supervisor.worker(fn(_) { start(barnacle, parent) })
82+
}
83+
84+
pub fn main() {
85+
let self = process.new_subject()
86+
let assert Ok(child) =
87+
local_epmd()
88+
|> start(None)
89+
90+
process.send(child, Refresh(Some(self)))
91+
92+
let assert Ok(msg) = process.receive(self, 10_000)
93+
94+
process.sleep_forever()
95+
}
96+
97+
pub type RefreshResult(error) =
98+
Result(List(atom.Atom), RefreshError(error))
99+
100+
pub opaque type Message(error) {
101+
Refresh(return: Option(Subject(RefreshResult(error))))
102+
Stop(return: Option(Subject(Nil)))
103+
Shutdown(return: Option(Subject(Nil)))
104+
}
105+
106+
type State(error) {
107+
State(
108+
self: Subject(Message(error)),
109+
barnacle: Barnacle(error),
110+
timer: Option(process.Timer),
111+
)
112+
}
113+
114+
pub type NodeDisconnectError {
115+
FailedToDisconnect
116+
LocalNodeIsNotAlive
117+
}
118+
119+
@external(erlang, "barnacle_ffi", "disconnect_from_node")
120+
fn disconnect_node(node: atom.Atom) -> Result(node.Node, NodeDisconnectError)
121+
122+
pub type RefreshError(error) {
123+
StrategyError(error)
124+
ConnectError(List(#(atom.Atom, node.ConnectError)))
125+
DisconnectError(List(#(atom.Atom, NodeDisconnectError)))
126+
}
127+
128+
fn handle_message(message: Message(error), state: State(error)) {
129+
let State(self:, barnacle:, timer:) = state
130+
case message {
131+
Refresh(return) -> {
132+
let refresh_result = refresh_nodes(barnacle)
133+
send_response(return, refresh_result)
134+
135+
let timer =
136+
process.send_after(self, barnacle.poll_interval, Refresh(None))
137+
actor.continue(State(..state, timer: Some(timer)))
138+
}
139+
Stop(return) -> {
140+
option.map(timer, process.cancel_timer)
141+
send_response(return, Nil)
142+
actor.continue(State(..state, timer: None))
143+
}
144+
Shutdown(return) -> {
145+
option.map(timer, process.cancel_timer)
146+
send_response(return, Nil)
147+
actor.Stop(process.Normal)
148+
}
149+
}
150+
}
151+
152+
fn send_response(maybe_client: Option(Subject(a)), response: a) -> Nil {
153+
case maybe_client {
154+
Some(client) -> process.send(client, response)
155+
None -> Nil
156+
}
157+
}
158+
159+
fn refresh_nodes(barnacle: Barnacle(error)) -> RefreshResult(error) {
160+
use available_nodes <- result.try(
161+
barnacle.discover_nodes()
162+
|> result.map(fn(nodes) {
163+
set.from_list(nodes)
164+
|> set.delete(node.self() |> node.to_atom)
165+
})
166+
|> result.map_error(StrategyError),
167+
)
168+
169+
let current_nodes =
170+
node.visible()
171+
|> list.map(node.to_atom)
172+
|> set.from_list
173+
174+
let nodes_to_add = set.difference(available_nodes, current_nodes)
175+
let nodes_to_remove = set.difference(current_nodes, available_nodes)
176+
177+
let connect_results =
178+
nodes_to_add
179+
|> set.to_list
180+
|> list.map(fn(node) {
181+
case node.connect(node) {
182+
Ok(_) -> Ok(Nil)
183+
Error(err) -> Error(#(node, err))
184+
}
185+
})
186+
|> result_apply
187+
188+
use _ <- result.try(connect_results |> result.map_error(ConnectError))
189+
190+
let disconnect_results =
191+
nodes_to_remove
192+
|> set.to_list
193+
|> list.map(fn(node) {
194+
case disconnect_node(node) {
195+
Ok(_) -> Ok(Nil)
196+
Error(err) -> Error(#(node, err))
197+
}
198+
})
199+
|> result_apply
200+
201+
use _ <- result.try(disconnect_results |> result.map_error(DisconnectError))
202+
203+
Ok(node.visible() |> list.map(node.to_atom))
204+
}
205+
206+
fn result_apply(results: List(Result(a, b))) -> Result(List(a), List(b)) {
207+
results
208+
|> list.fold(Ok([]), fn(acc, result) {
209+
case result {
210+
Ok(val) -> {
211+
case acc {
212+
Ok(vals) -> Ok([val, ..vals])
213+
Error(_) -> acc
214+
}
215+
}
216+
Error(err) -> {
217+
case acc {
218+
Ok(_) -> Error([err])
219+
Error(errs) -> Error([err, ..errs])
220+
}
221+
}
222+
}
223+
})
224+
}

src/barnacle/internal/actor.gleam

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import gleam/erlang/atom
2+
import gleam/erlang/node
3+
import gleam/list
4+
import gleam/result
5+
import gleam/string
6+
7+
@external(erlang, "barnacle_ffi", "list_local_nodes")
8+
fn list_local_nodes() -> Result(List(String), Nil)
9+
10+
fn get_hostname() -> String {
11+
let host =
12+
node.self()
13+
|> node.to_atom
14+
|> atom.to_string
15+
16+
let assert [_, hostname] = string.split(host, "@")
17+
hostname
18+
}
19+
20+
pub fn discover_nodes() -> Result(List(atom.Atom), Nil) {
21+
let hostname = get_hostname()
22+
list_local_nodes()
23+
|> result.map(list.map(_, fn(n) {
24+
{ n <> "@" <> hostname } |> atom.create_from_string
25+
}))
26+
}

src/barnacle_ffi.erl

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-module(barnacle_ffi).
2+
3+
-export([list_local_nodes/0, disconnect_from_node/1]).
4+
5+
list_local_nodes() ->
6+
case erl_epmd:names() of
7+
{error, address} ->
8+
{error, nil};
9+
{ok, Names} ->
10+
{ok, [list_to_binary(Name) || {Name, _} <- Names]}
11+
end.
12+
13+
disconnect_from_node(Node) ->
14+
case disconnect_node(Node) of
15+
true ->
16+
{ok, Node};
17+
false ->
18+
{error, failed_to_disconnect};
19+
ignored ->
20+
{error, local_node_is_not_alive}
21+
end.

test/barnacle_test.gleam

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import gleeunit
2+
import gleeunit/should
3+
4+
pub fn main() {
5+
gleeunit.main()
6+
}
7+
8+
// gleeunit test functions end in `_test`
9+
pub fn hello_world_test() {
10+
1
11+
|> should.equal(1)
12+
}

0 commit comments

Comments
 (0)