mirrorneuron-cli 1.2.5__tar.gz → 1.2.6__tar.gz

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.
Files changed (85) hide show
  1. {mirrorneuron_cli-1.2.5/mirrorneuron_cli.egg-info → mirrorneuron_cli-1.2.6}/PKG-INFO +1 -1
  2. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6/mirrorneuron_cli.egg-info}/PKG-INFO +1 -1
  3. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/server_cmds.py +232 -15
  4. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/update_cmds.py +15 -0
  5. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_server_cmds.py +206 -6
  6. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_update_cmds.py +2 -0
  7. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.github/workflows/ci.yml +0 -0
  8. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.github/workflows/release.yml +0 -0
  9. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.gitignore +0 -0
  10. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.python-version +0 -0
  11. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/AGENTS.md +0 -0
  12. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/LICENSE +0 -0
  13. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/README.md +0 -0
  14. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/RELEASE.md +0 -0
  15. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
  16. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
  17. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
  18. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/requires.txt +0 -0
  19. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
  20. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/__init__.py +0 -0
  21. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/banner.py +0 -0
  22. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/config.py +0 -0
  23. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/error_handler.py +0 -0
  24. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/__init__.py +0 -0
  25. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/artifacts.py +0 -0
  26. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/backup_cmds.py +0 -0
  27. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_cmds.py +0 -0
  28. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_models.py +0 -0
  29. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_observability.py +0 -0
  30. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_repository.py +0 -0
  31. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_resources.py +0 -0
  32. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/bundles.py +0 -0
  33. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/deployment_cmds.py +0 -0
  34. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/event_relay.py +0 -0
  35. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/job_cmds.py +0 -0
  36. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/model_cmds.py +0 -0
  37. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/resource_cmds.py +0 -0
  38. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/run_cmds.py +0 -0
  39. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/run_logs.py +0 -0
  40. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/run_manifest.py +0 -0
  41. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/runtime_health.py +0 -0
  42. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/schedule_cmds.py +0 -0
  43. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/service_cmds.py +0 -0
  44. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/skill_runtime.py +0 -0
  45. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/sys_cmds.py +0 -0
  46. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/ui.py +0 -0
  47. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/workflow_progress.py +0 -0
  48. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/workflow_validation.py +0 -0
  49. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/logging_config.py +0 -0
  50. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/main.py +0 -0
  51. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/runtime_mode.py +0 -0
  52. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/runtime_state.py +0 -0
  53. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/schemas/workflow_manifest.schema.json +0 -0
  54. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/sdk_path.py +0 -0
  55. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/shared.py +0 -0
  56. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/terminal.py +0 -0
  57. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/pyproject.toml +0 -0
  58. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/scripts/check-release-artifacts.sh +0 -0
  59. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/scripts/make-release-zip.sh +0 -0
  60. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/scripts/validate-version-tag.sh +0 -0
  61. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/setup.cfg +0 -0
  62. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/conftest.py +0 -0
  63. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_backup_cmds.py +0 -0
  64. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_blueprint_cmds.py +0 -0
  65. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_blueprint_repository.py +0 -0
  66. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_blueprint_resources.py +0 -0
  67. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_deployment_cmds.py +0 -0
  68. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_docker_network_integration.py +0 -0
  69. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_job_cmds.py +0 -0
  70. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_main.py +0 -0
  71. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_model_cmds.py +0 -0
  72. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_resource_cmds.py +0 -0
  73. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_run_cmds.py +0 -0
  74. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_run_helpers.py +0 -0
  75. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_runtime_health.py +0 -0
  76. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_runtime_mode.py +0 -0
  77. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_runtime_state.py +0 -0
  78. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_schedule_cmds.py +0 -0
  79. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_service_cmds.py +0 -0
  80. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_shared.py +0 -0
  81. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_sys_cmds.py +0 -0
  82. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_terminal.py +0 -0
  83. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_ui.py +0 -0
  84. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_workflow_validation.py +0 -0
  85. {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.2.5
3
+ Version: 1.2.6
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.2.5
3
+ Version: 1.2.6
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -53,6 +53,39 @@ def _erl_aflags_needs_update(value: Optional[str], dist_port: str | int) -> bool
53
53
  or f"inet_dist_listen_max {dist_port}" not in text
54
54
  )
55
55
 
56
+ def _distributed_core_command() -> list[str]:
57
+ command = (
58
+ "if [ -x \"bin/mirror_neuron\" ]; then "
59
+ "if [ -n \"${MN_NODE_NAME:-}\" ]; then "
60
+ "if [ -z \"${MN_COOKIE:-}\" ] || [ \"${MN_COOKIE:-}\" = \"mirrorneuron\" ]; then "
61
+ "echo \"MN_COOKIE must be set to a non-default secret when MN_NODE_NAME enables distributed Erlang\" >&2; exit 1; "
62
+ "fi; "
63
+ "unset ERL_EPMD_ADDRESS; "
64
+ "epmd_bin=\"$(find erts-* -path '*/bin/epmd' -type f | head -n 1)\"; "
65
+ "if [ -n \"$epmd_bin\" ]; then \"$epmd_bin\" -daemon; else epmd -daemon; fi; "
66
+ "export RELEASE_DISTRIBUTION=name; "
67
+ "export RELEASE_NODE=\"$MN_NODE_NAME\"; "
68
+ "export RELEASE_COOKIE=\"$MN_COOKIE\"; "
69
+ "else "
70
+ "export RELEASE_DISTRIBUTION=none; "
71
+ "fi; "
72
+ "exec bin/mirror_neuron start; "
73
+ "fi; "
74
+ "if [ -n \"${MN_NODE_NAME:-}\" ]; then "
75
+ "if [ -z \"${MN_COOKIE:-}\" ] || [ \"${MN_COOKIE:-}\" = \"mirrorneuron\" ]; then "
76
+ "echo \"MN_COOKIE must be set to a non-default secret when MN_NODE_NAME enables distributed Erlang\" >&2; exit 1; "
77
+ "fi; "
78
+ "dist_port=\"${MN_DIST_PORT:-4370}\"; "
79
+ "unset ERL_EPMD_ADDRESS; "
80
+ "epmd -daemon; "
81
+ "exec elixir --name \"$MN_NODE_NAME\" --cookie \"$MN_COOKIE\" --erl "
82
+ "\"-kernel inet_dist_listen_min ${dist_port} inet_dist_listen_max ${dist_port}\" -S mix run --no-halt; "
83
+ "else "
84
+ "exec mix run --no-halt; "
85
+ "fi"
86
+ )
87
+ return ["sh", "-c", command]
88
+
56
89
  def _mn_home() -> Path:
57
90
  return _runtime_mn_home()
58
91
 
