Skip to content

Commit f11fde4

Browse files
committed
Add gn-ten node lab admin commands
1 parent cd84d9c commit f11fde4

13 files changed

Lines changed: 718 additions & 1 deletion

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Distributed Node Lab Runbook
2+
3+
This runbook covers the Phase 6 local node-lab command surface. It is a
4+
StackLab harness workflow, not a production distribution model.
5+
6+
## Preflight
7+
8+
```bash
9+
mix stack_lab.gn_ten.node_lab.preflight --json
10+
```
11+
12+
The preflight verifies EPMD, local shortname distribution, peer startup,
13+
code-path sync, bounded remote calls, cleanup, and redacted cookie posture.
14+
15+
## Topology Boot
16+
17+
Use the deterministic fixture topology for package-level command checks:
18+
19+
```bash
20+
mix stack_lab.gn_ten.node_lab.up \
21+
--topology support/gn_ten_node_lab/priv/topologies/fixture_single_node.exs \
22+
--json
23+
```
24+
25+
The command starts a peer, syncs code paths, boots required apps, hosts the
26+
owner-defined facade group, probes `:pg`, writes a run-state receipt under
27+
`tmp/stack_lab/gn_ten_node_lab/`, and stops the peer before returning.
28+
29+
`--keep` records intent only in Phase 6. It does not claim cross-command peer
30+
retention. That claim belongs to a later daemon or release-path controller.
31+
32+
## Status, Probe, And Cleanup
33+
34+
```bash
35+
mix stack_lab.gn_ten.node_lab.status --json
36+
mix stack_lab.gn_ten.node_lab.probe --node fixture_profile_0 --json
37+
mix stack_lab.gn_ten.node_lab.down --json
38+
```
39+
40+
`status` reads the latest run-state receipt. `probe` reads one logical node
41+
receipt from that state. `down` removes the run-state file and is idempotent.
42+
43+
## Safety Notes
44+
45+
- Receipts do not include Erlang cookie values.
46+
- Peer PIDs are harness observations only, not platform DTOs.
47+
- Owner apps register owner-defined groups such as
48+
`{Mezzanine.RemoteFacade.Workflow, :workflow}`; StackLab topology maps proof
49+
profiles to those groups.
50+
- Domain semantics remain in owner repos.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
defmodule Mix.Tasks.StackLab.GnTen.NodeLab.Down do
2+
@moduledoc "Clears gn-ten node-lab run state after a proof run."
3+
4+
use Mix.Task
5+
6+
alias StackLab.GnTenNodeLab.{RunState, Runner}
7+
8+
@shortdoc "Clears gn-ten node-lab run state"
9+
10+
@impl Mix.Task
11+
def run(args) do
12+
Mix.Task.run("app.start")
13+
14+
{opts, _argv, invalid} =
15+
OptionParser.parse(args,
16+
strict: [
17+
state: :string,
18+
json: :boolean
19+
]
20+
)
21+
22+
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
23+
24+
runner_opts = [state_path: Keyword.get(opts, :state, RunState.default_path())]
25+
26+
case Runner.down(runner_opts) do
27+
{:ok, receipt} ->
28+
print(receipt, Keyword.get(opts, :json, false), :info)
29+
30+
{:error, receipt} ->
31+
print(receipt, Keyword.get(opts, :json, false), :error)
32+
exit({:shutdown, 1})
33+
end
34+
end
35+
36+
defp print(receipt, true, level) do
37+
encoded = Jason.encode!(receipt, pretty: true)
38+
if level == :error, do: Mix.shell().error(encoded), else: Mix.shell().info(encoded)
39+
end
40+
41+
defp print(receipt, false, :info) do
42+
Mix.shell().info("stack_lab.gn_ten.node_lab.down #{receipt["status"]}")
43+
Mix.shell().info("state=#{receipt["state_path"]}")
44+
end
45+
46+
defp print(receipt, false, :error) do
47+
Mix.shell().error("stack_lab.gn_ten.node_lab.down failed")
48+
Mix.shell().error("failures=#{inspect(receipt["failures"])}")
49+
end
50+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule Mix.Tasks.StackLab.GnTen.NodeLab.Probe do
2+
@moduledoc "Reads the last gn-ten node-lab run state for a logical node."
3+
4+
use Mix.Task
5+
6+
alias StackLab.GnTenNodeLab.{RunState, Runner}
7+
8+
@shortdoc "Probes a gn-ten node-lab logical node"
9+
10+
@impl Mix.Task
11+
def run(args) do
12+
Mix.Task.run("app.start")
13+
14+
{opts, _argv, invalid} =
15+
OptionParser.parse(args,
16+
strict: [
17+
node: :string,
18+
state: :string,
19+
json: :boolean
20+
]
21+
)
22+
23+
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
24+
25+
node_id = Keyword.get(opts, :node) || Mix.raise("expected --node <logical_node_id>")
26+
runner_opts = [state_path: Keyword.get(opts, :state, RunState.default_path())]
27+
28+
case Runner.probe(node_id, runner_opts) do
29+
{:ok, receipt} ->
30+
print(receipt, Keyword.get(opts, :json, false), :info)
31+
32+
{:error, receipt} ->
33+
print(receipt, Keyword.get(opts, :json, false), :error)
34+
exit({:shutdown, 1})
35+
end
36+
end
37+
38+
defp print(receipt, true, level) do
39+
encoded = Jason.encode!(receipt, pretty: true)
40+
if level == :error, do: Mix.shell().error(encoded), else: Mix.shell().info(encoded)
41+
end
42+
43+
defp print(receipt, false, :info) do
44+
Mix.shell().info("stack_lab.gn_ten.node_lab.probe #{receipt["status"]}")
45+
Mix.shell().info("node=#{receipt["node"]}")
46+
end
47+
48+
defp print(receipt, false, :error) do
49+
Mix.shell().error("stack_lab.gn_ten.node_lab.probe failed")
50+
Mix.shell().error("failures=#{inspect(receipt["failures"])}")
51+
end
52+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule Mix.Tasks.StackLab.GnTen.NodeLab.Status do
2+
@moduledoc "Reports the current gn-ten node-lab run state."
3+
4+
use Mix.Task
5+
6+
alias StackLab.GnTenNodeLab.{RunState, Runner}
7+
8+
@shortdoc "Reports gn-ten node-lab status"
9+
10+
@impl Mix.Task
11+
def run(args) do
12+
Mix.Task.run("app.start")
13+
14+
{opts, _argv, invalid} =
15+
OptionParser.parse(args,
16+
strict: [
17+
state: :string,
18+
json: :boolean
19+
]
20+
)
21+
22+
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
23+
24+
{:ok, receipt} = Runner.status(state_path: Keyword.get(opts, :state, RunState.default_path()))
25+
print(receipt, Keyword.get(opts, :json, false))
26+
end
27+
28+
defp print(receipt, true), do: Mix.shell().info(Jason.encode!(receipt, pretty: true))
29+
30+
defp print(receipt, false) do
31+
Mix.shell().info("stack_lab.gn_ten.node_lab.status #{receipt["status"]}")
32+
Mix.shell().info("state=#{receipt["state_path"]}")
33+
end
34+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
defmodule Mix.Tasks.StackLab.GnTen.NodeLab.Up do
2+
@moduledoc "Boots a gn-ten node-lab topology and writes a run receipt."
3+
4+
use Mix.Task
5+
6+
alias StackLab.GnTenNodeLab.{RunState, Runner}
7+
8+
@shortdoc "Boots a gn-ten node-lab topology"
9+
10+
@impl Mix.Task
11+
def run(args) do
12+
Mix.Task.run("app.start")
13+
14+
{opts, _argv, invalid} =
15+
OptionParser.parse(args,
16+
strict: [
17+
topology: :string,
18+
state: :string,
19+
json: :boolean,
20+
keep: :boolean
21+
]
22+
)
23+
24+
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
25+
26+
topology =
27+
Keyword.get(opts, :topology) ||
28+
Mix.raise("expected --topology <path>")
29+
30+
runner_opts = [
31+
state_path: Keyword.get(opts, :state, RunState.default_path()),
32+
keep?: Keyword.get(opts, :keep, false)
33+
]
34+
35+
case Runner.up(topology, runner_opts) do
36+
{:ok, receipt} ->
37+
print(receipt, Keyword.get(opts, :json, false), :info)
38+
39+
{:error, receipt} ->
40+
print(receipt, Keyword.get(opts, :json, false), :error)
41+
exit({:shutdown, 1})
42+
end
43+
end
44+
45+
defp print(receipt, true, level) do
46+
encoded = Jason.encode!(receipt, pretty: true)
47+
if level == :error, do: Mix.shell().error(encoded), else: Mix.shell().info(encoded)
48+
end
49+
50+
defp print(receipt, false, :info) do
51+
Mix.shell().info("stack_lab.gn_ten.node_lab.up #{receipt["status"]}")
52+
Mix.shell().info("state=#{receipt["state_path"]}")
53+
end
54+
55+
defp print(receipt, false, :error) do
56+
Mix.shell().error("stack_lab.gn_ten.node_lab.up failed")
57+
Mix.shell().error("failures=#{inspect(receipt["failures"])}")
58+
end
59+
end

