Skip to content

Commit cd84d9c

Browse files
committed
Add gn-ten node lab topology boot plan
1 parent 52b132c commit cd84d9c

10 files changed

Lines changed: 563 additions & 6 deletions

File tree

support/gn_ten_node_lab/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,26 @@ It owns local test-harness mechanics:
1212
- peer node lifecycle;
1313
- code-path sync in dev peer mode;
1414
- bounded `:erpc` admin/proof calls;
15+
- checked-in topology fixture loading;
16+
- required app boot probes;
17+
- owner-defined facade host and `:pg` readiness checks;
1518
- node cleanup receipts.
1619

1720
It does not own AppKit, Mezzanine, Citadel, OuterBrain, Jido Integration,
1821
Execution Plane, AITrace, TRINITY, GEPA, or product business semantics.
1922

23+
## Topology Fixtures
24+
25+
Temporary v2 topology fixtures live under `priv/topologies/` until
26+
`examples/gn_ten_distributed_stack` exists:
27+
28+
- `control_3_node.exs`
29+
- `context_6_node.exs`
30+
31+
These fixtures name owner facade modules and owner-defined `:pg` groups. The
32+
node lab can host those modules on peer nodes for readiness proof, but StackLab
33+
does not define the domain contract.
34+
2035
## Security Posture
2136

2237
Default Erlang distribution cookies are local development cluster authority,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
defmodule GnTenNodeLabFixture.RemoteFacade do
2+
@moduledoc false
3+
4+
def owner_group, do: {__MODULE__, :fixture}
5+
end