@@ -65,6 +98,7 @@ API_PID_FILE = PID_DIR / "api.pid"
65
98
  API_WATCHDOG_PID_FILE = PID_DIR / "api-watchdog.pid"
66
99
  WEB_UI_PID_FILE = PID_DIR / "web-ui.pid"
67
100
  WEB_UI_WATCHDOG_PID_FILE = PID_DIR / "web-ui-watchdog.pid"
101
+ API_TOKEN_FILE = DIR / "api.token"
68
102
  BEAM_LOG = LOG_DIR / "beam.log"
69
103
  API_LOG = LOG_DIR / "api.log"
70
104
  API_WATCHDOG_LOG = LOG_DIR / "api-watchdog.log"
@@ -143,6 +177,7 @@ DEFAULT_CONTAINER_BLOB_STORE_ROOT = "/root/.mn/blobs"
143
177
  DEFAULT_RUNTIME_SHARED_STORAGE_ROOT = "/root/.mn/shared"
144
178
  DEFAULT_CONTAINER_GRPC_AUTH_TOKEN_FILE = "/root/.mn/grpc_auth.token"
145
179
  DEFAULT_CONTAINER_GRPC_ADMIN_TOKEN_FILE = "/root/.mn/grpc_admin.token"
180
+ DEFAULT_REDIS_IMAGE = "redis:8"
146
181
  LEGACY_GRPC_PORT = "50051"
147
182
  LEGACY_API_PORT = "4001"
148
183
  LEGACY_EPMD_PORT = "4369"
@@ -727,6 +762,33 @@ def _write_grpc_token_file(token_file: Path, token: str, label: str) -> None:
727
762
  return
728
763
  write_private_text(token_file, f"{token}\n")
729
764
 
765
+ def _resolve_api_token() -> str:
766
+ env_token = os.getenv("MN_API_TOKEN", "").strip()
767
+ if env_token:
768
+ write_private_text(API_TOKEN_FILE, f"{env_token}\n")
769
+ return env_token
770
+ try:
771
+ existing_token = API_TOKEN_FILE.read_text(encoding="utf-8").strip()
772
+ if existing_token:
773
+ return existing_token
774
+ except OSError:
775
+ pass
776
+ generated_token = secrets.token_hex(32)
777
+ write_private_text(API_TOKEN_FILE, f"{generated_token}\n")
778
+ return generated_token
779
+
780
+ def _ensure_runtime_api_token(env: dict[str, str], *, persist_compose: bool = False) -> dict[str, str]:
781
+ resolved = dict(env)
782
+ if str(resolved.get("MN_ENV") or "").strip().lower() != "prod":
783
+ return resolved
784
+ if not str(resolved.get("MN_API_TOKEN") or "").strip():
785
+ resolved["MN_API_TOKEN"] = _resolve_api_token()
786
+ else:
787
+ write_private_text(API_TOKEN_FILE, f"{resolved['MN_API_TOKEN'].strip()}\n")
788
+ if persist_compose and runtime_compose_available():
789
+ _write_env_file_values(RUNTIME_COMPOSE_ENV, {"MN_API_TOKEN": resolved["MN_API_TOKEN"]})
790
+ return resolved
791
+
730
792
  def _ensure_runtime_grpc_tokens(env: dict[str, str], *, persist_compose: bool = False) -> dict[str, str]:
731
793
  resolved = dict(env)
732
794
  if not str(resolved.get("MN_GRPC_AUTH_TOKEN") or "").strip():