mix.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ defmodule StackLab.Workspace.MixProject do
163163
"docs/review/shared_library_governed_adapter_review.md",
164164
"docs/runbooks/up_single.md",
165165
"docs/runbooks/up_multi.md",
166+
"docs/runbooks/distributed_node_lab.md",
166167
"docs/runbooks/faults.md",
167168
"docs/runbooks/tre_lane_acceptance.md",
168169
"CHANGELOG.md",
@@ -187,6 +188,7 @@ defmodule StackLab.Workspace.MixProject do
187188
Runbooks: [
188189
"docs/runbooks/up_single.md",
189190
"docs/runbooks/up_multi.md",
191+
"docs/runbooks/distributed_node_lab.md",
190192
"docs/runbooks/faults.md",
191193
"docs/runbooks/tre_lane_acceptance.md"
192194
],

support/gn_ten_node_lab/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ It owns local test-harness mechanics:
1515
- checked-in topology fixture loading;
1616
- required app boot probes;
1717
- owner-defined facade host and `:pg` readiness checks;
18+
- JSON admin receipts for `preflight`, `up`, `status`, `probe`, and `down`;
1819
- node cleanup receipts.
1920

2021
It does not own AppKit, Mezzanine, Citadel, OuterBrain, Jido Integration,
@@ -27,11 +28,32 @@ Temporary v2 topology fixtures live under `priv/topologies/` until
2728