support/gn_ten_node_lab/lib/stack_lab/gn_ten_node_lab.ex

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,23 @@ defmodule StackLab.GnTenNodeLab do
77
evidence semantics remain in their owner repos.
88
"""
99

10-
alias StackLab.GnTenNodeLab.{Peer, Preflight, Topology}
10+
alias StackLab.GnTenNodeLab.{BootPlan, Peer, Preflight, Topology}
1111

1212
@spec preflight(keyword()) :: {:ok, map()} | {:error, map()}
1313
defdelegate preflight(opts \\ []), to: Preflight, as: :run
1414

1515
@spec validate_topology(map()) :: {:ok, Topology.t()} | {:error, [map()]}
1616
defdelegate validate_topology(spec), to: Topology, as: :validate
1717

18+
@spec load_topology(Path.t()) :: {:ok, Topology.t()} | {:error, [map()]}
19+
defdelegate load_topology(path), to: Topology, as: :load_file
20+
21+
@spec topology_instances(Topology.t()) :: [map()]
22+
defdelegate topology_instances(topology), to: BootPlan, as: :instances
23+
24+
@spec boot_instance(Peer.t() | node(), map(), keyword()) :: {:ok, map()} | {:error, map()}
25+
defdelegate boot_instance(target, instance, opts \\ []), to: BootPlan
26+
1827
@spec with_peer((Peer.t() -> term()), keyword()) :: {:ok, term()} | {:error, map()}
1928
defdelegate with_peer(fun, opts \\ []), to: Peer
2029
end
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
defmodule StackLab.GnTenNodeLab.BootPlan do
2+
@moduledoc """
3+
Peer-node app boot and owner-facade readiness probes for topology instances.
4+
"""
5+
6+
alias StackLab.GnTenNodeLab.{FacadeHost, Peer, Topology}
7+
8+
@remote_call_timeout_ms 5_000
9+
10+
@spec instances(Topology.t()) :: [map()]
11+
def instances(%Topology{} = topology), do: Topology.instance_specs(topology)
12+
13+
@spec boot_instance(Peer.t() | node(), map(), keyword()) :: {:ok, map()} | {:error, map()}
14+
def boot_instance(target, instance, opts \\ []) when is_map(instance) and is_list(opts) do
15+
node = target_node(target)
16+
17+
with {:ok, started_apps} <- start_required_apps(node, Map.get(instance, :required_apps, [])),
18+
{:ok, facade_hosts} <-
19+
maybe_start_facade_hosts(node, Map.get(instance, :owner_groups, []), opts),
20+
{:ok, owner_group_membership} <-
21+
probe_owner_groups(node, Map.get(instance, :owner_groups, [])) do
22+
{:ok,
23+
%{
24+
"node_id" => Map.fetch!(instance, :node_id),
25+
"profile" => instance.profile |> Atom.to_string(),
26+
"node" => Atom.to_string(node),
27+
"started_apps" => started_apps,
28+
"facade_hosts" => facade_hosts,
29+
"owner_group_membership" => owner_group_membership,
30+
"ready?" => true
31+
}}
32+
end
33+
end
34+
35+
@spec probe_owner_groups(Peer.t() | node(), [tuple()]) :: {:ok, [map()]} | {:error, map()}
36+
def probe_owner_groups(target, owner_groups) when is_list(owner_groups) do
37+
node = target_node(target)
38+
39+
owner_groups
40+
|> Enum.reduce_while({:ok, []}, fn owner_group, {:ok, receipts} ->
41+
case owner_group_members(node, owner_group) do
42+
{:ok, members} when members != [] ->
43+
{:cont, {:ok, [membership_receipt(owner_group, members) | receipts]}}
44+
45+
{:ok, []} ->
46+
{:halt,
47+
{:error, failure("owner_group_not_registered", owner_group: inspect(owner_group))}}
48+
49+
{:error, reason} ->
50+
{:halt, {:error, failure("owner_group_probe_failed", reason: inspect(reason))}}
51+
end
52+
end)
53+
|> case do
54+
{:ok, receipts} -> {:ok, Enum.reverse(receipts)}
55+
{:error, failure} -> {:error, failure}
56+
end
57+
end
58+
59+
defp maybe_start_facade_hosts(_node, _owner_groups, start_facade_hosts?: false), do: {:ok, []}
60+
61+
defp maybe_start_facade_hosts(node, owner_groups, _opts) do
62+
owner_groups
63+
|> Enum.reduce_while({:ok, []}, fn owner_group, {:ok, receipts} ->
64+
case start_facade_host(node, owner_group) do
65+
{:ok, receipt} -> {:cont, {:ok, [receipt | receipts]}}
66+
{:error, failure} -> {:halt, {:error, failure}}
67+
end
68+
end)
69+
|> case do
70+
{:ok, receipts} -> {:ok, Enum.reverse(receipts)}
71+
{:error, failure} -> {:error, failure}
72+
end
73+
end
74+
75+
defp start_required_apps(node, required_apps) do
76+
required_apps
77+
|> Enum.reduce_while({:ok, []}, fn app, {:ok, receipts} ->
78+
case Peer.remote_call(
79+
node,
80+
Application,
81+
:ensure_all_started,
82+
[app],
83+
@remote_call_timeout_ms
84+
) do
85+
{:ok, {:ok, apps}} ->
86+
{:cont,
87+
{:ok, [%{"app" => Atom.to_string(app), "started" => apps_to_strings(apps)} | receipts]}}
88+
89+
{:ok, {:error, reason}} ->
90+
{:halt,
91+
{:error,
92+
failure("app_start_failed", app: Atom.to_string(app), reason: inspect(reason))}}
93+
94+
{:error, reason} ->
95+
{:halt,
96+
{:error,
97+
failure("app_start_probe_failed", app: Atom.to_string(app), reason: inspect(reason))}}
98+
end
99+
end)
100+
|> case do
101+
{:ok, receipts} -> {:ok, Enum.reverse(receipts)}
102+
{:error, failure} -> {:error, failure}
103+
end
104+
end
105+
106+
defp start_facade_host(node, {facade_module, _name} = owner_group) do
107+
args = [[facade_module: facade_module, owner_group: owner_group]]
108+
109+
case Peer.remote_call(node, FacadeHost, :start, args, @remote_call_timeout_ms) do
110+
{:ok, {:ok, pid}} ->
111+
{:ok,
112+
%{
113+
"facade_module" => inspect(facade_module),
114+
"owner_group" => inspect(owner_group),
115+
"pid" => inspect(pid)
116+
}}
117+
118+
{:ok, {:error, reason}} ->
119+
{:error,
120+
failure("facade_host_start_failed",
121+
owner_group: inspect(owner_group),
122+
reason: inspect(reason)
123+
)}
124+
125+
{:error, reason} ->
126+
{:error,
127+
failure("facade_host_start_probe_failed",
128+
owner_group: inspect(owner_group),
129+
reason: inspect(reason)
130+
)}
131+
end
132+
end
133+
134+
defp owner_group_members(node, owner_group) do
135+
Peer.remote_call(node, :pg, :get_members, [owner_group], @remote_call_timeout_ms)
136+
end
137+
138+
defp target_node(%Peer{peer_node: peer_node}), do: peer_node
139+
defp target_node(node) when is_atom(node), do: node
140+
141+
defp membership_receipt(owner_group, members) do
142+
%{
143+
"owner_group" => inspect(owner_group),
144+
"member_count" => length(members),
145+
"members" => Enum.map(members, &inspect/1)
146+
}
147+
end
148+
149+
defp apps_to_strings(apps), do: Enum.map(apps, &Atom.to_string/1)
150+
151+
defp failure(code, attrs), do: Enum.into(attrs, %{code: code})
152+
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
defmodule StackLab.GnTenNodeLab.FacadeHost do
2+
@moduledoc """
3+
Generic test-harness process that exposes an owner facade module through
4+
the owner-defined `:pg` group for local distributed proofs.
5+
6+
This process is StackLab-owned harness infrastructure. It does not define the
7+
facade contract and it does not move owner semantics into StackLab.
8+
"""
9+
10+
use GenServer
11+
12+
@spec start_link(keyword()) :: GenServer.on_start()
13+
def start_link(opts) when is_list(opts) do
14+
GenServer.start_link(__MODULE__, opts)
15+
end
16+
17+
@spec start(keyword()) :: GenServer.on_start()
18+
def start(opts) when is_list(opts) do
19+
GenServer.start(__MODULE__, opts)
20+
end
21+
22+
@impl true
23+
def init(opts) do
24+
facade_module = Keyword.fetch!(opts, :facade_module)
25+
owner_group = Keyword.fetch!(opts, :owner_group)
26+
27+
with :ok <- ensure_facade_module(facade_module),
28+
:ok <- ensure_owner_group(facade_module, owner_group),
29+
:ok <- ensure_pg_started(),
30+
:ok <- join_owner_group(owner_group) do
31+
{:ok, %{facade_module: facade_module, owner_group: owner_group}}
32+
else
33+
{:error, reason} -> {:stop, reason}
34+
end
35+
end
36+
37+
defp ensure_facade_module(facade_module) when is_atom(facade_module) do
38+
with {:module, ^facade_module} <- Code.ensure_loaded(facade_module),
39+
true <- function_exported?(facade_module, :owner_group, 0) do
40+
:ok
41+
else
42+
_other -> {:error, {:facade_module_unavailable, facade_module}}
43+
end
44+
end
45+
46+
defp ensure_facade_module(facade_module), do: {:error, {:invalid_facade_module, facade_module}}
47+
48+
defp ensure_owner_group(facade_module, owner_group) do
49+
if facade_module.owner_group() == owner_group do
50+
:ok
51+
else
52+
{:error, {:owner_group_mismatch, facade_module, owner_group}}
53+
end
54+
end
55+
56+
defp ensure_pg_started do
57+
case Process.whereis(:pg) do
58+
nil ->
59+
case :pg.start_link() do
60+
{:ok, _pid} -> :ok
61+
{:error, {:already_started, _pid}} -> :ok
62+
{:error, reason} -> {:error, {:pg_start_failed, reason}}
63+
end
64+
65+
_pid ->
66+
:ok
67+
end
68+
end
69+
70+
defp join_owner_group(owner_group) do
71+
case :pg.join(owner_group, self()) do
72+
:ok -> :ok
73+
{:error, reason} -> {:error, {:owner_group_join_failed, reason}}
74+
end
75+
end
76+
end

0 commit comments

Comments
 (0)