Packages
macula
0.18.1
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula_dist_system/macula_cluster.erl
%%%-------------------------------------------------------------------
%%% @doc Cluster management utilities for Macula platform.
%%%
%%% This module provides cluster infrastructure functions that other
%%% applications (like bc_gitops) can delegate to when running on
%%% the Macula platform.
%%%
%%% == Distribution ==
%%%
%%% The `ensure_distributed/0' function ensures the node is running
%%% in distributed mode. If not already distributed, it starts
%%% distribution with a generated node name.
%%%
%%% == Cookie Management ==
%%%
%%% Cookies are resolved in this priority order:
%%% 1. Application env: `{macula, [{cookie, CookieValue}]}'
%%% 2. Environment variable: `MACULA_COOKIE' or `RELEASE_COOKIE'
%%% 3. User's ~/.erlang.cookie file
%%% 4. Auto-generated (persisted to ~/.erlang.cookie)
%%%
%%% == Node Monitoring ==
%%%
%%% The `monitor_nodes/0' and `unmonitor_nodes/0' functions wrap
%%% `net_kernel:monitor_nodes/1' for subscribing to nodeup/nodedown
%%% messages.
%%%
%%% == bc_gitops Integration ==
%%%
%%% When bc_gitops is running on the Macula platform, it detects
%%% these exports and delegates clustering operations here. This
%%% allows Macula to own cluster infrastructure while bc_gitops
%%% remains usable standalone.
%%%
%%% @copyright 2026 Macula.io Apache-2.0
%%% @end
%%%-------------------------------------------------------------------
-module(macula_cluster).
-include_lib("kernel/include/logger.hrl").
%% API - Distribution
-export([
ensure_distributed/0,
is_distributed/0
]).
%% API - Cookie Management
-export([
get_cookie/0,
set_cookie/1
]).
%% API - Node Monitoring
-export([
monitor_nodes/0,
unmonitor_nodes/0
]).
%% API - Utilities
-export([
get_hostname/0
]).
%% Internal exports for testing
-export([
resolve_cookie/0,
read_cookie_file/0,
cookie_file_path/0
]).
%%%===================================================================
%%% API - Distribution
%%%===================================================================
%% @doc Ensure this node is running in distributed mode.
%%
%% If the node is already distributed, returns `ok' immediately.
%% Otherwise, starts distribution with a generated node name in the
%% format `macula_host@hostname'.
%%
%% Examples:
%% ```
%% ok = macula_cluster:ensure_distributed().
%% '''
-spec ensure_distributed() -> ok | {error, term()}.
ensure_distributed() ->
case is_distributed() of
true ->
ok;
false ->
start_distribution()
end.
%% @doc Check if this node is running in distributed mode.
-spec is_distributed() -> boolean().
is_distributed() ->
node() =/= nonode@nohost.
%%%===================================================================
%%% API - Cookie Management
%%%===================================================================
%% @doc Get the Erlang cookie for the cluster.
%%
%% Resolves the cookie from various sources in priority order.
%% If no cookie is found, generates and persists a new one.
%%
%% Resolution Order:
%% 1. Application env: `{macula, [{cookie, CookieValue}]}'
%% 2. Environment variable: `MACULA_COOKIE' or `RELEASE_COOKIE'
%% 3. User's ~/.erlang.cookie file
%% 4. Auto-generated (persisted to ~/.erlang.cookie)
%%
%% Examples:
%% ```
%% Cookie = macula_cluster:get_cookie().
%% '''
-spec get_cookie() -> atom().
get_cookie() ->
case resolve_cookie() of
{ok, Cookie} ->
Cookie;
{error, not_found} ->
%% Generate and persist a new cookie
NewCookie = generate_cookie(),
ok = persist_cookie(NewCookie),
NewCookie
end.
%% @doc Set the Erlang cookie for this node and persist it.
%%
%% Sets the cookie for the current node and attempts to persist
%% it to ~/.erlang.cookie for future sessions.
%%
%% Examples:
%% ```
%% ok = macula_cluster:set_cookie(my_secret_cookie).
%% ok = macula_cluster:set_cookie(<<"my_secret_cookie">>).
%% '''
-spec set_cookie(atom() | binary()) -> ok.
set_cookie(Cookie) when is_binary(Cookie) ->
set_cookie(binary_to_atom(Cookie, utf8));
set_cookie(Cookie) when is_atom(Cookie) ->
true = erlang:set_cookie(node(), Cookie),
_ = persist_cookie(Cookie),
ok.
%%%===================================================================
%%% API - Node Monitoring
%%%===================================================================
%% @doc Subscribe to node up/down events.
%%
%% After calling this function, the calling process will receive
%% `{nodeup, Node}' and `{nodedown, Node}' messages when nodes
%% join or leave the cluster.
%%
%% Examples:
%% ```
%% ok = macula_cluster:monitor_nodes().
%% receive
%% {nodeup, Node} -> io:format("Node joined: ~p~n", [Node]);
%% {nodedown, Node} -> io:format("Node left: ~p~n", [Node])
%% end.
%% '''
-spec monitor_nodes() -> ok.
monitor_nodes() ->
ok = net_kernel:monitor_nodes(true),
ok.
%% @doc Unsubscribe from node up/down events.
%%
%% Stops the calling process from receiving nodeup/nodedown messages.
-spec unmonitor_nodes() -> ok.
unmonitor_nodes() ->
ok = net_kernel:monitor_nodes(false),
ok.
%%%===================================================================
%%% API - Utilities
%%%===================================================================
%% @doc Get the short hostname of this machine.
%%
%% Examples:
%% ```
%% "myhost" = macula_cluster:get_hostname().
%% '''
-spec get_hostname() -> string().
get_hostname() ->
{ok, Hostname} = inet:gethostname(),
Hostname.
%%%===================================================================
%%% Internal Functions - Distribution
%%%===================================================================
-spec start_distribution() -> ok | {error, term()}.
start_distribution() ->
Hostname = get_hostname(),
NodeName = list_to_atom("macula_host@" ++ Hostname),
case net_kernel:start([NodeName, shortnames]) of
{ok, _Pid} ->
?LOG_INFO("[macula_cluster] Started distribution as ~p", [NodeName]),
%% Set the cookie
Cookie = get_cookie(),
true = erlang:set_cookie(node(), Cookie),
ok;
{error, {already_started, _Pid}} ->
ok;
{error, Reason} ->
?LOG_ERROR("[macula_cluster] Failed to start distribution: ~p", [Reason]),
{error, {distribution_failed, Reason}}
end.
%%%===================================================================
%%% Internal Functions - Cookie Resolution
%%%===================================================================
%% @doc Resolve the cookie from various sources.
-spec resolve_cookie() -> {ok, atom()} | {error, not_found}.
resolve_cookie() ->
Sources = [
fun get_cookie_from_app_env/0,
fun get_cookie_from_env_var/0,
fun read_cookie_file/0
],
try_sources(Sources).
-spec try_sources([fun(() -> {ok, atom()} | {error, term()})]) ->
{ok, atom()} | {error, not_found}.
try_sources([]) ->
{error, not_found};
try_sources([Source | Rest]) ->
case Source() of
{ok, Cookie} -> {ok, Cookie};
{error, _} -> try_sources(Rest)
end.
-spec get_cookie_from_app_env() -> {ok, atom()} | {error, not_found}.
get_cookie_from_app_env() ->
case application:get_env(macula, cookie) of
{ok, Cookie} when is_atom(Cookie) ->
{ok, Cookie};
{ok, Cookie} when is_binary(Cookie) ->
{ok, binary_to_atom(Cookie, utf8)};
{ok, Cookie} when is_list(Cookie) ->
{ok, list_to_atom(Cookie)};
_ ->
{error, not_found}
end.
-spec get_cookie_from_env_var() -> {ok, atom()} | {error, not_found}.
get_cookie_from_env_var() ->
EnvVars = ["MACULA_COOKIE", "RELEASE_COOKIE", "ERLANG_COOKIE"],
get_first_env_var(EnvVars).
-spec get_first_env_var([string()]) -> {ok, atom()} | {error, not_found}.
get_first_env_var([]) ->
{error, not_found};
get_first_env_var([Var | Rest]) ->
case os:getenv(Var) of
false -> get_first_env_var(Rest);
"" -> get_first_env_var(Rest);
Value -> {ok, list_to_atom(Value)}
end.
%% @doc Read cookie from ~/.erlang.cookie file.
-spec read_cookie_file() -> {ok, atom()} | {error, term()}.
read_cookie_file() ->
CookieFile = cookie_file_path(),
case file:read_file(CookieFile) of
{ok, Content} ->
Cookie = string:trim(binary_to_list(Content)),
case Cookie of
"" -> {error, empty_cookie};
_ -> {ok, list_to_atom(Cookie)}
end;
{error, Reason} ->
{error, {cookie_file_read_failed, Reason}}
end.
%% @doc Get the path to the cookie file.
-spec cookie_file_path() -> file:filename().
cookie_file_path() ->
case os:getenv("HOME") of
false -> "/tmp/.erlang.cookie";
Home -> filename:join(Home, ".erlang.cookie")
end.
%%%===================================================================
%%% Internal Functions - Cookie Generation
%%%===================================================================
-spec generate_cookie() -> atom().
generate_cookie() ->
%% Generate a random 20-character cookie
Bytes = crypto:strong_rand_bytes(15),
Hex = binary:encode_hex(Bytes),
binary_to_atom(Hex, utf8).
-spec persist_cookie(atom()) -> ok | {error, term()}.
persist_cookie(Cookie) ->
CookieFile = cookie_file_path(),
CookieStr = atom_to_list(Cookie) ++ "\n",
case file:write_file(CookieFile, CookieStr) of
ok ->
%% Set restrictive permissions (owner read/write only)
_ = file:change_mode(CookieFile, 8#600),
ok;
{error, Reason} ->
?LOG_WARNING("[macula_cluster] Failed to persist cookie: ~p", [Reason]),
{error, {cookie_persist_failed, Reason}}
end.