2829
- `control_3_node.exs`
2930
- `context_6_node.exs`
31+
- `fixture_single_node.exs`
3032

3133
These fixtures name owner facade modules and owner-defined `:pg` groups. The
3234
node lab can host those modules on peer nodes for readiness proof, but StackLab
3335
does not define the domain contract.
3436

37+
## Admin Commands
38+
39+
Root StackLab Mix tasks delegate into this package:
40+
41+
```bash
42+
mix stack_lab.gn_ten.node_lab.preflight --json
43+
mix stack_lab.gn_ten.node_lab.up \
44+
--topology support/gn_ten_node_lab/priv/topologies/fixture_single_node.exs \
45+
--json
46+
mix stack_lab.gn_ten.node_lab.status --json
47+
mix stack_lab.gn_ten.node_lab.probe --node fixture_profile_0 --json
48+
mix stack_lab.gn_ten.node_lab.down --json
49+
```
50+
51+
Phase 6 peer mode starts peers, validates app and facade readiness, writes a
52+
run-state receipt, and cleans up peers before returning. `--keep` is accepted
53+
and recorded as an explicit intent, but cross-command peer retention is not a
54+
v2 Phase 6 claim. A later daemon or release-path controller must own that
55+
stronger claim.
56+
3557
## Security Posture
3658

3759
Default Erlang distribution cookies are local development cluster authority,

support/gn_ten_node_lab/lib/stack_lab/gn_ten_node_lab.ex

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

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

1212
@spec preflight(keyword()) :: {:ok, map()} | {:error, map()}
1313
defdelegate preflight(opts \\ []), to: Preflight, as: :run
@@ -24,6 +24,18 @@ defmodule StackLab.GnTenNodeLab do
2424
@spec boot_instance(Peer.t() | node(), map(), keyword()) :: {:ok, map()} | {:error, map()}
2525
defdelegate boot_instance(target, instance, opts \\ []), to: BootPlan
2626

27+
@spec up(Path.t(), keyword()) :: {:ok, map()} | {:error, map()}
28+
defdelegate up(topology_path, opts \\ []), to: Runner
29+
30+
@spec status(keyword()) :: {:ok, map()}
31+
defdelegate status(opts \\ []), to: Runner
32+
33+
@spec probe(String.t(), keyword()) :: {:ok, map()} | {:error, map()}
34+
defdelegate probe(node_id, opts \\ []), to: Runner
35+
36+
@spec down(keyword()) :: {:ok, map()} | {:error, map()}
37+
defdelegate down(opts \\ []), to: Runner
38+
2739
@spec with_peer((Peer.t() -> term()), keyword()) :: {:ok, term()} | {:error, map()}
2840
defdelegate with_peer(fun, opts \\ []), to: Peer
2941
end

support/gn_ten_node_lab/lib/stack_lab/gn_ten_node_lab/peer.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ defmodule StackLab.GnTenNodeLab.Peer do
4545
end
4646
end
4747

48+
@spec start(keyword()) :: {:ok, t()} | {:error, map()}
49+
def start(opts \\ []) when is_list(opts) do
50+
with {:ok, _epmd} <- ensure_epmd_started(),
51+
{:ok, _distribution} <- ensure_distribution_started() do
52+
start_peer(opts)
53+
end
54+
end
55+
56+
@spec stop(t()) :: map()
57+
def stop(%__MODULE__{} = peer), do: cleanup(peer)
58+
4859
@spec remote_call(t() | node(), module(), atom(), [term()], timeout()) ::
4960
{:ok, term()} | {:error, term()}
5061
def remote_call(target, module, function, args),

0 commit comments

Comments
 (0)