183 lines
6.3 KiB
Python
Executable File
183 lines
6.3 KiB
Python
Executable File
"""Launch the MLflow dev backend and the React dev server for local development.
|
|
|
|
Cleans up child process groups on exit/SIGINT/SIGTERM so we don't leave zombies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import atexit
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import dev_stubs
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
JS_DIR = REPO_ROOT / "mlflow" / "server" / "js"
|
|
|
|
|
|
def find_free_port(preferred: int, avoid: frozenset[int] = frozenset()) -> int:
|
|
for port in range(preferred, preferred + 100):
|
|
if port in avoid:
|
|
continue
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
try:
|
|
sock.bind(("127.0.0.1", port))
|
|
except OSError:
|
|
continue
|
|
return port
|
|
raise SystemExit(f"No free port in [{preferred}, {preferred + 100})")
|
|
|
|
|
|
def cleanup(children: list[subprocess.Popen[bytes]], tmp_paths: list[Path]) -> None:
|
|
for proc in children:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
for proc in children:
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
for path in tmp_paths:
|
|
if path.is_dir():
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
else:
|
|
path.unlink(missing_ok=True)
|
|
|
|
|
|
def on_signal(signum: int, _frame: object) -> None:
|
|
sys.exit(128 + signum)
|
|
|
|
|
|
def start_backend(port: int) -> tuple[subprocess.Popen[bytes], list[Path]]:
|
|
backend_args: list[str] = []
|
|
tmp_paths: list[Path] = []
|
|
if tracking_uri := os.environ.get("MLFLOW_TRACKING_URI"):
|
|
backend_args += ["--backend-store-uri", tracking_uri, "--default-artifact-root", "mlruns"]
|
|
elif backend_uri := os.environ.get("MLFLOW_BACKEND_STORE_URI"):
|
|
backend_args += ["--backend-store-uri", backend_uri, "--default-artifact-root", "mlruns"]
|
|
else:
|
|
db_fd, db_path_str = tempfile.mkstemp(prefix="mlflow-dev-", suffix=".db")
|
|
os.close(db_fd)
|
|
db_path = Path(db_path_str)
|
|
artifacts_path = Path(tempfile.mkdtemp(prefix="mlflow-dev-artifacts-"))
|
|
tmp_paths += [db_path, artifacts_path]
|
|
backend_args += [
|
|
"--backend-store-uri",
|
|
f"sqlite:///{db_path}",
|
|
"--default-artifact-root",
|
|
str(artifacts_path),
|
|
]
|
|
print(f"Using tmp SQLite store: {db_path} (artifacts: {artifacts_path})")
|
|
if registry_uri := os.environ.get("MLFLOW_REGISTRY_URI"):
|
|
backend_args += ["--registry-store-uri", registry_uri]
|
|
|
|
cmd = [sys.executable, "-m", "mlflow", "server", *backend_args, "--dev", "--port", str(port)]
|
|
print(f"Running tracking server: {shlex.join(cmd)}")
|
|
proc = subprocess.Popen(cmd, cwd=REPO_ROOT, start_new_session=True)
|
|
wait_ready(f"http://localhost:{port}/health", "tracking server")
|
|
return proc, tmp_paths
|
|
|
|
|
|
def start_frontend(backend_port: int, frontend_port: int) -> subprocess.Popen[bytes]:
|
|
proc = subprocess.Popen(
|
|
["yarn", "start"],
|
|
cwd=JS_DIR,
|
|
env={
|
|
**os.environ,
|
|
"PORT": str(frontend_port),
|
|
"MLFLOW_PROXY": f"http://localhost:{backend_port}",
|
|
"MLFLOW_DEV_PROXY_MODE": "1",
|
|
"BROWSER": "none",
|
|
},
|
|
start_new_session=True,
|
|
)
|
|
wait_ready(f"http://localhost:{frontend_port}/", "React dev server", timeout=180)
|
|
return proc
|
|
|
|
|
|
def wait_ready(url: str, label: str, timeout: float = 60.0) -> None:
|
|
print(f"Waiting for {label} to be ready...")
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=2) as resp:
|
|
if 200 <= resp.status < 300:
|
|
print(f"{label} is ready")
|
|
return
|
|
except (urllib.error.URLError, ConnectionError, TimeoutError):
|
|
pass
|
|
time.sleep(2)
|
|
raise SystemExit(f"Failed to launch {label} (gave up after {timeout:.0f}s)")
|
|
|
|
|
|
def main() -> None:
|
|
# Line-buffer prints so progress shows up live when stdout is redirected to a file.
|
|
sys.stdout.reconfigure(line_buffering=True) # type: ignore[union-attr]
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--stub-providers",
|
|
default="",
|
|
help=(
|
|
"Comma-separated credential-free stubs to install before launch so "
|
|
"provider-gated UI renders without real keys (for CI review / local dev). "
|
|
f"Available: {', '.join(dev_stubs.AVAILABLE_STUBS)}."
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
stub_names = [s.strip() for s in args.stub_providers.split(",") if s.strip()]
|
|
|
|
subprocess.check_call(["yarn", "install"], cwd=JS_DIR)
|
|
|
|
backend_port = find_free_port(5000)
|
|
frontend_port = find_free_port(3000, avoid=frozenset({backend_port}))
|
|
print(f"Backend: http://localhost:{backend_port}")
|
|
print(f"Frontend: http://localhost:{frontend_port} (with hot reload)")
|
|
|
|
children: list[subprocess.Popen[bytes]] = []
|
|
tmp_paths: list[Path] = []
|
|
|
|
atexit.register(cleanup, children, tmp_paths)
|
|
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
signal.signal(sig, on_signal)
|
|
|
|
if stub_names:
|
|
# Install before the backend launches so PATH changes propagate to it, and
|
|
# register temp dirs with the same cleanup as the other children.
|
|
stubs = dev_stubs.install_stubs(stub_names)
|
|
dev_stubs.apply_to_environ(stubs)
|
|
tmp_paths.extend(stubs.cleanup_paths)
|
|
for message in stubs.messages:
|
|
print(message)
|
|
|
|
backend_proc, backend_tmp = start_backend(backend_port)
|
|
children.append(backend_proc)
|
|
tmp_paths.extend(backend_tmp)
|
|
children.append(start_frontend(backend_port, frontend_port))
|
|
|
|
# Block until any child exits; atexit reaps the rest.
|
|
while all(proc.poll() is None for proc in children):
|
|
time.sleep(1)
|
|
exited = next(p for p in children if p.poll() is not None)
|
|
print(f"Child process (pid {exited.pid}) exited with code {exited.returncode}")
|
|
sys.exit(exited.returncode or 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|