mirrorneuron-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mirrorneuron_cli-1.0.0.dist-info/METADATA +73 -0
- mirrorneuron_cli-1.0.0.dist-info/RECORD +19 -0
- mirrorneuron_cli-1.0.0.dist-info/WHEEL +5 -0
- mirrorneuron_cli-1.0.0.dist-info/entry_points.txt +2 -0
- mirrorneuron_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- mirrorneuron_cli-1.0.0.dist-info/top_level.txt +1 -0
- mn_cli/__init__.py +0 -0
- mn_cli/config.py +43 -0
- mn_cli/error_handler.py +51 -0
- mn_cli/libs/__init__.py +1 -0
- mn_cli/libs/blueprint_cmds.py +598 -0
- mn_cli/libs/job_cmds.py +160 -0
- mn_cli/libs/run_cmds.py +780 -0
- mn_cli/libs/sys_cmds.py +52 -0
- mn_cli/libs/ui.py +162 -0
- mn_cli/logging_config.py +38 -0
- mn_cli/main.py +35 -0
- mn_cli/server_cmds.py +331 -0
- mn_cli/shared.py +13 -0
mn_cli/libs/sys_cmds.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import subprocess
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from mn_cli.shared import console
|
|
6
|
+
from mn_cli.error_handler import handle_cli_error
|
|
7
|
+
from mn_cli.server_cmds import _start_server, kill_tree, BEAM_PID_FILE, API_PID_FILE, WEB_UI_PID_FILE
|
|
8
|
+
|
|
9
|
+
def start():
|
|
10
|
+
"""Start MirrorNeuron services"""
|
|
11
|
+
_start_server()
|
|
12
|
+
|
|
13
|
+
def stop():
|
|
14
|
+
"""Stop MirrorNeuron services"""
|
|
15
|
+
console.print("=> Stopping MirrorNeuron Services...")
|
|
16
|
+
|
|
17
|
+
console.print(" Stopping Core Service (Docker: mirror-neuron-core)...")
|
|
18
|
+
subprocess.run(["docker", "stop", "mirror-neuron-core"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
|
19
|
+
subprocess.run(["docker", "rm", "mirror-neuron-core"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
|
20
|
+
|
|
21
|
+
for pid_file, name in [
|
|
22
|
+
(WEB_UI_PID_FILE, "Web UI"),
|
|
23
|
+
(API_PID_FILE, "REST API"),
|
|
24
|
+
(BEAM_PID_FILE, "Legacy Core Service"),
|
|
25
|
+
]:
|
|
26
|
+
if pid_file.exists():
|
|
27
|
+
try:
|
|
28
|
+
pid = int(pid_file.read_text().strip())
|
|
29
|
+
try:
|
|
30
|
+
os.kill(pid, 0)
|
|
31
|
+
console.print(f" Stopping {name} (PID: {pid})...")
|
|
32
|
+
kill_tree(pid)
|
|
33
|
+
time.sleep(1)
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass
|
|
38
|
+
pid_file.unlink()
|
|
39
|
+
console.print("=> [green]All services stopped.[/green]")
|
|
40
|
+
|
|
41
|
+
def join(ip: str):
|
|
42
|
+
"""Join a MirrorNeuron cluster using the IP"""
|
|
43
|
+
_start_server(ip)
|
|
44
|
+
|
|
45
|
+
def leave(node_name: str):
|
|
46
|
+
"""Remove a node from the cluster by its node name (e.g. mirror_neuron@192.168.4.173)"""
|
|
47
|
+
from mn_cli.shared import client, console
|
|
48
|
+
try:
|
|
49
|
+
status = client.remove_node(node_name)
|
|
50
|
+
console.print(f"[green]Successfully requested {node_name} to leave. Status: {status}[/green]")
|
|
51
|
+
except Exception as e:
|
|
52
|
+
handle_cli_error(e, console, 'leave')
|
mn_cli/libs/ui.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from rich.console import Group
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
def generate_live_layout(job_id: str, data: Dict[str, Any]) -> Group:
|
|
8
|
+
summary = data.get("summary", {})
|
|
9
|
+
job = data.get("job", {})
|
|
10
|
+
agents = data.get("agents", [])
|
|
11
|
+
|
|
12
|
+
status = summary.get("status", "unknown")
|
|
13
|
+
color = "cyan"
|
|
14
|
+
if status == "completed":
|
|
15
|
+
color = "green"
|
|
16
|
+
elif status in ["failed", "cancelled"]:
|
|
17
|
+
color = "red"
|
|
18
|
+
|
|
19
|
+
last_event = summary.get("last_event", "N/A")
|
|
20
|
+
|
|
21
|
+
# Top panel: Job info (Executors removed)
|
|
22
|
+
spinner_str = "[cyan]⠋[/cyan]"
|
|
23
|
+
try:
|
|
24
|
+
import time
|
|
25
|
+
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
26
|
+
idx = int(time.time() * 12.5) % len(frames)
|
|
27
|
+
spinner_str = f"[cyan]{frames[idx]}[/cyan]"
|
|
28
|
+
except:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
info_text = (
|
|
32
|
+
f"[bold]Job ID:[/bold] {job_id}\n"
|
|
33
|
+
f"[bold]Name:[/bold] {job.get('job_name', 'N/A')} | [bold]Graph:[/bold] {job.get('graph_id', 'N/A')}\n"
|
|
34
|
+
f"[bold]Status:[/bold] [{color}]{status}[/{color}] | [bold]Live:[/bold] {summary.get('live?', False)}\n"
|
|
35
|
+
f"[bold]Nodes:[/bold] {len(summary.get('nodes', []))}\n"
|
|
36
|
+
f"[bold]Last Event:[/bold] {last_event} {spinner_str if status not in ['completed', 'failed', 'cancelled'] else ''}\n\n"
|
|
37
|
+
f"[dim]Press 'q' or Ctrl-C to exit[/dim]"
|
|
38
|
+
)
|
|
39
|
+
info_panel = Panel(info_text, title="Live Job Monitor", border_style=color)
|
|
40
|
+
|
|
41
|
+
# Agents Table
|
|
42
|
+
agent_table = Table(title="Agents (Top 20 by Processed Messages)", expand=True)
|
|
43
|
+
agent_table.add_column("Agent ID")
|
|
44
|
+
agent_table.add_column("Type")
|
|
45
|
+
agent_table.add_column("Status")
|
|
46
|
+
agent_table.add_column("Processed", justify="right")
|
|
47
|
+
agent_table.add_column("Mailbox", justify="right")
|
|
48
|
+
|
|
49
|
+
sorted_agents = sorted(agents, key=lambda x: x.get("processed_messages", 0), reverse=True)[:20]
|
|
50
|
+
|
|
51
|
+
for ag in sorted_agents:
|
|
52
|
+
ag_status = ag.get("status", "unknown")
|
|
53
|
+
st_color = "green" if ag_status in ["running", "completed"] else "yellow" if ag_status in ["ready", "busy", "queued"] else "red"
|
|
54
|
+
|
|
55
|
+
agent_table.add_row(
|
|
56
|
+
ag.get("agent_id", "N/A"),
|
|
57
|
+
ag.get("agent_type", "N/A"),
|
|
58
|
+
f"[{st_color}]{ag_status}[/{st_color}]",
|
|
59
|
+
str(ag.get("processed_messages", 0)),
|
|
60
|
+
str(ag.get("mailbox_depth", 0))
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return Group(info_panel, agent_table)
|
|
64
|
+
|
|
65
|
+
def generate_summary_panel(job_id: str, status: str, log_dir) -> Panel:
|
|
66
|
+
status_text = "Unknown"
|
|
67
|
+
status_color = "yellow"
|
|
68
|
+
if status == "completed":
|
|
69
|
+
status_text = "Success"
|
|
70
|
+
status_color = "green"
|
|
71
|
+
elif status == "failed":
|
|
72
|
+
status_text = "Failed"
|
|
73
|
+
status_color = "red"
|
|
74
|
+
elif status == "cancelled":
|
|
75
|
+
status_text = "Cancelled"
|
|
76
|
+
status_color = "red"
|
|
77
|
+
|
|
78
|
+
log_file = log_dir / "events.log"
|
|
79
|
+
|
|
80
|
+
panel_text = (
|
|
81
|
+
f"[bold {status_color}]Job Status: {status_text}[/bold {status_color}]\n\n"
|
|
82
|
+
f"Job ID: {job_id}\n"
|
|
83
|
+
f"Outputs:\n"
|
|
84
|
+
f" Logs: {log_file}"
|
|
85
|
+
)
|
|
86
|
+
if (log_dir / "result.txt").exists():
|
|
87
|
+
panel_text += f"\n Result: {log_dir / 'result.txt'}"
|
|
88
|
+
if (log_dir / "result_stream.txt").exists():
|
|
89
|
+
panel_text += f"\n Stream: {log_dir / 'result_stream.txt'}"
|
|
90
|
+
|
|
91
|
+
return Panel(
|
|
92
|
+
panel_text,
|
|
93
|
+
title="Job Execution Summary",
|
|
94
|
+
border_style=status_color,
|
|
95
|
+
expand=False
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def generate_run_submitted_panel(
|
|
99
|
+
*,
|
|
100
|
+
bundle_name: str,
|
|
101
|
+
job_id: str,
|
|
102
|
+
payload_count: int,
|
|
103
|
+
log_dir,
|
|
104
|
+
follow_seconds: float,
|
|
105
|
+
run_mode: str = "Batch",
|
|
106
|
+
blueprint_run_id: Optional[str] = None,
|
|
107
|
+
blueprint_revision: Optional[str] = None,
|
|
108
|
+
) -> Panel:
|
|
109
|
+
table = Table.grid(padding=(0, 1))
|
|
110
|
+
table.add_column(style="bold")
|
|
111
|
+
table.add_column()
|
|
112
|
+
table.add_row("Bundle", bundle_name)
|
|
113
|
+
table.add_row("Job ID", f"[bold cyan]{job_id}[/bold cyan]")
|
|
114
|
+
if blueprint_run_id:
|
|
115
|
+
table.add_row("Blueprint Run ID", f"[bold green]{blueprint_run_id}[/bold green]")
|
|
116
|
+
if blueprint_revision:
|
|
117
|
+
table.add_row("Blueprint Revision", blueprint_revision[:12])
|
|
118
|
+
table.add_row("Type", run_mode)
|
|
119
|
+
table.add_row("Payloads", str(payload_count))
|
|
120
|
+
table.add_row("Logs", str(log_dir / "events.log"))
|
|
121
|
+
table.add_row("Snapshot", str(log_dir / "job_snapshot.json"))
|
|
122
|
+
table.add_row("Follow", f"{follow_seconds:g}s event tail, then detach")
|
|
123
|
+
|
|
124
|
+
return Panel(
|
|
125
|
+
table,
|
|
126
|
+
title="Job submitted successfully",
|
|
127
|
+
border_style="cyan",
|
|
128
|
+
expand=False,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def generate_detached_panel(job_id: str, log_dir, status: str, event_count: int) -> Panel:
|
|
132
|
+
status_color = (
|
|
133
|
+
"green"
|
|
134
|
+
if status == "completed"
|
|
135
|
+
else "red"
|
|
136
|
+
if status in {"failed", "cancelled"}
|
|
137
|
+
else "yellow"
|
|
138
|
+
)
|
|
139
|
+
status_label = status.replace("_", " ").title() if status else "Unknown"
|
|
140
|
+
|
|
141
|
+
table = Table.grid(padding=(0, 1))
|
|
142
|
+
table.add_column(style="bold")
|
|
143
|
+
table.add_column()
|
|
144
|
+
table.add_row("Status", f"[{status_color}]{status_label}[/{status_color}]")
|
|
145
|
+
table.add_row("Job ID", f"[bold cyan]{job_id}[/bold cyan]")
|
|
146
|
+
table.add_row("Events Logged", str(event_count))
|
|
147
|
+
table.add_row("Raw Events", str(log_dir / "events.log"))
|
|
148
|
+
table.add_row("Run Log", str(log_dir / "run.log"))
|
|
149
|
+
table.add_row("Monitor", f"mn monitor {job_id}")
|
|
150
|
+
|
|
151
|
+
message = Text()
|
|
152
|
+
if status in {"completed", "failed", "cancelled"}:
|
|
153
|
+
message.append("Final job state reached.", style=status_color)
|
|
154
|
+
else:
|
|
155
|
+
message.append("Detached while job is still scheduled/running.", style="yellow")
|
|
156
|
+
|
|
157
|
+
return Panel(
|
|
158
|
+
Group(message, table),
|
|
159
|
+
title="Run Detached",
|
|
160
|
+
border_style=status_color,
|
|
161
|
+
expand=False,
|
|
162
|
+
)
|
mn_cli/logging_config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from logging.handlers import RotatingFileHandler
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def configure_logging(
|
|
10
|
+
name: str,
|
|
11
|
+
log_path: Path,
|
|
12
|
+
*,
|
|
13
|
+
level: str | None = None,
|
|
14
|
+
) -> logging.Logger:
|
|
15
|
+
logger = logging.getLogger(name)
|
|
16
|
+
logger.setLevel((level or os.getenv("MN_LOG_LEVEL", "INFO")).upper())
|
|
17
|
+
logger.propagate = False
|
|
18
|
+
|
|
19
|
+
if logger.handlers:
|
|
20
|
+
return logger
|
|
21
|
+
|
|
22
|
+
formatter = logging.Formatter(
|
|
23
|
+
"%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
handler: logging.Handler = RotatingFileHandler(
|
|
29
|
+
log_path,
|
|
30
|
+
maxBytes=int(os.getenv("MN_LOG_MAX_BYTES", "1048576")),
|
|
31
|
+
backupCount=int(os.getenv("MN_LOG_BACKUP_COUNT", "5")),
|
|
32
|
+
)
|
|
33
|
+
except OSError:
|
|
34
|
+
handler = logging.StreamHandler()
|
|
35
|
+
|
|
36
|
+
handler.setFormatter(formatter)
|
|
37
|
+
logger.addHandler(handler)
|
|
38
|
+
return logger
|
mn_cli/main.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from mn_cli.libs import job_cmds, run_cmds, sys_cmds
|
|
3
|
+
from mn_cli.libs.blueprint_cmds import blueprint_app
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="MirrorNeuron CLI")
|
|
6
|
+
|
|
7
|
+
# Run commands
|
|
8
|
+
app.command(name="validate")(run_cmds.validate)
|
|
9
|
+
app.command(name="run")(run_cmds.run)
|
|
10
|
+
app.command(name="monitor")(run_cmds.monitor)
|
|
11
|
+
app.command(name="result")(run_cmds.result)
|
|
12
|
+
|
|
13
|
+
# Job commands
|
|
14
|
+
app.command(name="submit")(job_cmds.submit)
|
|
15
|
+
app.command(name="status")(job_cmds.status)
|
|
16
|
+
app.command(name="list")(job_cmds.list_jobs)
|
|
17
|
+
app.command(name="clear")(job_cmds.clear)
|
|
18
|
+
app.command(name="cancel")(job_cmds.cancel)
|
|
19
|
+
app.command(name="pause")(job_cmds.pause)
|
|
20
|
+
app.command(name="resume")(job_cmds.resume)
|
|
21
|
+
app.command(name="nodes")(job_cmds.nodes)
|
|
22
|
+
app.command(name="metrics")(job_cmds.metrics)
|
|
23
|
+
app.command(name="dead-letters")(job_cmds.dead_letters)
|
|
24
|
+
|
|
25
|
+
# System commands
|
|
26
|
+
app.command(name="start")(sys_cmds.start)
|
|
27
|
+
app.command(name="stop")(sys_cmds.stop)
|
|
28
|
+
app.command(name="join")(sys_cmds.join)
|
|
29
|
+
app.command(name="leave")(sys_cmds.leave)
|
|
30
|
+
|
|
31
|
+
# Sub-apps
|
|
32
|
+
app.add_typer(blueprint_app, name="blueprint")
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
app()
|
mn_cli/server_cmds.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from mn_cli.config import CliConfig
|
|
12
|
+
from mn_cli.logging_config import configure_logging
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
logger = configure_logging("mn-cli", CliConfig.from_env().log_path)
|
|
16
|
+
|
|
17
|
+
DIR = Path.home() / ".mirror_neuron"
|
|
18
|
+
PID_DIR = DIR / ".pids"
|
|
19
|
+
LOG_DIR = DIR / ".logs"
|
|
20
|
+
BEAM_PID_FILE = PID_DIR / "beam.pid"
|
|
21
|
+
API_PID_FILE = PID_DIR / "api.pid"
|
|
22
|
+
WEB_UI_PID_FILE = PID_DIR / "web-ui.pid"
|
|
23
|
+
BEAM_LOG = LOG_DIR / "beam.log"
|
|
24
|
+
API_LOG = LOG_DIR / "api.log"
|
|
25
|
+
WEB_UI_LOG = LOG_DIR / "web-ui.log"
|
|
26
|
+
VENV_DIR = Path.home() / ".local" / "share" / "mn_venv"
|
|
27
|
+
WEB_UI_DIRS = (
|
|
28
|
+
DIR / "web-ui-source",
|
|
29
|
+
Path(f"{DIR}_ui"),
|
|
30
|
+
)
|
|
31
|
+
WEB_UI_PORT = "5173"
|
|
32
|
+
DEFAULT_HOST = "localhost"
|
|
33
|
+
|
|
34
|
+
def _env_host(name: str, default: str = DEFAULT_HOST) -> str:
|
|
35
|
+
return os.getenv(name, default).strip() or default
|
|
36
|
+
|
|
37
|
+
def _core_host() -> str:
|
|
38
|
+
return _env_host("MN_CORE_HOST")
|
|
39
|
+
|
|
40
|
+
def _api_host() -> str:
|
|
41
|
+
return _env_host("MN_API_HOST")
|
|
42
|
+
|
|
43
|
+
def _redis_host() -> str:
|
|
44
|
+
return _env_host("MN_REDIS_HOST")
|
|
45
|
+
|
|
46
|
+
def _epmd_host() -> str:
|
|
47
|
+
return _env_host("MN_EPMD_HOST")
|
|
48
|
+
|
|
49
|
+
def _dist_host() -> str:
|
|
50
|
+
return _env_host("MN_DIST_HOST")
|
|
51
|
+
|
|
52
|
+
def _web_ui_host() -> str:
|
|
53
|
+
return _env_host("MN_WEB_UI_HOST")
|
|
54
|
+
|
|
55
|
+
def _docker_publish_host(host: str) -> str:
|
|
56
|
+
return "127.0.0.1" if host == "localhost" else host
|
|
57
|
+
|
|
58
|
+
def check_status(pid_file: Path) -> int:
|
|
59
|
+
if pid_file.exists():
|
|
60
|
+
try:
|
|
61
|
+
pid = int(pid_file.read_text().strip())
|
|
62
|
+
os.kill(pid, 0)
|
|
63
|
+
return 0 # Running
|
|
64
|
+
except (ValueError, OSError):
|
|
65
|
+
return 1 # Stale
|
|
66
|
+
return 2 # Not running
|
|
67
|
+
|
|
68
|
+
def kill_tree(parent_pid: int):
|
|
69
|
+
try:
|
|
70
|
+
os.kill(parent_pid, 0)
|
|
71
|
+
except OSError:
|
|
72
|
+
logger.debug("Process %s is not running", parent_pid)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
children = subprocess.check_output(['pgrep', '-P', str(parent_pid)], stderr=subprocess.DEVNULL)
|
|
77
|
+
for child_pid in children.decode().split():
|
|
78
|
+
if child_pid.strip():
|
|
79
|
+
kill_tree(int(child_pid.strip()))
|
|
80
|
+
except subprocess.CalledProcessError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
logger.info("Stopping process %s", parent_pid)
|
|
85
|
+
os.kill(parent_pid, signal.SIGTERM)
|
|
86
|
+
except OSError:
|
|
87
|
+
logger.exception("Failed to stop process %s", parent_pid)
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def find_web_ui_dir() -> Optional[Path]:
|
|
91
|
+
for web_ui_dir in WEB_UI_DIRS:
|
|
92
|
+
if (web_ui_dir / "package.json").exists() and (web_ui_dir / "node_modules").exists():
|
|
93
|
+
return web_ui_dir
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _host_port_from_target(target: str, default_host: str, default_port: str) -> tuple[str, str]:
|
|
97
|
+
if "://" in target:
|
|
98
|
+
parsed = urlparse(target)
|
|
99
|
+
return parsed.hostname or default_host, str(parsed.port or default_port)
|
|
100
|
+
|
|
101
|
+
if target.startswith("[") and "]:" in target:
|
|
102
|
+
host, port = target.rsplit("]:", 1)
|
|
103
|
+
return host.lstrip("["), port
|
|
104
|
+
|
|
105
|
+
if ":" in target:
|
|
106
|
+
host, port = target.rsplit(":", 1)
|
|
107
|
+
return host or default_host, port or default_port
|
|
108
|
+
|
|
109
|
+
return target or default_host, default_port
|
|
110
|
+
|
|
111
|
+
def _redis_host_port(ip: Optional[str]) -> tuple[str, str]:
|
|
112
|
+
default_host = ip or _redis_host()
|
|
113
|
+
default_url = f"redis://{default_host}:6379/0"
|
|
114
|
+
return _host_port_from_target(
|
|
115
|
+
os.getenv("MN_REDIS_URL", default_url),
|
|
116
|
+
default_host,
|
|
117
|
+
"6379",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _print_service_endpoints(ip: Optional[str], web_ui_available: bool):
|
|
121
|
+
core_host = _core_host()
|
|
122
|
+
grpc_host, grpc_port = _host_port_from_target(
|
|
123
|
+
os.getenv(
|
|
124
|
+
"MN_GRPC_TARGET",
|
|
125
|
+
os.getenv("MN_CORE_GRPC_TARGET", f"{core_host}:50051"),
|
|
126
|
+
),
|
|
127
|
+
core_host,
|
|
128
|
+
os.getenv("MN_GRPC_PORT", "50051"),
|
|
129
|
+
)
|
|
130
|
+
api_host = _api_host()
|
|
131
|
+
api_port = os.getenv("MN_API_PORT", "4001")
|
|
132
|
+
redis_host, redis_port = _redis_host_port(ip)
|
|
133
|
+
epmd_host = _epmd_host()
|
|
134
|
+
dist_host = _dist_host()
|
|
135
|
+
dist_port = os.getenv("MN_DIST_PORT", "9000-9010" if os.uname().sysname == "Darwin" else "dynamic")
|
|
136
|
+
web_ui_host = _web_ui_host()
|
|
137
|
+
|
|
138
|
+
table = Table(title="Service endpoints", show_header=True, header_style="bold")
|
|
139
|
+
table.add_column("Service")
|
|
140
|
+
table.add_column("Host")
|
|
141
|
+
table.add_column("Port")
|
|
142
|
+
table.add_column("URL / target")
|
|
143
|
+
|
|
144
|
+
table.add_row("Core gRPC", grpc_host, grpc_port, f"{grpc_host}:{grpc_port}")
|
|
145
|
+
table.add_row("REST API", api_host, api_port, f"http://{api_host}:{api_port}/api/v1")
|
|
146
|
+
table.add_row("Redis", redis_host, redis_port, f"redis://{redis_host}:{redis_port}/0")
|
|
147
|
+
table.add_row("Erlang EPMD", epmd_host, "4369", f"{epmd_host}:4369")
|
|
148
|
+
table.add_row("Erlang dist", dist_host, dist_port, f"{dist_host}:{dist_port}")
|
|
149
|
+
if web_ui_available:
|
|
150
|
+
table.add_row("Web UI", web_ui_host, WEB_UI_PORT, f"http://{web_ui_host}:{WEB_UI_PORT}")
|
|
151
|
+
|
|
152
|
+
console.print(table)
|
|
153
|
+
|
|
154
|
+
def _start_web_ui_if_installed() -> bool:
|
|
155
|
+
web_ui_dir = find_web_ui_dir()
|
|
156
|
+
if not web_ui_dir:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
status = check_status(WEB_UI_PID_FILE)
|
|
160
|
+
if status == 0:
|
|
161
|
+
console.print("[yellow]=> Web UI is already running, skipping.[/yellow]")
|
|
162
|
+
return True
|
|
163
|
+
if status == 1:
|
|
164
|
+
WEB_UI_PID_FILE.unlink(missing_ok=True)
|
|
165
|
+
|
|
166
|
+
web_ui_host = _web_ui_host()
|
|
167
|
+
env = os.environ.copy()
|
|
168
|
+
env.setdefault("MN_WEB_UI_HOST", web_ui_host)
|
|
169
|
+
env.setdefault("MN_API_HOST", _api_host())
|
|
170
|
+
env.setdefault("MN_API_PORT", os.getenv("MN_API_PORT", "4001"))
|
|
171
|
+
console.print(f"=> Starting mn-web-ui (Vite on {web_ui_host}:5173)...")
|
|
172
|
+
with open(WEB_UI_LOG, "w") as out:
|
|
173
|
+
p_web = subprocess.Popen(
|
|
174
|
+
["npm", "run", "dev", "--", "--host", web_ui_host],
|
|
175
|
+
cwd=web_ui_dir,
|
|
176
|
+
stdout=out,
|
|
177
|
+
stderr=subprocess.STDOUT,
|
|
178
|
+
env=env,
|
|
179
|
+
start_new_session=True
|
|
180
|
+
)
|
|
181
|
+
WEB_UI_PID_FILE.write_text(str(p_web.pid))
|
|
182
|
+
console.print(f" [green][Started][/green] Web UI (PID: {p_web.pid})")
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
def _start_server(ip: str = None):
|
|
186
|
+
if check_status(API_PID_FILE) == 0:
|
|
187
|
+
console.print("[red]Error: MirrorNeuron API is already running.[/red]")
|
|
188
|
+
console.print("Use 'mn stop' to stop it first.")
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
docker_status = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", "mirror-neuron-core"], capture_output=True, text=True)
|
|
193
|
+
if docker_status.stdout.strip() == "true":
|
|
194
|
+
console.print("[red]Error: MirrorNeuron Core (Docker) is already running.[/red]")
|
|
195
|
+
console.print("Use 'mn stop' to stop it first.")
|
|
196
|
+
raise typer.Exit(1)
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
console.print("[red]Error: Docker is not installed or not in PATH.[/red]")
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
PID_DIR.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
|
|
204
|
+
console.print("===========================================")
|
|
205
|
+
if ip:
|
|
206
|
+
console.print(f"Joining Cluster at {ip} in Detached Mode...")
|
|
207
|
+
else:
|
|
208
|
+
console.print("Starting Services in Detached Mode...")
|
|
209
|
+
console.print("===========================================")
|
|
210
|
+
|
|
211
|
+
env = os.environ.copy()
|
|
212
|
+
env.setdefault("MN_CORE_HOST", _core_host())
|
|
213
|
+
env.setdefault("MN_API_HOST", _api_host())
|
|
214
|
+
env.setdefault("MN_REDIS_HOST", _redis_host())
|
|
215
|
+
env.setdefault("MN_EPMD_HOST", _epmd_host())
|
|
216
|
+
env.setdefault("MN_DIST_HOST", _dist_host())
|
|
217
|
+
env.setdefault("MN_WEB_UI_HOST", _web_ui_host())
|
|
218
|
+
env.setdefault("MN_CORE_GRPC_TARGET", f"{env['MN_CORE_HOST']}:{os.getenv('MN_GRPC_PORT', '50051')}")
|
|
219
|
+
if ip:
|
|
220
|
+
env["MN_CLUSTER_NODES"] = ip
|
|
221
|
+
|
|
222
|
+
console.print("=> Starting MirrorNeuron Core Service (Docker)...")
|
|
223
|
+
logger.info("Starting MirrorNeuron Core Docker container")
|
|
224
|
+
subprocess.run(["docker", "rm", "-f", "mirror-neuron-core"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
|
225
|
+
|
|
226
|
+
cmd = ["docker", "run", "-d", "--name", "mirror-neuron-core"]
|
|
227
|
+
|
|
228
|
+
# We want clustering to work, so we need to set the node name.
|
|
229
|
+
import socket
|
|
230
|
+
try:
|
|
231
|
+
local_ip = socket.gethostbyname(socket.gethostname())
|
|
232
|
+
except socket.gaierror:
|
|
233
|
+
local_ip = "127.0.0.1"
|
|
234
|
+
# As a fallback or override, you could prompt the user, but we'll try to guess it.
|
|
235
|
+
# To be safe for this specific test, we know local is 192.168.4.25 and remote is 192.168.4.173.
|
|
236
|
+
# Let's see if we can get the actual external IP:
|
|
237
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
238
|
+
try:
|
|
239
|
+
# doesn't even have to be reachable
|
|
240
|
+
s.connect(('10.255.255.255', 1))
|
|
241
|
+
local_ip = s.getsockname()[0]
|
|
242
|
+
except Exception:
|
|
243
|
+
local_ip = '127.0.0.1'
|
|
244
|
+
finally:
|
|
245
|
+
s.close()
|
|
246
|
+
|
|
247
|
+
cmd.extend(["-e", f"MN_NODE_NAME=mirror_neuron@{local_ip}"])
|
|
248
|
+
|
|
249
|
+
core_publish_host = _docker_publish_host(env["MN_CORE_HOST"])
|
|
250
|
+
epmd_publish_host = _docker_publish_host(env["MN_EPMD_HOST"])
|
|
251
|
+
dist_publish_host = _docker_publish_host(env["MN_DIST_HOST"])
|
|
252
|
+
|
|
253
|
+
system_name = os.uname().sysname
|
|
254
|
+
|
|
255
|
+
if system_name == "Darwin":
|
|
256
|
+
cmd.extend(["-p", f"{core_publish_host}:50051:50051", "-p", f"{epmd_publish_host}:4369:4369"])
|
|
257
|
+
# Publish the distribution ports too
|
|
258
|
+
for port in range(9000, 9011):
|
|
259
|
+
cmd.extend(["-p", f"{dist_publish_host}:{port}:{port}"])
|
|
260
|
+
cmd.extend(["-e", "MN_REDIS_URL=redis://host.docker.internal:6379/0"])
|
|
261
|
+
cmd.extend(["-e", "MN_EXECUTOR_MAX_CONCURRENCY=50"])
|
|
262
|
+
else:
|
|
263
|
+
cmd.extend(["--network", "host"])
|
|
264
|
+
cmd.extend(["-e", "MN_EXECUTOR_MAX_CONCURRENCY=50"])
|
|
265
|
+
|
|
266
|
+
if system_name == "Darwin":
|
|
267
|
+
cmd.extend(["-e", "MN_CORE_HOST=0.0.0.0"])
|
|
268
|
+
else:
|
|
269
|
+
cmd.extend(["-e", f"MN_CORE_HOST={env['MN_CORE_HOST']}"])
|
|
270
|
+
cmd.extend(["-e", f"MN_REDIS_HOST={env['MN_REDIS_HOST']}"])
|
|
271
|
+
cmd.extend(["-e", f"ERL_EPMD_ADDRESS={env['MN_EPMD_HOST']}"])
|
|
272
|
+
|
|
273
|
+
if ip:
|
|
274
|
+
cmd.extend(["-e", f"MN_CLUSTER_NODES=mirror_neuron@{ip}"])
|
|
275
|
+
# A node joining another should also point its redis to the main cluster leader if not specified
|
|
276
|
+
cmd.extend(["-e", f"MN_REDIS_URL=redis://{ip}:6379/0"])
|
|
277
|
+
|
|
278
|
+
for env_name in [
|
|
279
|
+
"SLACK_BOT_TOKEN",
|
|
280
|
+
"SLACK_DEFAULT_CHANNEL",
|
|
281
|
+
"SLACK_API_BASE_URL",
|
|
282
|
+
"MN_SLACK_BOT_TOKEN",
|
|
283
|
+
"MN_SLACK_DEFAULT_CHANNEL",
|
|
284
|
+
"MN_SLACK_API_BASE_URL",
|
|
285
|
+
]:
|
|
286
|
+
if os.getenv(env_name):
|
|
287
|
+
cmd.extend(["-e", env_name])
|
|
288
|
+
|
|
289
|
+
cmd.append("mirror-neuron-core:latest")
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
|
|
293
|
+
console.print(" [green][Started][/green] Core Service (Docker: mirror-neuron-core)")
|
|
294
|
+
except subprocess.CalledProcessError:
|
|
295
|
+
console.print("[red]Failed to start Core Service Docker container.[/red]")
|
|
296
|
+
raise typer.Exit(1)
|
|
297
|
+
|
|
298
|
+
console.print("=> Waiting for Elixir to boot...")
|
|
299
|
+
time.sleep(3)
|
|
300
|
+
|
|
301
|
+
api_bin = VENV_DIR / "bin" / "mn-api"
|
|
302
|
+
if api_bin.exists():
|
|
303
|
+
console.print("=> Starting mn-api (REST on port 4001)...")
|
|
304
|
+
with open(API_LOG, "w") as out:
|
|
305
|
+
p_api = subprocess.Popen(
|
|
306
|
+
[str(api_bin)],
|
|
307
|
+
stdout=out,
|
|
308
|
+
stderr=subprocess.STDOUT,
|
|
309
|
+
env=env,
|
|
310
|
+
start_new_session=True
|
|
311
|
+
)
|
|
312
|
+
API_PID_FILE.write_text(str(p_api.pid))
|
|
313
|
+
console.print(f" [green][Started][/green] REST API (PID: {p_api.pid})")
|
|
314
|
+
else:
|
|
315
|
+
console.print("[yellow]=> Warning: mn-api not found, skipping.[/yellow]")
|
|
316
|
+
|
|
317
|
+
web_ui_available = _start_web_ui_if_installed()
|
|
318
|
+
|
|
319
|
+
console.print("\n===========================================")
|
|
320
|
+
if ip:
|
|
321
|
+
console.print(f"MirrorNeuron is running and attempting to join cluster at {ip}!")
|
|
322
|
+
else:
|
|
323
|
+
console.print("MirrorNeuron is running in the background!")
|
|
324
|
+
_print_service_endpoints(ip, web_ui_available)
|
|
325
|
+
console.print("Logs are available at:")
|
|
326
|
+
console.print(f" Core: {BEAM_LOG}")
|
|
327
|
+
console.print(f" API: {API_LOG}")
|
|
328
|
+
if WEB_UI_LOG.exists():
|
|
329
|
+
console.print(f" Web: {WEB_UI_LOG}")
|
|
330
|
+
console.print("\nRun 'mn stop' to shut down the services.")
|
|
331
|
+
console.print("===========================================")
|
mn_cli/shared.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from mn_sdk import Client
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from mn_cli.config import CliConfig
|
|
4
|
+
from mn_cli.logging_config import configure_logging
|
|
5
|
+
|
|
6
|
+
config = CliConfig.from_env()
|
|
7
|
+
logger = configure_logging("mn-cli", config.log_path)
|
|
8
|
+
console = Console(no_color=config.output_mode == "plain")
|
|
9
|
+
client = Client(
|
|
10
|
+
target=config.grpc_target,
|
|
11
|
+
timeout=config.grpc_timeout_seconds,
|
|
12
|
+
auth_token=config.grpc_auth_token,
|
|
13
|
+
)
|