@@ -1167,6 +1229,9 @@ def _network_core_env(
1167
1229
  "MN_NODE_ROLE": "runtime",
1168
1230
  "MN_CLUSTER_NODES": cluster_nodes,
1169
1231
  "MN_REDIS_URL": redis_url,
1232
+ "MN_HOST_SHARED_STORAGE_ROOT": str(DIR / "shared"),
1233
+ "MN_SHARED_STORAGE_ROOT": DEFAULT_RUNTIME_SHARED_STORAGE_ROOT,
1234
+ "MN_RUNTIME_SHARED_STORAGE_ROOT": DEFAULT_RUNTIME_SHARED_STORAGE_ROOT,
1170
1235
  "MN_DIST_PORT": str(dist_port),
1171
1236
  "MN_COOKIE": _derive_network_secret(token, "cookie"),
1172
1237
  "MN_GRPC_AUTH_TOKEN": _resolve_grpc_auth_token(),
@@ -1186,6 +1251,50 @@ def _docker_env_args(env: dict[str, str]) -> list[str]:
1186
1251
  args.extend(["-e", f"{key}={env[key]}"])
1187
1252
  return args
1188
1253
 
1254
+ def _network_redis_image() -> str:
1255
+ return os.getenv("MN_REDIS_IMAGE", "").strip() or DEFAULT_REDIS_IMAGE
1256
+
1257
+ def _docker_host_socket() -> Optional[Path]:
1258
+ candidates = [
1259
+ os.getenv("DOCKER_HOST_SOCKET", "").strip(),
1260
+ "/var/run/docker.sock",
1261
+ str(Path.home() / ".docker" / "run" / "docker.sock"),
1262
+ ]
1263
+ for candidate in candidates:
1264
+ if not candidate:
1265
+ continue
1266
+ path = Path(candidate).expanduser()
1267
+ if path.exists():
1268
+ return path
1269
+ return None
1270
+
1271
+ def _network_core_bind_args() -> list[str]:
1272
+ args: list[str] = []
1273
+ shared_dir = DIR / "shared"
1274
+ shared_dir.mkdir(parents=True, exist_ok=True)
1275
+
1276
+ for host_path, container_path in (
1277
+ (DIR, "/root/.mn"),
1278
+ (DIR, "/opt/mirror_neuron/.mn"),
1279
+ (DIR, str(DIR)),
1280
+ (shared_dir, "/root/.mn/shared"),
1281
+ (shared_dir, "/opt/mirror_neuron/.mn/shared"),
1282
+ ):
1283
+ args.extend(["-v", f"{host_path}:{container_path}:rw"])
1284
+
1285
+ args.extend(_docker_worker_bind_args())
1286
+ return args
1287
+
1288
+ def _docker_worker_bind_args() -> list[str]:
1289
+ args: list[str] = []
1290
+ socket_path = _docker_host_socket()
1291
+ if socket_path is not None:
1292
+ args.extend(["-v", f"{socket_path}:/var/run/docker.sock:rw"])
1293
+ docker_cli = shutil.which("docker") if sys.platform.startswith("linux") else None
1294
+ if docker_cli:
1295
+ args.extend(["-v", f"{docker_cli}:/usr/local/bin/docker:ro"])
1296
+ return args
1297
+
1189
1298
  def _start_network_redis(
1190
1299
  host: str,
1191
1300
  redis_port: Optional[int],
@@ -1210,7 +1319,7 @@ def _start_network_redis(
1210
1319
  *_docker_network_run_args(docker_network_mode, docker_network_name, redis_alias),
1211
1320
  "-v",
1212
1321
  f"{data_dir}:/data",
1213
- "redis:7",
1322
+ _network_redis_image(),
1214
1323
  "redis-server",
1215
1324
  "--appendonly",
1216
1325
  "yes",
@@ -1257,8 +1366,10 @@ def _start_network_core(
1257
1366
  NETWORK_CORE_CONTAINER,
1258
1367
  *_docker_network_run_args(docker_network_mode, docker_network_name, node_alias),
1259
1368
  *port_args,
1369
+ *_network_core_bind_args(),
1260
1370
  *env_args,
1261
1371
  "mirror-neuron-core:latest",
1372
+ *_distributed_core_command(),
1262
1373
  ]
1263
1374
  subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
1264
1375
 
@@ -1501,6 +1612,8 @@ def _start_network_seed(
1501
1612
  redis_public_port = selected_redis_port or REDIS_CONTAINER_PORT
1502
1613
 
1503
1614
  _ensure_network_docker_network(container_network_mode, network_name)
1615
+ if container_network_mode == "disabled" and _docker_host_socket() is not None:
1616
+ _ensure_docker_network("bridge", network_name)
1504
1617
  if not external_redis_url:
1505
1618
  console.print("=> Starting network Redis...")
1506
1619
  _start_network_redis(
@@ -1608,6 +1721,8 @@ def _join_network(
1608
1721
  console.print("Check that the local MirrorNeuron core is running, and that the remote host and token are correct.")
1609
1722
  console.print(f"[dim]{exc}[/dim]")
1610
1723
  raise typer.Exit(1) from exc
1724
+ if runtime_compose_available():
1725
+ _persist_compose_cluster_node(remote_node)
1611
1726
  details: list[tuple[str, str]] = [("Node", remote_node)]
1612
1727
  if runtime_compose_available() or os.getenv("MN_REDIS_URL", "").strip():
1613
1728
  replication = _configure_worker_redis_replica(seed_host, handshake, token)
@@ -1625,6 +1740,17 @@ def _join_network(
1625
1740
  )
1626
1741
  return handshake
1627
1742
 
1743
+ def _persist_compose_cluster_node(node_name: str) -> None:
1744
+ node_name = str(node_name or "").strip()
1745
+ if not node_name:
1746
+ return
1747
+
1748
+ env = _read_env_file(RUNTIME_COMPOSE_ENV)
1749
+ nodes = _split_env_list(env.get("MN_CLUSTER_NODES"))
1750
+ if node_name not in nodes:
1751
+ nodes.append(node_name)
1752
+ _write_env_file_values(RUNTIME_COMPOSE_ENV, {"MN_CLUSTER_NODES": ",".join(nodes)})
1753
+
1628
1754
  def _ensure_local_cluster_runtime_for_join(
1629
1755
  *,
1630
1756
  local_host: str,
@@ -2065,6 +2191,80 @@ def _runtime_endpoint_snapshot(env: dict[str, str], web_ui_available: bool = Fal
2065
2191
  }
2066
2192
  return snapshot
2067
2193
 
2194
+ def _read_runtime_api_health(api_host: str, api_port: str, *, timeout_seconds: float = 2.0) -> Optional[dict[str, Any]]:
2195
+ url = _api_http_url(api_host, api_port, "/api/v1/health")
2196
+ try:
2197
+ with urllib.request.urlopen(url, timeout=timeout_seconds) as response:
2198
+ payload = json.loads(response.read().decode("utf-8"))
2199
+ return payload if isinstance(payload, dict) else None
2200
+ except Exception:
2201
+ return None
2202
+
2203
+ def _is_url_like(value: str) -> bool:
2204
+ normalized = str(value or "").strip()
2205
+ if re.fullmatch(r"[\w.-]+@[\w.-]+:[^\s]+", normalized):
2206
+ return True
2207
+ try:
2208
+ parsed = urlparse(normalized)
2209
+ except Exception:
2210
+ return False
2211
+ return parsed.scheme in {"http", "https", "ssh", "git"} and bool(parsed.netloc)
2212
+
2213
+ def _normalize_blueprint_location(value: str) -> str:
2214
+ normalized = str(value or "").strip()
2215
+ if not normalized:
2216
+ return ""
2217
+ if _is_url_like(normalized):
2218
+ if re.fullmatch(r"[\w.-]+@[\w.-]+:[^\s]+", normalized):
2219
+ host, _, repo_path = normalized.partition(":")
2220
+ return f"{host.lower()}:{repo_path.rstrip('/').removesuffix('.git')}"
2221
+ parsed = urlparse(normalized)
2222
+ repo_path = parsed.path.rstrip("/").removesuffix(".git")
2223
+ return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{repo_path}"
2224
+ return str(Path(normalized).expanduser().resolve())
2225
+
2226
+ def _same_blueprint_location(active: str, expected: str) -> bool:
2227
+ active_normalized = _normalize_blueprint_location(active)
2228
+ expected_normalized = _normalize_blueprint_location(expected)
2229
+ return bool(active_normalized and expected_normalized and active_normalized == expected_normalized)
2230
+
2231
+ def _same_runtime_path(active: str, expected: str) -> bool:
2232
+ active_path = str(active or "").strip()
2233
+ expected_path = str(expected or "").strip()
2234
+ if not active_path or not expected_path:
2235
+ return False
2236
+ try:
2237
+ return Path(active_path).expanduser().resolve() == Path(expected_path).expanduser().resolve()
2238
+ except OSError:
2239
+ return active_path == expected_path
2240
+
2241
+ def _expected_blueprint_location(env: dict[str, str]) -> str:
2242
+ source = str(env.get("MN_BLUEPRINT_SOURCE") or "github").strip().lower()
2243
+ if source == "local":
2244
+ return str(env.get("MN_BLUEPRINT_LOCAL") or "").strip()
2245
+ return str(env.get("MN_BLUEPRINT_REPO") or DEFAULT_BLUEPRINT_REPO).strip()
2246
+
2247
+ def _runtime_api_config_mismatches(env: dict[str, str], health: Optional[dict[str, Any]]) -> list[tuple[str, str, str]]:
2248
+ if not health:
2249
+ return []
2250
+ mismatches: list[tuple[str, str, str]] = []
2251
+ expected_blueprint = _expected_blueprint_location(env)
2252
+ active_blueprint = str(
2253
+ health.get("active_blueprint_location")
2254
+ or health.get("activeBlueprintLocation")
2255
+ or health.get("blueprint_repo")
2256
+ or health.get("blueprintRepo")
2257
+ or ""
2258
+ ).strip()
2259
+ if active_blueprint and expected_blueprint and not _same_blueprint_location(active_blueprint, expected_blueprint):
2260
+ mismatches.append(("blueprint repo", active_blueprint, expected_blueprint))
2261
+
2262
+ expected_runs_root = str(env.get("MN_RUNS_ROOT") or env.get("MN_HOST_ARTIFACTS_DIR") or "").strip()
2263
+ active_runs_root = str(health.get("runs_root") or health.get("runsRoot") or "").strip()
2264
+ if active_runs_root and expected_runs_root and not _same_runtime_path(active_runs_root, expected_runs_root):
2265
+ mismatches.append(("runs root", active_runs_root, expected_runs_root))
2266
+ return mismatches
2267
+
2068
2268
  def _write_runtime_endpoints_file(env: dict[str, str], web_ui_available: bool = False) -> dict[str, object]:
2069
2269
  snapshot = _runtime_endpoint_snapshot(env, web_ui_available=web_ui_available)
2070
2270
  RUNTIME_ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -2399,15 +2599,6 @@ def _configure_worker_redis_replica(
2399
2599
  worker_redis_host,
2400
2600
  worker_redis_port,
2401
2601
  worker_password,
2402
- "CONFIG",
2403
- "SET",
2404
- "requirepass",
2405
- primary_password,
2406
- )
2407
- _redis_command(
2408
- worker_redis_host,
2409
- worker_redis_port,
2410
- primary_password,
2411
2602
  "REPLICAOF",
2412
2603
  primary_host,
2413
2604
  str(primary_port),
@@ -2704,7 +2895,12 @@ def _start_api_watchdog(env: dict[str, str]) -> subprocess.Popen:
2704
2895
  start_new_session=True,
2705
2896
  )
2706
2897
 
2707
- def _start_api_if_installed(runtime_env: Optional[dict[str, str]] = None) -> bool:
2898
+ def _start_api_if_installed(
2899
+ runtime_env: Optional[dict[str, str]] = None,
2900
+ *,
2901
+ restart_running: bool = False,
2902
+ restart_reason: str = "",
2903
+ ) -> bool:
2708
2904
  if _api_command() is None:
2709
2905
  console.print("[yellow]=> Warning: mn-api not found, skipping.[/yellow]")
2710
2906
  return False
@@ -2723,12 +2919,16 @@ def _start_api_if_installed(runtime_env: Optional[dict[str, str]] = None) -> boo
2723
2919
  watchdog_status = check_status(API_WATCHDOG_PID_FILE)
2724
2920
  child_status = check_status(API_PID_FILE)
2725
2921
  if watchdog_status == 0:
2726
- if _wait_for_api(api_host, api_port, timeout_seconds=5.0):
2922
+ if _wait_for_api(api_host, api_port, timeout_seconds=5.0) and not restart_running:
2727
2923
  console.print("[yellow]=> REST API watchdog is already running, skipping.[/yellow]")
2728
2924
  return True
2925
+ if restart_running:
2926
+ detail = f" ({restart_reason})" if restart_reason else ""
2927
+ console.print(f"[yellow]=> REST API watchdog is already running; restarting it{detail}.[/yellow]")
2928
+ else:
2929
+ console.print("[yellow]=> REST API watchdog is running, but the API is not responding; restarting it.[/yellow]")
2729
2930
  try:
2730
2931
  watchdog_pid = int(API_WATCHDOG_PID_FILE.read_text().strip())
2731
- console.print("[yellow]=> REST API watchdog is running, but the API is not responding; restarting it.[/yellow]")
2732
2932
  kill_tree(watchdog_pid)
2733
2933
  time.sleep(1)
2734
2934
  except (ValueError, OSError):
@@ -3159,9 +3359,10 @@ def _build_core_docker_run_command(
3159
3359
  cmd.extend(["-v", f"{host_blob_store_dir}:/opt/mirror_neuron/.mn/blobs"])
3160
3360
  cmd.extend(["-v", f"{host_shared_storage_root}:{runtime_shared_storage_root}"])
3161
3361
  cmd.extend(["-v", f"{host_shared_storage_root}:/opt/mirror_neuron/.mn/shared"])
3362
+ cmd.extend(_docker_worker_bind_args())
3162
3363
  cmd.extend(["-e", f"MN_BLOB_STORE_ROOT={container_blob_store_root}"])
3163
3364
 
3164
- cmd.append("mirror-neuron-core:latest")
3365
+ cmd.extend(["mirror-neuron-core:latest", *_distributed_core_command()])
3165
3366
  return cmd
3166
3367
 
3167
3368
  def _start_server(
@@ -3187,6 +3388,7 @@ def _start_server(
3187
3388
  if value:
3188
3389
  env[key] = value
3189
3390
  env = _ensure_runtime_grpc_tokens(env, persist_compose=compose_runtime)
3391
+ env = _ensure_runtime_api_token(env, persist_compose=compose_runtime)
3190
3392
  if compose_runtime:
3191
3393
  env = _ensure_compose_native_port_settings(env)
3192
3394
  if not _docker_container_running("mirror-neuron-core"):
@@ -3201,7 +3403,21 @@ def _start_server(
3201
3403
  env.setdefault("MN_API_PORT", DEFAULT_API_PORT)
3202
3404
  env.setdefault("MN_WEB_UI_HOST", _web_ui_host())
3203
3405
  env.setdefault("MN_WEB_UI_PORT", DEFAULT_WEB_UI_PORT)
3204
- _start_api_if_installed(env)
3406
+ api_host = str(env.get("MN_API_HOST") or DEFAULT_HOST)
3407
+ api_port = _valid_port_text(str(env.get("MN_API_PORT") or DEFAULT_API_PORT), DEFAULT_API_PORT)
3408
+ api_health = _read_runtime_api_health(api_host, api_port)
3409
+ api_mismatches = _runtime_api_config_mismatches(env, api_health)
3410
+ if api_mismatches:
3411
+ mismatch_details = ", ".join(
3412
+ f"{key} {active or '(unset)'} -> {expected or '(unset)'}"
3413
+ for key, active, expected in api_mismatches
3414
+ )
3415
+ console.print(f"[yellow]=> REST API runtime config changed; restarting API ({mismatch_details}).[/yellow]")
3416
+ _start_api_if_installed(
3417
+ env,
3418
+ restart_running=bool(api_mismatches),
3419
+ restart_reason="runtime config changed",
3420
+ )
3205
3421
  web_ui_available = _start_web_ui_if_installed(env)
3206
3422
  endpoint_snapshot = _write_runtime_endpoints_file(env, web_ui_available=web_ui_available)
3207
3423
  console.print(f" Runtime endpoints: {RUNTIME_ENDPOINTS_FILE}")
@@ -3266,6 +3482,7 @@ def _start_server(
3266
3482
  if join_handshake:
3267
3483
  _validate_remote_redis_details(join_handshake, ip, network_token)
3268
3484
  env.update(_grpc_tokens_from_handshake(join_handshake))
3485
+ env = _ensure_runtime_api_token(env, persist_compose=compose_runtime)
3269
3486
  reconnecting_joined_node = bool(compose_runtime and not ip and _persisted_join_profile(env))
3270
3487
  if compose_runtime:
3271
3488
  env = _ensure_compose_native_port_settings(env)
@@ -50,6 +50,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
50
50
  procps \\
51
51
  && rm -rf /var/lib/apt/lists/*
52
52
 
53
+ ARG DOCKER_CLI_VERSION=29.2.1
54
+ RUN set -eux; \\
55
+ arch="$(dpkg --print-architecture)"; \\
56
+ case "$arch" in \\
57
+ arm64) docker_target="aarch64" ;; \\
58
+ amd64) docker_target="x86_64" ;; \\
59
+ *) echo "unsupported architecture for Docker CLI: $arch" >&2; exit 1 ;; \\
60
+ esac; \\
61
+ curl -fLsS -o /tmp/docker-cli.tgz \\
62
+ "https://download.docker.com/linux/static/stable/${docker_target}/docker-${DOCKER_CLI_VERSION}.tgz"; \\
63
+ tar -xzf /tmp/docker-cli.tgz -C /tmp docker/docker; \\
64
+ install -m 0755 /tmp/docker/docker /usr/local/bin/docker; \\
65
+ rm -rf /tmp/docker /tmp/docker-cli.tgz; \\
66
+ docker --version
67
+
53
68
  ARG OPENSHELL_VERSION=v0.0.47
54
69
  RUN set -eux; \\
55
70
  arch="$(dpkg --print-architecture)"; \\
@@ -47,6 +47,7 @@ def isolated_mn_cookie_home(mocker, tmp_path, monkeypatch):
47
47
  monkeypatch.delenv("MN_GRPC_ADMIN_TOKEN", raising=False)
48
48
  monkeypatch.delenv("MN_GRPC_AUTH_TOKEN_FILE", raising=False)
49
49
  monkeypatch.delenv("MN_GRPC_ADMIN_TOKEN_FILE", raising=False)
50
+ monkeypatch.delenv("MN_API_TOKEN", raising=False)
50
51
  monkeypatch.delenv("MN_ARTIFACT_AUTH_TOKEN", raising=False)
51
52
  monkeypatch.delenv("MN_NODE_GPU", raising=False)
52
53
  monkeypatch.delenv("MN_NODE_GPU_COUNT", raising=False)
@@ -64,6 +65,8 @@ def isolated_mn_cookie_home(mocker, tmp_path, monkeypatch):
64
65
  monkeypatch.delenv("MN_BLUEPRINT_LOCAL", raising=False)
65
66
  monkeypatch.delenv("MN_DOCKER_NETWORK_MODE", raising=False)
66
67
  monkeypatch.delenv("MN_DOCKER_NETWORK_NAME", raising=False)
68
+ monkeypatch.delenv("MN_REDIS_IMAGE", raising=False)
69
+ monkeypatch.delenv("DOCKER_HOST_SOCKET", raising=False)
67
70
  monkeypatch.delenv("MN_NETWORK_JOIN_TOKEN", raising=False)
68
71
  state_dir = tmp_path / ".mn"
69
72
  log_dir = state_dir / ".logs"
@@ -74,6 +77,7 @@ def isolated_mn_cookie_home(mocker, tmp_path, monkeypatch):
74
77
  mocker.patch('mn_cli.server_cmds.BEAM_PID_FILE', pid_dir / "beam.pid")
75
78
  mocker.patch('mn_cli.server_cmds.API_PID_FILE', pid_dir / "api.pid")
76
79
  mocker.patch('mn_cli.server_cmds.API_WATCHDOG_PID_FILE', pid_dir / "api-watchdog.pid")
80
+ mocker.patch('mn_cli.server_cmds.API_TOKEN_FILE', state_dir / "api.token")
77
81
  mocker.patch('mn_cli.server_cmds.WEB_UI_PID_FILE', pid_dir / "web-ui.pid")
78
82
  mocker.patch('mn_cli.server_cmds.WEB_UI_WATCHDOG_PID_FILE', pid_dir / "web-ui-watchdog.pid")
79
83
  mocker.patch('mn_cli.server_cmds.BEAM_LOG', log_dir / "beam.log")
@@ -84,6 +88,7 @@ def isolated_mn_cookie_home(mocker, tmp_path, monkeypatch):
84
88
  mocker.patch('mn_cli.server_cmds.VENV_DIR', tmp_path / "mn_venv")
85
89
  mocker.patch('mn_cli.server_cmds.RUNTIME_COMPOSE_FILE', state_dir / "docker-compose.yml")
86
90
  mocker.patch('mn_cli.server_cmds.RUNTIME_COMPOSE_ENV', state_dir / "docker-compose.env")
91
+ mocker.patch('mn_cli.server_cmds._docker_host_socket', return_value=None)
87
92
  mocker.patch('mn_cli.server_cmds.RUNTIME_ENDPOINTS_FILE', state_dir / "runtime-endpoints.json")
88
93
  mocker.patch('mn_cli.server_cmds.NETWORK_TOKEN_FILE', state_dir / "network.token")
89
94
  mocker.patch('mn_cli.server_cmds.NETWORK_REDIS_ENV_FILE', state_dir / "network-redis.env")
@@ -349,6 +354,30 @@ def test_resolve_grpc_admin_token_ignores_legacy_env(monkeypatch):
349
354
 
350
355
  assert _resolve_grpc_admin_token() != "legacy-admin-token"
351
356
 
357
+ def test_runtime_api_token_is_generated_for_prod_and_persisted(mocker):
358
+ mocker.patch("mn_cli.server_cmds.secrets.token_hex", return_value="generated-api-token")
359
+
360
+ env = server_cmds._ensure_runtime_api_token({"MN_ENV": "prod"})
361
+
362
+ assert env["MN_API_TOKEN"] == "generated-api-token"
363
+ assert server_cmds.API_TOKEN_FILE.read_text().strip() == "generated-api-token"
364
+ assert server_cmds.API_TOKEN_FILE.stat().st_mode & 0o777 == 0o600
365
+ assert server_cmds._ensure_runtime_api_token({"MN_ENV": "prod"})["MN_API_TOKEN"] == "generated-api-token"
366
+
367
+ def test_runtime_api_token_prefers_env_and_persists_compose(mocker, monkeypatch):
368
+ monkeypatch.setenv("MN_API_TOKEN", "explicit-api-token")
369
+ compose_file = server_cmds.RUNTIME_COMPOSE_FILE
370
+ compose_env = server_cmds.RUNTIME_COMPOSE_ENV
371
+ compose_file.parent.mkdir(parents=True, exist_ok=True)
372
+ compose_file.write_text("services: {}\n", encoding="utf-8")
373
+ compose_env.write_text("COMPOSE_PROJECT_NAME=mirror-neuron\n", encoding="utf-8")
374
+
375
+ env = server_cmds._ensure_runtime_api_token({"MN_ENV": "prod"}, persist_compose=True)
376
+
377
+ assert env["MN_API_TOKEN"] == "explicit-api-token"
378
+ assert server_cmds.API_TOKEN_FILE.read_text().strip() == "explicit-api-token"
379
+ assert "MN_API_TOKEN=explicit-api-token" in compose_env.read_text()
380
+
352
381
  def test_start_server_persists_env_grpc_tokens_for_later_cli_process(mocker, monkeypatch):
353
382
  monkeypatch.setenv("MN_GRPC_AUTH_TOKEN", "runtime-auth-token")
354
383
  monkeypatch.setenv("MN_GRPC_ADMIN_TOKEN", "runtime-admin-token")
@@ -697,6 +726,10 @@ def test_start_network_seed_starts_only_core_and_redis(mocker, tmp_path, monkeyp
697
726
  assert any(cmd[:4] == ["docker", "run", "-d", "--name"] and cmd[4] == "mirror-neuron-network-redis" for cmd in commands)
698
727
  core_run = next(cmd for cmd in commands if len(cmd) > 4 and cmd[:4] == ["docker", "run", "-d", "--name"] and cmd[4] == "mirror-neuron-network-core")
699
728
  assert "mirror-neuron-core:latest" in core_run
729
+ image_index = core_run.index("mirror-neuron-core:latest")
730
+ assert core_run[image_index + 1 : image_index + 3] == ["sh", "-c"]
731
+ assert "epmd_bin=" in core_run[image_index + 3]
732
+ assert "RELEASE_DISTRIBUTION=name" in core_run[image_index + 3]
700
733
  assert "redis:7" not in core_run
701
734
  assert core_run.count("-p") == 1
702
735
  assert "0.0.0.0:50055:50055" in core_run
@@ -741,6 +774,7 @@ def test_start_network_seed_default_disabled_ignores_stale_named_network(mocker,
741
774
  mocker.patch('mn_cli.server_cmds.secrets.token_urlsafe', return_value="worker-token")
742
775
  mocker.patch('mn_cli.server_cmds._docker_container_running', return_value=False)
743
776
  mocker.patch('mn_cli.server_cmds._port_available_or_owned', return_value=True)
777
+ mocker.patch('mn_cli.server_cmds._docker_host_socket', return_value=None)
744
778
 
745
779
  commands = []
746
780
 
@@ -782,6 +816,34 @@ def test_start_network_seed_default_disabled_ignores_stale_named_network(mocker,
782
816
  assert "MN_DOCKER_NETWORK_MODE=disabled" in core_run
783
817
  assert "MN_NODE_NAME=mirror_neuron@192.168.4.173" in core_run
784
818
 
819
+ def test_start_network_seed_uses_configured_redis_image(mocker, tmp_path, monkeypatch):
820
+ monkeypatch.delenv("MN_REDIS_URL", raising=False)
821
+ monkeypatch.setenv("MN_REDIS_IMAGE", "redis:8.8")
822
+ token_file = tmp_path / "network.token"
823
+ mocker.patch('mn_cli.server_cmds.DIR', tmp_path)
824
+ mocker.patch('mn_cli.server_cmds.NETWORK_TOKEN_FILE', token_file)
825
+ mocker.patch('mn_cli.server_cmds.NETWORK_REDIS_ENV_FILE', tmp_path / "network-redis.env")
826
+ mocker.patch('mn_cli.server_cmds.secrets.token_urlsafe', return_value="worker-token")
827
+ mocker.patch('mn_cli.server_cmds._docker_container_running', return_value=False)
828
+ mocker.patch('mn_cli.server_cmds._port_available_or_owned', return_value=True)
829
+ mocker.patch('mn_cli.server_cmds._docker_host_socket', return_value=None)
830
+
831
+ commands = []
832
+
833
+ def mock_run(cmd, **kwargs):
834
+ commands.append(cmd)
835
+ m = mocker.Mock()
836
+ m.returncode = 0
837
+ m.stdout = "false\n"
838
+ return m
839
+
840
+ mocker.patch('mn_cli.server_cmds.subprocess.run', side_effect=mock_run)
841
+
842
+ _start_network_seed(host="192.168.4.173", grpc_port=50055, dist_port=4500)
843
+
844
+ redis_run = next(cmd for cmd in commands if len(cmd) > 4 and cmd[:4] == ["docker", "run", "-d", "--name"] and cmd[4] == "mirror-neuron-network-redis")
845
+ assert "redis:8.8" in redis_run
846
+
785
847
  def test_start_network_seed_already_exposed_prints_existing_token(mocker):
786
848
  output = StringIO()
787
849
  mocker.patch('mn_cli.server_cmds.console', Console(file=output, force_terminal=False, width=120))
@@ -896,6 +958,17 @@ def test_sidecar_pid_files_include_legacy_checkout_paths():
896
958
  assert (legacy_pid_dir / "web-ui-watchdog.pid", "Web UI watchdog") in server_cmds.web_ui_pid_files()
897
959
  assert (legacy_pid_dir / "web-ui.pid", "Web UI") in server_cmds.web_ui_pid_files()
898
960
 
961
+ def test_persist_compose_cluster_node_appends_remote_once():
962
+ server_cmds.RUNTIME_COMPOSE_ENV.parent.mkdir(parents=True, exist_ok=True)
963
+ server_cmds.RUNTIME_COMPOSE_ENV.write_text("MN_CLUSTER_NODES=mirror_neuron@local\n")
964
+
965
+ server_cmds._persist_compose_cluster_node("mirror_neuron@worker")
966
+ server_cmds._persist_compose_cluster_node("mirror_neuron@worker")
967
+
968
+ env = server_cmds._read_env_file(server_cmds.RUNTIME_COMPOSE_ENV)
969
+ assert env["MN_CLUSTER_NODES"] == "mirror_neuron@local,mirror_neuron@worker"
970
+
971
+
899
972
  def test_add_node_uses_handshake_and_local_core(mocker, tmp_path, capsys):
900
973
  import mn_sdk
901
974
  import mn_cli.shared
@@ -1136,14 +1209,9 @@ def test_join_network_configures_worker_redis_replica(mocker, tmp_path, capsys):
1136
1209
  "192.168.4.20",
1137
1210
  56380,
1138
1211
  worker_password,
1139
- ("CONFIG", "SET", "requirepass", primary_password),
1140
- ) in redis_calls
1141
- assert (
1142
- "192.168.4.20",
1143
- 56380,
1144
- primary_password,
1145
1212
  ("REPLICAOF", "192.168.4.99", "56379"),
1146
1213
  ) in redis_calls
1214
+ assert not any(call[3][:3] == ("CONFIG", "SET", "requirepass") for call in redis_calls)
1147
1215
  assert (
1148
1216
  "192.168.4.99",
1149
1217
  56379,
@@ -1245,6 +1313,7 @@ def test_start_server_already_running(mocker, tmp_path):
1245
1313
  mocker.patch('mn_cli.server_cmds.API_PID_FILE', tmp_path / "api.pid")
1246
1314
  (tmp_path / "api.pid").write_text("1234")
1247
1315
  mocker.patch('mn_cli.server_cmds.os.kill') # check_status returns 0
1316
+ mocker.patch('mn_cli.server_cmds._read_runtime_api_health', return_value=None)
1248
1317
 
1249
1318
  mock_container_tokens = mocker.patch(
1250
1319
  'mn_cli.server_cmds._runtime_grpc_tokens_from_running_container',
@@ -1266,6 +1335,54 @@ def test_start_server_already_running(mocker, tmp_path):
1266
1335
  mock_write_endpoints.assert_called_once()
1267
1336
  mock_print_endpoints.assert_called_once_with(None, True)
1268
1337
 
1338
+ def test_start_server_restarts_existing_api_when_runtime_blueprint_env_changes(mocker, tmp_path, monkeypatch):
1339
+ mocker.patch('mn_cli.server_cmds.API_PID_FILE', tmp_path / "api.pid")
1340
+ (tmp_path / "api.pid").write_text("1234")
1341
+ mocker.patch('mn_cli.server_cmds.os.kill') # check_status returns 0
1342
+
1343
+ compose_file = server_cmds.RUNTIME_COMPOSE_FILE
1344
+ compose_env = server_cmds.RUNTIME_COMPOSE_ENV
1345
+ compose_file.parent.mkdir(parents=True, exist_ok=True)
1346
+ compose_file.write_text("services: {}\n", encoding="utf-8")
1347
+ compose_env.write_text(
1348
+ "COMPOSE_PROJECT_NAME=mirror-neuron\n"
1349
+ "MN_BLUEPRINT_SOURCE=github\n"
1350
+ "MN_BLUEPRINT_REPO=https://github.com/MirrorNeuronLab/mn-blueprints.git\n"
1351
+ "MN_RUNS_ROOT=/tmp/mn-runs\n",
1352
+ encoding="utf-8",
1353
+ )
1354
+ monkeypatch.setenv("MN_BLUEPRINT_SOURCE", "github")
1355
+ monkeypatch.setenv("MN_BLUEPRINT_REPO", "https://github.com/MirrorNeuronLab/otterdesk-blueprints")
1356
+ monkeypatch.setenv("MN_RUNS_ROOT", "/tmp/otterdesk-runs")
1357
+ mocker.patch('mn_cli.server_cmds._runtime_grpc_tokens_from_running_container', return_value={})
1358
+ mocker.patch('mn_cli.server_cmds._docker_container_running', return_value=True)
1359
+ mocker.patch(
1360
+ 'mn_cli.server_cmds._read_runtime_api_health',
1361
+ return_value={
1362
+ "status": "ok",
1363
+ "blueprint_source": "github",
1364
+ "blueprint_repo": "https://github.com/MirrorNeuronLab/mn-blueprints.git",
1365
+ "active_blueprint_location": "https://github.com/MirrorNeuronLab/mn-blueprints.git",
1366
+ "runs_root": "/tmp/mn-runs",
1367
+ },
1368
+ )
1369
+ start_api = mocker.patch('mn_cli.server_cmds._start_api_if_installed')
1370
+ mocker.patch('mn_cli.server_cmds._start_web_ui_if_installed', return_value=False)
1371
+ mocker.patch('mn_cli.server_cmds._write_runtime_endpoints_file', return_value={"api": {}})
1372
+ mocker.patch('mn_cli.server_cmds._print_service_endpoints')
1373
+
1374
+ _start_server()
1375
+
1376
+ start_api.assert_called_once()
1377
+ api_env = start_api.call_args.args[0]
1378
+ assert start_api.call_args.kwargs["restart_running"] is True
1379
+ assert start_api.call_args.kwargs["restart_reason"] == "runtime config changed"
1380
+ assert api_env["MN_BLUEPRINT_REPO"] == "https://github.com/MirrorNeuronLab/otterdesk-blueprints"
1381
+ assert api_env["MN_RUNS_ROOT"] == "/tmp/otterdesk-runs"
1382
+ compose_text = compose_env.read_text()
1383
+ assert "MN_BLUEPRINT_REPO=https://github.com/MirrorNeuronLab/otterdesk-blueprints" in compose_text
1384
+ assert "MN_RUNS_ROOT=/tmp/otterdesk-runs" in compose_text
1385
+
1269
1386
  def test_start_server_join_compose_imports_primary_grpc_tokens(mocker, tmp_path):
1270
1387
  compose_file = server_cmds.RUNTIME_COMPOSE_FILE
1271
1388
  compose_env = server_cmds.RUNTIME_COMPOSE_ENV
@@ -2209,6 +2326,10 @@ def test_start_server_passes_slack_env_to_docker(mocker, tmp_path, monkeypatch):
2209
2326
  _start_server()
2210
2327
 
2211
2328
  docker_run = next(cmd for cmd in commands if cmd[:3] == ["docker", "run", "-d"])
2329
+ image_index = docker_run.index("mirror-neuron-core:latest")
2330
+ assert docker_run[image_index + 1 : image_index + 3] == ["sh", "-c"]
2331
+ assert "epmd_bin=" in docker_run[image_index + 3]
2332
+ assert "RELEASE_DISTRIBUTION=name" in docker_run[image_index + 3]
2212
2333
  cookie_env = next(value for flag, value in zip(docker_run, docker_run[1:]) if flag == "-e" and value.startswith("MN_COOKIE="))
2213
2334
  auth_env = next(value for flag, value in zip(docker_run, docker_run[1:]) if flag == "-e" and value.startswith("MN_GRPC_AUTH_TOKEN="))
2214
2335
  auth_file_env = next(
@@ -2244,6 +2365,48 @@ def test_start_server_passes_slack_env_to_docker(mocker, tmp_path, monkeypatch):
2244
2365
  docker_run.index("SLACK_DEFAULT_CHANNEL") - 1 : docker_run.index("SLACK_DEFAULT_CHANNEL") + 1
2245
2366
  ]
2246
2367
 
2368
+ def test_start_server_mounts_docker_worker_socket_and_linux_cli(mocker, tmp_path, monkeypatch):
2369
+ mocker.patch('mn_cli.server_cmds.API_PID_FILE', tmp_path / "api.pid")
2370
+ mocker.patch('mn_cli.server_cmds.WEB_UI_DIRS', ())
2371
+ docker_socket = tmp_path / "docker.sock"
2372
+ docker_socket.touch()
2373
+ host_docker = tmp_path / "docker"
2374
+ host_docker.write_text("#!/bin/sh\n", encoding="utf-8")
2375
+ mocker.patch('mn_cli.server_cmds._docker_host_socket', return_value=docker_socket)
2376
+ mocker.patch('mn_cli.server_cmds.shutil.which', return_value=str(host_docker))
2377
+ monkeypatch.setattr(sys, "platform", "linux")
2378
+
2379
+ commands = []
2380
+
2381
+ def mock_run(cmd, **kwargs):
2382
+ commands.append(cmd)
2383
+ m = mocker.Mock()
2384
+ m.stdout = "false\n"
2385
+ return m
2386
+
2387
+ mocker.patch('mn_cli.server_cmds.subprocess.run', side_effect=mock_run)
2388
+ mocker.patch('mn_cli.server_cmds.time.sleep')
2389
+ mocker.patch('mn_cli.server_cmds.PID_DIR', tmp_path / ".pids")
2390
+ mocker.patch('mn_cli.server_cmds.LOG_DIR', tmp_path / ".logs")
2391
+ mocker.patch('mn_cli.server_cmds.BEAM_LOG', tmp_path / "beam.log")
2392
+ mocker.patch('mn_cli.server_cmds.API_LOG', tmp_path / "api.log")
2393
+ mocker.patch('mn_cli.server_cmds.VENV_DIR', tmp_path)
2394
+
2395
+ class UnameMock:
2396
+ sysname = "Linux"
2397
+
2398
+ mocker.patch('mn_cli.server_cmds.os.uname', return_value=UnameMock())
2399
+
2400
+ _start_server()
2401
+
2402
+ docker_run = next(cmd for cmd in commands if cmd[:3] == ["docker", "run", "-d"])
2403
+ assert ["-v", f"{docker_socket}:/var/run/docker.sock:rw"] == docker_run[
2404
+ docker_run.index(f"{docker_socket}:/var/run/docker.sock:rw") - 1 : docker_run.index(f"{docker_socket}:/var/run/docker.sock:rw") + 1
2405
+ ]
2406
+ assert ["-v", f"{host_docker}:/usr/local/bin/docker:ro"] == docker_run[
2407
+ docker_run.index(f"{host_docker}:/usr/local/bin/docker:ro") - 1 : docker_run.index(f"{host_docker}:/usr/local/bin/docker:ro") + 1
2408
+ ]
2409
+
2247
2410
  def test_detach_local_docker_node_stops_compose_core_for_local_alias(mocker, tmp_path):
2248
2411
  compose_file = tmp_path / "docker-compose.yml"
2249
2412
  compose_env = tmp_path / "docker-compose.env"
@@ -2378,6 +2541,43 @@ def test_start_api_restarts_untracked_healthy_instance_under_watchdog(mocker, tm
2378
2541
  call("localhost", "54001", timeout_seconds=10.0),
2379
2542
  ]
2380
2543
 
2544
+ def test_start_api_restarts_running_watchdog_when_requested(mocker, tmp_path):
2545
+ api_bin = tmp_path / "mn_venv" / "bin" / "mn-api"
2546
+ api_bin.parent.mkdir(parents=True)
2547
+ api_bin.write_text("#!/bin/sh\n")
2548
+ api_pid = tmp_path / "api.pid"
2549
+ watchdog_pid = tmp_path / "api-watchdog.pid"
2550
+ api_pid.write_text("1234")
2551
+ watchdog_pid.write_text("5678")
2552
+
2553
+ mocker.patch('mn_cli.server_cmds.VENV_DIR', tmp_path / "mn_venv")
2554
+ mocker.patch('mn_cli.server_cmds.API_PID_FILE', api_pid)
2555
+ mocker.patch('mn_cli.server_cmds.API_WATCHDOG_PID_FILE', watchdog_pid)
2556
+ mocker.patch('mn_cli.server_cmds.API_LOG', tmp_path / "api.log")
2557
+ mocker.patch('mn_cli.server_cmds.API_WATCHDOG_LOG', tmp_path / "api-watchdog.log")
2558
+ mocker.patch('mn_cli.server_cmds.os.kill') # check_status returns running
2559
+ mocker.patch('mn_cli.server_cmds.time.sleep')
2560
+ kill = mocker.patch('mn_cli.server_cmds.kill_tree')
2561
+ mock_wait = mocker.patch('mn_cli.server_cmds._wait_for_api', side_effect=[True, True, True])
2562
+ stop_matching = mocker.patch('mn_cli.server_cmds.stop_matching_sidecar_processes', return_value=False)
2563
+ mock_popen = mocker.patch('mn_cli.server_cmds.subprocess.Popen')
2564
+ mock_popen.return_value.pid = 54001
2565
+
2566
+ assert _start_api_if_installed(
2567
+ {"MN_API_HOST": "localhost", "MN_API_PORT": "54001"},
2568
+ restart_running=True,
2569
+ restart_reason="runtime config changed",
2570
+ ) is True
2571
+
2572
+ assert kill.call_args_list == [call(5678)]
2573
+ stop_matching.assert_called_once_with("mn-api", "REST API")
2574
+ mock_popen.assert_called_once()
2575
+ assert mock_wait.call_args_list == [
2576
+ call("localhost", "54001", timeout_seconds=5.0),
2577
+ call("localhost", "54001", timeout_seconds=1.0),
2578
+ call("localhost", "54001", timeout_seconds=10.0),
2579
+ ]
2580
+
2381
2581
  def test_start_web_ui_if_installed(mocker, tmp_path):
2382
2582
  web_ui_dir = tmp_path / "web-ui"
2383
2583
  (web_ui_dir / "dist").mkdir(parents=True)
@@ -221,6 +221,8 @@ def test_prepare_core_docker_context_copies_release_and_writes_dockerfile(tmp_pa
221
221
  assert copied_binary.read_text(encoding="utf-8") == "run\n"
222
222
  dockerfile = (context_dir / "Dockerfile").read_text(encoding="utf-8")
223
223
  assert "COPY mirror_neuron /opt/mirror_neuron" in dockerfile
224
+ assert "ARG DOCKER_CLI_VERSION=29.2.1" in dockerfile
225
+ assert "download.docker.com/linux/static/stable" in dockerfile
224
226
  assert 'CMD ["bin/mirror_neuron", "foreground"]' in dockerfile
225
227
 
226
228