#!/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 -…). ' '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()