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.
- {mirrorneuron_cli-1.2.5/mirrorneuron_cli.egg-info → mirrorneuron_cli-1.2.6}/PKG-INFO +1 -1
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6/mirrorneuron_cli.egg-info}/PKG-INFO +1 -1
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/server_cmds.py +232 -15
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/update_cmds.py +15 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_server_cmds.py +206 -6
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_update_cmds.py +2 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.github/workflows/ci.yml +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.github/workflows/release.yml +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.gitignore +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/.python-version +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/AGENTS.md +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/LICENSE +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/README.md +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/RELEASE.md +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/requires.txt +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/__init__.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/banner.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/config.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/error_handler.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/__init__.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/artifacts.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/backup_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_models.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_observability.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_repository.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/blueprint_resources.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/bundles.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/deployment_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/event_relay.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/job_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/model_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/resource_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/run_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/run_logs.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/run_manifest.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/runtime_health.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/schedule_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/service_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/skill_runtime.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/sys_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/ui.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/workflow_progress.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/libs/workflow_validation.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/logging_config.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/main.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/runtime_mode.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/runtime_state.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/schemas/workflow_manifest.schema.json +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/sdk_path.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/shared.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/terminal.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/pyproject.toml +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/scripts/check-release-artifacts.sh +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/scripts/make-release-zip.sh +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/scripts/validate-version-tag.sh +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/setup.cfg +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/conftest.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_backup_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_blueprint_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_blueprint_repository.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_blueprint_resources.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_deployment_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_docker_network_integration.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_job_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_main.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_model_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_resource_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_run_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_run_helpers.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_runtime_health.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_runtime_mode.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_runtime_state.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_schedule_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_service_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_shared.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_sys_cmds.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_terminal.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_ui.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/tests/test_workflow_validation.py +0 -0
- {mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/uv.lock +0 -0
|
@@ -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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mirrorneuron_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mirrorneuron_cli-1.2.5 → mirrorneuron_cli-1.2.6}/mn_cli/schemas/workflow_manifest.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|