bootstrapper/stop.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
Atlas - Stop Script
Cross-platform stop script for Atlas — the self-hosted engineering platform.
"""
import os
import subprocess
import sys
from pathlib import Path
import click
# Add the current directory to the path so we can import our modules
sys.path.insert(0, str(Path(__file__).parent))
from utils.banner import BannerDisplay
from utils.hosts_manager import HostsManager
from utils.submodule_pin_guard import warn_if_submodule_pin_drifted
from core.config_parser import ConfigParser
from core.docker_manager import DockerManager
def _report_managed_host_advisory(
banner, manager, running_message: str, unknown_message: str
) -> None:
"""Report owned liveness or fail-closed unverified tracking evidence."""
from services import tracked_process_may_survive
try:
running = bool(manager.status().running)
except Exception: # noqa: BLE001 — advisory probes fail closed
running = False
if running:
banner.show_status_message(running_message, "info")
return
pid, may_survive = tracked_process_may_survive(manager)
if may_survive:
detail = f" (tracked pid {pid})" if pid is not None else ""
banner.show_status_message(f"{unknown_message}{detail}", "warning")
def _run_privileged_hosts_cleanup() -> bool:
"""Elevate only the hosts-file mutation, never the repository workflow."""
from utils.system import is_elevated
if is_elevated():
return HostsManager().cleanup_hosts_entries()
if os.name == "nt":
print(" • Please run from an Administrator shell to modify the hosts file")
return False
bootstrapper_dir = Path(__file__).resolve().parent
repo_root = bootstrapper_dir.parent
env = os.environ.copy()
existing_pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = (
str(bootstrapper_dir)
if not existing_pythonpath
else f"{bootstrapper_dir}{os.pathsep}{existing_pythonpath}"
)
env["PYTHONDONTWRITEBYTECODE"] = "1"
helper = (
"from utils.hosts_manager import HostsManager; "
"raise SystemExit(0 if HostsManager().cleanup_hosts_entries() else 1)"
)
print(" • --clean-hosts needs to edit your hosts file; requesting sudo for that write only.")
try:
result = subprocess.run(
[
"sudo",
"env",
f"PYTHONPATH={env['PYTHONPATH']}",
"PYTHONDONTWRITEBYTECODE=1",
sys.executable,
"-c",
helper,
],
cwd=repo_root,
env=env,
check=False,
)
except OSError as exc:
print(f" • Could not launch sudo for hosts cleanup: {exc}")
return False
return result.returncode == 0
class AtlasStopper:
"""Main class for stopping Atlas."""
def __init__(self):
# Set root directory first
self.root_dir = Path(__file__).resolve().parent.parent
# Initialize all managers with correct root_dir
self.banner = BannerDisplay()
self.hosts_manager = HostsManager()
self.config_parser = ConfigParser(str(self.root_dir))
self.docker_manager = DockerManager(str(self.root_dir))
def persist_project_name(self, project_name: str) -> bool:
"""Best-effort persist PROJECT_NAME to .env so this teardown — and the
next bare start/stop — target the same container family. No-op if .env
is absent (nothing to tear down anyway); the resolved name is still used
for this invocation."""
if not project_name or not self.config_parser.env_file_exists():
return True
if self.config_parser.stored_project_name_matches(project_name):
return True
from utils.source_override_manager import SourceOverrideManager
return SourceOverrideManager(self.config_parser).update_env_file(
{"PROJECT_NAME": project_name}
)
def validate_persisted_project_name(self, project_name_override: str = None) -> bool:
"""Validate the stored PROJECT_NAME before Docker or compose preflights."""
if project_name_override is not None or not self.config_parser.env_file_exists():
return True
try:
self.config_parser.get_project_name()
except ValueError as exc:
click.echo(
f"stop.sh: invalid PROJECT_NAME in {self.config_parser.env_file_path}: {exc}",
err=True,
)
return False
return True
def show_usage(self):
"""Display usage information."""
usage_text = """
Usage: ./stop.sh [options] (or: python bootstrapper/stop.py)
Options:
--project, -p Compose project to tear down (defaults to PROJECT_NAME / "atlas")
--consumer PATH atlas.consumer.yml the stack was started with (loads its overlays)
--cold Remove volumes (data will be lost)
--clean-hosts Remove Atlas hosts file entries (requires sudo/admin)
--stop-managed-hosts Also stop HOST-GLOBAL managed runtimes (ComfyUI-MPS / vLLM-Metal / Blender MCP)
--help-usage Show this detailed usage message
--help Show the option summary
Managed host runtimes:
Apple-Silicon/Metal ComfyUI-MPS, vLLM-Metal, and Blender MCP run as native HOST-GLOBAL
processes on fixed loopback ports, shared by every Atlas consumer on this
machine. A project-scoped stop leaves them running by default (with an
advisory) so it can't interrupt another consumer. Pass --stop-managed-hosts
to tear them down explicitly — this affects ALL consumers using them.
Examples:
./stop.sh # Stop this project's containers, preserve data + managed hosts
./stop.sh --cold # Stop this project and remove its containers, orphans, and named volumes
./stop.sh --clean-hosts # Stop containers and clean up hosts file
./stop.sh --stop-managed-hosts # Also stop ComfyUI-MPS / vLLM-Metal / Blender MCP
"""
print(usage_text)
def show_configuration_info(self, cold_stop: bool, clean_hosts: bool,
project_name_override: str = None):
"""Display environment configuration information.
``project_name_override`` (from --project / -p) wins over the .env value
so the teardown targets exactly the requested container family.
"""
self.banner.show_section_header("Environment Configuration", "📋")
# Check .env file
if self.config_parser.env_file_exists():
timestamp = self.config_parser.get_env_file_timestamp()
self.banner.show_status_message(f"Found .env file with timestamp: {timestamp}", "info")
# Get project name
project_name = project_name_override or self.config_parser.get_project_name()
self.banner.show_status_message(f"Project name: {project_name}", "info")
else:
self.banner.show_status_message(".env file not found. Using default configuration.", "warning")
project_name = project_name_override or self.config_parser.get_project_name()
# Show Docker compose command
compose_cmd = self.docker_manager.get_compose_command_display()
self.banner.show_status_message(f"Using Docker Compose command: {compose_cmd}", "info")
# Show stop options
if cold_stop:
self.banner.show_status_message("Cold Stop: Yes (removing project volumes)", "warning")
if clean_hosts:
self.banner.show_status_message("Clean Hosts: Yes (will remove hosts file entries)", "info")
return project_name
def ensure_dependencies_available(self) -> bool:
"""Validate Docker Compose before running commands that read include:."""
if not self.docker_manager.check_docker_available():
self.banner.show_status_message(
"Docker is not available. Please start Docker Desktop or install Docker.",
"error",
)
return False
compose_ok, compose_msg = self.docker_manager.check_compose_version()
level = "info" if compose_ok else "error"
self.banner.show_status_message(compose_msg, level)
return compose_ok
def stop_services(self, cold_stop: bool, project_name: str) -> bool:
"""Stop Docker services."""
self.banner.show_section_header("Stopping Docker Compose Services", "🐳")
previous_project = self.docker_manager.project_name_override
self.docker_manager.project_name_override = project_name
try:
if cold_stop:
self.banner.show_status_message("Performing cold stop (removing project volumes)...", "warning")
self.banner.console.print("⚠️ WARNING: This will permanently delete all data!", style="bold red")
print()
# Use the project-scoped cold stop cleanup from Docker manager.
success = self.docker_manager.perform_cold_stop_cleanup()
if success:
self.banner.show_status_message("Cold stop completed successfully - all containers stopped and data removed", "success")
else:
self.banner.show_status_message("Some issues occurred during cold stop", "warning")
if getattr(self.docker_manager, "teardown_overlays_dropped", False):
self.banner.show_status_message(
"Consumer overlays were not applied: volumes they declare "
"were NOT removed. Fix the overlay and rerun ./stop.sh --cold.",
"warning",
)
return success
else:
self.banner.show_status_message("Performing standard stop (preserving volumes)...", "info")
result = self.docker_manager.stop_services(remove_volumes=False, remove_orphans=True)
if result == 0:
self.banner.show_status_message("All containers stopped successfully - data volumes preserved", "success")
return True
else:
self.banner.show_status_message("Some issues occurred while stopping containers", "warning")
return False
finally:
self.docker_manager.project_name_override = previous_project
def stop_managed_comfyui_mps(self) -> bool:
"""Stop the Atlas-managed Apple-Silicon/Metal ComfyUI host process (#335).
The ``managed-localhost-mps`` source runs a native ComfyUI process on the
host (not a container), so ``docker compose down`` never touches it — Atlas
must tear it down explicitly. A no-op when no process is running. The
current SOURCE value is intentionally ignored: a prior launch may still
own a process after the selection changed.
"""
try:
env = (
self.config_parser.parse_env_file()
if self.config_parser.env_file_exists()
else {}
)
from services.comfyui_mps_manager import manager_from_env
manager = manager_from_env(env)
before = manager.status()
stopped = manager.stop()
after = manager.status()
if after.running or (
not stopped and getattr(manager, "pid_file", None) is not None
and manager.pid_file.exists()
):
self.banner.show_status_message(
"Managed ComfyUI (MPS) host is still running after stop.",
"warning",
)
return False
if before.running and stopped:
self.banner.show_status_message(
"Stopped the managed Apple-Silicon/Metal ComfyUI host process.",
"info",
)
return True
except Exception as exc: # noqa: BLE001 — teardown must never break stop
self.banner.show_status_message(
f"Could not stop the managed ComfyUI (MPS) host: {exc}", "warning"
)
return False
def stop_managed_blender_mcp(self) -> bool:
"""Stop the Atlas-managed headless Blender MCP bridge (#759).
The ``managed-localhost`` source runs a native headless Blender on the
host (not a container), so ``docker compose down`` never touches it —
and it serves an execute_code TCP socket, so it must not silently
outlive an explicit managed-hosts teardown. A no-op when not running.
The current SOURCE value is intentionally ignored: a prior launch may
still own a process after the selection changed.
"""
try:
env = (
self.config_parser.parse_env_file()
if self.config_parser.env_file_exists()
else {}
)
from services.blender_mcp_manager import pool_from_env
# Every pool instance (#851), including any a smaller pool left behind.
results = [self._stop_blender_mcp_instance(m) for m in pool_from_env(env, include_strays=True)]
return all(results) # every instance is tried, whatever an earlier one did
except Exception as exc: # noqa: BLE001 — teardown must never break stop
self.banner.show_status_message(
f"Could not stop the managed Blender MCP bridge: {exc}", "warning"
)
return False
def _stop_blender_mcp_instance(self, manager) -> bool:
index = getattr(manager, "pool_index", 0)
label = f"Managed Blender MCP bridge #{index}" if index else "Managed Blender MCP bridge"
try:
before = manager.status()
stopped = manager.stop()
after = manager.status()
except Exception as exc: # noqa: BLE001 — the other instances still stop
self.banner.show_status_message(f"Could not stop the {label}: {exc}", "warning")
return False
if after.running or (
not stopped and getattr(manager, "pid_file", None) is not None
and manager.pid_file.exists()
):
self.banner.show_status_message(f"{label} is still running after stop.", "warning")
return False
if before.running and stopped:
self.banner.show_status_message(
"Stopped the managed headless Blender MCP bridge"
+ (f" #{index}." if index else "."),
"info",
)
return True
def stop_managed_vllm_metal(self) -> bool:
"""Stop the Atlas-managed vLLM Metal host process (#379).
The ``managed-localhost`` source runs a native vLLM process on the host
(not a container), so ``docker compose down`` never touches it — Atlas
must tear it down explicitly. A no-op when no process is running. The
current SOURCE value is intentionally ignored: a prior launch may still
own a process after the selection changed.
"""
try:
env = (
self.config_parser.parse_env_file()
if self.config_parser.env_file_exists()
else {}
)
from services.vllm_metal_manager import manager_from_env
manager = manager_from_env(env)
before = manager.status()
stopped = manager.stop()
after = manager.status()
if after.running or (
not stopped and getattr(manager, "pid_file", None) is not None
and manager.pid_file.exists()
):
self.banner.show_status_message(
"Managed vLLM (Metal) host is still running after stop.",
"warning",
)
return False
if before.running and stopped:
self.banner.show_status_message(
"Stopped the managed Apple-Silicon/Metal vLLM host process.",
"info",
)
return True
except Exception as exc: # noqa: BLE001 — teardown must never break stop
self.banner.show_status_message(
f"Could not stop the managed vLLM (Metal) host: {exc}", "warning"
)
return False
def _report_blender_mcp_left_running(self, env: dict) -> None:
"""The Blender MCP part of the advisory, once per pool instance (#851)."""
try:
from services.blender_mcp_manager import pool_from_env
managers = pool_from_env(env, include_strays=True)
except Exception: # noqa: BLE001 — advisory only
return
for manager in managers:
try:
_report_managed_host_advisory(
self.banner,
manager,
"Managed Blender MCP bridge left running (host-global, shared "
"across consumers; serves execute_code on loopback). Stop it "
"explicitly with `./stop.sh --stop-managed-hosts` or "
"`./start.sh blender-mcp stop`.",
"Managed Blender MCP tracking evidence remains, but process "
"ownership is unknown and the bridge may still be running. "
"Inspect it or use `./stop.sh --stop-managed-hosts`; Atlas will "
"not silently adopt or signal an unverified process.",
)
except Exception: # noqa: BLE001 — advisory only
pass
def report_managed_hosts_left_running(self) -> None:
"""Advisory-only counterpart to the managed-host stop methods (#655).
Managed ComfyUI-MPS, vLLM-Metal, and Blender MCP runtimes are host-global singletons on
a fixed loopback port, shared by every Atlas consumer on the machine. A
project-scoped ``./stop.sh`` must NOT terminate it just because *this*
project stopped — another consumer may still be using it. When one is
detected running, point the operator at the explicit opt-in. Never stops
anything; status probes are read-only.
"""
env = (
self.config_parser.parse_env_file()
if self.config_parser.env_file_exists()
else {}
)
self._report_blender_mcp_left_running(env)
try:
from services.comfyui_mps_manager import manager_from_env
_report_managed_host_advisory(
self.banner,
manager_from_env(env),
"Managed ComfyUI (MPS) host left running (host-global, shared "
"across consumers). Run ./stop.sh --stop-managed-hosts to stop it.",
"Managed ComfyUI (MPS) tracking evidence remains, but process "
"ownership is unknown and the host may still be running. Inspect "
"it or use ./stop.sh --stop-managed-hosts.",
)
except Exception: # noqa: BLE001 — advisory must never break stop
pass
try:
from services.vllm_metal_manager import manager_from_env
_report_managed_host_advisory(
self.banner,
manager_from_env(env),
"Managed vLLM (Metal) host left running (host-global, shared "
"across consumers). Run ./stop.sh --stop-managed-hosts to stop it.",
"Managed vLLM (Metal) tracking evidence remains, but process "
"ownership is unknown and the host may still be running. Inspect "
"it or use ./stop.sh --stop-managed-hosts.",
)
except Exception: # noqa: BLE001 — advisory must never break stop
pass
def cleanup_hosts_entries(self) -> bool:
"""Clean up hosts file entries if requested."""
self.banner.show_section_header("Cleaning Up Hosts File", "🧹")
return _run_privileged_hosts_cleanup()
def show_final_status(self, cold_stop: bool, clean_hosts: bool, services_ok: bool = True, hosts_ok: bool = True, managed_hosts_ok: bool = True):
"""Display final stop status and next steps."""
print()
self.banner.console.print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", style="bright_white")
if not services_ok or not hosts_ok or not managed_hosts_ok:
self.banner.console.print("⚠️ Atlas stop completed with errors — see messages above", style="bold bright_yellow")
elif cold_stop:
self.banner.console.print("🎯 Atlas stopped with complete data cleanup", style="bold bright_green")
self.banner.console.print(" ✅ All containers stopped and removed")
self.banner.console.print(" ✅ All data volumes removed")
self.banner.console.print(" ✅ Project orphans and default network removed")
# Cold stop deletes data, not configuration (#1032).
self.banner.console.print(" ✅ Configuration (.env) kept")
else:
self.banner.console.print("🎯 Atlas stopped successfully", style="bold bright_green")
self.banner.console.print(" ✅ All containers stopped and removed")
self.banner.console.print(" ✅ Data volumes preserved")
self.banner.console.print(" ✅ Configuration (.env) kept")
if clean_hosts:
if hosts_ok:
self.banner.console.print(" ✅ Hosts file entries cleaned up")
else:
self.banner.console.print(
" ⚠️ Hosts file cleanup FAILED — re-run "
"./stop.sh --clean-hosts from an interactive terminal that can approve sudo",
style="bold bright_yellow",
)
self.banner.console.print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", style="bright_white")
print()
# Show restart instructions
self.banner.console.print("🔄 To restart the stack, run:", style="bold bright_white")
self.banner.console.print(" ./start.sh # Start with default settings")
self.banner.console.print(" ./start.sh --base-port 64567 # Start with custom base port")
# No `./start.sh --cold` advice after a cold stop: the stop kept
# .env, and a cold start would rebuild it from .env.example
# (BASE_PORT, sources and typed keys reset). A plain start
# re-initialises the removed volumes with the kept credentials.
print()
self.banner.console.print("📚 For more information, check the README.md file", style="bright_white")
def _persist_project_override(stopper: AtlasStopper, project_name: str) -> None:
"""Best-effort persistence; the current teardown still uses the override."""
try:
if not stopper.persist_project_name(project_name):
click.echo(
"stop.sh: warning: could not persist PROJECT_NAME; "
"this teardown still uses the requested override",
err=True,
)
except Exception as exc:
click.echo(
f"stop.sh: warning: could not persist PROJECT_NAME: {exc}", err=True
)
def _select_cli_consumers(paths) -> None:
"""Scope the lossless path companion to this CLI invocation."""
from core.consumer_manifest import consumer_manifest_cli_paths
click.get_current_context().with_resource(
consumer_manifest_cli_paths(paths, restore_primary=False)
)
@click.command()
@click.option('--project', '-p', 'project_name', type=str, default=None,
help='Docker Compose project name — the container family to tear '
'down (every container/volume/network is prefixed <name>-…). '
'Defaults to PROJECT_NAME in .env (or "atlas"), so a bare '
'./stop.sh tears down exactly what ./start.sh launched. Pass '
'this (or set PROJECT_NAME in .env) to stop a specific stack '
'when running Atlas as a submodule; it persists to .env.')
@click.option('--consumer', 'consumer_manifests', multiple=True,
type=click.Path(exists=False),
help='atlas.consumer.yml the stack was started with (repeatable). '
'Its compose overlays are loaded so --cold also removes the '
'volumes they declare. Relative paths resolve from the '
'directory that invoked stop.sh, as in start.sh.')
@click.option('--cold', is_flag=True, help='Remove volumes (data will be lost)')
@click.option('--clean-hosts', is_flag=True, help='Remove Atlas hosts file entries (requires sudo/admin)')
@click.option('--stop-managed-hosts', is_flag=True,
help='Also stop host-global managed runtimes (Apple-Silicon/Metal '
'ComfyUI-MPS, vLLM-Metal, and Blender MCP). These are HOST-GLOBAL processes '
'shared by every consumer on this machine, so a project-scoped '
'stop leaves them running by default; pass this to tear them '
'down explicitly (affects ALL consumers using them).')
@click.option('--help-usage', is_flag=True, help='Show detailed usage information')
def main(project_name, consumer_manifests, cold, clean_hosts, stop_managed_hosts, help_usage):
"""Stop Atlas — the self-hosted engineering platform."""
if consumer_manifests:
# Same channel start.py uses: the compose seam reads the manifests
# (and their overlays) from ATLAS_CONSUMER_MANIFEST.
_select_cli_consumers(consumer_manifests)
stopper = AtlasStopper()
if help_usage:
stopper.show_usage()
return
# ─── Project name (-p / --project) ───────────────────────────────
# Validate fail-fast, persist to .env (so the next bare start/stop agrees),
# and use it as the authoritative teardown target for THIS run.
if project_name is not None:
from core.config_parser import normalize_project_name
try:
project_name = normalize_project_name(project_name)
except ValueError as exc:
click.echo(f"stop.sh: {exc}", err=True)
raise SystemExit(2)
_persist_project_override(stopper, project_name)
# Show initial message
stopper.banner.show_status_message("Stopping Atlas...", "info")
print()
try:
if not stopper.validate_persisted_project_name(project_name):
sys.exit(2)
# Step 1: Show configuration information
project_name = stopper.show_configuration_info(cold, clean_hosts,
project_name_override=project_name)
dependencies_ok = stopper.ensure_dependencies_available()
# Step 2: Stop Docker services when the Docker/Compose preflight is
# available. Keep going after a preflight or down failure so native
# hosts and optional hosts-file cleanup are not stranded, but retain a
# nonzero final status for the container teardown failure.
services_ok = (
stopper.stop_services(cold, project_name) if dependencies_ok else False
)
# Step 2b/2c: Managed host runtimes (#655). ComfyUI-MPS (#335),
# vLLM-Metal (#379), and Blender MCP (#759) are native host processes —
# compose `down` never touches them — but they are HOST-GLOBAL
# singletons shared by every consumer on this machine, not
# Compose-project resources. A project-scoped stop must NOT terminate
# one just because THIS project stopped: another consumer may still be
# using it (and SOURCE can't prove ownership — `.env` is mutable and the
# state dir is shared). Default leaves them running with an advisory;
# --stop-managed-hosts is the explicit, host-global-impact opt-in.
# Standard and --cold behave identically here.
if stop_managed_hosts:
stopper.banner.show_status_message(
"Stopping HOST-GLOBAL managed runtimes (--stop-managed-hosts) — "
"this affects every consumer sharing them on this host.",
"warning",
)
comfyui_host_ok = stopper.stop_managed_comfyui_mps()
vllm_host_ok = stopper.stop_managed_vllm_metal()
blender_host_ok = stopper.stop_managed_blender_mcp()
managed_hosts_ok = comfyui_host_ok and vllm_host_ok and blender_host_ok
else:
stopper.report_managed_hosts_left_running()
managed_hosts_ok = True
# Step 3: Clean up hosts entries if requested
hosts_ok = True
if clean_hosts:
# Don't exit on hosts cleanup failure — but DO tell the truth
# about it in the final banner instead of a blanket ✅.
hosts_ok = stopper.cleanup_hosts_entries()
# #797: read-only pin-drift tripwire (mirrors start.py). After any
# stop, surface a consumer submodule pin drift loudly instead of
# silently — the launcher never moves/stages the pin, only warns.
warn_if_submodule_pin_drifted(stopper.root_dir)
# Step 4: Show final status
stopper.show_final_status(
cold, clean_hosts, services_ok=services_ok, hosts_ok=hosts_ok,
managed_hosts_ok=managed_hosts_ok,
)
if not services_ok or not hosts_ok or not managed_hosts_ok:
sys.exit(1)
except KeyboardInterrupt:
print("\n❌ Stop process interrupted by user")
sys.exit(1)
except Exception as e:
print(f"\n❌ Unexpected error during stop: {e}")
sys.exit(1)
if __name__ == "__main__":
main()