#!/usr/bin/env python3 """ Atlas - Start Script Python implementation of start.sh with full feature parity. Cross-platform startup script for Atlas — the self-hosted engineering platform. """ import importlib import re import sys import os import json import shlex import subprocess from datetime import date from pathlib import Path import click from typing import Dict, Optional from services.migrations.migration_v1 import ( apply as _apply_v1, needs_migration as _needs_v1, stamp_version as _stamp_v1, ) from services.migrations.migration_v2 import ( URL_VAR_TO_PORT_VAR as _V2_URL_TO_PORT, apply as _apply_v2, needs_migration as _needs_v2, stamp_version as _stamp_v2, ) from services.migrations.migration_v3 import ( apply as _apply_v3, needs_migration as _needs_v3, stamp_version as _stamp_v3, ) from services.migrations.migration_v4 import ( apply as _apply_v4, needs_migration as _needs_v4, stamp_version as _stamp_v4, ) from services.migrations.migration_v5 import ( MigrationV5Error, apply as _apply_v5, needs_migration as _needs_v5, stamp_version as _stamp_v5, ) from services.migrations.build_pin_refresh import refresh as _refresh_build_pins def _format_today() -> str: """Return today's date as ``YYYY-MM-DD`` for env-backfill markers. Factored to a tiny helper so it's trivial to monkey-patch in tests without freezing the system clock globally. """ return date.today().isoformat() def _write_private_text(path: Path, text: str) -> None: """Atomically replace *path* with an owner-readable text file.""" atomic_write_text(path, text, mode=0o600) def _read_env_backfill_text(path: Path) -> str: with path.open(encoding="utf-8", newline="") as handle: return handle.read() def _backfill_env_assignment(line: str, origin: str) -> tuple[str, str, str, bool] | None: """Keep the decoded value and its validated encoded RHS separate.""" assignment = decode_env_assignment(line, origin=origin) if assignment is None: return None key, value = assignment encoded = line.partition("=")[2].rstrip("\r\n") exported = re.match(r"^export[ \t]+", line.strip()) is not None return key, value, encoded, exported def _backfill_default_values(text: str, origin: str) -> dict[str, tuple[str, str]]: values = {} for index, line in enumerate(env_lines(text.removeprefix("\ufeff")), 1): item = _backfill_env_assignment(line, f"{origin}:{index}") if item is not None: key, value, encoded, _exported = item values[key] = value, encoded return values def _fill_env_backfill_blanks(text: str, replacements: dict[str, str], origin: str) -> str: out = [] for index, original in enumerate(env_lines(text, keepends=True)): bom = "\ufeff" if index == 0 and original.startswith("\ufeff") else "" line = original.removeprefix(bom) if bom else original item = _backfill_env_assignment(line, f"{origin}:{index + 1}") if item is not None: key, value, _encoded, exported = item if key in replacements and value == "" and not exported: ending = line[len(line.rstrip("\r\n")):] out.append(f"{bom}{key}={replacements[key]}{ending}") continue out.append(original) return "".join(out) _ENV_MIGRATION_VERSION = 5 _MIGRATION_MARKER = "BOOTSTRAPPER_PORT_LAYOUT_VERSION" def _is_migration_marker_assignment(line: str) -> bool: """Use the reader's key normalization without decoding unrelated values.""" key, separator, _ = line.strip().partition("=") key = re.sub(r"^export[ \t]+", "", key).strip() return bool(separator) and key == _MIGRATION_MARKER def _duplicate_migration_markers(lines: list[str], env_path: Path) -> list[tuple[int, str]]: """Decode duplicate sentinel assignments with the canonical env grammar.""" indices = [index for index, line in enumerate(lines) if _is_migration_marker_assignment(line)] if len(indices) < 2: return [] return [ (index, decode_env_assignment(lines[index], origin=f"{env_path}:{index + 1}")[1]) for index in indices ] def _consolidated_migration_marker(lines: list[str], indices: set[int], version: int) -> str: """Retain original marker text as comments and append one active marker.""" text = "".join("# " + line if index in indices else line for index, line in enumerate(lines)) separator = "" if not text or text.endswith(("\r", "\n")) else "\n" return f"{text}{separator}{_MIGRATION_MARKER}={version}\n" def _prepare_migration_marker(env_path: Path) -> bool: """Reconcile duplicate last-wins metadata before the frozen migration chain. Return True only when an effective current/future duplicate marker makes the entire chain a no-op. A legacy repair keeps the effective old version. """ if not env_path.is_file(): return False try: original = env_path.read_bytes().decode("utf-8") bom = "\ufeff" if original.startswith("\ufeff") else "" lines = env_lines(original.removeprefix("\ufeff"), keepends=True) markers = _duplicate_migration_markers(lines, env_path) if not markers: return False effective = markers[-1][1].strip() or "0" if not re.fullmatch(r"[0-9]+", effective): raise ValueError("invalid duplicate migration version") version = int(effective) if version >= _ENV_MIGRATION_VERSION: return True create_private_backup(env_path, version="migration-marker") text = bom + _consolidated_migration_marker(lines, {index for index, _ in markers}, version) atomic_write_text(env_path, text, mode=0o600) except (OSError, ValueError): raise click.ClickException( f"{env_path}: {_MIGRATION_MARKER}: could not safely resolve duplicate migration metadata; " "check the marker assignments and file permissions." ) from None return False def _other_profile_binds(active: str, bundles: dict) -> set[str]: return { candidate.host_bind_ip for name, candidate in bundles.items() if name != active and candidate.host_bind_ip } def _profile_owned_bind( current_bind: str, switching: bool, other_binds: set[str], ) -> bool: return switching and current_bind in other_binds def _profile_host_bind_overrides( active: str, bundle, bundles: dict, env_vars: dict[str, str], ) -> tuple[dict[str, str], str, bool]: """Resolve profile-owned host binding without clobbering operator input.""" prior_applied = (env_vars.get("ATLAS_PROFILE_APPLIED", "") or "").strip() switching = prior_applied not in ("", active) other_binds = _other_profile_binds(active, bundles) declared_bind = bundle.host_bind_ip current_bind = env_vars.get("HOST_BIND_IP", "") if declared_bind: if not current_bind: return {"HOST_BIND_IP": declared_bind}, prior_applied, switching if _profile_owned_bind(current_bind, switching, other_binds): if current_bind != declared_bind: return {"HOST_BIND_IP": declared_bind}, prior_applied, switching elif _profile_owned_bind(current_bind, switching, other_binds): print( f"profile={active}: cleared HOST_BIND_IP (ports return to all-interfaces)" ) return {"HOST_BIND_IP": ""}, prior_applied, switching return {}, prior_applied, switching def _ollama_is_engine(env: Dict[str, str]) -> bool: return (env.get("LLM_PROVIDER_SOURCE") or "").strip().lower().startswith("ollama-") _DEFAULT_MODEL_KEYS = ( "LITELLM_DEFAULT_MODEL", "LITELLM_VISION_MODEL", "LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL", ) def _cloud_provider_of(model: str): """The cloud provider a model id belongs to (a ``/`` prefix or a catalog name), or None for anything else, e.g. a consumer model.""" from utils import llm_catalog # noqa: PLC0415 from utils.cloud_providers import CLOUD_PROVIDERS # noqa: PLC0415 for provider in CLOUD_PROVIDERS: names = {entry.name for entry in llm_catalog.cloud_entries(provider.key)} if model.startswith(f"{provider.key}/") or model in names: return provider return None def _unrouted_ollama_reason(env: Dict[str, str]) -> Optional[str]: """Why an unregistered ``ollama/*`` default will not be routed, or None when host auto-import may register tags this run cannot see.""" if not _ollama_is_engine(env): return "Ollama is not enabled" # The same test litellm-init uses before importing the host's tags. host_imports = (env.get("LLM_PROVIDER_SOURCE") or "").strip().lower().startswith( "ollama-localhost" ) and (env.get("OLLAMA_AUTO_IMPORT_LOCAL_MODELS") or "").strip().lower() in ( "true", "1", "yes", "on", "enabled") return None if host_imports else "not among the Ollama models this launch registers" def _unrouted_reason(env: Dict[str, str], model: str, routed: set) -> Optional[str]: """Why ``model`` will not be routed this launch, or None when it will be, or when that cannot be known here (a host auto-import or a consumer model): a default that may still be routed is never overwritten.""" if not model or model in routed: return None if model.startswith("ollama/"): return _unrouted_ollama_reason(env) provider = _cloud_provider_of(model) if provider is None: return None if (env.get(provider.source_var) or "").strip() != "enabled": return f"{provider.name} is not enabled" if not (env.get(provider.api_key_var) or "").strip(): # A missing key is a misconfiguration to fix, not a reason to move the # embedding pair (which would re-embed memory) for good. return None return f"not among the {provider.name} models this launch registers" def _routed_model_names(env: Dict[str, str]) -> set: """Model names litellm-init registers this launch: every active model (an Ollama one under both names) and the managed vLLM Metal model, often an `openai/...` Hugging Face id.""" from utils.model_resolver import active_models # noqa: PLC0415 active = active_models(env) routed = {entry.name for entry in active} routed |= {f"ollama/{entry.name}" for entry in active if entry.provider == "ollama"} if (env.get("VLLM_METAL_SOURCE") or "").strip().lower() == "managed-localhost": routed.add((env.get("VLLM_METAL_MODEL") or "").strip()) return routed def _preserve_keyless_cloud_embedding(env: Dict[str, str], routed: set) -> bool: """Keep an unrouted cloud memory contract until credentials are restored. Source normalization persists disabled providers, so this must also hold on later launches. Routed aliases can belong to a different upstream. """ for key in ("LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL"): model = (env.get(key) or "").strip() if not model or model in routed: continue provider = _cloud_provider_of(model) if provider is not None and not (env.get(provider.api_key_var) or "").strip(): return True return False def _stale_default_keys(env: Dict[str, str]) -> Dict[str, str]: """Default-model keys naming a model this launch will not route, with why (#1359).""" routed = _routed_model_names(env) reasons = {key: _unrouted_reason(env, (env.get(key) or "").strip(), routed) for key in _DEFAULT_MODEL_KEYS} if _preserve_keyless_cloud_embedding(env, routed): for key in ("LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL"): reasons.pop(key, None) return {key: reason for key, reason in reasons.items() if reason} def _embedding_replacement(env: Dict[str, str], stale: set) -> str: """The embedding model the stale ``ollama/*`` embedding pair moves to. A deliberate non-Ollama LangMem override wins, so the pair stays aligned (``apply_user_model_selections`` refuses a misaligned pair); otherwise the best active embedding model, or the already non-Ollama LiteLLM one. """ from utils.model_resolver import best # noqa: PLC0415 langmem = (env.get("LANGMEM_EMBEDDING_MODEL") or "").strip() if langmem and "LANGMEM_EMBEDDING_MODEL" not in stale: return langmem if "LITELLM_EMBEDDING_MODEL" not in stale: return (env.get("LITELLM_EMBEDDING_MODEL") or "").strip() return best("embeddings", env) or "" def _launch_model_env(env: Dict[str, str]) -> Dict[str, str]: """Project this launch's cloud sources into resolver activation flags.""" from utils.cloud_providers import CLOUD_PROVIDERS # noqa: PLC0415 # The derived flags are written later, during service configuration. # First launches and provider toggles must use this run's source values. return { **env, **{ p.enabled_flag_var: "true" if env.get(p.source_var) == "enabled" else "false" for p in CLOUD_PROVIDERS }, } def _default_model_replacements(env: Dict[str, str]) -> tuple[Dict[str, str], Dict[str, str]]: """(replacements, reason per stale key) for default models this launch will not route (see ``AtlasStarter.reconcile_default_models``). Blank resolutions are dropped: the end-of-run backfill would only restore the template's value in the same launch.""" from utils.model_resolver import resolved_defaults # noqa: PLC0415 # The LITELLM__ENABLED flags the resolver reads are written # later (generate_service_configuration); derive them from this run's # CLOUD_*_SOURCE values so a first launch, or a provider just toggled, # is judged by what this launch will route. env = _launch_model_env(env) reasons = _stale_default_keys(env) stale = set(reasons) updates = { key: value for key, value in resolved_defaults(env).items() if key in stale and value } if not stale & {"LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL"}: return updates, reasons embedding = _embedding_replacement(env, stale) if not embedding: print( "WARNING: the default embedding model will not be routed this " "launch, and no active provider offers an embedding model; memory " "and RAG embedding calls will fail until one is selected." ) return updates, reasons # Both keys are always passed so apply_user_model_selections recomputes # LANGMEM_EMBEDDING_DIM for the pair. updates["LITELLM_EMBEDDING_MODEL"] = embedding if (env.get("LANGMEM_EMBEDDING_MODEL") or "").strip(): updates["LANGMEM_EMBEDDING_MODEL"] = embedding return updates, reasons def _run_privileged_hosts_setup(non_interactive: bool = False) -> bool: """Run only the hosts-file mutation in a sudo child process. The shell wrapper intentionally refuses to run the whole startup flow as root because that creates root-owned repo artifacts. For `--setup-hosts`, ask for elevation only around the one operation that needs it. ``non_interactive`` uses ``sudo -n`` with captured output, for callers that cannot hand the terminal to a password prompt (the Textual wizard): it succeeds only when sudo needs no password. """ from utils.system import is_elevated if is_elevated(): return HostsManager().setup_hosts_entries() if os.name == "nt": print(" • ❌ Please run from an Administrator shell to modify 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().setup_hosts_entries() else 1)" ) if not non_interactive: print(" • --setup-hosts needs to edit your hosts file; requesting sudo for that write only.") try: result = subprocess.run( [ "sudo", *(["-n"] if non_interactive else []), "env", f"PYTHONPATH={env['PYTHONPATH']}", "PYTHONDONTWRITEBYTECODE=1", sys.executable, "-c", helper, ], cwd=repo_root, env=env, check=False, capture_output=non_interactive, ) except OSError as exc: # no sudo binary: hosts setup is never fatal print(f" • Could not launch sudo for hosts setup: {exc}") return False return result.returncode == 0 # Add the current directory to the path so we can import our modules sys.path.insert(0, str(Path(__file__).parent)) from utils.atomic_write import ( atomic_replace_text, atomic_write_text, create_private_backup, decode_env_assignment, env_lines, render_env_assignment, set_env_assignment, ) from utils.banner import BannerDisplay from utils.hosts_manager import HostsManager from utils.key_generator import KeyGenerator from core.env_snapshot import EnvSnapshot from core.linear_startup import LinearStartupOptions, json_cli_guard, run_linear_startup from core.launch_outcome import ( DETACH, ProbeSkipped, build_skip_rows, cancel_notice, launch_cancelled_notice, started_without, ) from utils.localhost_validator import LocalhostValidator from core.config_parser import ( ConfigParser, DEFAULT_BASE_PORT, DEFAULT_PROJECT_NAME, external_env_user_overlay_path, parse_env_overlay_file, ) from core.docker_manager import DockerManager from core.port_manager import PortManager from core.process_runner import ( _one_shot_row_verdict, run_with_deadline, compose_container_identity, validate_compose_status_rows, ) from utils.system import service_replica_targets from services.source_validator import SourceValidator from services.service_config import ServiceConfig from services.dependency_manager import DependencyManager from utils.source_override_manager import SourceOverrideManager from utils.source_availability import source_has_endpoint #: Lists (mostly wizard multi-selects) where BLANK is a deliberate answer — #: "I selected none" — not a missing value. `.env.example` ships a non-empty #: default for each, so the blank-backfill in backfill_missing_env_vars would #: otherwise re-seed them on the very run that cleared them: deselecting every #: Ollama model writes `OLLAMA_USER_MODELS=`, backfill restored the default #: trio, and `ollama-pull` then downloaded several GB the user had just #: declined. Backfill exists to repair values that were never set, so it must #: not overwrite an intentional empty. _USER_OWNED_BLANKABLE: frozenset = frozenset({ "OLLAMA_USER_MODELS", "OLLAMA_CUSTOM_MODELS", "OPENAI_USER_MODELS", "ANTHROPIC_USER_MODELS", "OPENROUTER_USER_MODELS", "COMFYUI_USER_MODELS", # NOT WEAVIATE_ENABLE_MODULES: nothing in the wizard writes it, so it can # only be blank by hand-edit or a legacy `.env` — precisely what backfill # exists to repair. Exempting it meant the blank survived into # `ServiceConfig`, where a present-but-empty key made `.get(key, default)` # return `''` and collapse the module list to nothing, durably. # # N8N_INIT_NODES stays for symmetry with the other multi-select lists, but # is belt-and-braces: `services/n8n/compose.yml` substitutes its defaults # with `${N8N_INIT_NODES:-...}`, so a blank there is already harmless. "N8N_INIT_NODES", # Blanked on purpose by ServiceConfig when MULTI2VEC_CLIP_SOURCE is # disabled; refilling it here contradicted that decision every launch. # ServiceConfig restores the URL itself when CLIP is enabled again (its # `... or 'http://multi2vec-clip:8080'` treats a blank as unset). "CLIP_INFERENCE_API", # Documented as "blank = no Atlas-created demo topics"; refilling it from # .env.example re-created atlas_stream_events on every launch. "REDPANDA_DEMO_TOPICS", }) def _env_example_values(env_example_path) -> dict[str, str]: """Shipped ``.env.example`` scalar defaults (``KEY=value`` lines).""" try: text = env_example_path.read_text(encoding="utf-8") except OSError: return {} values: dict[str, str] = {} for raw_line in text.splitlines(): line = raw_line.strip() if line and not line.startswith("#") and "=" in line: key, _, val = line.partition("=") values[key.strip()] = val.strip() return values # Consumer-manifest keys derived from plugins/sidecars (#451): the only ones # preflight refreshes once a start has persisted the stack's own values. _PREFLIGHT_DERIVED_KEYS = frozenset({ "BACKEND_PLUGINS_DIR", "COMFYUI_CUSTOM_MODELS_FILE", "COMFYUI_CUSTOM_NODES_FILE", "OLLAMA_CUSTOM_MODELS", "ATLAS_DERIVED_KEYS", }) def _with_stale_derived_keys_cleared( overrides: dict, env_vars: dict, consumer_config=None, protected=frozenset() ) -> dict: """Blank the derived keys a previous start wrote (per ATLAS_DERIVED_KEYS) whose plugin or sidecar is gone, e.g. a removed model_sidecars.ollama left OLLAMA_CUSTOM_MODELS pinning models no manifest declares (#1368). Only while a consumer manifest is configured: the manifest is passed on each run, so a plain `doctor` or start without it must not strip a running consumer stack. Keys in ``protected`` (just set by a .env.user overlay) are never blanked.""" from core.consumer_manifest import DERIVED_ENV_KEYS, DERIVED_KEYS_MARKER def keys(raw) -> set: return {item.strip() for item in str(raw or "").split(",") if item.strip()} if consumer_config is not None and not getattr(consumer_config, "consumers", None): return overrides previous = keys(env_vars.get(DERIVED_KEYS_MARKER)) & set(DERIVED_ENV_KEYS) current = keys(overrides.get(DERIVED_KEYS_MARKER)) if not previous - current: return overrides cleared = dict(overrides) for key in previous - current - set(overrides) - set(protected): cleared[key] = "" cleared.setdefault(DERIVED_KEYS_MARKER, "") return cleared def _should_record_profile(overrides, auto_vars, switching: bool, env_vars: dict) -> bool: """Record ATLAS_PROFILE_APPLIED when the profile changed anything, and always on the first start (even a no-op default profile): the marker is what tells doctor's preflight that this stack's values, including CLI overrides, are persisted and must not be re-merged.""" return bool(overrides or auto_vars or switching) or not _known_applied_profile(env_vars) def _preflight_declared(consumer_config, env_vars: dict) -> dict: """Manifest env a preflight may write. Once a start has persisted this stack's values, including CLI overrides (--base-port, -p, ---source) that must beat the manifest, re-merging the manifest silently undid them (doctor then pointed .env, ports and PROJECT_NAME at a different stack), so only the derived overlay paths (#451) and keys .env does not hold yet (newly added to the manifest) remain.""" declared = dict(consumer_config.env_overrides or {}) if not _known_applied_profile(env_vars): return declared return { key: value for key, value in declared.items() if key in _PREFLIGHT_DERIVED_KEYS or key not in env_vars } def _validate_consumer_manifests_early(starter, from_cli: bool) -> None: """Fail on a bad --consumer / ATLAS_CONSUMER_MANIFEST manifest as a usage error before anything mutates the checkout (not a traceback mid-setup).""" if not from_cli and not os.environ.get("ATLAS_CONSUMER_MANIFEST", "").strip(): return try: starter.config_parser.load_consumer_config() except Exception as exc: # ConsumerManifestError + I/O failures source = "--consumer" if from_cli else "ATLAS_CONSUMER_MANIFEST" raise click.UsageError(f"invalid {source} manifest: {exc}") from exc def _published_port_label(port_var: str, env_vars: dict) -> str: """":", or "-" for a reserved slot no compose file publishes (pg-meta, Supabase Studio): advertising it read as reachable.""" from services.topology import unpublished_port_vars if port_var in unpublished_port_vars(): return "-" return f":{env_vars.get(port_var, '?')}" def _ascii_int(raw: str): """ASCII digits as an int, else None (`"²".isdigit()` is true).""" raw = (raw or "").strip() return int(raw) if raw.isascii() and raw.isdigit() else None def _doctor_active_profile(starter, env: dict, declared) -> str: """The profile the stack runs: this run's --profile, else the last applied one, else the consumer `profile:`. Reading only `profile:` reported `default` after `./start.sh --profile prod`.""" from services.profiles import canonical_profile return canonical_profile(getattr(starter, "profile", None) or _known_applied_profile(env) or declared) def _known_applied_profile(env_vars: dict) -> str: """``ATLAS_PROFILE_APPLIED`` canonicalized, or "" when blank/unknown.""" from services.profiles import canonical_profile, is_known_profile applied = (env_vars.get("ATLAS_PROFILE_APPLIED") or "").strip() return canonical_profile(applied) if applied and is_known_profile(applied) else "" def _manifest_source_vars(consumer_config) -> set[str]: """Non-empty ``*_SOURCE`` keys a consumer manifest declares (env.values or env.file).""" declared = getattr(consumer_config, "env_overrides", None) or {} return {var for var, value in declared.items() if var.endswith("_SOURCE") and value} def _env_user_source_vars(starter) -> set[str]: """``*_SOURCE`` keys this run's ``.env.user`` / ATLAS_ENV_USER_FILE set.""" return {key for key in getattr(starter, "_env_user_keys", None) or () if key.endswith("_SOURCE")} def _declared_sources(values: dict) -> dict: """The ``*_SOURCE`` entries of a consumer manifest's env, which the auto port probe must see before they reach .env (#1391).""" return {key: value for key, value in values.items() if key.endswith("_SOURCE") and value} #: The ConsumerRecord field each plugin/sidecar-derived env key comes from. _DERIVED_KEY_FIELDS = ( ("BACKEND_PLUGINS_DIR", "backend_plugins"), ("COMFYUI_CUSTOM_MODELS_FILE", "comfyui_sidecars"), ("COMFYUI_CUSTOM_NODES_FILE", "comfyui_custom_node_files"), ("OLLAMA_CUSTOM_MODELS", "ollama_models"), ) def _derived_env_origins(consumer_config) -> Dict[str, str]: """The manifest that derives each plugin/sidecar key (#1057 origins). ``env_origins`` covers only ``env.values`` and ``env.file`` keys, so a derived key looked like a ``.env`` value. The last manifest that declares the field wins.""" return { key: str(record.manifest_path) for record in consumer_config.consumers for key, attribute in _DERIVED_KEY_FIELDS if getattr(record, attribute) } def _cli_source_overrides(params: dict) -> dict: """``---source`` values given this run, keyed by env var.""" return {name.upper(): value for name, value in params.items() if name.endswith("_source") and value is not None} def _operator_env_keys(starter, consumer_config) -> set[str]: """Keys an operator pinned this run: ``.env.user`` overlay + manifest env.""" keys = set(getattr(starter, "_env_user_keys", None) or ()) return keys | set(getattr(consumer_config, "env_overrides", None) or {}) def _replaceable_env(maps: list[dict], protected: set[str]) -> list[dict]: """Drop operator-pinned keys so a profile never treats them as defaults.""" return [{k: v for k, v in m.items() if k not in protected} for m in maps] def _prior_profile_env(bundles: dict, prior_applied: str, switching: bool) -> dict: """The env the prior profile wrote, when this run switches profiles.""" prior = bundles.get(prior_applied) if switching else None return prior.env if prior is not None else {} def _profile_env_overrides( active: str, declared: dict, env_vars: dict, replaceable: list[dict], ) -> dict[str, str]: """A profile's ``env`` values for .env, keeping operator-set ones. A value is operator-set only when it differs from every ``replaceable`` map (the shipped ``.env.example`` defaults, the prior profile's env): every profile-managed knob ships a non-empty default, so "unset or empty" alone never let a profile (or a consumer ``profile_overrides`` env) apply. """ overrides: dict[str, str] = {} for env_key, declared_value in declared.items(): current = env_vars.get(env_key) if current == declared_value: continue if not current or any(current == m.get(env_key) for m in replaceable): overrides[env_key] = declared_value if current: print( f"profile={active}: set {env_key}={declared_value!r} " f"(was the shipped or prior-profile default {current!r})" ) else: print( f"profile={active}: keeping operator-set {env_key}={current!r} " f"(profile default is {declared_value!r})" ) return overrides def _catalog_embedding_dim_repair(env: dict) -> dict[str, str]: """LANGMEM_EMBEDDING_DIM when a catalog embedding model's declared dim differs from .env; the contract rejects that pair, so it only arises from a corrected catalog entry. Custom (uncatalogued) models are left alone.""" from utils.model_resolver import ( # noqa: PLC0415 dim_for_model_id, embedding_dimension_contract, ) active_env = _launch_model_env(env) if _preserve_keyless_cloud_embedding(active_env, _routed_model_names(active_env)): return {} model = (env.get("LANGMEM_EMBEDDING_MODEL") or env.get("LITELLM_EMBEDDING_MODEL") or "").strip() dim = dim_for_model_id(model, env=active_env) if dim is None or str(dim) == (env.get("LANGMEM_EMBEDDING_DIM") or "").strip(): return {} return {"LANGMEM_EMBEDDING_DIM": str(embedding_dimension_contract(model, None, env=active_env))} def _detect_env_image_drift( existing_env: dict, env_example_path, ) -> list[tuple[str, str, str]]: """Return [(key, user_value, example_value), ...] for every ``*_IMAGE`` key whose value in the user's ``.env`` differs from ``.env.example``. Why this matters: CI tests `docker compose ... --env-file .env.example config -q`, so divergence in the user's `.env` is invisible to CI but breaks `docker build` at user-side launch. Example incident: PR #35 migrated SPARK_IMAGE bitnami/spark:4.1.2 → apache/spark:4.1.2 (Bitnami went paywalled), but a user's pre-migration `.env` retained the stale Bitnami pin → `docker.io/bitnami/spark:4.1.2: not found` at the spark-history image-pull step. See PR #35 docs. Scope: ONLY ``*_IMAGE`` keys (image pins control what gets pulled / built and are the only class with this CI-blind failure mode). Other env divergence (ports, secrets, source toggles) is often intentional and would produce noisy false-positives. Empty user values are skipped — placeholder lines in `.env` and auto-managed keys correctly defer to compose `:-` fallbacks. Kept as a module-level free function so it can be unit-tested without spinning up the full Starter. """ if not env_example_path.exists(): return [] example_pins: dict[str, str] = {} for raw_line in env_example_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith('#'): continue if '_IMAGE=' not in line: continue key, _, val = line.partition('=') if key.endswith('_IMAGE') and val: example_pins[key] = val.split('#', 1)[0].strip() drift: list[tuple[str, str, str]] = [] for key, example_val in example_pins.items(): user_val = (existing_env.get(key, '') or '').strip() if not user_val: continue if user_val != example_val: drift.append((key, user_val, example_val)) return drift def _detect_port_collisions(rows) -> list[str]: """Return human-readable warning strings, one per colliding host port. A *collision* is two or more rows whose port value (the ":" suffix or just the bare number) is equal AND nonempty. Disabled rows (port = "-" / "" / None) don't participate. `rows` is an iterable of ``(name, port_val)`` tuples — the same shape the pre-launch summary builder already accumulates as it iterates services. Kept as a module-level free function so it can be unit-tested without instantiating ``AtlasStarter``. The warnings are purely informational — launch still proceeds. Compose-up would otherwise fail with an opaque "address already in use" error from Docker, so this gives the user a chance to ack and continue or step back and pick another port. """ by_port: dict[str, list[str]] = {} for name, port_val in rows: port = (port_val or "").lstrip(":").strip() if not port or port == "-": continue # Only digits count as a host port. Skip anything that doesn't # look like a numeric port (e.g. an external URL). if not port.isdigit(): continue by_port.setdefault(port, []).append(name or "") warnings: list[str] = [] for port, names in by_port.items(): if len(names) >= 2: warnings.append( f"⚠ port {port} used by {' + '.join(names)} — " f"compose-up may fail to bind." ) return warnings class AtlasStarter: """Main class for starting 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.key_generator = KeyGenerator(str(self.root_dir)) self.config_parser = ConfigParser(str(self.root_dir)) self.localhost_validator = LocalhostValidator(self.config_parser) self.docker_manager = DockerManager(str(self.root_dir)) self.port_manager = PortManager(str(self.root_dir)) self.source_validator = SourceValidator(self.config_parser) self.service_config = ServiceConfig(self.config_parser) self.dependency_manager = DependencyManager(self.config_parser) self.source_override_manager = SourceOverrideManager(self.config_parser) self.active_track = None self.active_track_overrides = frozenset() # Managers recorded here were started by this AtlasStarter invocation. # Pre-existing native hosts are deliberately never rollback-owned. self._managed_hosts_started_this_run: list[tuple[str, object]] = [] # True once a nonzero `up --wait` was reclassified as converged only # after re-polling still-`starting` rows within the grace window # (#677/#681) — surfaced in the --detach --json summary so automation # can see the health race happened. self._up_converged_after_grace: bool = False # Services the image build left out of `up` because their image failed # and the launch could go without them (#989). Both front ends read it # to name them; one-shot verification skips them. self.skipped_builds: list[str] = [] # --support-bundle PATH (#1057): where a failed start writes its # redacted bundle. Both the linear flow and the Textual launch screen # read it from here. self.support_bundle_path: Optional[Path] = None # Set when the port check stopped this project's running stack, so a # later decline/failure can say it is down (the stop is not undone). self.stopped_previous_instance: bool = False # True when this run chose BASE_PORT with `auto` (CLI flag or a fresh # manifest resolution) rather than reusing a block. self.base_port_auto_chosen: bool = False # True once this run's cold cleanup has taken the project down; its # ports may still be held for a moment afterwards (#1438). self.project_stopped_this_run: bool = False def show_banner(self): """Display the startup banner (hero + wordmark/credits).""" self.banner.show_hero(no_splash=getattr(self, "no_splash", False)) self.banner.show_banner() def ensure_dependencies_available(self) -> bool: """Ensure all required dependencies are available.""" self.banner.show_section_header("Checking Dependencies", "🔍") # Check Docker availability if not self.docker_manager.check_docker_available(): self.banner.show_status_message( "Docker is not available. Please install Docker and ensure it's running.", "error" ) return False # Show detected 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") compose_ok, compose_message = self.docker_manager.check_compose_version() self.banner.show_status_message( compose_message, "success" if compose_ok else "error", ) if not compose_ok: return False # Check docker-compose.yml exists compose_file = self.root_dir / "docker-compose.yml" if not compose_file.exists(): self.banner.show_status_message( f"Docker Compose file not found: {compose_file}", "error" ) return False self.banner.show_status_message(f"Docker Compose file found: {compose_file}", "success") # Python YAML parsing replaces yq dependency self.banner.show_status_message("Using native Python YAML parsing (replaces yq dependency)", "info") return True def apply_source_overrides(self, **kwargs) -> bool: """ Apply SOURCE overrides from command-line arguments. Args: **kwargs: Command-line SOURCE override arguments Returns: bool: True if successful """ overrides = self.source_override_manager.collect_overrides(**kwargs) # Remember which SOURCE vars the operator set explicitly THIS run, so # the profile applier can honor operator-wins for any profile-managed # source (generalizes the historical prometheus/grafana-only guards). self._explicit_source_vars = set(overrides.keys()) if overrides: self._warn_on_consumer_pin_overrides(overrides) return self.source_override_manager.apply_overrides(overrides) return True def _warn_on_consumer_pin_overrides(self, overrides: Dict[str, str]) -> None: consumer = self.config_parser.load_consumer_config() for key, reason in consumer.env_reasons.items(): if key not in overrides or _consumer_pin_is_auto(key, consumer.env_overrides[key]): continue if overrides[key] != consumer.env_overrides[key]: flag = "--" + key.lower().replace("_", "-") self.banner.show_status_message( f"{key} consumer pin overridden by {flag}: " f"{reason} ({consumer.env_origins[key]}). The next start reapplies the pin.", "warning", ) def apply_profile_overrides(self, profile: str) -> bool: """Apply the active deployment profile's declarative bundle to .env (#755). Bundles live in ``bootstrapper/profiles.yml`` (platform-defined ``default``/``prod``; ``dev`` aliases ``default``) with consumer ``profile_overrides:`` merged on top. Field semantics (preserving the behaviors this method used to hard-code): - ``host_bind_ip``: non-empty → fills a blank ``HOST_BIND_IP`` and replaces a bind the prior profile asserted during a profile switch; an operator's non-empty bind (e.g. ``0.0.0.0:``) is kept, as ``profiles.yml`` documents. Empty/undeclared → cleared only when the current value equals another profile's non-empty bind. - ``sources``: asserted on every start of this profile, EXCEPT when that service's source was set by an explicit CLI flag this run (tracked via ``_explicit_source_vars``, which ``apply_source_overrides`` fills before this runs) or is pinned in the consumer manifest's ``env.values``, ``.env.user`` or ``ATLAS_ENV_USER_FILE`` (CLI flag > manifest/.env.user > profile). ``auto`` delegates to the durable ``_SOURCE: auto`` resolver (#753). On a profile SWITCH (tracked via ``ATLAS_PROFILE_APPLIED``), sources the prior profile asserted are reset to their service default first, so transitions leave no residue; a same-profile restart never resets (a wizard/operator selection that happens to equal a bundle value is safe). - ``env``: applied when the var is unset/empty, the shipped ``.env.example`` default, or (on a switch) the prior profile's value for the same key, unless ``.env.user`` or the consumer manifest pins it; any other operator-set value is kept with a notice (LOG_MAX_*). Called from both the linear (--no-tui) path and the TUI wizard pipeline so profile configuration applies regardless of how Atlas is started. """ from services.host_capabilities import probe_host_capabilities # noqa: F401 from services.manifests import load_manifests, option_in_profile from services.profiles import ( ProfileConfigError, canonical_profile, load_profile_bundles, merge_consumer_profile_overrides, ) active = canonical_profile(profile) try: bundles = load_profile_bundles() consumer_config = self.config_parser.load_consumer_config() bundles = merge_consumer_profile_overrides( bundles, getattr(consumer_config, "profile_overrides", None) or {} ) except ProfileConfigError as exc: print(f"profile={active}: invalid profile configuration: {exc}") return False bundle = bundles.get(active) if bundle is None: print(f"profile={active}: unknown profile") return False manifests = load_manifests(self.root_dir / "services") by_name = {m.name: m for m in manifests if m.sources is not None} env_vars = self.config_parser.parse_env_file() overrides, prior_applied, switching = _profile_host_bind_overrides( active, bundle, bundles, env_vars, ) explicit_vars = set(getattr(self, "_explicit_source_vars", set()) or set()) # Consumer manifest env.values beat the profile, as they beat the # track (#783): CLI flag > manifest > profile. explicit_vars.update(_manifest_source_vars(consumer_config)) # A *_SOURCE pinned in .env.user / ATLAS_ENV_USER_FILE wins too; the # overlay only guarded the profile's env, not its sources (#1391). explicit_vars.update(_env_user_source_vars(self)) # ── sources: undo the prior profile's asserts on a SWITCH ──── auto_vars: list[str] = [] if switching: prior_bundle = bundles.get(prior_applied) if prior_bundle is not None: for svc, prior_id in prior_bundle.sources.items(): if svc not in by_name: continue var = by_name[svc].sources.var if var in explicit_vars or svc in bundle.sources: continue # operator wins / new profile re-declares it if prior_id == "auto": # A prior bundle-`auto` resolution: reset only when the # resolved value is not offered under the NEW profile # (dev-only residue that would fail validation). current_value = (env_vars.get(var, "") or "").strip() if current_value and not option_in_profile( manifests, svc, current_value, active ): overrides[var] = by_name[svc].sources.default print( f"profile={active}: reset {var} to " f"'{by_name[svc].sources.default}' (prior profile's " f"auto-resolved '{current_value}' is not offered here)" ) continue if (env_vars.get(var, "") or "").strip() == prior_id: overrides[var] = by_name[svc].sources.default print( f"profile={active}: reset {var} to " f"'{by_name[svc].sources.default}' (was asserted by " f"profile '{prior_applied}')" ) # ── sources: assert this profile's selections ──────────────── for svc, source_id in bundle.sources.items(): manifest = by_name.get(svc) if manifest is None: print( f"profile={active}: sources.{svc} names a service without a " f"sources block; ignoring" ) continue var = manifest.sources.var if var in explicit_vars: continue # explicit CLI flag this run wins if source_id == "auto": current = (env_vars.get(var, "") or "").strip() if switching: prior_bundle = bundles.get(prior_applied) prior_id = (prior_bundle.sources.get(svc) if prior_bundle else None) if prior_id and prior_id != "auto" and current == prior_id: # Clear the prior profile's assert so `auto` re-resolves # instead of durably keeping the residue. self._merge_env_file_overrides({var: manifest.sources.default}) auto_vars.append(var) continue option_ids = {opt.id for opt in manifest.sources.options} if source_id not in option_ids: print( f"profile={active}: sources.{svc}='{source_id}' is not one of " f"{sorted(option_ids)}" ) return False if not option_in_profile(manifests, svc, source_id, active): print( f"profile={active}: sources.{svc}='{source_id}' is not offered " f"under this profile" ) return False overrides[var] = source_id # ── env: defaults unless operator-set ──────────────────────── overrides.update(_profile_env_overrides( active, bundle.env, env_vars, _replaceable_env( [ _env_example_values(self.config_parser.env_example_path), _prior_profile_env(bundles, prior_applied, switching), ], _operator_env_keys(self, consumer_config), ), )) # Write the marker when this run changes something or completes a # switch, and on the first start; a later no-op run leaves .env # byte-identical. if _should_record_profile(overrides, auto_vars, switching, env_vars): overrides["ATLAS_PROFILE_APPLIED"] = active if overrides and not self.source_override_manager.update_env_file(overrides): return False # `auto` selections route through the durable #753 resolver (which # reads the just-updated .env for its keep/resolve decision). if auto_vars: resolved = self._resolve_auto_source_overrides( {var: "auto" for var in auto_vars} ) concrete = {k: v for k, v in resolved.items() if v != "auto"} if concrete: self._merge_env_file_overrides(concrete) return True def apply_cloud_api_keys(self, keys: Dict[str, str]) -> bool: """ Persist cloud LLM provider API keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY) into ``.env``. Reuses the in-place .env writer the source-override manager already employs, so format and comment lines are preserved. Empty values are written verbatim — used to clear a key. Args: keys: Mapping of env-var name to value (e.g. {'OPENAI_API_KEY': 'sk-...'}). Empty dict is a no-op. Returns: bool: True on success (or no-op). """ if not keys: return True return self.source_override_manager.update_env_file(keys) def apply_user_model_selections(self, selections: Dict[str, str]) -> bool: """ Persist user-selected model lists (OPENAI_USER_MODELS, ANTHROPIC_USER_MODELS, OPENROUTER_USER_MODELS, OLLAMA_USER_MODELS, OLLAMA_CUSTOM_MODELS) into ``.env``. Values are comma-separated model names. ``litellm-init`` consumes them on the next ``docker compose up`` via ``model_resolver`` (YAML catalogs + env) to build the active model set. Args: selections: Mapping of env-var name to comma-separated model names. Empty dict is a no-op. Returns: bool: True on success (or no-op). """ if not selections: return True # Persist one model/dimension contract. Curated models derive their # dimension from catalog metadata; custom models must carry a selected # dimension or preserve an explicit value already present in .env. embed = (selections.get("LITELLM_EMBEDDING_MODEL", "") or "").strip() if embed: from utils.model_resolver import ( # noqa: PLC0415 dim_for_model_id, embedding_dimension_contract, ) selections = dict(selections) parser = getattr(self, "config_parser", None) current = parser.parse_env_file() if parser is not None else {} active_env = _launch_model_env({**current, **selections}) current_override = ( current.get("LANGMEM_EMBEDDING_MODEL", "") or "" ).strip() explicit_override = ( selections.get("LANGMEM_EMBEDDING_MODEL", "") or "" ).strip() if explicit_override and explicit_override != embed: raise ValueError( "LITELLM_EMBEDDING_MODEL and LANGMEM_EMBEDDING_MODEL " "must select the same embedding contract" ) if current_override and not explicit_override and current_override != embed: raise ValueError( "LANGMEM_EMBEDDING_MODEL override must be explicitly aligned " "with the selected LITELLM_EMBEDDING_MODEL" ) effective_embed = explicit_override or embed configured_dimension = selections.get("LANGMEM_EMBEDDING_DIM") if ( configured_dimension is None and dim_for_model_id(effective_embed, env=active_env) is None ): current_model = ( current.get("LANGMEM_EMBEDDING_MODEL") or current.get("LITELLM_EMBEDDING_MODEL") or "ollama/nomic-embed-text" ) # Preserve an existing explicit custom contract only when it # belongs to the same effective model. A generic fresh 768 # must never silently declare a newly selected custom model. if str(current_model).strip() == effective_embed: configured_dimension = current.get("LANGMEM_EMBEDDING_DIM") selections["LANGMEM_EMBEDDING_DIM"] = str( embedding_dimension_contract(effective_embed, configured_dimension, env=active_env) ) return self.source_override_manager.update_env_file(selections) def reconcile_default_models(self) -> bool: """Repoint default models this launch will not route. LiteLLM registers only active models, so a default naming a disabled engine or cloud provider, or a model no longer selected, made every default chat or embedding call fail with "invalid model" (#1359). `.env.example`'s ``ollama/*`` defaults under a cloud-only launch were the first case fixed. A default is replaced only when it is known not to be routed; a routed model, a consumer model or a host-imported Ollama tag is never overwritten. """ env = self.config_parser.parse_env_file() replacements, reasons = _default_model_replacements(env) changed = { key: value for key, value in replacements.items() if value != env.get(key) } for key, value in sorted(changed.items()): print(f"Default model {key}: {env.get(key)} -> {value} ({reasons.get(key, 'its pair moved')})") for key in sorted(set(reasons) - set(replacements)): print(f"WARNING: default model {key}={env.get(key)} is not routed ({reasons[key]}) " "and no active provider offers a replacement") # The embedding pair goes through the normal path, unchanged half # included, so its dimension contract (LANGMEM_EMBEDDING_DIM) # follows the new model. if changed.keys() & {"LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL"}: for key in ("LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL"): if key in replacements: changed[key] = replacements[key] if not changed: return self._repair_catalog_embedding_dim(env) return self._apply_default_model_changes(env, changed) def _apply_default_model_changes(self, env: dict, changed: dict) -> bool: """Write the repointed defaults, then run the catalog dimension repair when no embedding key moved: repointing only a chat or vision default skipped it, so a corrected catalog dimension waited a launch.""" try: if not self.apply_user_model_selections(changed): return False except ValueError as exc: # An embedding contract that was already inconsistent (e.g. a # custom model with no declared dimension) must not abort a launch # this best-effort repair was never needed for. print(f"WARNING: default models left unchanged: {exc}") return True if changed.keys() & {"LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL"}: return True # the selection path already rewrote LANGMEM_EMBEDDING_DIM return self._repair_catalog_embedding_dim({**env, **changed}) def _repair_catalog_embedding_dim(self, env: dict) -> bool: """Follow a corrected catalog dimension (qwen3-embedding:0.6b was declared 1536 but emits 1024) without waiting for a wizard re-run.""" repair = _catalog_embedding_dim_repair(env) if not repair: return True print( f"Embedding dimension LANGMEM_EMBEDDING_DIM: {env.get('LANGMEM_EMBEDDING_DIM')} " f"-> {repair['LANGMEM_EMBEDDING_DIM']} (catalog value for the embedding model)" ) return self.source_override_manager.update_env_file(repair) def validate_source_configurations(self) -> bool: """Validate all SOURCE configurations and scale values against YAML. Calls the validator's repair pass first (auto-disables cloud providers with missing keys, etc.), then runs the read-only validation. Splitting the two lets pure-tooling callers (linters, CI dry-runs) run validation alone without mutating .env — see SourceValidator.enforce_runtime_invariants. """ # Repair pass — mutates .env when necessary. A failed write # (disk full, permissions, etc.) is not silently recoverable: # halt before the read-only validate pass, which would # otherwise see stale .env. if not self.source_validator.enforce_runtime_invariants(): self.source_validator.print_validation_results() return False # Read-only validation pass. sources_valid = self.source_validator.validate_all_sources() if not sources_valid: self.source_validator.print_validation_results() return False scales_valid = self.source_validator.validate_scale_values() if not scales_valid: self.banner.console.print("[bright_red]❌ Scale validation failed:[/bright_red]") for error in self.source_validator.get_validation_errors(): self.banner.console.print(f" {error}") return False return True def _persist_project_name(self, project_name: Optional[str]) -> bool: """Persist ``PROJECT_NAME`` to .env so start AND stop (and every ``docker compose -p``) target the same container family. Sticky by design: a later bare ``./stop.sh`` reads PROJECT_NAME back from .env and tears down exactly what ``./start.sh`` launched. Consumer stacks reusing Atlas as a submodule just set this once (via --project or by editing .env) to isolate their namespace from a base Atlas stack. """ if not project_name: return True if self.config_parser.stored_project_name_matches(project_name): self.docker_manager.project_name_override = project_name return True if self.source_override_manager.update_env_file({"PROJECT_NAME": project_name}): self.docker_manager.project_name_override = project_name self.banner.show_status_message( f"Project name set to '{project_name}' (persisted to .env " f"PROJECT_NAME; container family: {project_name}-*)", "info", ) return True self.banner.show_status_message( f"Failed to persist PROJECT_NAME={project_name!r}; aborting so " "docker compose does not target a stale project.", "error", ) return False def _env_user_overlay_path(self) -> Path: """Return the optional user-owned env overlay next to the active .env.""" return self.config_parser.env_file_path.parent / ".env.user" def _apply_single_env_user_overlay( self, overlay_path: Path, *, label: str, warn_if_missing: bool, ) -> Dict[str, str]: if not warn_if_missing and not overlay_path.exists(): return {} overrides = self.config_parser.read_env_user_overlay( overlay_path, label=label, warn=lambda message: self.banner.show_status_message(message, "warning"), ) if overrides is None: return {} if not overrides: self.banner.show_status_message(f" • {overlay_path} has no env overrides", "info") return {} self._merge_env_file_overrides(overrides) self.banner.show_status_message( f" • Applied {overlay_path} ({len(overrides)} override{'s' if len(overrides) != 1 else ''})", "info", ) return overrides def _apply_env_user_overlay(self, project_context=None) -> Dict[str, str]: """Merge optional user-owned env overlays into the active .env file. Precedence is deterministic: the sibling .env.user is applied first, then ATLAS_ENV_USER_FILE, then any consumer manifest env values. """ applied_overrides: Dict[str, str] = {} context = dict(project_context or {}) if not context.get("previous_project"): context["previous_project"] = (self.config_parser.parse_env_file().get("PROJECT_NAME") or "").strip().lower() sibling_overrides = self._apply_single_env_user_overlay( self._env_user_overlay_path(), label=".env.user", warn_if_missing=False, ) applied_overrides.update(sibling_overrides) external_overlay_path = external_env_user_overlay_path() if external_overlay_path is not None: external_overrides = self._apply_single_env_user_overlay( external_overlay_path, label="ATLAS_ENV_USER_FILE", warn_if_missing=True, ) applied_overrides.update(external_overrides) context["overlay_project"] = applied_overrides.get("PROJECT_NAME") applied_overrides.update(self._apply_consumer_manifest_env(set(applied_overrides), context)) return applied_overrides def _apply_consumer_manifest_env(self, overlay_keys: set, project_context=None) -> Dict[str, str]: """Merge the consumer manifest's env into .env, blanking derived keys whose source is gone (never the overlay's ``overlay_keys``), and return the values an operator pinned this way.""" consumer_config = self.config_parser.load_consumer_config() declared = dict(consumer_config.env_overrides or {}) manifest_overrides = _with_stale_derived_keys_cleared( declared, self.config_parser.parse_env_file(), consumer_config, protected=overlay_keys, ) if not manifest_overrides: return {} resolved_overrides = self._resolve_auto_source_overrides( self._resolve_auto_base_port_override(manifest_overrides, project_context) ) self._merge_env_file_overrides(resolved_overrides) # The bookkeeping marker is not a consumer setting. count = len([key for key in resolved_overrides if key != "ATLAS_DERIVED_KEYS"]) names = ", ".join(consumer.name for consumer in consumer_config.consumers) self.banner.show_status_message( f" • Applied consumer manifest env ({count} override{'s' if count != 1 else ''})" f" for {names}", "info", ) # Blanked stale keys are Atlas housekeeping, not values an operator # pinned, so later profile handling must not treat them as pinned. return { key: value for key, value in resolved_overrides.items() if key in declared or key not in manifest_overrides } def build_support_bundle(self, options, checks=None): """Assemble the redacted support bundle (#1057); writes nothing. ``checks`` reuses an earlier ``(results, notes)`` from ``support_bundle.run_checks``; otherwise the doctor checks run here, offline and inside the bundle's wall-time budget. """ from core import support_bundle as sb results, notes = checks or sb.run_checks(DOCTOR_CHECKS, self, options.limits) notes = list(notes) logs = list(options.logs) try: env = self.config_parser.parse_env_file() except Exception as exc: # noqa: BLE001 - recorded in the bundle # Fail closed: without .env the known secret values cannot be # scrubbed, so no log text goes in. env, logs = {}, [] notes.append(f"could not read .env ({exc}); log excerpts left out") return sb.build_bundle(sb.BundleRequest( env=env, env_origins=self._env_origins(), env_file=Path(self.config_parser.env_file_path), checks=results, logs=logs, context=dict(options.context), include_unlisted=options.include_unlisted, limits=options.limits, notes=notes, )) def _env_origins(self) -> Dict[str, str]: """The file that sets each overridden env key, in the order a start applies them: .env.user, ATLAS_ENV_USER_FILE, then consumer manifests (or their env files). The last one wins.""" origins: Dict[str, str] = {} for overlay in (self._env_user_overlay_path(), external_env_user_overlay_path()): if overlay is None or not overlay.is_file(): continue try: keys = parse_env_overlay_file(overlay) except Exception: # noqa: BLE001 - an unreadable overlay sets nothing continue origins.update(dict.fromkeys(keys, str(overlay))) try: consumer = self.config_parser.load_consumer_config() origins.update(consumer.env_origins) origins.update(_derived_env_origins(consumer)) except Exception: # noqa: BLE001 - a broken manifest has its own check pass return origins def materialize_consumer_env_for_preflight(self) -> Dict[str, str]: """Persist the consumer manifest's derived ``env_overrides`` into ``.env`` before a standalone ``doctor`` / ``compose validate``. The full ``start`` flow materializes these (via ``_apply_env_user_overlay``), but the preflight subcommands validate the assembled compose *without* applying them — so a consumer overlay that interpolates ``${BACKEND_PLUGINS_DIR}`` (etc.) fails on a fresh checkout that has never started (#451). This applies the same derived, deterministic values a real start would write, so preflight validates the same assembled config a launch produces. Quiet by construction (no banner) so ``doctor --format json`` stays machine-clean. No-op when there is no ``.env`` yet or no overrides. Returns the overrides applied (empty dict when none). """ if not self.config_parser.env_file_path.exists(): return {} try: consumer_config = self.config_parser.load_consumer_config() except Exception: # noqa: BLE001 — best-effort: a malformed manifest # must surface through the doctor's own consumer-manifest check # (which reports it as a structured `fail`), not crash preflight # before any check has run. return {} if getattr(self, "profile", None) in (None, ""): # Standalone doctor/endpoints runs never pass --profile; resolve # against the consumer manifest's declared default so `auto` # cannot poison a prod deployment's .env with dev-only sources. # A running stack's applied profile (a launch-time --profile) # outranks the manifest default. self.profile = ( _known_applied_profile(self.config_parser.parse_env_file()) or getattr(consumer_config, "profile", None) or "default" ) env_vars = self.config_parser.parse_env_file() declared = _with_stale_derived_keys_cleared( _preflight_declared(consumer_config, env_vars), env_vars, consumer_config ) overrides = self._resolve_auto_source_overrides( self._resolve_auto_base_port_override(declared), quiet=True, ) if overrides: self._merge_env_file_overrides(overrides) # Writing BASE_PORT alone left every *_PORT on the old block, so # `endpoints export` after `doctor` passed its drift check while # emitting ports from the wrong stack. Recompute them, as a start does. base_port = _parsed_base_port(str(overrides.get("BASE_PORT", ""))) # Validate first: update_env_ports prints to stdout on a bad value, # which would corrupt `doctor --format json`; the doctor base-port # check fails on it instead. if base_port is not None and self.port_manager.validate_base_port(base_port): # Same env file as the merge above (ATLAS_ENV_FILE / test roots). self.port_manager.config_parser = self.config_parser self.port_manager.update_env_ports(base_port, create_backup=False) return overrides def _merge_env_file_overrides(self, overrides: Dict[str, str]) -> None: """Replace or append env keys without duplicate-key ambiguity.""" env_file_path = self.config_parser.env_file_path content = env_file_path.read_text(encoding="utf-8") updated_content = content for var_name, raw_value in overrides.items(): # Belt-and-braces against `.env` line injection. `_set_scalar` # rejects these at the parse boundary, but several paths build # overrides without going through it — the env-user overlay, # storage exports, auto-source and auto-BASE_PORT resolution, and # the two profile-apply sites — so the writer must be safe whatever # produced the entry. # # Both halves matter, because the emitted line is `KEY=VALUE`: a # newline in the VALUE appends further assignments, and a newline # or `=` in the KEY does exactly the same. `parse_env_file` # resolves last-wins, so an injected line beats the real one — and # the `^KEY=.*$` rewrite below is MULTILINE, not DOTALL, so a later # run rewrites only the first line and leaves the remainder in # place permanently. var_value = render_env_assignment(var_name, raw_value) # `export KEY=` is KEY to Compose and parse_env_file: rewrite it # in place too (keeping `export`) or it would win over this value. updated_content = set_env_assignment(updated_content, var_name, var_value) atomic_write_text(env_file_path, updated_content, mode=0o600) def _resolve_auto_base_port_override(self, overrides: Dict[str, str], project_context=None) -> Dict[str, str]: """Resolve a manifest ``BASE_PORT: auto`` to a concrete, **durable** free block (returns a new dict; non-auto overrides are returned unchanged). Manifest ``auto`` means "give this consumer a stable free BASE_PORT block". It resolves to the first wholly-free ``BASE_PORT+0..N`` block — which skips the default 63000 AND any block whose ports are in use by another running stack, so several consumers started in turn each land on a **distinct** block. The resolved value is persisted to ``.env`` and **kept** on later starts (a previously-resolved non-default block is reused as-is, so a warm restart never moves and never mistakes its own running containers for another stack's). A cold start (regenerated ``.env`` → default) re-resolves, still skipping occupied blocks. Unlike the one-off ``--base-port auto`` CLI flag (which resolves fresh every time it is passed), the manifest form is committed and must be stable — that is the whole point of pinning identity in the manifest. """ if str(overrides.get("BASE_PORT", "")).strip().lower() != "auto": return overrides self._durable_auto_manifest = True if (project_context or {}).get("explicit_base") is not None: return {**overrides, "BASE_PORT": str(project_context["explicit_base"])} current = (self.config_parser.parse_env_file().get("BASE_PORT", "") or "").strip() in_use = False try: current_int: Optional[int] = int(current) except ValueError: current_int = None if ( current_int is not None and current_int != DEFAULT_BASE_PORT and self.port_manager.validate_base_port(current_int) ): # Durable keep — but only when the block is actually OURS to keep. # Several consumers resolving `auto` at different times (while the # others are down) can each persist the SAME "first free" block; # the first to start binds it and every later stack must re-resolve # or refuse to boot (#727 composed-run finding). Distinguish: # block free → keep (durable, incl. cold) # occupied + our containers up → keep (warm restart) # occupied + no containers of ours → FOREIGN stack: re-resolve in_use = self.port_manager.check_port_range_availability( current_int, _declared_sources(overrides) ) if not in_use or self._can_keep_auto_block(overrides, project_context): resolved = current_int # durable: keep this consumer's block else: self.banner.show_status_message( f"BASE_PORT=auto: persisted block {current_int} is bound by " f"another stack on this host — re-resolving to a free block " f"(the prior block was not ours to keep).", "warning", ) resolved = self.port_manager.auto_base_port(source_overrides=_declared_sources(overrides)) self.base_port_auto_chosen = resolved is not None if resolved is None: self.banner.show_status_message( "BASE_PORT=auto could not find a free port block; keeping " f"{current_int} (expect bind conflicts).", "warning", ) resolved = current_int else: resolved = self.port_manager.auto_base_port(source_overrides=_declared_sources(overrides)) self.base_port_auto_chosen = resolved is not None if resolved is None: self.banner.show_status_message( "BASE_PORT=auto could not find a free port block; using the " f"default {DEFAULT_BASE_PORT}. Set an explicit BASE_PORT.", "warning", ) resolved = DEFAULT_BASE_PORT project = self._auto_port_project(overrides, project_context) previous = (project_context or {}).get("previous_project") self._durable_auto_project = previous if resolved == current_int and in_use and previous else project merged = dict(overrides) merged["BASE_PORT"] = str(resolved) return merged def _auto_port_project(self, overrides, context=None) -> str: """Resolve identity without writing it or introducing a consumer pin.""" from core.config_parser import normalize_project_name context = context or {} project = (context.get("requested_project") or overrides.get("PROJECT_NAME") or context.get("overlay_project") or context.get("fallback_project") or self.config_parser.parse_env_file().get("PROJECT_NAME") or DEFAULT_PROJECT_NAME) return normalize_project_name(project) def _can_keep_auto_block(self, overrides, context=None) -> bool: if context is None: return self._project_has_running_containers(overrides) project = self._auto_port_project(overrides, context) running = self._project_running_state(project) if running is None: self.base_port_auto_chosen = False previous = context.get("previous_project") return running is None or bool(running and (not previous or previous == project)) def resolve_wizard_base_port(self, sources, options) -> None: """Finalize only port intent; never replay consumer sources, keys or models.""" intent = options.get("base_port_intent") if intent == "numeric": self.base_port_auto_chosen = False elif options.get("base_port_auto"): self.base_port_auto_chosen = True elif getattr(self, "_durable_auto_manifest", False): context = {"requested_project": options.get("project_name"), "previous_project": getattr(self, "_durable_auto_project", None)} declared = {key.upper(): value for key, value in sources.items()} resolved = self._resolve_auto_base_port_override({**declared, "BASE_PORT": "auto"}, context) options["base_port"] = int(resolved["BASE_PORT"]) def _project_has_running_containers(self, overrides: Dict[str, str]) -> bool: """True when this consumer's compose project has containers up — the warm-restart signal that its persisted BASE_PORT block is genuinely its own. Conservative on any docker error: returns True (keep the block; never surprise-move ports when ownership can't be verified).""" state = self._project_running_state(self._auto_port_project(overrides)) return state is not False def _project_running_state(self, project: str) -> Optional[bool]: """None means Docker could not verify ownership; retain conservatively.""" try: probe = subprocess.run( [ "docker", "ps", "-q", "--filter", f"label=com.docker.compose.project={project}", ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, check=False, ) if probe.returncode != 0: return None # docker unreachable → conservative keep return bool(probe.stdout.strip()) except (OSError, subprocess.SubprocessError): return None # conservative keep def _resolve_auto_source_overrides( self, overrides: Dict[str, str], *, quiet: bool = False ) -> Dict[str, str]: """Resolve manifest ``_SOURCE: auto`` sentinels to concrete, **durable**, host-correct option ids (#753) — the source-selection analog of ``_resolve_auto_base_port_override`` above, sharing its durability contract: 1. **Durable keep.** A concrete, valid, **non-default** value already in ``.env`` (a prior ``auto`` resolution or an operator's explicit ``---source`` override) is kept — provided the active profile offers it (a dev-only value under ``--profile prod`` re-resolves, or prod validation would fail every start). KNOWN LIMIT: an operator override *to the service default id* is indistinguishable from a cold regen and is re-resolved on the next start — to durably force the default, commit the concrete id instead of ``auto``. 2. **Platform-adaptive resolve.** Otherwise pick the first ``sources.auto_prefer`` entry whose host capability holds (services/host_capabilities.py) and whose option is offered under the active deployment profile (``option_in_profile``). Entries are declarative, ordered, and end in an unconditional terminal fallback (lint-enforced), so resolution never dead-ends. 3. **Persist & keep.** The concrete id is merged into ``.env``; the next start hits step 1. A cold regen (``.env`` back to defaults) re-resolves — still host-correct. Runs before source validation, so the validator only ever sees concrete ids. ``*_SOURCE`` vars set to ``auto`` that match no manifest sources block are left untouched — the validator then rejects the literal ``auto`` with its normal valid-options error, keeping the mistake loud rather than silently dropped. Other keys (``DOCLING_USE_OCR``) may hold ``auto`` as a value and pass through. """ auto_keys = [ key for key, value in overrides.items() # Only a *_SOURCE key is a sentinel: DOCLING_USE_OCR=auto is a value. if key.endswith("_SOURCE") and str(value).strip().lower() == "auto" ] if not auto_keys: return overrides from services.host_capabilities import probe_host_capabilities from services.manifests import load_manifests, option_in_profile manifests = load_manifests(self.root_dir / "services") by_source_var = {m.sources.var: m for m in manifests if m.sources is not None} env = self.config_parser.parse_env_file() profile = getattr(self, "profile", "default") or "default" capabilities = probe_host_capabilities() merged = dict(overrides) for key in auto_keys: manifest = by_source_var.get(key) if manifest is None or manifest.sources is None: if not quiet: self.banner.show_status_message( f"{key}=auto has no manifest sources block to resolve against; " "leaving as-is (source validation will reject it).", "warning", ) continue sources = manifest.sources option_ids = {opt.id for opt in sources.options} current = (env.get(key, "") or "").strip() if ( current and current.lower() != "auto" and current != sources.default and current in option_ids ): if option_in_profile(manifests, manifest.name, current, profile): merged[key] = current # durable: keep prior resolution/override continue # The kept value is not offered under the ACTIVE profile (e.g. # a dev-only managed-localhost-mps under --profile prod): keeping # it would fail prod source validation on every start until a # hand-edit. Re-resolve instead — self-healing beats durability # when the durable value is invalid for this run. if not quiet: self.banner.show_status_message( f" • [auto] {key}: prior value '{current}' is not offered " f"under profile '{profile}' — re-resolving.", "info", ) resolved: Optional[str] = None matched = "" for pref in sources.auto_prefer: if pref.id not in option_ids: continue # lint catches this; stay safe at runtime too if not option_in_profile(manifests, manifest.name, pref.id, profile): continue if pref.requires_capability is not None and not capabilities.has( pref.requires_capability ): continue resolved = pref.id matched = pref.requires_capability or "terminal fallback" break if resolved is None: reason = ( "no auto_prefer entries declared" if not sources.auto_prefer else "no auto_prefer entry eligible on this host/profile" ) if not quiet: self.banner.show_status_message( f"{key}=auto could not resolve ({reason}); using the service " f"default '{sources.default}'.", "warning", ) resolved = sources.default matched = "service default" merged[key] = resolved if not quiet: self.banner.show_status_message( f" • [auto] {key}=auto → {resolved} (matched capability: {matched})", "info", ) return merged def _remove_env_keys_by_prefix(self, prefix: str) -> None: """Drop every ``.env`` line whose KEY starts with ``prefix``. Used to clear stale storage export vars before re-emitting the current set, so a removed/renamed store leaves no dangling export.""" env_file_path = self.config_parser.env_file_path if not env_file_path.exists(): return content = env_file_path.read_text(encoding="utf-8") kept = [ line for line in env_lines(content, keepends=True) if not line.lstrip().startswith(prefix) ] updated = "".join(kept) if updated == content: return atomic_write_text(env_file_path, updated, mode=0o600) def validate_persisted_project_name(self) -> bool: """Fail before mutating .env when its stored PROJECT_NAME is invalid.""" try: self.config_parser.get_project_name() except ValueError as e: self.banner.show_status_message( f"Invalid PROJECT_NAME in {self.config_parser.env_file_path}: {e}", "error", ) return False return True def _strip_env_byte_order_mark(self) -> None: """Drop a UTF-8 byte-order mark from .env before any writer runs: the reader ignores it, but the writers' line patterns did not match the first key behind it (#1391).""" path = getattr(self.config_parser, "env_file_path", None) try: data = path.read_bytes() if path is not None else b"" except OSError: return if data.startswith(b"\xef\xbb\xbf"): from utils.atomic_write import atomic_write_text atomic_write_text(path, data[3:].decode("utf-8"), mode=path.stat().st_mode & 0o777) def prepare_environment( self, cold_start: bool, base_port: Optional[int] = None, project_name: Optional[str] = None, ) -> bool: """Clean a cold project before replacing its credential-bearing env file.""" self._strip_env_byte_order_mark() if cold_start: # A fresh clone has no env file for Compose interpolation, and no # existing credentials to preserve. Materialize it first. Existing # installs must clean first so a failed `down --volumes` leaves the # credential file matching any surviving volumes. if not self.config_parser.env_file_exists(): if not self.setup_env_file( cold_start=True, base_port=base_port, project_name=project_name, ): return False return self.perform_cold_start_cleanup(project_name=project_name) # Backfill is additive and preserves every existing credential. It # gives upgraded Compose fragments all required interpolation keys # without replacing the env file before volume deletion succeeds. if not self.backfill_missing_env_vars(): return False if not self.perform_cold_start_cleanup(project_name=project_name): return False return self.setup_env_file( cold_start=cold_start, base_port=base_port, project_name=project_name, ) def _setup_port_context(self, base_port, project_name) -> dict: """Capture the saved block's identity before replacement or overlays.""" prior_env = self.config_parser.parse_env_file() if base_port is not None and not getattr(self, "_cli_base_port_auto", False): self.base_port_auto_chosen = False return {"requested_project": project_name, "previous_project": (prior_env.get("PROJECT_NAME") or "").strip().lower(), "explicit_base": base_port} def setup_env_file(self, cold_start: bool, base_port: Optional[int] = None, project_name: Optional[str] = None) -> bool: """ Setup .env file from .env.example if needed. Supports custom .env file paths via ATLAS_ENV_FILE environment variable (and the deprecated GENAI_ENV_FILE alias). Replicates the .env setup logic from the original start.sh. Args: cold_start: Whether this is a cold start base_port: Optional custom base port project_name: Optional normalized PROJECT_NAME to persist (from --project / -p). Written AFTER the .env is created/preserved so a cold-start copy of .env.example doesn't reset it to the default. Returns: bool: True if successful """ # Use config_parser paths which respect ATLAS_ENV_FILE env_file_path = self.config_parser.env_file_path env_example_path = self.config_parser.env_example_path # Show which env file we're using if custom if self.config_parser.is_using_custom_env_file(): self.banner.show_status_message( f"Using custom env file: {env_file_path}", "info" ) if project_name is None and env_file_path.exists() and not cold_start: if not self.validate_persisted_project_name(): return False project_context = self._setup_port_context(base_port, project_name) existing_project_name: Optional[str] = None if cold_start and env_file_path.exists(): try: existing_project_name = self.config_parser.get_project_name() except ValueError: existing_project_name = None project_context["fallback_project"] = existing_project_name # A cold start replaces .env with .env.example; keep a private copy # first. It holds generated secrets plus operator-only values such # as BACKUP_MANIFEST_HMAC_KEY, without which backups cannot be # restored. # Versioned: routine start-up backups share the plain slot and # would rotate this copy out within a couple of starts; the cold # slot keeps its own five most recent copies. try: saved_env = create_private_backup(env_file_path, version="cold") except OSError as exc: self.banner.show_status_message( f"Could not back up {env_file_path} before the cold start: {exc}", "error", ) return False self.banner.show_status_message(f"Saved the previous env file to {saved_env}", "info") # Check if .env exists, if not or if cold start is requested, create from .env.example if not env_file_path.exists() or cold_start: if not env_example_path.exists(): self.banner.show_status_message( f".env.example file not found: {env_example_path}", "error" ) return False self.banner.show_section_header("Setting Up Environment", "📋") if cold_start: self.banner.show_status_message("Creating new .env file from .env.example (cold start)...", "info") else: self.banner.show_status_message("Creating new .env file from .env.example", "info") try: # Ensure parent directory exists (important for custom paths) env_file_path.parent.mkdir(parents=True, exist_ok=True) # Materialize atomically with owner-only permissions. A direct # copy can leave a partial or briefly world-readable secret file. _write_private_text( env_file_path, env_example_path.read_text(encoding="utf-8"), ) self.banner.show_status_message(f" • Copied {env_example_path}", "info") self.banner.show_status_message(f" • to {env_file_path}", "info") overlay_overrides = self._apply_env_user_overlay(project_context) self._env_user_keys = set(overlay_overrides) # Unset potentially lingering port environment variables if cold start and custom base port are used effective_base_port = base_port if base_port is not None else DEFAULT_BASE_PORT if cold_start and effective_base_port != DEFAULT_BASE_PORT: self.unset_port_environment_variables() self.banner.show_status_message("Environment file setup completed", "success") project_name_to_persist = project_name if ( project_name_to_persist is None and existing_project_name and "PROJECT_NAME" not in overlay_overrides ): project_name_to_persist = existing_project_name if not self._persist_project_name(project_name_to_persist): return False return self.validate_persisted_project_name() except Exception as e: self.banner.show_status_message(f"Failed to create .env file: {e}", "error") return False os.chmod(env_file_path, 0o600) overlay_overrides = self._apply_env_user_overlay(project_context) self._env_user_keys = set(overlay_overrides) if not self._persist_project_name(project_name): # .env already exists and not cold start return False if project_name is None and overlay_overrides: return self.validate_persisted_project_name() return True def _refresh_retired_build_pins(self) -> None: """Move a build pin that still holds a retired shipped default to the ``.env.example`` value (``services/migrations/build_pin_refresh.py``). An operator's own value stays; a failure warns and the start goes on.""" try: changes, backup = _refresh_build_pins( Path(self.config_parser.env_file_path), Path(self.config_parser.env_example_path), # A pin an overlay or consumer manifest set this run is theirs. frozenset(getattr(self, "_env_user_keys", None) or ()), ) except (OSError, UnicodeError) as exc: self.banner.show_status_message(f"Could not refresh retired build pins: {exc}", "warning") return for key, old, new in changes: self.banner.show_status_message( f"Refreshed {key} from the retired default {old} to {new} (backup: {backup.name})", "info", ) def backfill_missing_env_vars(self) -> bool: """Append variables present in ``.env.example`` but missing from the user's ``.env`` (preserving every existing nonblank value). Catches the merge-from-upstream case: a worktree adds new services to ``.env.example`` (e.g. MinIO's ``MINIO_IMAGE``, ``MINIO_PORT``, bucket names) but the user's pre-existing ``.env`` doesn't carry those keys. ``docker compose config`` then warns ``variable X not set, defaulting to blank`` and fails with ``service has neither an image nor a build context`` when a critical key like ``${MINIO_IMAGE}`` is empty. Preserves the source file's organisation: missing vars are emitted under their original section heading (the ``# === FOO ===`` banner above them in ``.env.example``), with the immediate context comment block kept intact. The result reads as if the new entries had been there from the start — no flat "everything dumped at the bottom" lump. Idempotent — running again with no missing keys is a no-op. Adds missing keys and fills eligible bare empty assignments; other assignments retain their values and order (so user-edited nonblank values and pre-existing comments stay put). Auto-managed keys with empty defaults in the example (passwords, access keys) are appended blank; the cold-start secret-generation step fills them later. The one exception is a build pin still on a retired shipped default (``_refresh_retired_build_pins``). """ env_file_path = self.config_parser.env_file_path env_example_path = self.config_parser.env_example_path if not env_file_path.exists() or not env_example_path.exists(): return True # Nothing to backfill against. # The one non-blank value backfill changes: a build pin still on a # default an earlier Atlas shipped, which the new image rejects. self._refresh_retired_build_pins() try: example_text = _read_env_backfill_text(env_example_path) env_text = _read_env_backfill_text(env_file_path) except UnicodeError: self.banner.show_status_message( f"Cannot decode {env_file_path} or {env_example_path} as UTF-8 for backfill.", "error", ) return False except OSError as e: self.banner.show_status_message( f"Could not read env files for backfill: {e}", "warning", ) return True # Non-fatal — surface compose's own error later. try: existing_keys, blank_keys = _scan_env_keys(env_text, str(env_file_path)) example_values = _backfill_default_values(example_text, str(env_example_path)) except ValueError as exc: self.banner.show_status_message(f"Cannot backfill env files: {exc}", "error") return False # Keys the migration chain (services/migrations) is about to # write: backfill must NOT seed them from .env.example, or # run_port_migration() — which runs AFTER backfill — finds them # already present and skips the user's legacy values. Concretely: # seeding the sentinel stamps a legacy .env as already-migrated # (every migration silently skips); seeding a *_LOCALHOST_PORT # while the legacy *_LOCALHOST_URL is still in the file makes v2 # discard the URL's custom port; seeding the COMFYUI model vars # while COMFYUI_MODEL_SET exists pre-empts v3's translation. migration_owned: set[str] = {"BOOTSTRAPPER_PORT_LAYOUT_VERSION"} for _url_var, _port_var in _V2_URL_TO_PORT.items(): if _url_var in existing_keys: migration_owned.add(_port_var) if "COMFYUI_MODEL_SET" in existing_keys: migration_owned.update( {"COMFYUI_USER_MODELS", "COMFYUI_CUSTOM_MODELS_FILE"} ) # Build a lookup of .env.example values so we can fill in BLANK # entries (a key exists in .env but with no value) using a # non-blank manifest default. This handles the case where the # user's .env was created when a secret's manifest `default` was # different from the current one — e.g., the supabase DB # password placeholder got reintroduced to the example after a # secret-emission policy change. Intentional autogen blanks # (LITELLM_MASTER_KEY etc.) have `default: ""` in the manifest # and therefore stay blank in .env.example, so this branch is a # no-op for them. blank_fills = { key: example_values[key][1] for key in blank_keys if example_values.get(key, ("", ""))[0] and key not in migration_owned and key not in _USER_OWNED_BLANKABLE } if blank_fills: filled_text = _fill_env_backfill_blanks(env_text, blank_fills, str(env_file_path)) try: atomic_write_text( env_file_path, filled_text, mode=0o600, ) except OSError as e: self.banner.show_status_message( f"Failed to fill blank values in {env_file_path}: {e}", "error", ) return False env_text = _read_env_backfill_text(env_file_path) self.banner.show_status_message( f"Filled {len(blank_fills)} blank value(s) from .env.example: " f"{', '.join(sorted(blank_fills)[:4])}" f"{' …' if len(blank_fills) > 4 else ''}", "info", ) groups = self._parse_env_example_sections( example_text, existing_keys | migration_owned, ) if not groups: return True groups = self._rebase_backfilled_ports(groups) # Insert each group AT THE END of its matching section in the # user's .env. If the section doesn't exist in .env (older # layout, e.g. a brand-new service family), the section banner # plus its entries land in an "Auto-backfilled" trailer at the # bottom. This preserves the source-of-truth grouping the # docstring promised — historically the regex below was # `[=]{3,}` which never matched .env.example's `─` (U+2500) # bars, so EVERY key fell into "(unsectioned)" and got dumped # at the bottom. The fix is to (a) match both bar chars and # (b) actually splice in-place. new_env_text, total, in_place_sections, trailer_sections = ( self._splice_backfill_in_place(env_text, groups) ) try: atomic_write_text(env_file_path, new_env_text, mode=0o600) except OSError as e: self.banner.show_status_message( f"Failed to backfill {env_file_path}: {e}", "error", ) return False msg_bits = [] if in_place_sections: msg_bits.append( f"into {len(in_place_sections)} existing section" f"{'s' if len(in_place_sections) != 1 else ''} " f"({', '.join(in_place_sections[:3])}" f"{' …' if len(in_place_sections) > 3 else ''})" ) if trailer_sections: msg_bits.append( f"with {len(trailer_sections)} new section" f"{'s' if len(trailer_sections) != 1 else ''} appended " f"({', '.join(trailer_sections[:3])}" f"{' …' if len(trailer_sections) > 3 else ''})" ) self.banner.show_status_message( f"Backfilled {total} missing env var(s) from .env.example, " + ("; ".join(msg_bits) or "no placement available"), "info", ) return True @staticmethod def _splice_backfill_in_place( env_text: str, groups: "list[tuple[str, list[tuple[list[str], str, str]]]]", ) -> "tuple[str, int, list[str], list[str]]": """Insert each backfill group at the END of its matching section in ``env_text``. Sections that don't exist in ``env_text`` get an auto-backfilled trailer at the bottom. Returns ``(new_env_text, total_keys_added, in_place_section_names, trailer_section_names)``. Section identity matches the banner-title text emitted by ``env_assembler`` (e.g. ``"data: Apache Spark (standalone cluster) (services/spark/service.yml)"``). We tolerate both ``─`` and ``=`` bar chars for back-compat with hand-edited ``.env`` files. """ bar_re = re.compile(r"^#\s*[=─]{3,}\s*$") lines = env_lines(env_text, keepends=True) n = len(lines) # Walk env_text and record [(section_name, start_idx, end_idx)] # — start_idx is the line AFTER the closing bar of the banner # block; end_idx is exclusive of the next banner's opening bar. sections: list[tuple[str, int, int]] = [] current_name = "(preamble)" current_start = 0 i = 0 while i < n: line = lines[i].removeprefix("\ufeff") if i == 0 else lines[i] # Banner = bar / # TITLE / bar (3 lines). if ( bar_re.match(line.rstrip("\r\n")) and i + 2 < n and lines[i + 1].lstrip().startswith("#") and bar_re.match(lines[i + 2].rstrip("\r\n")) ): title = lines[i + 1].lstrip("#").strip() # Close out the prior section at the line that holds # this banner's opening bar. sections.append((current_name, current_start, i)) current_name = title or "(unnamed)" current_start = i + 3 i += 3 continue i += 1 sections.append((current_name, current_start, n)) # For each group, find an in-place insertion point or queue for # the trailer. section_lookup = {name: idx for idx, (name, _, _) in enumerate(sections)} # Map section index → list of additional lines to splice in # right BEFORE the section's end (so they land at the bottom of # the section, before any blank-line gap to the next banner). per_section_splice: dict[int, list[str]] = {} trailer_groups: list[tuple[str, list[tuple[list[str], str, str]]]] = [] in_place_names: list[str] = [] trailer_names: list[str] = [] total = 0 for section_name, entries in groups: if section_name in section_lookup: in_place_names.append(section_name) idx = section_lookup[section_name] bucket = per_section_splice.setdefault(idx, []) for context, key, value in entries: for ctx_line in context: bucket.append(ctx_line + "\n") bucket.append(f"{key}={value}\n") total += 1 else: trailer_names.append(section_name) trailer_groups.append((section_name, entries)) total += len(entries) # Reassemble env_text with in-place splices applied. Walk from # the end backwards so prior splices don't shift later indices. out_lines = list(lines) for idx in sorted(per_section_splice.keys(), reverse=True): name, start, end = sections[idx] # Trim trailing blank lines from the section body so the # spliced entries sit flush with the prior content; the # blank is reinserted between sections. insertion_point = end while ( insertion_point > start and out_lines[insertion_point - 1].strip() == "" ): insertion_point -= 1 splice = per_section_splice[idx] if insertion_point and not out_lines[insertion_point - 1].endswith(("\r", "\n")): splice = ["\n", *splice] out_lines[insertion_point:insertion_point] = splice # Append the trailer for any groups whose section didn't exist. if trailer_groups: trailer: list[str] = [] joined = "".join(out_lines) if joined and not joined.endswith("\n"): trailer.append("\n") trailer.extend([ "\n", "# ────────────────────────────────────────────────────────\n", f"# Auto-backfilled from .env.example on {_format_today()}\n", "# Sections new in .env.example since this .env was written.\n", "# ────────────────────────────────────────────────────────\n", ]) for section_name, entries in trailer_groups: trailer.append("\n") trailer.append(f"# === {section_name} ===\n") for context, key, value in entries: for ctx_line in context: trailer.append(ctx_line + "\n") trailer.append(f"{key}={value}\n") out_lines.extend(trailer) return "".join(out_lines), total, in_place_names, trailer_names def _rebase_backfilled_ports(self, groups): """Seed newly backfilled *_PORT keys on this stack's BASE_PORT block: .env.example carries the default 63000 layout, which a headless `env backfill` + compose/doctor/endpoints run would otherwise use.""" # Decoded like every reader (quotes, `export`, last assignment wins). base_port = _parsed_base_port(self.config_parser.parse_env_file().get("BASE_PORT", "") or "") if base_port is None or not self.port_manager.validate_base_port(base_port): return groups assignments = self.port_manager.calculate_port_assignments(base_port) return [ (section, [ (context, key, str(assignments[key]) if key in assignments else value) for context, key, value in entries ]) for section, entries in groups ] @staticmethod def _parse_env_example_sections( example_text: str, existing_keys: set[str], ) -> "list[tuple[str, list[tuple[list[str], str, str]]]]": """Walk ``example_text`` and group missing variables by section. Returns a list of ``(section_name, entries)`` where entries is a list of ``(context_comments, key, value)``. Section name comes from the most recent ``# ────`` banner (legacy ``# ====`` too). Context comments are the contiguous comment lines immediately preceding the variable (an inline description like ``# Required when COMFYUI_SOURCE=localhost:``), capped to the previous variable or section banner so the backfill doesn't drag unrelated commentary along. """ # Match the 3-line section banner pattern in .env.example. # env_assembler emits box-drawing `─` (U+2500), the canonical # form. Legacy `=` bars are also tolerated for # backwards-compat with hand-edited `.env.example` files. # # Example match: # # ────────────────────────────────────────────────── # # data: Apache Spark (standalone cluster) (services/spark/service.yml) # # ────────────────────────────────────────────────── bar_re = re.compile(r"^#\s*[=─]{3,}\s*$") lines = env_lines(example_text.removeprefix("\ufeff")) current_section = "(unsectioned)" # Buffer of comment lines accumulated since the last variable # line or section banner — used as the immediate context for # the next variable we encounter. comment_buf: list[str] = [] # Section name → list of (context, key, value). per_section: dict[str, list[tuple[list[str], str, str]]] = {} ordered_sections: list[str] = [] seen_keys: set[str] = set() i = 0 n = len(lines) while i < n: line = lines[i] # Section banner detection: bar, title, bar (3 lines). if ( bar_re.match(line) and i + 2 < n and lines[i + 1].startswith("#") and bar_re.match(lines[i + 2]) ): title = lines[i + 1].lstrip("#").strip() if title: current_section = title comment_buf = [] i += 3 continue stripped = line.strip() if not stripped: # Blank line resets the running comment buffer so we # don't paste unrelated text above a variable two # sections later. comment_buf = [] i += 1 continue if stripped.startswith("#"): comment_buf.append(line) i += 1 continue if "=" not in stripped: comment_buf = [] i += 1 continue key, _decoded, value, _exported = _backfill_env_assignment(stripped, "env example") if key and key not in existing_keys and key not in seen_keys: seen_keys.add(key) if current_section not in per_section: per_section[current_section] = [] ordered_sections.append(current_section) per_section[current_section].append( (list(comment_buf), key, value), ) comment_buf = [] i += 1 return [(s, per_section[s]) for s in ordered_sections] def unset_port_environment_variables(self) -> None: """ Unset potentially lingering port environment variables. Replicates the unset logic from the original start.sh. """ # Every container-PORT slot the bootstrapper allocates. A stale # shell-exported value would shadow the freshly-computed value # on cold-start with a custom --base-port, so unset before we # re-allocate. Only *_LOCALHOST_PORT vars (host-side override # ports) stay out of the slot allocator and out of this list; # every slot-allocated *_PORT must appear here — including the # cAdvisor / node / postgres / redis exporter ports, which DO # recompute from BASE_PORT (test_port_unset_covers_topology guards # this list against topology.port_defaults). port_variables = [ 'SUPABASE_DB_PORT', 'REDIS_PORT', 'KONG_HTTP_PORT', 'KONG_HTTPS_PORT', 'SUPABASE_META_PORT', 'SUPABASE_STORAGE_PORT', 'SUPABASE_AUTH_PORT', 'SUPABASE_API_PORT', 'SUPABASE_REALTIME_PORT', 'SUPABASE_STUDIO_PORT', 'GRAPH_DB_PORT', 'GRAPH_DB_DASHBOARD_PORT', 'LITELLM_PORT', 'LOCAL_DEEP_RESEARCHER_PORT', 'ASSET_WORKER_PORT', 'ASSET_BAKER_PORT', 'SEARXNG_PORT', 'CRAWL4AI_PORT', 'TIKA_PORT', 'LLM_GRAPH_BUILDER_PORT', 'FLOWER_PORT', 'OPEN_WEB_UI_PORT', 'BACKEND_PORT', 'N8N_PORT', 'COMFYUI_PORT', 'WEAVIATE_PORT', 'WEAVIATE_GRPC_PORT', 'DOC_PROCESSOR_PORT', 'STT_PROVIDER_PORT', 'TTS_PROVIDER_PORT', 'SPEACHES_PORT', 'CHATTERBOX_PORT', 'OPENCLAW_GATEWAY_PORT', 'OPENCLAW_BRIDGE_PORT', 'HERMES_API_PORT', 'HERMES_DASHBOARD_PORT', 'MCP_SERVERS_PORT', 'LANGFUSE_PORT', 'TRUEFORGE_PORT', 'TEI_RERANKER_PORT', 'LIGHTRAG_API_PORT', 'ICEBERG_REST_PORT', 'TRINO_PORT', 'REDPANDA_KAFKA_PORT', 'REDPANDA_CONSOLE_PORT', 'MINIO_PORT', 'MINIO_CONSOLE_PORT', 'JUPYTERHUB_PORT', 'JENKINS_PORT', 'MLFLOW_PORT', 'LABEL_STUDIO_PORT', 'VERBA_PORT', # PR #29 / PR #35 additions: ray + spark + airflow + zeppelin # + prometheus + grafana. Without these the previous-run # exports silently shadow the freshly-computed slots. 'RAY_DASHBOARD_PORT', 'RAY_CLIENT_PORT', 'RAY_GCS_PORT', 'SPARK_MASTER_UI_PORT', 'SPARK_HISTORY_PORT', 'AIRFLOW_PORT', 'ZEPPELIN_PORT', 'PROMETHEUS_PORT', 'GRAFANA_PORT', # Observability exporters: all four are slot-allocated (recompute # from BASE_PORT) and host-published, so a stale shell export # shadows the fresh slot on a --base-port cold start just like any # other port. They were missing here for several releases. 'CADVISOR_PORT', 'NODE_EXPORTER_PORT', 'POSTGRES_EXPORTER_PORT', 'REDIS_EXPORTER_PORT', ] self.banner.show_status_message(" • Unsetting potentially lingering port environment variables...", "info") for var in port_variables: if var in os.environ: del os.environ[var] def validate_supabase_keys(self, cold_start: bool = False) -> bool: """ Ensure required Supabase JWT keys are present. Three outcomes: - All three keys present → no-op. - All three keys blank (fresh clone, or post-cold-reset of .env) → auto-generate. No --cold flag required. - Mixed state (some present, some blank) → refuse and direct the user to ./bootstrapper/generate_supabase_keys.sh, since the anon and service keys are HMAC-signed by SUPABASE_JWT_SECRET and the generator always rewrites all three. Auto-regenerating here would silently clobber whatever values the user hand-pasted. Args: cold_start: Phrases the status message only. Auto-generation triggers on the all-blank case regardless of this flag. Returns: bool: True if all keys are present or successfully generated. """ env_vars = self.config_parser.parse_env_file() keys = { 'SUPABASE_JWT_SECRET': env_vars.get('SUPABASE_JWT_SECRET', '').strip(), 'SUPABASE_ANON_KEY': env_vars.get('SUPABASE_ANON_KEY', '').strip(), 'SUPABASE_SERVICE_KEY': env_vars.get('SUPABASE_SERVICE_KEY', '').strip(), } missing_keys = [name for name, value in keys.items() if not value] if not missing_keys: return True # Mixed state: some keys set, others blank. Don't auto-regenerate — # the SupabaseKeyGenerator rewrites all three together, which would # destroy whatever the user pasted into the present ones. if len(missing_keys) < len(keys): present_keys = [name for name in keys if name not in missing_keys] self.banner.show_section_header("Inconsistent Supabase Keys", "⚠️") self.banner.show_status_message( "Some Supabase keys are set and others are blank — refusing to " "auto-regenerate to avoid clobbering the values you've already set.", "warning", ) self.banner.show_status_message( f" Present: {', '.join(present_keys)}", "warning", ) self.banner.show_status_message( f" Missing: {', '.join(missing_keys)}", "warning", ) self.banner.show_status_message(" To resolve:", "info") self.banner.show_status_message( " • Run ./bootstrapper/generate_supabase_keys.sh to regenerate " "ALL three (overwrites existing), or", "info", ) self.banner.show_status_message( " • Manually fill in the missing keys in .env so all three " "are populated.", "info", ) return False self.banner.show_section_header("Generating Supabase Keys", "🔐") if cold_start: self.banner.show_status_message( "Cold start detected - generating fresh Supabase JWT keys...", "info", ) else: self.banner.show_status_message( "No Supabase keys found in .env; generating fresh JWT keys...", "info", ) from utils.supabase_keys import SupabaseKeyGenerator key_generator = SupabaseKeyGenerator(str(self.root_dir)) if key_generator.generate_and_update_env(): self.banner.show_status_message( "Supabase keys generated and applied successfully!", "success" ) return True self.banner.show_status_message("Failed to generate Supabase keys", "error") return False def handle_port_configuration(self, base_port: Optional[int]) -> bool: """Handle port configuration and updates.""" # No --base-port flag: preserve the BASE_PORT already configured in # .env (e.g. from an earlier --base-port 64000 run) instead of # silently rewriting every *_PORT back to the default layout. This # mirrors the TUI path's fallback in ui/textual/integration.py. if base_port is None: current = (self.config_parser.parse_env_file() .get('BASE_PORT', '') or '').strip() if current.lower() == "auto": # Defensive: a manifest BASE_PORT=auto is normally resolved to a # concrete value by _resolve_auto_base_port_override during the # env-overlay merge; if it ever reaches here unresolved, pick a # free block rather than int()-failing to the default 63000. base_port = self.port_manager.auto_base_port() or DEFAULT_BASE_PORT else: try: base_port = int(current) except ValueError: base_port = DEFAULT_BASE_PORT base_port = self._reresolve_auto_block(base_port) # Validate base port if not self.port_manager.validate_base_port(base_port): offsets = self.port_manager.port_offsets() max_offset = max(offsets.values()) if offsets else 0 self.banner.show_status_message( f"Invalid base port: {base_port}. Must be between 1024 and " f"{65535 - max_offset}", "error" ) return False # Check for port conflicts conflicts = self.port_manager.get_port_conflicts(base_port) if conflicts or self._port_block_moves(base_port): # Our own containers hold the ports, or the block moves: a moved # block re-publishes every container, and a targeted warm start # would leave a now-disabled service running on its old ports. if self.docker_manager.are_project_containers_running(): self.banner.show_status_message( "Previous instance detected — stopping existing containers...", "info" ) stop_result = self.docker_manager.stop_services( remove_volumes=False, remove_orphans=True ) if stop_result != 0: self.banner.show_status_message( "Failed to stop previous instance", "error" ) return False self.banner.show_status_message( "Previous instance stopped successfully", "success" ) self.stopped_previous_instance = True # Re-check ports after cleanup, waiting for Docker to # release the ports it just unpublished (#1438). conflicts = self.port_manager.conflicts_after_release(base_port) elif conflicts and getattr(self, "project_stopped_this_run", False): # A cold start stopped the stack before this check; wait for # Docker to release its ports as the warm path does. conflicts = self.port_manager.conflicts_after_release(base_port) # If conflicts remain, show the original error if conflicts: self.banner.show_status_message("Port conflicts detected:", "warning") if self.stopped_previous_instance: self.banner.show_status_message( " (the previous instance was stopped above and stays down)", "warning", ) for port_var, port in conflicts.items(): self.banner.show_status_message( f" • {port_var}: Port {port} is already in use", "warning" ) # Suggest alternative base port suggested_port = self.port_manager.suggest_available_base_port() if suggested_port: self.banner.show_status_message( f"Suggested available base port: {suggested_port}", "info" ) return False # Update ports in .env file if not self.port_manager.update_env_ports(base_port): return False return True def _reresolve_auto_block(self, base_port: int) -> int: """An `auto` block was probed before the profile, the consumer manifest and the CLI flags had all written their *_SOURCE values. If a service they enable now finds its port taken, choose again with the sources in .env, before a running stack is stopped for a block this launch would then refuse (2026-10-08 run, cycle 75).""" if not self.base_port_auto_chosen: return base_port if not self.port_manager.check_port_range_availability(base_port): return base_port fresh = self.port_manager.auto_base_port() if fresh is None or fresh == base_port: return base_port self.banner.show_status_message( f"BASE_PORT=auto: block {base_port} has a port in use by a service " f"this launch enables; using {fresh} instead.", "warning", ) return fresh def _port_block_moves(self, base_port: int) -> bool: """Whether `.env`'s ports differ from ``base_port``'s block: a --base-port change, or a hand-edited BASE_PORT whose *_PORT values still name the old block the running containers publish.""" env = self.config_parser.parse_env_file() current = (env.get('BASE_PORT', '') or '').strip() # ASCII only: "²".isdigit() is true but int("²") raises, which # crashed the start where handle_port_configuration falls back. if _ascii_int(current) not in (None, base_port): return True target = self.port_manager.calculate_port_assignments(base_port) # A *_PORT pinned in .env.user / a consumer manifest is merged into # .env on every start and reset by update_env_ports; it is not a # move, and counting it tore the stack down on every warm start. pinned = set(getattr(self, "_env_user_keys", None) or ()) return any( _ascii_int(str(env.get(var, ''))) not in (None, port) for var, port in target.items() if var not in pinned ) def run_port_migration(self, no_port_migrate: bool) -> None: """Chained .env migrations: v0 → v1 (port-layout), v1 → v2 (URL→PORT), v2 → v3 (COMFYUI_MODEL_SET → COMFYUI_USER_MODELS schema), v3 → v4 (stale curated Ollama model reference cleanup), v4 → v5 (enable Weaviate's native filesystem backup module). Idempotent. Each step is gated by its own ``_needs_*`` predicate so re-running is safe and stamping is independent. Reads ``BOOTSTRAPPER_PORT_LAYOUT_VERSION`` from the active env file (honors ``ATLAS_ENV_FILE``). v1: rewrites every port var whose current value matches the v0 default to the topology-derived v1 default. User-customized values are left alone. v2: rewrites legacy ``_LOCALHOST_URL`` lines into ``_LOCALHOST_PORT`` lines, commenting the old URLs for audit. Drives both compose runtime and Kong routes off the new PORT schema. v3: translates the old ``COMFYUI_MODEL_SET`` enum to the new ``COMFYUI_USER_MODELS`` CSV + sidecar/cache vars introduced in the model-picker feature. Removes the old enum var and any preceding comment block. v4: rewrites LITELLM_DEFAULT_MODEL / LITELLM_VISION_MODEL / OLLAMA_USER_MODELS entries that reference a curated Ollama model retired from ``services/ollama/models.yaml`` (e.g. ``qwen3.6`` → ``qwen3.8``) to the current catalog equivalent. v5: appends ``backup-filesystem`` to a persisted ``WEAVIATE_ENABLE_MODULES`` list without changing operator-selected modules, so upgrades receive the executable backup contract. When ``no_port_migrate`` is True we skip all five migrations AND skip the sentinel stamps so the next run re-prompts — matches the user intent "skip this run, ask next time." Must be called AFTER setup_env_file + backfill so the file exists and is fully populated, and BEFORE any caller that relies on the v2 port values. """ env_path = self.config_parser.env_file_path if not no_port_migrate and _prepare_migration_marker(env_path): return # v0 → v1: port-layout rewrite. if _needs_v1(env_path): if no_port_migrate: self.banner.console.print( "[dim]Skipping port-layout v1 migration (--no-port-migrate); " "will re-prompt next run.[/dim]" ) else: from services.topology import get_topology as _get_topology services_root = self.root_dir / "services" env_vars = self.config_parser.parse_env_file() # ``.get(key, default)`` returns the empty string when the key is # present-but-blank — only missing keys hit the default. A blank # BASE_PORT (auto-managed quirk) would crash ``int("")``. _raw_base = (env_vars.get("BASE_PORT") or "").strip() try: base_port = int(_raw_base) if _raw_base else DEFAULT_BASE_PORT except ValueError: base_port = DEFAULT_BASE_PORT topology = _get_topology(services_root, base_port=base_port) result = _apply_v1(env_path, topology.port_defaults, base_port=base_port) if result.backup_path: self.banner.console.print( f"[green]• Backed up .env to {result.backup_path}[/green]" ) self.banner.console.print( f"[green]• Port layout updated (v0 → v1)[/green]: " f"rewrote {len(result.rewritten)} ports; " f"preserved {len(result.preserved)} customizations." ) if result.rewritten: self.banner.console.print("[dim] Changes:[/dim]") for var, (old, new) in sorted(result.rewritten.items()): self.banner.console.print( f"[dim] {var}: {old} → {new}[/dim]" ) _stamp_v1(env_path, 1) # v1 → v2: URL → PORT schema rewrite. Idempotent on re-run. # Runs after v1 so the sentinel transitions cleanly 0/none → 1 → 2 # rather than skipping intermediate state on a v0 .env (the # combined behavior is what we want for users on older checkouts # who haven't run any bootstrapper since the topology refactor). if _needs_v2(env_path): if no_port_migrate: self.banner.console.print( "[dim]Skipping LOCALHOST schema migration " "(--no-port-migrate); will re-prompt next run.[/dim]" ) else: self.banner.show_status_message( "Migrating .env to LOCALHOST_PORT schema (v2) ...", "info", ) _apply_v2(env_path) _stamp_v2(env_path) self.banner.show_status_message( "LOCALHOST schema migration complete (v2). " "Old _LOCALHOST_URL lines are commented out for " "audit; new _LOCALHOST_PORT lines drive both " "compose runtime and Kong routes.", "success", ) # v2 → v3: COMFYUI_MODEL_SET → COMFYUI_USER_MODELS schema rewrite. # Runs after v2 so the sentinel transitions cleanly: … → 2 → 3. if _needs_v3(env_path): if no_port_migrate: self.banner.console.print( "[dim]Skipping model-set schema migration " "(--no-port-migrate); will re-prompt next run.[/dim]" ) else: self.banner.show_status_message( "Migrating .env to COMFYUI_USER_MODELS schema (v3) ...", "info", ) _apply_v3(env_path) _stamp_v3(env_path) self.banner.show_status_message( "Model-set migration complete (v3). " "COMFYUI_MODEL_SET translated to COMFYUI_USER_MODELS.", "success", ) # v3 → v4: stale curated Ollama model reference cleanup. # Runs after v3 so the sentinel transitions cleanly: … → 3 → 4. if _needs_v4(env_path): if no_port_migrate: self.banner.console.print( "[dim]Skipping stale-model-reference migration " "(--no-port-migrate); will re-prompt next run.[/dim]" ) else: self.banner.show_status_message( "Checking for retired curated Ollama model references (v4) ...", "info", ) if _apply_v4(env_path): _stamp_v4(env_path) self.banner.show_status_message( "Stale-model-reference migration complete (v4).", "success", ) else: self.banner.show_status_message( "Could not load the curated Ollama catalog; migration v4 " "was not stamped and will retry on the next start.", "warning", ) # v4 → v5: retain custom modules and add the native backup provider. # Never leapfrog a retryable v4 failure by stamping the shared # migration sentinel directly to 5. if not _needs_v4(env_path) and _needs_v5(env_path): if no_port_migrate: self.banner.console.print( "[dim]Skipping Weaviate backup-module migration " "(--no-port-migrate); will re-prompt next run.[/dim]" ) else: try: _apply_v5(env_path) except MigrationV5Error as exc: # A hand-edited .env (duplicate or malformed # WEAVIATE_ENABLE_MODULES line) must not crash every start # with a traceback. Like v4: report, leave unstamped, retry. self.banner.show_status_message( f"Weaviate backup-module migration (v5) skipped: {exc} " f"in {env_path}. Fix the line; the migration retries on " "the next start.", "warning", ) else: _stamp_v5(env_path) self.banner.show_status_message( "Weaviate backup-module migration complete (v5).", "success", ) def generate_service_configuration(self) -> bool: """Generate and update service configuration.""" try: generated = self.service_config.generate_and_update_env() except ValueError as exc: # The per-service gates (e.g. Spark needs MinIO) raise ValueError # with an actionable message; the --no-tui flow has no handler # above this step, so report it instead of a raw traceback. # print, not the banner: the TUI swaps in a no-op banner and # captures stdout into its log pane. print(f"ERROR: {exc}") return False if not generated: return False # Finalize consumer object-storage (#404) AFTER endpoints are resolved # into .env by generate_and_update_env, and before compose up. Covers # both the linear flow and the TUI/launch pipeline (single call site). if not self._finalize_consumer_storage(): return False # Finalize consumer LiteLLM model rows (#411): write the generated # consumer-models.yaml (merged by litellm-init) + the api-key overlay, # or remove them when no consumer declares litellm_models. Same call # site so both the linear and TUI/launch flows are covered. if not self._finalize_consumer_litellm_models(): return False # Finalize consumer n8n workflow seeding (#412): write the normalized # workflow JSONs + plan + seed overlay, or remove stale artifacts when no # consumer declares n8n_workflows. if not self._finalize_consumer_n8n_workflows(): return False # Finalize consumer RAG ingestion profiles (#413): write the compiled # profiles JSON + backend-mount overlay, or remove them when no consumer # declares rag_ingestion_profiles. if not self._finalize_consumer_rag_ingestion_profiles(): return False # Finalize consumer LightRAG query profiles (#414): write the compiled # query-profile registry JSON + backend-mount overlay, or remove them # when no consumer declares lightrag_query_profiles. if not self._finalize_consumer_lightrag_query_profiles(): return False return True def _finalize_consumer_storage(self) -> bool: """Provision manifest-declared object stores (#404): generate scoped credentials (blank-only), export stable endpoint/credential-reference fields, and write the Atlas-owned minio-init overlay so no consumer compose override is needed. Idempotent; a no-storage config removes any stale generated overlay. """ from core.consumer_manifest import ( MINIO_STORAGE_OVERLAY_PATH, _validate_storage_collisions, compile_storage_exports, storage_credential_tokens, ) try: config = self.config_parser.load_consumer_config() if config.storage: env = self.config_parser.parse_env_file() if str(env.get("MINIO_SOURCE", "container")).strip() != "disabled": _validate_storage_collisions( config.storage, self.docker_manager.resolved_minio_environment(), ) except Exception as exc: # ConsumerManifestError etc. — surface + fail self.banner.show_status_message( f"Consumer storage manifest error: {exc}", "error" ) return False overlay_path = self.root_dir / MINIO_STORAGE_OVERLAY_PATH # Always clear stale export vars first — a removed or renamed store must # not leave dangling ATLAS_STORE_* fields; the current set is re-emitted # below. Provisioning (buckets / MINIO_EXTRA_CONSUMERS) lives only in the # regenerated overlay, so there is nothing stale to strip from .env there. self._remove_env_keys_by_prefix("ATLAS_STORE_") if not config.storage: if overlay_path.exists(): overlay_path.unlink() return True if str(env.get("MINIO_SOURCE", "container")).strip() == "disabled": # A storage declaration against disabled MinIO can't be provisioned; # skip loudly rather than emit an unusable (empty-endpoint) contract. if overlay_path.exists(): overlay_path.unlink() self.banner.show_status_message( " • MINIO_SOURCE=disabled — skipping consumer storage " f"provisioning for {len(config.storage)} declared store(s)", "warning", ) return True # 1. Scoped credentials — generated once, persist across restarts. tokens = storage_credential_tokens(config.storage) self.key_generator.generate_and_update_extra_minio_consumer_keys(tokens) # 2. Stable export fields (resolved endpoints track BASE_PORT/host). exports = compile_storage_exports( config.storage, minio_endpoint=env.get("MINIO_ENDPOINT", ""), minio_public_endpoint=env.get("MINIO_PUBLIC_ENDPOINT", ""), minio_region=env.get("MINIO_REGION", "us-east-1"), ) if exports: self._merge_env_file_overrides(exports) # 3. Atlas-owned minio-init overlay (routed via consumer overlays). overlay = config.storage_overlay if overlay is not None: overlay.path.parent.mkdir(parents=True, exist_ok=True) overlay.path.write_text(overlay.content, encoding="utf-8") self.banner.show_status_message( f" • Provisioned {len(config.storage)} consumer object store(s)", "info", ) return True def _finalize_consumer_litellm_models(self) -> bool: """Materialize consumer-owned LiteLLM model rows (#411): write the generated ``consumer-models.yaml`` (merged by litellm-init into the LiteLLM config before startup) and the companion api-key compose overlay (injects consumer api-key references into the litellm container). Idempotent; a no-models config removes any stale generated artifacts so a removed manifest drops only its own rows on a warm restart. """ from core.consumer_manifest import ( LITELLM_CONSUMER_MODELS_PATH, LITELLM_CONSUMER_OVERLAY_PATH, ) try: config = self.config_parser.load_consumer_config() except Exception as exc: # ConsumerManifestError etc. — surface + fail self.banner.show_status_message( f"Consumer LiteLLM manifest error: {exc}", "error" ) return False models_path = self.root_dir / LITELLM_CONSUMER_MODELS_PATH overlay_path = self.root_dir / LITELLM_CONSUMER_OVERLAY_PATH # No consumer declares litellm_models → remove any stale generated # artifacts so nothing leaks into a later, model-less start. if not config.litellm_models: for stale in (models_path, overlay_path): if stale.exists(): stale.unlink() return True artifact = config.litellm_models_file if artifact is not None: artifact.path.parent.mkdir(parents=True, exist_ok=True) artifact.path.write_text(artifact.content, encoding="utf-8") # The overlay only exists when at least one model declares an api_key_var. # Remove a stale overlay when the current config no longer needs one. if config.litellm_overlay is not None: config.litellm_overlay.path.parent.mkdir(parents=True, exist_ok=True) config.litellm_overlay.path.write_text( config.litellm_overlay.content, encoding="utf-8" ) elif overlay_path.exists(): overlay_path.unlink() owners = sorted({model.consumer for model in config.litellm_models}) self.banner.show_status_message( f" • Merged {len(config.litellm_models)} consumer LiteLLM model(s) " f"from {', '.join(owners)}", "info", ) return True def _finalize_consumer_n8n_workflows(self) -> bool: """Materialize consumer-owned n8n workflow seeding (#412): write the normalized workflow JSONs + plan.json into the gitignored seed dir and the seed compose overlay, or remove stale generated artifacts when no consumer declares n8n_workflows. Idempotent; a removed manifest or workflow drops exactly its own artifacts on the next start.""" from core.consumer_manifest import ( N8N_CONSUMER_OVERLAY_PATH, N8N_CONSUMER_WORKFLOWS_DIR, ) try: config = self.config_parser.load_consumer_config() except Exception as exc: # ConsumerManifestError etc. — surface + fail self.banner.show_status_message( f"Consumer n8n workflow manifest error: {exc}", "error" ) return False seed_dir = self.root_dir / N8N_CONSUMER_WORKFLOWS_DIR overlay_path = self.root_dir / N8N_CONSUMER_OVERLAY_PATH if self._skip_n8n_seed_when_disabled(config, seed_dir, overlay_path): return True # No consumer declares n8n_workflows → remove any stale generated # artifacts so a warm restart doesn't re-seed removed workflows. if not config.n8n_workflows: if (seed_dir / "plan.json").exists(): return self._reconcile_last_n8n_workflows(seed_dir, overlay_path) if seed_dir.exists(): for stale in seed_dir.glob("*.json"): stale.unlink() if overlay_path.exists(): overlay_path.unlink() return True seed_dir.mkdir(parents=True, exist_ok=True) # Drop stale *.json (workflows removed since the last start) first, or a # removed workflow's file lingers and the seed would re-import it. current_files = {artifact.path.name for artifact in config.n8n_artifacts} for stale in seed_dir.glob("*.json"): if stale.name not in current_files: stale.unlink() for artifact in config.n8n_artifacts: artifact.path.parent.mkdir(parents=True, exist_ok=True) artifact.path.write_text(artifact.content, encoding="utf-8") if config.n8n_overlay is not None: config.n8n_overlay.path.parent.mkdir(parents=True, exist_ok=True) config.n8n_overlay.path.write_text( config.n8n_overlay.content, encoding="utf-8" ) owners = sorted({wf.consumer for wf in config.n8n_workflows}) self.banner.show_status_message( f" • Seeding {len(config.n8n_workflows)} consumer n8n workflow(s) " f"from {', '.join(owners)}", "info", ) return True def _skip_n8n_seed_when_disabled(self, config, seed_dir: Path, overlay_path: Path) -> bool: """With N8N_SOURCE=disabled there is no n8n for the seed to wait on: drop the overlay and warn, as consumer storage does for disabled MinIO. The seed dir and plan.json stay, so a pending empty-plan reconcile resumes once n8n is enabled again.""" if not _n8n_disabled(self.config_parser.parse_env_file()): return False if overlay_path.exists(): overlay_path.unlink() if config.n8n_workflows: detail = f" for {len(config.n8n_workflows)} declared workflow(s)" elif (seed_dir / "plan.json").exists(): detail = "; previously seeded workflows are reconciled when n8n is enabled again" else: return True self.banner.show_status_message( f" • N8N_SOURCE=disabled — skipping the consumer n8n seed{detail}", "warning", ) return True def _reconcile_last_n8n_workflows(self, seed_dir: Path, overlay_path: Path) -> bool: """Workflows were seeded before and none is declared now: run the seed with an empty plan on every start, so its reconcile deactivates and deletes every ``atlas-consumer-*`` workflow. Dropping the overlay skipped the seed and left their webhooks live (cycle 31); dropping it after one run lost the cleanup whenever that run could not reconcile (no N8N_API_KEY, n8n not healthy) (2026-10-08 run, cycle 34). Delete volumes/n8n/consumer-workflows/ to stop it.""" from core.consumer_manifest import compile_n8n_plan, render_n8n_seed_overlay for stale in seed_dir.glob("*.json"): if stale.name != "plan.json": stale.unlink() (seed_dir / "plan.json").write_text(compile_n8n_plan([]), encoding="utf-8") overlay_path.parent.mkdir(parents=True, exist_ok=True) overlay_path.write_text(render_n8n_seed_overlay([]), encoding="utf-8") self.banner.show_status_message( " • No consumer n8n workflows declared: the n8n seed removes any previously seeded ones", "info", ) if not (self.config_parser.parse_env_file().get("N8N_API_KEY") or "").strip(): self.banner.show_status_message( " • N8N_API_KEY is not set, so previously seeded consumer workflows stay active " "until it is set", "warning", ) return True def _finalize_consumer_rag_ingestion_profiles(self) -> bool: """Materialize consumer-owned RAG ingestion profiles (#413): write the compiled profiles JSON + the backend-mount compose overlay, or remove any stale generated artifacts when no consumer declares rag_ingestion_profiles. Idempotent; a removed manifest drops exactly its own artifacts next start. """ from core.consumer_manifest import ( RAG_INGESTION_OVERLAY_PATH, RAG_INGESTION_PROFILES_PATH, ) try: config = self.config_parser.load_consumer_config() except Exception as exc: # ConsumerManifestError etc. — surface + fail self.banner.show_status_message( f"Consumer RAG ingestion manifest error: {exc}", "error" ) return False profiles_path = self.root_dir / RAG_INGESTION_PROFILES_PATH overlay_path = self.root_dir / RAG_INGESTION_OVERLAY_PATH if not config.rag_ingestion_profiles: # Remove stale artifacts so a warm restart doesn't mount removed # profiles into the backend. if profiles_path.exists(): profiles_path.unlink() if overlay_path.exists(): overlay_path.unlink() return True if config.rag_ingestion_file is not None: config.rag_ingestion_file.path.parent.mkdir(parents=True, exist_ok=True) config.rag_ingestion_file.path.write_text( config.rag_ingestion_file.content, encoding="utf-8" ) if config.rag_ingestion_overlay is not None: config.rag_ingestion_overlay.path.parent.mkdir(parents=True, exist_ok=True) config.rag_ingestion_overlay.path.write_text( config.rag_ingestion_overlay.content, encoding="utf-8" ) owners = sorted({p.consumer for p in config.rag_ingestion_profiles}) self.banner.show_status_message( f" • Registering {len(config.rag_ingestion_profiles)} consumer RAG " f"ingestion profile(s) from {', '.join(owners)}", "info", ) return True def _finalize_consumer_lightrag_query_profiles(self) -> bool: """Materialize consumer-owned LightRAG query profiles (#414): write the compiled query-profile registry JSON + the backend-mount compose overlay, or remove any stale generated artifacts when no consumer declares lightrag_query_profiles. Idempotent; a removed manifest drops exactly its own artifacts next start, so a no-profile deployment stays compatible. """ from core.consumer_manifest import ( LIGHTRAG_QUERY_PROFILES_OVERLAY_PATH, LIGHTRAG_QUERY_PROFILES_PATH, ) try: config = self.config_parser.load_consumer_config() except Exception as exc: # ConsumerManifestError etc. — surface + fail self.banner.show_status_message( f"Consumer LightRAG query profile manifest error: {exc}", "error" ) return False profiles_path = self.root_dir / LIGHTRAG_QUERY_PROFILES_PATH overlay_path = self.root_dir / LIGHTRAG_QUERY_PROFILES_OVERLAY_PATH if not config.lightrag_query_profiles: # Remove stale artifacts so a warm restart doesn't mount removed # profiles into the backend. if profiles_path.exists(): profiles_path.unlink() if overlay_path.exists(): overlay_path.unlink() return True if config.lightrag_query_profiles_file is not None: config.lightrag_query_profiles_file.path.parent.mkdir( parents=True, exist_ok=True ) config.lightrag_query_profiles_file.path.write_text( config.lightrag_query_profiles_file.content, encoding="utf-8" ) if config.lightrag_query_profiles_overlay is not None: config.lightrag_query_profiles_overlay.path.parent.mkdir( parents=True, exist_ok=True ) config.lightrag_query_profiles_overlay.path.write_text( config.lightrag_query_profiles_overlay.content, encoding="utf-8" ) owners = sorted({p.consumer for p in config.lightrag_query_profiles}) self.banner.show_status_message( f" • Registering {len(config.lightrag_query_profiles)} consumer LightRAG " f"query profile(s) from {', '.join(owners)}", "info", ) return True def preflight_managed_host_processes(self) -> bool: """Refuse a launch whose managed host would refuse to start (#1342). Runs before port configuration, which stops a running stack on a warm start. The checks are the ones the host start treats as fatal (host preflight, untrusted pid record, a foreign listener on the port), all read-only, so a failure leaves the previous containers running. """ env = self.config_parser.parse_env_file() for var, value, label, module in _MANAGED_HOST_SOURCES: if (env.get(var) or "").strip() != value: continue problem = _managed_host_pool_blocker(importlib.import_module(module), env, label) if problem: self.banner.show_status_message( f"Managed {label} host cannot start: {problem}. The launch " "stops here, before a warm start would stop the running " "containers.", "error", ) return False return True def start_managed_host_processes(self) -> bool: """Start selected native hosts immediately before Compose startup. Configuration generation remains side-effect free, and a later managed host failure rolls back only processes created by this invocation. """ try: if not self._finalize_managed_comfyui_mps(): self.rollback_managed_host_processes() return False if not self._finalize_managed_vllm_metal(): self.rollback_managed_host_processes() return False if not self._finalize_managed_blender_mcp(): self.rollback_managed_host_processes() return False # #757: non-fatal — a host daemon the user hasn't started (or a # typo'd tag) must never abort the stack; it only warns. self._finalize_ollama_localhost_models() except BaseException: self.rollback_managed_host_processes() raise return True def _leave_legacy_managed_host(self, label: str, exc: BaseException) -> bool: """Warn and carry on when ``exc`` is the stamp-less pid refusal (#990). A pid file from an Atlas pin older than the managed-host framework has no identity stamp, so the guard cannot prove the live process is Atlas's and will not signal it. That refusal stands. What changes is its cost: it no longer aborts the whole bring-up before Compose runs. The process is left exactly as found (typically the previous pin's host, still serving) and nothing is added to rollback ownership, because this run created nothing. Every other ownership refusal stays fatal. A stamped record whose live pid is provably a younger process never reaches here: the guard treats that recycled pid as a stale record and the start replaces it (#1341). """ from services import legacy_pid_refusal_file if legacy_pid_refusal_file(exc) is None: return False # The remediation commands end the message, so nothing is appended # after them that could be pasted into a shell with the path. self.banner.show_status_message( f"Managed {label} host left as found; the rest of the stack starts, " f"and once the old process is stopped the next ./start.sh launches " f"a fresh, identity-stamped {label} process. {exc}", "warning", ) return True def rollback_managed_host_processes(self) -> bool: """Stop native hosts started by this invocation, in reverse order.""" from services import tracked_process_may_survive remaining: list[tuple[str, object]] = [] all_stopped = True for label, manager in reversed(self._managed_hosts_started_this_run): try: manager.stop() pid, may_survive = tracked_process_may_survive(manager) except Exception as exc: # noqa: BLE001 - preserve other cleanup self.banner.show_status_message( f"Could not roll back managed {label} host: {exc}", "warning" ) remaining.append((label, manager)) all_stopped = False continue if not may_survive: self.banner.show_status_message( f"Rolled back managed {label} host started by this launch.", "info", ) else: detail = f" (pid {pid})" if pid is not None else "" self.banner.show_status_message( f"Managed {label} host may still be running after rollback" f"{detail}; tracking evidence was preserved.", "warning", ) remaining.append((label, manager)) all_stopped = False self._managed_hosts_started_this_run = list(reversed(remaining)) return all_stopped def commit_managed_host_processes(self) -> None: """Release rollback ownership after the stack has converged.""" for _label, manager in self._managed_hosts_started_this_run: commit = getattr(manager, "commit_started_process", None) if callable(commit): commit() self._managed_hosts_started_this_run.clear() def _finalize_managed_blender_mcp(self) -> bool: """Bring up the Atlas-managed headless Blender + MCP bridge (#759). ``BLENDER_MCP_SOURCE=managed-localhost`` provisions the pinned add-on + launcher and runs `blender --background` with the main-thread queue shim, so composition automation needs no GUI and no manual Connect click. Fatal on failure (the user explicitly chose this source); no-op for localhost (user-run GUI) and disabled. With BLENDER_MCP_INSTANCES=N (#851) every pool instance is started and health-gated on its own allocated port, and instances left above N by a smaller pool are stopped first. """ from services.blender_mcp_manager import pool_from_env, pool_moves, share_verified_addon env = self.config_parser.parse_env_file() if (env.get("BLENDER_MCP_SOURCE", "") or "").strip() != "managed-localhost": return True pool = pool_from_env(env) count = f" ({len(pool)} instances)" if len(pool) > 1 else "" self.banner.show_status_message( " • BLENDER_MCP_SOURCE=managed-localhost — provisioning the pinned " f"blender-mcp add-on and launching headless Blender{count}…", "info", ) self._reap_stray_blender_mcp(env) if len(pool) > 1 and pool_moves(pool): # A shifted base port can make one instance's new port another's # old one, so the pool restarts together rather than one by one. for manager in pool: manager.stop() for manager in pool: if getattr(manager, "pool_index", 0) == 1: share_verified_addon(pool) if not self._start_blender_mcp_instance(manager): return False return True def _reap_stray_blender_mcp(self, env: dict) -> None: """Stop pool instances above the configured size (#851).""" from services.blender_mcp_manager import manager_from_env reap_stray_blender_mcp(manager_from_env(env), self.banner.show_status_message) def _start_blender_mcp_instance(self, manager) -> bool: from services.blender_mcp_manager import BlenderMcpError index = getattr(manager, "pool_index", 0) label = f"Blender MCP #{index}" if index else "Blender MCP" try: status, created = manager.ensure_running() except BlenderMcpError as exc: if self._leave_legacy_managed_host(label, exc): return True if exc.surviving_process: self._managed_hosts_started_this_run.append((label, manager)) self.banner.show_status_message( f"Managed {label} bridge could not start: {exc}", "error" ) return False if created: self._managed_hosts_started_this_run.append((label, manager)) health = manager.health() if health.get("reachable"): self.banner.show_status_message( f" • {label} bridge healthy on tcp://{manager.bind}:{manager.port} " f"(scene objects: {health.get('objects')})", "info", ) else: self.banner.show_status_message( f" • {label} bridge started but not yet answering commands — " "first load can lag; check `./start.sh blender-mcp health`.", "warning", ) return True def _finalize_ollama_localhost_models(self) -> None: """Pull declared models onto the host Ollama for ``ollama-localhost`` (#757) — the host analog of the ``ollama-pull`` init container, so a consumer's ``model_sidecars.ollama`` declaration provisions identically across sources. Idempotent (present tags skip; Ollama verifies layers natively) and strictly non-fatal: an unreachable daemon or a failed tag warns and moves on. Pulled models surface in LiteLLM ``/v1/models`` without a restart via OLLAMA_AUTO_IMPORT_LOCAL_MODELS (default true). """ from services.ollama_localhost import declared_models, pull_declared_models env = self.config_parser.parse_env_file() if (env.get("LLM_PROVIDER_SOURCE", "") or "").strip() != "ollama-localhost": return declared = declared_models(env) if not declared: return self.banner.show_status_message( f" • LLM_PROVIDER_SOURCE=ollama-localhost — ensuring {len(declared)} " f"declared model tag(s) on the host Ollama (present tags skip)…", "info", ) result = pull_declared_models( env, log=lambda m: self.banner.show_status_message(f" {m}", "info") ) if not result.reachable: self.banner.show_status_message( " Host Ollama is not reachable — start it (`ollama serve`) and " "re-run ./start.sh to provision the declared models.", "warning", ) return if result.failed: for failure in result.failed: self.banner.show_status_message(f" ✗ {failure}", "warning") self.banner.show_status_message( " • Some Ollama models could not be pulled — the stack starts " "anyway; fix the tag(s) and re-run ./start.sh.", "warning", ) def _finalize_managed_comfyui_mps(self) -> bool: """Bring up the Atlas-managed Apple-Silicon/Metal ComfyUI host (#335). Docker Desktop on macOS can't pass Metal into a Linux container, so the ``managed-localhost-mps`` source runs a native ComfyUI process on the HOST and containers reach it via ``host.docker.internal`` (identical wiring to the unmanaged ``localhost`` source). When that source is selected, preflight + install (idempotent — only the first run downloads Torch) + start the host process here, before ``docker compose up``, so the endpoint is live when downstream containers come up. A no-op for every other source, so CI (whose ``.env.example`` default is ``container-cpu``) never touches it. Fatal on failure: the user explicitly chose this source, and downstream image generation is broken without the host process — surface it loudly rather than boot a half-configured stack. """ from services.comfyui_mps_manager import ComfyUiMpsError, manager_from_env env = self.config_parser.parse_env_file() if str(env.get("COMFYUI_SOURCE", "")).strip() != "managed-localhost-mps": return True manager = manager_from_env(env) self.banner.show_status_message( " • COMFYUI_SOURCE=managed-localhost-mps — preparing the native " "Apple-Silicon/Metal ComfyUI host (first run downloads Torch; this " "can take several minutes)…", "info", ) # #754: provision the resolved model set into the host tree BEFORE the # process serves requests — same catalog selection the container init # would pull, same non-fatal philosophy (a failed download must not # abort the stack; generation just 404s for that model until re-run). rows = _resolved_comfyui_model_rows(env) if rows and not manager.preflight().ok: self.banner.show_status_message( " • Skipping model provisioning — host preflight fails (the " "start below will report why); nothing multi-GB is downloaded " "onto an unsupported host.", "warning", ) rows = [] if rows: self.banner.show_status_message( f" • Provisioning {len(rows)} declared ComfyUI model file(s) " f"into {manager.models_path} (idempotent; present files skip)…", "info", ) provision = manager.provision_models( rows, log=lambda m: self.banner.show_status_message(f" {m}", "info") ) for warning in provision.warnings: self.banner.show_status_message(f" {warning}", "warning") if not provision.ok: for failure in provision.failed: self.banner.show_status_message(f" ✗ {failure}", "warning") self.banner.show_status_message( " • Some ComfyUI models could not be provisioned — the host " "starts anyway; re-run `./start.sh comfyui-mps provision` " "after fixing the issue.", "warning", ) # #905: custom nodes live inside the ComfyUI checkout's custom_nodes/ and # are scanned only at process STARTUP (unlike models, which load lazily), # so they must be on disk before the process launches below. node_rows = _resolved_comfyui_custom_nodes(env) if node_rows and not manager.preflight().ok: self.banner.show_status_message( " • Skipping custom-node provisioning — host preflight fails.", "warning", ) node_rows = [] if node_rows and not manager.repo_dir.exists(): # The checkout is cloned inside ensure_running_with_ownership on first # run; clone it now so custom nodes have a home before provisioning. try: manager.install() except ComfyUiMpsError as exc: self.banner.show_status_message( f" • Could not install ComfyUI for custom-node provisioning " f"({exc}); nodes skipped — the start below retries the install.", "warning", ) node_rows = [] if node_rows: already_running = manager.status().running self.banner.show_status_message( f" • Provisioning {len(node_rows)} ComfyUI custom node(s) into " f"{manager.repo_dir}/custom_nodes (idempotent; present skip)…", "info", ) node_provision = manager.provision_custom_nodes( node_rows, log=lambda m: self.banner.show_status_message(f" {m}", "info") ) for warning in node_provision.warnings: self.banner.show_status_message(f" {warning}", "warning") if not node_provision.ok: for failure in node_provision.failed: self.banner.show_status_message(f" ✗ {failure}", "warning") self.banner.show_status_message( " • Some ComfyUI custom nodes could not be provisioned — the " "host starts anyway; re-run `./start.sh comfyui-mps " "provision-nodes` after fixing the issue.", "warning", ) elif node_provision.provisioned and already_running: # Custom nodes load only at process startup; a process that was # already running before this start does not rescan custom_nodes/. self.banner.show_status_message( " • Newly installed ComfyUI custom node(s) take effect only " "after a ComfyUI (MPS) restart — run `./start.sh comfyui-mps " "stop` then `./start.sh` (the nodes are cloned; a warm process " "does not reload them).", "warning", ) try: status, created = manager.ensure_running_with_ownership() except ComfyUiMpsError as exc: if self._leave_legacy_managed_host("ComfyUI (MPS)", exc): return True if exc.surviving_process: self._managed_hosts_started_this_run.append( ("ComfyUI (MPS)", manager) ) self.banner.show_status_message( f"Managed ComfyUI (MPS) host could not start: {exc}", "error" ) return False if created: self._managed_hosts_started_this_run.append(("ComfyUI (MPS)", manager)) health = manager.wait_healthy(timeout=60.0) if not manager.confirm_started_process(status.pid): self.banner.show_status_message( f"Managed ComfyUI (MPS) process {status.pid} exited before " "startup verification completed.", "error", ) return False if health.get("reachable"): self.banner.show_status_message( f" • Managed ComfyUI (MPS) is up on port {status.port} " f"(device={health.get('device')}, pid={status.pid})", "info", ) else: # Not fatal: ComfyUI loads lazily and downstream containers retry. self.banner.show_status_message( f" • Managed ComfyUI (MPS) launched (pid={status.pid}, port " f"{status.port}); still warming up — the first request loads the " f"model. Logs: {status.log_file}", "warning", ) return True def _finalize_managed_vllm_metal(self) -> bool: """Bring up the Atlas-managed vLLM Metal host (#379). Docker Desktop on macOS can't pass Metal into a Linux container, so the ``managed-localhost`` source runs a native vLLM process on the HOST (via the ``vllm-metal`` plugin) and LiteLLM reaches it as an OpenAI-compatible upstream at ``host.docker.internal:/v1``. When that source is selected, preflight + install (idempotent — only the first run installs the wheel + downloads weights) + start the host process here, before ``docker compose up``, so the endpoint is live when litellm-init renders the model_list. A no-op for every other source, so CI (whose ``.env.example`` default is ``disabled``) never touches it. Fatal on failure: the user explicitly chose this source, so surface a broken host process loudly rather than boot a stack whose LiteLLM upstream is dead. """ from services.vllm_metal_manager import VllmMetalError, manager_from_env env = self.config_parser.parse_env_file() if str(env.get("VLLM_METAL_SOURCE", "")).strip() != "managed-localhost": return True manager = manager_from_env(env) self.banner.show_status_message( " • VLLM_METAL_SOURCE=managed-localhost — preparing the native " "Apple-Silicon/Metal vLLM host (first run installs the wheel and " "downloads model weights; this can take several minutes)…", "info", ) try: status, created = manager.ensure_running_with_ownership() except VllmMetalError as exc: if self._leave_legacy_managed_host("vLLM (Metal)", exc): return True if exc.surviving_process: self._managed_hosts_started_this_run.append( ("vLLM (Metal)", manager) ) self.banner.show_status_message( f"Managed vLLM (Metal) host could not start: {exc}", "error" ) return False if created: self._managed_hosts_started_this_run.append(("vLLM (Metal)", manager)) health = manager.wait_healthy(timeout=120.0) if not manager.confirm_started_process(status.pid): self.banner.show_status_message( f"Managed vLLM (Metal) process {status.pid} exited before " "startup verification completed.", "error", ) return False if health.get("reachable"): self.banner.show_status_message( f" • Managed vLLM (Metal) is up on port {status.port} " f"(pid={status.pid}); LiteLLM will register model " f"'{env.get('VLLM_METAL_MODEL', '').strip()}'.", "info", ) else: # Not fatal: vLLM's OpenAI server answers HTTP only after it has # loaded the weights (downloading them on a first run), which can # outlast the 120 s wait. litellm-init registers the model without # probing it, so calls through LiteLLM fail to connect until the # server answers. self.banner.show_status_message( f" • Managed vLLM (Metal) launched (pid={status.pid}, port " f"{status.port}); still loading weights — calls through LiteLLM " f"fail to connect until it answers. Logs: {status.log_file}", "warning", ) return True def generate_litellm_configuration(self) -> bool: """Write a STUB volumes/litellm/config.yaml so the bind mount has a file to attach to. The real model_list is rendered by ``litellm-init`` from the YAML catalogs + env on every ``docker compose up``. ``force=True`` here, but the writer is NOT unconditionally destructive — ``LiteLLMConfigGenerator.write_config`` checks for the litellm-init sentinel header + non-empty model_list and preserves that file even with force=True. This protects the previous run's real config across re-runs that haven't yet completed a docker compose up. Stub / corrupt / missing files DO get overwritten. See ``_is_litellm_init_managed``. """ try: from utils.litellm_config_generator import LiteLLMConfigGenerator generator = LiteLLMConfigGenerator(self.config_parser) config_path = self.root_dir / "volumes/litellm/config.yaml" # Pre-flight: ensure the bind-mount target directory is # writable. Earlier docker compose runs (litellm-init runs # as root inside the container) can leave the host directory # root-owned, blocking subsequent container writes — # symptom is ``PermissionError: '/litellm-config/config.yaml.tmp'``. self._ensure_volume_dir_writable(config_path.parent) generator.write_config(config_path, force=True) return True except Exception as e: self.banner.show_status_message(f"Failed to generate LiteLLM configuration: {e}", "error") return False def generate_comfyui_manifest(self) -> bool: """Write ``volumes/comfyui/selected-models.yaml`` and ``volumes/comfyui/active-models.tsv`` so ``comfyui-init`` can download the active model set without querying the DB. Resolves the active set via ``comfyui_resolver.active_comfyui_models`` (C2 — DB-free, pure env + catalog computation). Both files are written atomically; the TSV uses the same column order as the former ``psql SELECT`` so ``download_models.sh``'s existing loop is unchanged. Skipped cleanly when ``COMFYUI_SOURCE == "disabled"``; that case returns True (not an error) after ensuring ``volumes/comfyui/`` exists — the always-on backend bind-mounts the directory, and a Docker-auto-created one would be root-owned on rootful Linux daemons (#568). Mirrors ``generate_litellm_configuration`` — see that method for the general pattern. """ try: from utils.comfyui_manifest_generator import ComfyUIManifestGenerator env = self.config_parser.parse_env_file() generator = ComfyUIManifestGenerator(env) manifest_dir = self.root_dir / "volumes/comfyui" if not generator.is_enabled(): # Disabled — nothing to write, but keep the directory # present: the always-on backend bind-mounts it, and a # Docker-auto-created host dir is root-owned on rootful # Linux daemons (#568). Plain mkdir only — no permission # repair, so the tracked marker files are never wiped. manifest_dir.mkdir(parents=True, exist_ok=True) return True self._ensure_volume_dir_writable(manifest_dir) generator.write(manifest_dir) if getattr(generator, "unresolved", None): self.banner.show_status_message( "ComfyUI models in COMFYUI_USER_MODELS not resolvable from the " "catalog, the custom sidecar or an earlier start, so not staged: " + ", ".join(generator.unresolved), "warning", ) return True except Exception as e: self.banner.show_status_message( f"Failed to generate ComfyUI manifest: {e}", "error" ) return False def _ensure_volume_dir_writable(self, path: "Path") -> None: """Make sure a bootstrapper-managed host directory is writable by both the current host user and any future container that bind-mounts it. Earlier container runs can leave the directory root-owned with 755 mode, blocking subsequent re-writes. Strategy: if the directory exists and is not writable, add owner rwx. Only the owner can chmod, and no container writes these directories as a non-root user, so other-write is never added (#1387). If chmod fails (another user owns it), preserve the directory and report the ownership repair command. Never raises — falls back to letting the original write fail with its native error when ownership prevents the permission repair. """ if not path.exists(): path.mkdir(parents=True, exist_ok=True) return if not path.is_dir(): return # caller's problem; not our job to second-guess if os.access(path, os.W_OK): return # already writable, nothing to do # Owner rwx is the cheapest, least destructive repair. try: path.chmod(path.stat().st_mode & 0o7777 | 0o700) if os.access(path, os.W_OK): self.banner.show_status_message( f" • Relaxed permissions on {path} (was root-owned from a prior container run)", "info", ) return except OSError: pass # Never delete a bind-mounted directory to recover permissions: it may # contain user models, generated configuration, or application state. self.banner.show_status_message( f" • Could not make {path} writable; existing contents were preserved. " f"Repair ownership with `sudo chown -R $(id -u):$(id -g) " f"{shlex.quote(str(path))}` " f"and re-run ./start.sh", "warning", ) def _derive_plugin_route_policies(self) -> tuple[list, list]: """Derive Kong auth and timeout policy from plugin.yml manifests. Returns ``(route_auth, route_timeouts)`` from one discovery snapshot. Auth entries are ``(route_prefix, mode)`` for non-inherit auth; timeout entries are ``(plugin_name, route_prefix, explicit_timeout_mapping)``. A malformed/conflicting *plugin* manifest is handled inside ``discover_plugin_manifests`` (collected as an error, its plugin dropped) and never raises — the consumer doctor surfaces those. This deliberately does NOT swallow other exceptions: an Atlas-internal failure (missing schema, import error) must **fail closed** through the caller's handler, which aborts Kong generation with a visible message, rather than silently downgrading a ``key-auth`` prefix to the (possibly open) default (#402 review M2). """ from core.plugin_manifest import ( derive_route_auth, derive_route_timeouts, discover_plugin_manifests, ) plugin_dirs = _resolve_plugin_dirs(self) if not plugin_dirs: return [], [] discovery = discover_plugin_manifests(plugin_dirs) return ( derive_route_auth(discovery.manifests), derive_route_timeouts(discovery.manifests), ) def generate_kong_configuration(self) -> bool: """Generate dynamic Kong configuration based on SOURCE values.""" try: from utils.kong_config_generator import KongConfigGenerator generator = KongConfigGenerator(self.config_parser) generator.track_key = getattr(self, "active_track", None) generator.overridden_services = getattr( self, "active_track_overrides", frozenset() ) # Derive auth and service-level timeout policy from one plugin.yml # discovery snapshot. Empty for base Atlas (no plugins) preserves # the historical backend route shape. ( generator.plugin_route_auth, generator.plugin_route_timeouts, ) = self._derive_plugin_route_policies() # Pre-flight: same root-owned-from-prior-container guard as # the litellm bind-mount uses (kong-api-gateway also writes # nothing into volumes/api but the bootstrapper drops the # dynamic config there for the container to read). self._ensure_volume_dir_writable(self.root_dir / "volumes/api") kong_config = generator.generate_kong_config() errors = generator.validate_config(kong_config) if errors: self.banner.show_status_message("Kong configuration validation failed:", "error") for error in errors: self.banner.console.print(f" • {error}") return False config_path = self.root_dir / "volumes/api/kong-dynamic.yml" if not generator.write_config(kong_config, config_path): return False return True except Exception as e: self.banner.show_status_message(f"Failed to generate Kong configuration: {e}", "error") return False def check_service_dependencies(self) -> bool: """Check and enforce service dependencies. Silent on success.""" dependencies_satisfied = self.dependency_manager.check_service_dependencies() if not dependencies_satisfied: violations = self.dependency_manager.get_dependency_violations() self.banner.show_status_message("Service dependency violations found:", "warning") for violation in violations: self.banner.console.print(f" ⚠️ {violation['error_message']}") disabled_services = self.dependency_manager.auto_resolve_dependency_violations() if not disabled_services: self.banner.show_status_message("Could not auto-resolve dependency violations", "error") return False for service in disabled_services: self.banner.show_status_message(f"Auto-disabled {service} due to missing dependencies", "warning") # RE-CHECK. A non-empty list only says SOMETHING was disabled — not # that every violation is resolved. If four of five writes failed, # startup proceeded with the rest unresolved; and because the # original check ran against the pre-write env, a service requiring # one just auto-disabled was never re-evaluated (no such 2-hop # chain exists in today's graph, but nothing prevents one). if self.dependency_manager.check_service_dependencies(): return True self.banner.show_status_message( "Dependency violations remain after auto-resolution", "error" ) return False return True def handle_hosts_configuration(self, setup_hosts: bool, skip_hosts: bool) -> bool: """Handle hosts file configuration. Never fatal: missing entries only break the friendly ``*.localhost`` URLs, not the stack. skip: no check. setup: add missing entries (see below). default: warn once when entries are missing, as the wizard option and ``--skip-hosts`` help promise. """ if skip_hosts: return True if setup_hosts: # The wizard runs this inside the Textual app, which the # non-root wrapper never elevates and where sudo cannot prompt. # Nothing to write needs no privilege; a passwordless sudo is # used; otherwise the missing entries are reported, not fatal. if not self.hosts_manager.check_missing_hosts(): return True if _run_privileged_hosts_setup(non_interactive=True): return True # print: the TUI's log pane captures stdout as well as its banner. print( "WARNING: hosts entries not added: sudo needs a password, which " "the wizard cannot prompt for. Run ./start.sh --setup-hosts in a " "terminal to add them." ) return True missing = self.hosts_manager.check_missing_hosts() if missing: print( f"WARNING: {len(missing)} *.localhost hosts entries are missing; " "friendly URLs need them. Run ./start.sh --setup-hosts to add " "them, or --skip-hosts to silence this check." ) return True def perform_cold_start_cleanup(self, project_name: Optional[str] = None) -> bool: """Perform cold start cleanup if requested.""" self.banner.show_section_header("Cold Start Cleanup", "🧹") self.banner.show_status_message("Performing cold start cleanup...", "info") # Use the enhanced cold start cleanup. Forward the CLI project name so a # `--cold --project foo` run tears down `foo` rather than the stale # project still recorded in .env (the override is not persisted until # setup_env_file runs later). success = self.docker_manager.perform_cold_start_cleanup(project_name=project_name) self.project_stopped_this_run = getattr(self, "project_stopped_this_run", False) or bool(success) if not success: self.banner.show_status_message("Cold cleanup failed; secrets were not rotated", "error") else: self.banner.show_status_message("Cold cleanup completed successfully", "success") return success def generate_encryption_keys(self, cold_start: bool = False) -> bool: """ Generate missing encryption keys for services. Always generates missing keys; regenerates ALL keys on cold start (``cold_start=True``). Args: cold_start: If True, regenerate all keys. If False, only generate missing ones. Returns: bool: True if successful """ force_regenerate = cold_start try: results = self.key_generator.generate_missing_keys(force_regenerate=force_regenerate) if all(results.values()): return True else: failed_keys = [key for key, success in results.items() if not success] self.banner.show_status_message( f"Failed to generate encryption keys: {', '.join(failed_keys)}", "error" ) return False except Exception as e: self.banner.show_status_message(f"Error generating encryption keys: {e}", "error") return False def validate_localhost_services(self) -> bool: """Validate localhost services are accessible before starting.""" # Check if any services are configured for localhost if not self.localhost_validator.has_localhost_services(): return True # No localhost services to validate self.banner.show_section_header("Validating Localhost Services", "🔍") try: results = self.localhost_validator.validate_all_localhost_services() if not results: return True # No localhost services found # Display results all_valid = True # STT_PROVIDER_SOURCE / TTS_PROVIDER_SOURCE use the per_source # SERVICE_CHECKS shape (service_name lives inside each variant, not # at the top level), so index via the per_source-aware resolver — # raw SERVICE_CHECKS[source_var]['service_name'] KeyErrors for them. service_sources = self.localhost_validator.config_parser.parse_service_sources() for source_var, (is_valid, messages) in results.items(): config = self.localhost_validator._resolve_source_config( source_var, service_sources.get(source_var, "") ) or {} service_name = config.get('service_name', source_var) level = "info" if is_valid else "warning" self.banner.show_status_message(f" • {service_name}:", level) for message in messages: self.banner.show_status_message(f" {message}", level) if not is_valid: all_valid = False if all_valid: self.banner.show_status_message("All localhost services are accessible", "success") else: self.banner.show_status_message( "Some localhost services are not accessible (warnings above)", "warning" ) self.banner.show_status_message( " • The stack will still start, but affected services may not work correctly", "warning", ) self.banner.show_status_message( " • Please ensure localhost services are running as indicated", "warning", ) return True # Always continue, just show warnings except Exception as e: self.banner.show_status_message(f"Error validating localhost services: {e}", "error") return True # Continue anyway def start_docker_services(self, cold_start: bool = False, wait: bool = False) -> bool: """Start Docker services, building local images first when needed. A cold start builds every enabled image without cache; a warm start builds only when its local images are stale (#506). Either way, an image that fails to build stops the launch only when another enabled service depends on it or it belongs to the always-running core; otherwise it is named and left out of ``up`` (#989, see ``isolate_failed_build``). """ self.banner.show_section_header("Starting Services", "🚀") self.skipped_builds = [] self._readiness_snapshot = None if cold_start: self.banner.show_status_message("Starting containers with fresh build (cold start)...", "info") result = self._cold_compose_up(wait) else: self.banner.show_status_message("Starting Atlas services...", "info") result = self._warm_compose_up(wait) if result is None: self.banner.show_status_message("Failed to build some services", "error") self.rollback_managed_host_processes() return False if result != 0: # Known benign race (#508): `up -d --wait` can return nonzero when # an enabled one-shot init exits 0 during the wait window (Compose # reports e.g. "container …-n8n-init exited (0)"), even though the # whole stack converged. Before failing, inspect the resolved # state — if every expected service/replica is running/healthy or # a declared one-shot exited 0, continue to the normal one-shot # verification instead of false-failing automation consumers. Genuine failures (nonzero # exits, unhealthy/non-running services) still fail, with names. if not (wait and self._up_wait_race_converged()): self.banner.show_status_message("Failed to start some services", "error") self.rollback_managed_host_processes() return False if not self.verify_one_shot_init_containers(): self.rollback_managed_host_processes() return False if not self._reactivate_n8n_if_needed(): self.rollback_managed_host_processes() return False self.commit_managed_host_processes() self._show_started_status() return True def _show_started_status(self) -> None: """Say all services started, or which ones the image build left out.""" if self.skipped_builds: self.banner.show_status_message( started_without(self.skipped_builds), "warning" ) return self.banner.show_status_message("All services started successfully", "success") def _cold_compose_up(self, wait: bool): """Build every enabled image without cache, then ``up``. Returns the ``up`` exit code, or None when a build stopped the launch. """ # Enabled-service target set from the rendered projection (#504) — # a broken build for a disabled/out-of-track service must not # abort the cold start. None (projection failure) falls back to # the historical full-graph build/up. targets = self.docker_manager.enabled_service_targets() self.docker_manager.capture_build_state(targets) # Build images without cache (matching original Bash script behavior) print(" - Building images without cache...") built, targets = self._build_launch_images(targets, no_cache=True) if not built: return None # Cold start just built every enabled local image fresh — record the # source commit so the next warm start (#506) doesn't rebuild them # again until the source actually changes. When a failed image was # left out, `targets` no longer matches the captured build state, # so nothing is recorded and the next start rebuilds. self.docker_manager.mark_source_built(targets) print(" - Starting containers...") return self._compose_up(targets, wait) def _warm_compose_up(self, wait: bool): """``up`` the enabled services, building stale images first (#506). Returns the ``up`` exit code, or None when a build stopped the launch. Current images: one ``up`` through ``start_services``, as before. Stale images with a known target set: an explicit build of the targets, so a failed optional image can be left out (#989), then ``up`` without ``--build``; the build state is recorded only when ``up`` succeeded with every target. Without a target set, ``start_services`` keeps the historical full-graph ``up --build``. """ targets = self.docker_manager.enabled_service_targets() if not (targets and self.docker_manager.prepare_build_args(False, targets)): self._capture_readiness_targets(targets) return self.docker_manager.start_services( detached=True, wait=wait, services=targets ) print(" - Building stale images...") built, targets = self._build_launch_images(targets, no_cache=False) if not built: return None print(" - Starting containers...") result = self._compose_up(targets, wait) if result == 0: self.docker_manager.mark_source_built(targets) return result def _compose_up(self, targets, wait: bool) -> int: self._capture_readiness_targets(targets) # Always force-recreate, matching start_services. up_args = ['up', '-d', '--force-recreate'] if wait: up_args.extend(['--wait', '--wait-timeout', '900']) if targets: up_args.extend(targets) return self.docker_manager.execute_compose_command(up_args) def _build_launch_images(self, targets, no_cache: bool): """Build the targets' local images before ``up`` (#989). Returns ``(built, targets)``: the build passed, or every failed image was one the launch can go without, in which case those services are named here and dropped from the returned targets. """ build_result = self.docker_manager.build_services( no_cache=no_cache, pull=False, services=targets ) if build_result == 0: return True, targets built, targets, skipped = self.isolate_failed_build(targets, no_cache) for text, tone in build_skip_rows(skipped): self.banner.show_status_message( text, {"warn": "warning"}.get(tone, tone) ) return built, targets def isolate_failed_build(self, targets, no_cache: bool = True): """Find out which images failed after the whole-target build did (#989). Returns ``(built, targets_to_start, skipped)``. The normal build is untouched: this runs only after one ``docker compose build`` of every enabled target has failed. The images another enabled service lists in ``depends_on``, and those of the always-running core, are rebuilt together first and still stop the launch if they fail. Each remaining image is then rebuilt on its own; one that fails again is left out of the targets and named in ``skipped`` (and ``self.skipped_builds``) so the rest of the stack starts. Without a known target set, or with no image that could be left out, the launch stops exactly as before, with no rebuild. """ self.skipped_builds = [] plan = self._local_build_plan(targets) if plan is None: return False, targets, () required, optional = plan print( " - Build failed; rebuilding to find which image failed " "(images other services depend on and core images first, then " "each other image on its own)..." ) if required and self._build_images(required, no_cache) != 0: print( " - An image other enabled services depend on, or a core " "image, did not build." ) return False, targets, () skipped = tuple( name for group in optional if self._build_images(group, no_cache) != 0 for name in group ) self.skipped_builds = list(skipped) return True, [name for name in targets if name not in skipped], skipped def _build_images(self, services: list[str], no_cache: bool) -> int: return self.docker_manager.build_services( no_cache=no_cache, pull=False, services=services ) def _local_build_plan(self, targets): """``(required, optional_groups)`` for ``isolate_failed_build``, or None. None when the target set, the rendered configuration or the core services are unknown, or when every local image is required, because then nothing could be left out and the launch stops as before. """ if not targets: return None services = self._rendered_compose_services() core = self._always_running_services() if services is None or core is None: return None required, optional = _split_local_builds(services, targets, core) return (required, optional) if optional else None def _always_running_services(self) -> Optional[set[str]]: """Compose services of the always-running core, or None if unknown. A manifest the topology marks ``locked`` has no source choice, so its containers run in every configuration: Supabase, Kong, Redis, LiteLLM and Backend. (``chatterbox`` and ``speaches`` are also locked, as TTS engines activated through TTS_PROVIDER_SOURCE; neither builds a local image, and a locked image is only ever kept required.) """ try: from services.manifests import load_manifests from services.topology import get_topology root = self.root_dir / "services" locked = {row.manifest for row in get_topology(root).rows if row.locked} return { str(container) for manifest in load_manifests(root) if manifest.name in locked for container in manifest.containers } except Exception: # noqa: BLE001 — unknown means stop as before return None def _rendered_compose_services(self) -> Optional[dict]: """The resolved ``docker compose config`` services, or None.""" try: cmd = self.docker_manager._build_compose_command( ['config', '--format', 'json'] ) result = run_with_deadline( cmd, cwd=str(self.docker_manager.root_dir), timeout_seconds=60.0 ) if result.returncode != 0: return None return (json.loads(result.stdout) or {}).get("services") or None except Exception: # noqa: BLE001 — unknown means stop as before return None def _capture_readiness_targets(self, targets) -> None: """Freeze the effective post-build launch model for recovery and status.""" self._readiness_snapshot = self._resolve_readiness_targets(targets) def _resolve_readiness_targets(self, targets=None) -> tuple[dict, set, str | None, str | None]: """Resolve requested services and dependency companions from Compose.""" services = self._rendered_compose_services() try: replicas = service_replica_targets(services) skipped = set(getattr(self, "skipped_builds", ())) selected = set(targets) if targets else { name for name, count in replicas.items() if count > 0 } - skipped expected = _desired_compose_targets(services, replicas, selected, skipped) project = ( self.docker_manager.project_name_override or self.docker_manager.config_parser.get_project_name() ) return expected, _one_shot_service_names(services), project, None except ValueError as exc: return {}, set(), None, f"could not determine desired Compose services: {exc}" def _reactivate_n8n_if_needed(self) -> bool: """Restart n8n once after seeding so a consumer's production webhook registers when the workflow was activated **without** an ``N8N_API_KEY``. n8n CE registers a workflow's production webhook only when the n8n *server* (re)starts. With a key, the seed activates over the public API and the webhook registers immediately (no restart). Without a key, the seed persists ``active=true`` via ``n8n publish:workflow`` — but the running server ignores that until it restarts (empirically verified on n8nio/n8n:2.28.2; the 2.36.7 image retains the same ``publish:workflow --id`` CLI boundary). So Atlas performs the one restart the consumer would otherwise do by hand. No-op with a key, with n8n disabled, or when no active consumer workflow is declared. """ try: consumer = self.config_parser.load_consumer_config() env = self.config_parser.parse_env_file() except Exception as exc: self.banner.show_status_message( f"Could not determine whether n8n webhook reactivation is " f"required: {exc}", "error", ) return False if not _n8n_needs_reactivation_restart(env, consumer): return True self.banner.show_status_message( "Restarting n8n to register consumer webhook(s) (no N8N_API_KEY)...", "info", ) try: result = self.docker_manager.execute_compose_command(["restart", "n8n"]) except Exception as exc: # noqa: BLE001 — launch must convert to a verdict self.banner.show_status_message( f"Failed to restart n8n for webhook registration: {exc}", "error" ) return False if result != 0: self.banner.show_status_message( "Failed to restart n8n for consumer webhook registration; " f"compose returned exit code {result}.", "error", ) return False return self._n8n_restart_converged() def _n8n_restart_converged(self) -> bool: services, converged, _waited, error = self._poll_until_converged( grace_seconds=120.0, poll_interval_seconds=2.0, poll_rows=self._strict_n8n_health_rows, desired_scope={"n8n"}, ) if error is not None: detail = error elif not services: detail = "n8n is absent from compose status" elif not converged: detail = "; ".join( f"{entry.get('service', 'n8n')}: {entry.get('reason', 'not healthy')}" for entry in services if not entry.get("ok") ) or "n8n did not become healthy" else: return True self.banner.show_status_message( f"n8n restart did not converge to healthy: {detail}", "error" ) return False def _strict_n8n_health_rows(self) -> tuple[list[dict], str | None]: rows, error = self.docker_manager.compose_service_ps_json("n8n") return [self._strict_n8n_health_row(row) for row in rows], error @staticmethod def _strict_n8n_health_row(row: dict) -> dict: state = str(row.get("State") or "").strip().lower() health = str(row.get("Health") or "").strip().lower() if state == "running" and health == "healthy": return row strict = dict(row) if state == "running" and health in {"", "starting"}: strict["Health"] = "starting" elif state != "running": strict["ExitCode"] = "1" strict["Status"] = "n8n is not running" return strict def _poll_until_converged( self, *, grace_seconds: float = 120.0, poll_interval_seconds: float = 2.0, poll_rows=None, sleep=None, monotonic=None, desired_scope: set[str] | None = None, ) -> tuple[list[dict], bool, bool, str | None]: """Inspect ``compose ps`` and re-poll convergent-pending rows within a bounded grace window (#677/#681). A ``state=running, health=starting`` row is *convergent-pending*, not a failure — its healthcheck is simply still in its start period. This re-polls while any row is pending, up to ``grace_seconds``, so a stack that is merely mid-probe at the first snapshot is given time to settle before being classified. Returns ``(services, converged, waited, error)``: - ``services`` — classified target rows, including missing replicas. - ``converged`` — True iff every desired service/replica is observed and ok, with at least one target and nothing still pending. - ``waited`` — True iff a pending row was observed and re-polled, so a caller can flag "converged after grace". - ``error`` — a ``compose ps`` inspection error, or None. A genuine failure (``not ok and not pending``) short-circuits immediately — there is no point waiting on a doomed start. Dependencies (``poll_rows`` / ``sleep`` / ``monotonic``) are injectable so the grace loop is unit-testable with fake ps snapshots and a fake clock. """ import time as _time poll_rows = poll_rows or self.docker_manager.compose_ps_json sleep = sleep or _time.sleep monotonic = monotonic or _time.monotonic waited = False snapshot = getattr(self, "_readiness_snapshot", None) expected, finite_services, project, error = ( snapshot if snapshot is not None else self._resolve_readiness_targets() ) if error is not None: return [], False, waited, error if desired_scope is not None: if not desired_scope <= expected.keys(): return [], False, waited, "requested status scope is absent from desired Compose services" expected = {name: count for name, count in expected.items() if name in desired_scope} # Config inspection has its own deadline; it must not spend health grace. deadline = monotonic() + grace_seconds while True: rows, error = poll_rows() if error is not None: return [], False, waited, error try: name_ids = validate_compose_status_rows(rows, project) except ValueError as exc: return [], False, waited, f"invalid docker compose ps inventory: {exc}" services = [] identities = {name: set() for name in expected} for row in rows: name = row["Service"] if name not in expected: continue identity = compose_container_identity(row, name_ids) identities[name].add(identity) services.append(self._compose_row_status(row, name in finite_services)) for name, count in expected.items(): observed = len(identities[name]) if observed < count: services.append({ "service": name, "state": "missing", "health": None, "status": None, "exit_code": None, "ok": False, "pending": False, "reason": f"missing containers: expected {count}, observed {observed} (missing {count - observed})", "expected_replicas": count, "observed_replicas": observed, "missing_replicas": count - observed, }) genuine_failure = any( not entry["ok"] and not entry["pending"] for entry in services ) pending = [entry for entry in services if entry["pending"]] # Classify now on a genuine failure (don't wait on a doomed start), # an empty stack, or once nothing is still pending. if genuine_failure or not services or not pending: converged = bool(services) and all( entry["ok"] for entry in services ) return services, converged, waited, None # Only ``starting`` rows remain and nothing has failed — grace-wait. if monotonic() >= deadline: # Grace exhausted; still-pending rows now count as failures. return services, False, waited, None waited = True sleep(min(poll_interval_seconds, max(0.0, deadline - monotonic()))) def _up_wait_race_converged( self, *, grace_seconds: float = 120.0, poll_interval_seconds: float = 2.0, poll_rows=None, sleep=None, monotonic=None, ) -> bool: """Classify a nonzero ``up -d --wait`` as a benign, converged start. Two benign races are covered: the #508 one-shot-init race (a one-shot exits 0 during the wait window) and the #677/#681 long-lived-healthcheck race (a service is still ``health=starting`` at the snapshot). Returns True only when every desired service/replica is accounted for and ok per ``_compose_row_status`` — after re-polling any still-starting rows within ``grace_seconds``. Any other outcome — inspection error, empty stack, unhealthy/non-running service, or a row still starting after the grace window — returns False and names the offenders, so a genuine failure keeps failing loudly. Injectable deps mirror ``_poll_until_converged`` for testing. """ services, converged, waited, error = self._poll_until_converged( grace_seconds=grace_seconds, poll_interval_seconds=poll_interval_seconds, poll_rows=poll_rows, sleep=sleep, monotonic=monotonic, ) if error is not None: self.banner.show_status_message( f"Could not inspect services after nonzero `up --wait`: {error}", "error", ) return False if not services: return False if not converged: for entry in services: if entry["ok"]: continue exit_note = ( f", exit code {entry['exit_code']}" if entry["exit_code"] else "" ) reason = entry["reason"] if entry["pending"]: # Still starting after the full grace window — a genuine # timeout now, not a benign mid-probe. reason = f"{reason} (still not healthy after grace window)" self.banner.show_status_message( f"{entry['service']}: {reason}{exit_note}", "error", ) return False self._up_converged_after_grace = waited detail = ( "services converged within the health-check grace window" if waited else "known successful one-shot init race" ) self.banner.show_status_message( "Compose `up --wait` returned nonzero, but every service is " f"running/healthy and every one-shot exited 0 — continuing ({detail}).", "warning", ) return True @staticmethod def _compose_row_status(row: dict, one_shot: bool = False) -> dict: service = str(row.get("Service") or row.get("Name") or "unknown") state = str(row.get("State") or "").strip().lower() health = str(row.get("Health") or "").strip().lower() status = str(row.get("Status") or "").strip() exit_code = str(row.get("ExitCode") if row.get("ExitCode") is not None else "").strip() ok = False pending = False reason = status or state or health or "unknown" if state == "running": # A running container's exit code is meaningless (Compose reports 0 # for a live container); never surface it as a failure signal — it # is the source of the misleading "starting, exit code 0" line. exit_code = "" if health == "starting": # Convergent-pending, NOT failed: the healthcheck is still in # its start period. Callers re-poll pending rows within a grace # window before classifying (#677/#681). pending = True reason = "starting" else: ok = health in {"", "healthy"} if health and health != "healthy": reason = health elif state == "exited": ok = one_shot and _one_shot_row_verdict(row)[0] == "done" if not one_shot: reason = f"{reason}; successful one-shot lifecycle not declared" elif state in {"created", "restarting", "paused", "dead"}: ok = False return { "service": service, "state": state or None, "health": health or None, "status": status or None, "exit_code": exit_code or None, "ok": ok, "pending": pending, "reason": reason, } def show_detached_status_summary( self, *, json_output: bool = False, grace_seconds: float = 120.0, poll_interval_seconds: float = 2.0, poll_rows=None, sleep=None, monotonic=None, ) -> bool: """Print final compose status for automation-friendly detached starts. Grace-aware (#677/#681): a service still ``health=starting`` at the summary moment is re-polled within a bounded window before classifying, so the final summary never false-fails on a mid-probe healthcheck. The ``--json`` payload carries ``converged_after_grace`` so automation can tell a health race apart from a first-pass-healthy start. """ services, converged, waited, error = self._poll_until_converged( grace_seconds=grace_seconds, poll_interval_seconds=poll_interval_seconds, poll_rows=poll_rows, sleep=sleep, monotonic=monotonic, ) if error is not None: payload = { "ok": False, "error": error, "services": [], "converged_after_grace": False, "not_started": list(self.skipped_builds), } if json_output: print(json.dumps(payload, indent=2, sort_keys=True)) else: self.banner.show_status_message(f"Could not inspect services: {error}", "error") return False ok = converged # The race happened if this summary had to wait, or the earlier # `up --wait` reclassification did. converged_after_grace = bool(self._up_converged_after_grace or waited) payload = { "ok": ok, "services": services, "converged_after_grace": converged_after_grace, # Images that failed to build were left out of `up` (#989); the # stack still counts as started, but automation must see the gap. "not_started": list(self.skipped_builds), } if json_output: print(json.dumps(payload, indent=2, sort_keys=True)) return ok self.banner.show_section_header("Detached Status", "📊") if not services: self.banner.show_status_message("No compose services were reported", "error") return False for entry in services: level = "success" if entry["ok"] else "error" health = f" / {entry['health']}" if entry["health"] else "" self.banner.show_status_message( f"{entry['service']}: {entry['state'] or 'unknown'}{health} — {entry['reason']}", level, ) return ok def verify_one_shot_init_containers(self, on_line=None) -> bool: """Fail startup if enabled post-start one-shot init containers failed. The TUI's cancel ends the up-to-900 s wait early through ``docker_manager.should_stop`` (#1357).""" env_vars = self.config_parser.parse_env_file() services: list[str] = [] if env_vars.get("N8N_INIT_SCALE", "0") != "0": services.append("n8n-init") if env_vars.get("OPEN_WEB_UI_INIT_SCALE", "0") != "0": services.append("open-webui-init") if env_vars.get("COMFYUI_INIT_SCALE", "0") != "0": services.append("comfyui-init") if env_vars.get("REDPANDA_INIT_SCALE", "0") != "0": services.append("redpanda-init") if env_vars.get("ZEPPELIN_INIT_SCALE", "0") != "0": services.append("zeppelin-init") try: consumer_config = self.config_parser.load_consumer_config() except Exception as exc: message = ( "Could not inspect consumer one-shot services " f"(error_type={type(exc).__name__})" ) if on_line is None: self.banner.show_status_message(message, "error") else: on_line(message, "error") return False if _n8n_seed_expected(consumer_config, env_vars): services.append("n8n-seed") # An image build that left a failed optional image out never started # it, so there is no one-shot run to wait for (#989). services = [name for name in services if name not in self.skipped_builds] if not services: return True failures = self.docker_manager.failed_one_shot_services( services, timeout_seconds=900.0, poll_interval_seconds=5.0, ) if not failures: return True for service, reason in failures: msg = f"{service} failed after compose up ({reason})" if reason != "cancelled" and not ( self.docker_manager.should_stop is not None and self.docker_manager.should_stop() ): # The exit status alone does not say why (#1357). tail = self.docker_manager.one_shot_log_tail(service) if tail: msg += "\n last log lines:\n" + "\n".join(f" {line}" for line in tail) if on_line is None: self.banner.show_status_message(msg, "error") else: on_line(f"❌ {msg}", "error") return False def show_pre_launch_summary( self, *, track: str | None = None, assume_yes: bool = False, ) -> bool: """ Display the combined configuration summary table with access URLs and hosted endpoints, then prompt for confirmation. ``track`` — forwarded to ``build_pre_launch_summary_table`` so the ``Track: `` banner line is emitted when a track is active. Returns: bool: True if user confirms, False to cancel. """ table = self.build_pre_launch_summary_table(track=track) self.banner.console.print(table) self.banner.console.print() from rich.text import Text as _Text from ui.textual.palette import style_for_category as _style_for_category from services.topology import CATEGORY_LABELS, CATEGORY_ORDER _legend = _Text() _first = True for _slug in CATEGORY_ORDER: if not _first: _legend.append(" ") _first = False _legend.append("▰", style=_style_for_category(_slug)) _legend.append(f" {CATEGORY_LABELS[_slug]}") self.banner.console.print(_legend) self.banner.console.print() # Confirmation prompt — legacy linear flow only. TUI mode runs the # launch confirmation as the wizard's last step; this branch is # reached only when --no-tui or non-TTY. if assume_yes: return True if sys.stdin.isatty(): response = self.banner.console.input( " [color(245)]Launch the stack? (Y/n):[/color(245)] " ).strip().lower() return response in ('', 'y', 'yes') return True # non-TTY: auto-confirm def build_pre_launch_summary_table(self, *, track: str | None = None): """ Build the configuration summary as a Rich Table renderable — used by the --no-tui / non-TTY linear flow (`show_pre_launch_summary`). The Textual wizard renders its own info-box and never reaches this table. ``track`` — the active track key (e.g. ``"gen-ai-rag"``), or None when no track was selected. When set, a ``Track: `` line is prepended above the services table per spec §5.2 #7. """ from rich.table import Table from rich.text import Text from rich.box import HEAVY_HEAD from wizard.model.state_builder import all_services, all_cloud_apis, alias_for, cloud_api_status_text, kong_listener_port from services.topology import get_topology from ui.textual.palette import style_for_category _topology = get_topology() _category_by_name = {r.display_name: r.category for r in _topology.rows} env_vars = self.config_parser.parse_env_file() service_sources = self.config_parser.parse_service_sources() kong_port = kong_listener_port(env_vars) # Check if hosts entries are configured (yields the set of hostnames # that are PRESENT in /etc/hosts). hosts_present = set() try: existing_missing = self.hosts_manager.check_missing_hosts() all_hosts = self.hosts_manager.get_atlas_hosts() hosts_present = set(all_hosts) - set(existing_missing) except Exception: pass table = Table( title="Stack Services Overview", title_style="bold bright_white", box=HEAVY_HEAD, border_style="color(240)", header_style="bold bright_white", show_lines=True, padding=(0, 1), expand=True, ) table.add_column("PORT", style="color(248)", justify="left", ratio=1, no_wrap=True) table.add_column("SERVICE", style="color(252)", justify="left", ratio=3, no_wrap=True) # Category marker — between SERVICE and SOURCE, mirroring the # TUI box layout so both surfaces speak the same visual language. table.add_column("", justify="left", width=2, no_wrap=True) table.add_column("SOURCE", justify="left", ratio=3, no_wrap=True) table.add_column("ALIAS", justify="left", ratio=4, no_wrap=True) table.add_column("STATUS", justify="left", ratio=2, no_wrap=True) # Service definitions come from state_builder.all_services() — single # source of truth shared with the TUI info-box (no more inline list # to drift out of sync). services = list(all_services()) # Sort by port number ascending; services with no port go to the end. def _sort_key(svc): name, source_var, port_var, _scale_var = svc source = service_sources.get(source_var, env_vars.get(source_var, 'container')) if source == 'disabled' or not port_var: return (2, 99999) if 'localhost' in source: lp = self._get_localhost_port(name, env_vars) match = re.search(r':(\d+)', lp) return (1, int(match.group(1)) if match else 99999) try: return (0, int(env_vars.get(port_var, '99999'))) except ValueError: return (2, 99999) services.sort(key=_sort_key) from ui.textual.palette import style_for_source_choice as _style_for_source # Collected `(name, port_val)` for post-loop collision detection. # Disabled / portless rows still flow through here as ("-",); the # detector filters them out. collision_rows: list[tuple[str, str]] = [] for name, source_var, port_var, scale_var in services: source = service_sources.get(source_var, env_vars.get(source_var, 'container')) scale = env_vars.get(scale_var, '0') if scale_var else '1' status_text, status_style = self._get_service_status(source, scale) # Color the SOURCE cell with the same helper the TUI uses: # container → green, localhost / external / api → blue, # disabled → muted grey. Previously hardcoded grey, which # made localhost variants visually indistinguishable from # containerised ones. source_style = _style_for_source(source) # PORT column if source == 'disabled': port_val = "-" elif 'localhost' in source: port_val = self._get_localhost_port(name, env_vars) elif port_var: port_val = _published_port_label(port_var, env_vars) else: port_val = "-" # ALIAS column — alias map from state_builder (single source). hostname = alias_for(name) if hostname and hostname in hosts_present and source_has_endpoint(source): alias_text = Text(f"{hostname}:{kong_port}", style="color(75)") else: alias_text = Text("-", style="color(243)") category = _category_by_name.get(name, "") bar = Text("▰", style=style_for_category(category)) table.add_row( port_val, name, bar, Text(source, style=source_style), alias_text, Text(status_text, style=status_style), ) collision_rows.append((name, port_val)) # Cloud APIs panel — renders below the services table. Cloud # providers don't run as containers (scale: 0) so they don't # belong as rows in the services grid; this keeps them visible # without misleading the user about what's getting started. from rich.console import Group from rich.panel import Panel cloud_lines = [] for name, source_var, api_key_var in all_cloud_apis(): source = ( service_sources.get(source_var, env_vars.get(source_var, 'disabled')) or '' ).strip().lower() key_set = bool((env_vars.get(api_key_var, '') or '').strip()) enabled = source == 'enabled' line = Text() line.append(f" {name:<11}", style="bright_white") # Status string is shared with the Textual CloudApisRow via # state_builder.cloud_api_status_text — only the Rich style # is local to this renderer. if enabled and key_set: style = "bright_green" elif enabled and not key_set: style = "bright_yellow" else: style = "color(243)" line.append(cloud_api_status_text(enabled, key_set), style=style) cloud_lines.append(line) cloud_panel = Panel( Group(*cloud_lines) if cloud_lines else Text("(none)", style="color(243)"), title="[bold bright_white]Cloud APIs[/bold bright_white] " "[color(243)](LiteLLM-routed, no containers)[/color(243)]", border_style="color(240)", padding=(0, 1), expand=True, ) consumer_config = self.config_parser.load_consumer_config() consumer_panel = None if consumer_config.consumers: consumer_lines = [] for consumer in consumer_config.consumers: line = Text() line.append(f" {consumer.name:<16}", style="bright_white") details = [] if consumer.compose_overlays: details.append(f"{len(consumer.compose_overlays)} overlay") if consumer.backend_plugins: details.append(f"{len(consumer.backend_plugins)} plugin dir") if consumer.comfyui_sidecars: details.append(f"{len(consumer.comfyui_sidecars)} ComfyUI sidecar") if consumer.ollama_models: details.append(f"{len(consumer.ollama_models)} Ollama model") line.append(", ".join(details) if details else "registered", style="color(75)") consumer_lines.append(line) consumer_panel = Panel( Group(*consumer_lines), title="[bold bright_white]Consumers[/bold bright_white] " "[color(243)](atlas.consumer.yml)[/color(243)]", border_style="color(240)", padding=(0, 1), expand=True, ) # Track banner line (spec §5.2 #7): when a track was active, # prepend a "Track: " line above the services table. track_line: list = [] if track: _track_label = track try: from tracks import load_tracks as _lt_sum _reg_sum = _lt_sum() _t_sum = _reg_sum.by_key.get(track) if _t_sum: _track_label = _t_sum.display_name except Exception: # noqa: BLE001 pass track_line = [Text.from_markup( f"[bold bright_white]Track:[/bold bright_white] " f"[color(75)]{_track_label}[/color(75)]" )] # Port-collision warnings — informational only (warn-don't-block). # When two rows resolve to the same host port (e.g. the user # picked ollama-localhost on Kong's port), surface that here so # the user can step back and adjust before Docker barfs with an # opaque "address already in use" error. warning_lines = _detect_port_collisions(collision_rows) panels = [table, cloud_panel] if consumer_panel is not None: panels.append(consumer_panel) if warning_lines: warning_texts = [ Text.from_markup(f"[yellow]{msg}[/yellow]") for msg in warning_lines ] return Group(*track_line, *panels, *warning_texts) return Group(*track_line, *panels) @staticmethod def _get_localhost_port(service_name: str, env_vars: dict) -> str: """Extract the localhost port for the pre-launch summary. Prefers the service's endpoint env var (which encodes the active source variant's port — e.g. ComfyUI's MPS port), then falls back to the dedicated localhost_port_var for services that declare only a port number and no endpoint URL (e.g. OpenClaw, Neo4j). Without the fallback those rows rendered '-' even when a localhost port was configured.""" from services.topology import get_topology _topology = get_topology() row = None for r in _topology.rows: if r.display_name == service_name: row = r break if row is None: return "-" # ONE definition, shared with the Textual ServiceTable. The local copy # parsed the endpoint with a bare `:(\d+)`, which cannot match the # stored literal `http://host:${COMFYUI_MPS_LOCALHOST_PORT:-8188}` — # the `:` is followed by `$` — so it fell through to the wrong var and # reported ComfyUI-MPS on 8000 instead of 8188, then raised a phantom # collision against vLLM Metal. from wizard.model.state_builder import resolve_localhost_port port = resolve_localhost_port(row, env_vars) return f":{port}" if port else "-" @staticmethod def _get_service_status(source: str, scale: str) -> tuple: """Get a status label with ● indicator and style for a service.""" if not source_has_endpoint(source): return "● off", "color(245)" if 'localhost' in source: return "● local", "bright_cyan" if 'external' in source: return "● external", "bright_yellow" if source == 'api': return "● API", "bright_yellow" if 'gpu' in source: return "● GPU", "bright_green" if scale == '0': return "● off", "color(245)" return "● on", "bright_green" def check_comfyui_models(self, on_line=None): """Check ComfyUI local models. Returns the probe result: ``True``/``False`` for a localhost ComfyUI, or a ``ProbeSkipped`` naming why the check does not apply, so neither front end reports a check that never ran as a pass (#1032). """ found = self.service_config.check_comfyui_local_models(on_line=on_line) if found is not None: return found source = self.service_config.service_sources.get("COMFYUI_SOURCE", "container-cpu") return ProbeSkipped( f"applies only to COMFYUI_SOURCE=localhost; this run uses {source}" ) def show_container_status_and_verify_ports(self, on_line=None): """ Show container status and verify actual vs expected ports. Replicates the verification logic from original start.sh. When `on_line` is provided (TUI mode), the redundant `docker compose ps` text dump is dropped and per-service results route through `on_line` with a level keyword ("ok"/"warn"/"error"). When `on_line` is None (legacy mode), the printed output is unchanged from the original implementation. Both branches return the number of ports that could not be determined or did not match, which the launch result reports as a failed check rather than a pass (#1032). """ # Get expected ports from .env (used by both branches) env_vars = self.config_parser.parse_env_file() def include_port_check( source_var: str | None = None, scale_var: str | None = None, ) -> bool: if source_var: source = env_vars.get(source_var, "").strip() if source == "disabled" or "localhost" in source: return False if scale_var and env_vars.get(scale_var, "1").strip() == "0": return False return True # Service definitions matching original Bash script services_to_check = [ ("supabase-db", "5432", env_vars.get("SUPABASE_DB_PORT", ""), None, None), ("redis", "6379", env_vars.get("REDIS_PORT", ""), None, None), ("supabase-storage", "5000", env_vars.get("SUPABASE_STORAGE_PORT", ""), "SUPABASE_STORAGE_SOURCE", "SUPABASE_STORAGE_SCALE"), # supabase-auth is not published on the host (2026-10-08 run, cycle 56). ("supabase-api", "3000", env_vars.get("SUPABASE_API_PORT", ""), "SUPABASE_API_SOURCE", "SUPABASE_API_SCALE"), ("supabase-realtime", "4000", env_vars.get("SUPABASE_REALTIME_PORT", ""), "SUPABASE_REALTIME_SOURCE", "SUPABASE_REALTIME_SCALE"), ("neo4j-graph-db", "7687", env_vars.get("GRAPH_DB_PORT", ""), "NEO4J_GRAPH_DB_SOURCE", "NEO4J_SCALE"), ("weaviate", "8080", env_vars.get("WEAVIATE_PORT", ""), "WEAVIATE_SOURCE", "WEAVIATE_SCALE"), ("local-deep-researcher", "2024", env_vars.get("LOCAL_DEEP_RESEARCHER_PORT", ""), "LOCAL_DEEP_RESEARCHER_SOURCE", "LOCAL_DEEP_RESEARCHER_SCALE"), ("open-web-ui", "8080", env_vars.get("OPEN_WEB_UI_PORT", ""), "OPEN_WEB_UI_SOURCE", "OPEN_WEB_UI_SCALE"), ("backend", "8000", env_vars.get("BACKEND_PORT", ""), "BACKEND_SOURCE", "BACKEND_SCALE"), ("kong-api-gateway", "8000", env_vars.get("KONG_HTTP_PORT", ""), None, None), ("kong-api-gateway", "8443", env_vars.get("KONG_HTTPS_PORT", ""), None, None), ("n8n", "5678", env_vars.get("N8N_PORT", ""), "N8N_SOURCE", "N8N_SCALE"), ("searxng", "8080", env_vars.get("SEARXNG_PORT", ""), "SEARXNG_SOURCE", "SEARXNG_SCALE"), ("crawl4ai", "11235", env_vars.get("CRAWL4AI_PORT", ""), "CRAWL4AI_SOURCE", "CRAWL4AI_SCALE"), ("flower", "5555", env_vars.get("FLOWER_PORT", ""), "CELERY_SOURCE", "FLOWER_SCALE"), ("jupyterhub", "8888", env_vars.get("JUPYTERHUB_PORT", ""), "JUPYTERHUB_SOURCE", "JUPYTERHUB_SCALE"), ] services_to_check = [ (service_name, internal_port, expected_port) for service_name, internal_port, expected_port, source_var, scale_var in services_to_check if include_port_check(source_var, scale_var) ] # LiteLLM is the always-on LLM front door — its host port is the # canonical LLM-stack port now. services_to_check.append(("litellm", "4000", env_vars.get("LITELLM_PORT", ""))) # Add conditional services based on their scales ollama_scale = env_vars.get("OLLAMA_SCALE", "0") if ollama_scale != "0": # Ollama upstream is internal-only; no host port mapping to verify. pass comfyui_scale = env_vars.get("COMFYUI_SCALE", "0") if comfyui_scale != "0": services_to_check.append(("comfyui", "18188", env_vars.get("COMFYUI_PORT", ""))) if on_line is None: # Legacy linear flow — preserve today's exact behavior including # the `docker compose ps` text dump. print() self.docker_manager.show_container_status() print() print("🔍 Checking if Docker assigned the expected ports...") # Counted the same way as the TUI branch below, so both front # ends classify the same mismatches as the same outcome (#1032). mismatches = 0 for service_name, internal_port, expected_port in services_to_check: if not expected_port: continue actual_port = self.docker_manager.get_service_port(service_name, internal_port) if not actual_port: print(f" • ❌ {service_name}: Could not determine port mapping") mismatches += 1 elif actual_port == expected_port: print(f" • ✅ {service_name}: Using expected port {expected_port}") else: print(f" • ⚠️ {service_name}: Expected port {expected_port} but got {actual_port}") mismatches += 1 return mismatches # TUI mode — route per-service lines through on_line, skip the ps dump. # The dots in the anchored box already convey "is the container up"; # this verification is specifically about port-mapping correctness. mismatches = 0 for service_name, internal_port, expected_port in services_to_check: if not expected_port: continue actual_port = self.docker_manager.get_service_port(service_name, internal_port) if not actual_port: on_line(f"❌ {service_name}: could not determine port mapping", "error") mismatches += 1 elif actual_port == expected_port: on_line(f"✅ {service_name}: port {expected_port} ok", "ok") else: on_line(f"⚠️ {service_name}: expected :{expected_port}, got :{actual_port}", "warn") mismatches += 1 return mismatches def show_container_logs(self) -> int: """ Show container logs with follow option. Replicates the logs display from original start.sh. """ try: return self.docker_manager.show_container_logs(follow=True) except KeyboardInterrupt: print("\n🔄 Log viewing interrupted by user") print(" Use 'docker compose logs -f' to view logs again") # Stopping the log stream is a detach, not a teardown (#1032). print(f" Detached — {DETACH.consequences}; ./stop.sh stops them") return 130 def _parse_env_values(path: Path) -> dict[str, str]: if not path.exists(): return {} values = _backfill_default_values(_read_env_backfill_text(path), str(path)) return {key: value for key, (value, _encoded) in values.items()} def _env_example_section_by_key(path: Path) -> dict[str, str]: if not path.exists(): return {} bar_re = re.compile(r"^#\s*[=─]{3,}\s*$") lines = env_lines(_read_env_backfill_text(path).removeprefix("\ufeff")) current_section = "(unsectioned)" by_key: dict[str, str] = {} i = 0 while i < len(lines): line = lines[i] if ( bar_re.match(line) and i + 2 < len(lines) and lines[i + 1].startswith("#") and bar_re.match(lines[i + 2]) ): current_section = lines[i + 1].lstrip("#").strip() or "(unnamed)" i += 3 continue stripped = line.strip() if stripped and not stripped.startswith("#") and "=" in stripped: key, _decoded, _encoded, _exported = _backfill_env_assignment(stripped, str(path)) by_key[key] = current_section i += 1 return by_key def _print_env_backfill_summary( *, env_path: Path, env_example_path: Path, before: dict[str, str], after: dict[str, str], ) -> None: added = sorted(key for key in after if key not in before) filled = sorted( key for key, prior in before.items() if key in after and not prior and bool(after[key]) ) # Backfill changes a non-blank value only to refresh a retired build pin. refreshed = sorted(key for key, prior in before.items() if prior and after.get(key, prior) != prior) if not added and not filled and not refreshed: click.echo(f"No env changes needed: {env_path}") return section_by_key = _env_example_section_by_key(env_example_path) grouped: dict[str, list[str]] = {} for keys, label in ((added, "added"), (filled, "filled blank"), (refreshed, "refreshed retired default")): for key in keys: section = section_by_key.get(key, "(unsectioned)") grouped.setdefault(section, []).append(f"{key} ({label})") click.echo(f"Env backfill updated {env_path}:") for section in sorted(grouped): click.echo(f"{section}:") for item in grouped[section]: click.echo(f" - {item}") def _compose_validation_summary(output: str) -> str | None: variable: str | None = None service: str | None = None variable_match = re.search( r'["\\]*([A-Z0-9_]+)["\\]* variable is not set', output, ) if variable_match: variable = variable_match.group(1) service_match = re.search( r'service ["\\]*([^"\\]+)["\\]* has neither an image nor a build context', output, ) if service_match: service = service_match.group(1) if variable and service: return f"Service {service} is missing compose variable {variable}." if variable: return f"Compose variable {variable} is not set." if service: return f"Service {service} has neither an image nor a build context." return None def _n8n_workflow_effective_active(wf) -> bool: """Mirror seed-workflows.js ``effectiveActive``: ``'true'`` → active, ``'false'`` → not, ``'fromJson'`` → read the workflow file's own ``active`` flag (defaulting to inactive on any read/parse error).""" if wf.active == "true": return True if wf.active == "false": return False try: data = json.loads(Path(wf.source_path).read_text(encoding="utf-8")) return bool(data.get("active")) except Exception: return False def _n8n_disabled(env: "dict[str, str]") -> bool: """True only for an explicit ``N8N_SOURCE=disabled``. A missing value means the manifest default (container), as for ``MINIO_SOURCE`` in consumer storage.""" return str(env.get("N8N_SOURCE") or "container").strip().lower() == "disabled" def _n8n_seed_expected(consumer_config, env: "dict[str, str]") -> bool: """Whether the one-shot ``n8n-seed`` runs: workflows are declared and n8n is not disabled (the overlay is dropped then).""" return bool(consumer_config.n8n_workflows) and not _n8n_disabled(env) def _n8n_needs_reactivation_restart(env: "dict[str, str]", consumer) -> bool: """True when Atlas must restart n8n after seeding to register a consumer's production webhook: an active n8n workflow is declared, n8n is enabled, and **no** ``N8N_API_KEY`` is configured. With a key the seed activates over the API and the webhook registers immediately (no restart needed).""" if (env.get("N8N_API_KEY", "") or "").strip(): return False if (env.get("N8N_SOURCE", "") or "").strip() in ("", "disabled"): return False return any( _n8n_workflow_effective_active(wf) for wf in getattr(consumer, "n8n_workflows", ()) ) # Exact Atlas-owned finite identities, including post-start jobs and the # generated consumer seed. Effective Compose metadata still controls admission. _COMPOSE_FINITE_SERVICES = frozenset({ "airflow-init", "backup", "comfyui-init", "hermes-init", "iceberg-rest-init", "label-studio-init", "langfuse-init", "lightrag-init", "litellm-init", "minio-init", "mlflow-init", "n8n-init", "n8n-seed", "ollama-pull", "open-webui-init", "openclaw-init", "redpanda-init", "spark-init", "supabase-db-init", "trueforge-init", "weaviate-init", "zeppelin-init", }) def _readiness_dependencies(name, spec, replicas, skipped): """Validate dependencies; optional absent/disabled/skipped services stay out.""" dependencies = spec.get("depends_on", {}) if not isinstance(dependencies, dict): raise ValueError(f"{name}: invalid rendered dependencies") required = [] for dependency, settings in dependencies.items(): if not isinstance(settings, dict): raise ValueError(f"{name}: invalid dependency {dependency}") unavailable = dependency in skipped or replicas.get(dependency, 0) == 0 if unavailable: if settings.get("required") is False: continue raise ValueError(f"{name}: required dependency {dependency} is absent, disabled or skipped") required.append(dependency) return required def _desired_compose_targets(services, replicas, targets, skipped): """Follow the actual up target list, including Compose dependency companions.""" selected = set(targets) pending = list(selected) while pending: name = pending.pop() if name not in replicas or name in skipped: raise ValueError(f"{name}: requested service absent or skipped in rendered Compose config") dependencies = _readiness_dependencies(name, services[name], replicas, skipped) for dependency in dependencies: if dependency not in selected: selected.add(dependency) pending.append(dependency) return {name: replicas[name] for name in sorted(selected) if replicas[name] > 0} def _one_shot_service_names(services) -> frozenset[str]: """Positive finite-role evidence from the effective Compose model.""" if not isinstance(services, dict): return frozenset() try: completed = { name for spec in services.values() for name, dependency in (spec.get("depends_on") or {}).items() if dependency.get("condition") == "service_completed_successfully" } return frozenset( name for name, spec in services.items() if _finite_compose_service(name, spec, completed) ) except (AttributeError, TypeError): return frozenset() # malformed metadata cannot prove completion def _finite_compose_service(name: str, spec: dict, completed: set[str]) -> bool: restart = str(spec.get("restart") or "no").strip().lower() if restart.split(":", 1)[0] not in {"no", "on-failure"}: return False if name in completed: return True health = spec.get("healthcheck") or {} return ( name in _COMPOSE_FINITE_SERVICES and restart == "no" and not _enabled_compose_healthcheck(health) ) def _enabled_compose_healthcheck(health: dict) -> bool: return bool(health) and not health.get("disable") and health.get("test") != ["NONE"] def _split_local_builds( services: dict, targets: list[str], core: set[str] ) -> tuple[list[str], list[list[str]]]: """Split the enabled local builds by whether the launch can go on without them (#989). An image is required when an enabled service lists one of the services that build it in ``depends_on``, or when one of them is in ``core``, the always-running tier. Services that share an ``image`` name build the same image, so they are one unit: required if any of them is, otherwise rebuilt and reported together. Returns the required services as one list and every other image as its own group of services. """ needed, groups = _local_build_groups(services, targets) needed |= core required = [ name for group in groups if needed.intersection(group) for name in group ] optional = [group for group in groups if not needed.intersection(group)] return required, optional def _local_build_groups( services: dict, targets: list[str] ) -> tuple[set[str], list[list[str]]]: """Every service an enabled target lists in ``depends_on``, and the enabled local-build services grouped by the image they build.""" needed: set[str] = set() groups: dict[str, list[str]] = {} for name in sorted(set(targets)): spec = services.get(name) or {} needed.update(spec.get("depends_on") or ()) if spec.get("build") is not None: groups.setdefault(spec.get("image") or name, []).append(name) return needed, list(groups.values()) # Selected-source values that run a managed native host, with the module that # builds its manager (#1342). Order matches start_managed_host_processes. _MANAGED_HOST_SOURCES = ( ("COMFYUI_SOURCE", "managed-localhost-mps", "ComfyUI (MPS)", "services.comfyui_mps_manager"), ("VLLM_METAL_SOURCE", "managed-localhost", "vLLM (Metal)", "services.vllm_metal_manager"), ("BLENDER_MCP_SOURCE", "managed-localhost", "Blender MCP", "services.blender_mcp_manager"), ) def _managed_host_pool_blocker(module, env: dict, label: str) -> Optional[str]: """The launch blocker for every instance a host module starts: one, or each of a Blender MCP pool (#851). A port the pool's own running instances hold is freed when the pool restarts, so it is not foreign.""" pool_from_env = getattr(module, "pool_from_env", None) if pool_from_env is None: return _managed_host_launch_blocker(module.manager_from_env, env, label) pool = pool_from_env(env) # Strays above the pool size are stopped before the start, so the # ports they hold are not foreign either. held = module.pool_held_ports(pool_from_env(env, include_strays=True)) for member in pool: name = f"{label} #{member.pool_index}" if member.pool_index else label problem = _managed_host_launch_blocker(lambda _env, m=member: m, env, name, held) if problem: return f"instance {member.pool_index}: {problem}" if len(pool) > 1 else problem return None def _port_held_by_stranger(manager, held_ports) -> bool: """The manager's port is in use, and not by its own pool (#851).""" return manager.port not in held_ports and manager._port_in_use() def _managed_host_launch_blocker(factory, env: dict, label: str, held_ports=frozenset()) -> Optional[str]: """Why the host start would refuse, or None. Read-only: nothing is installed, launched or signalled.""" from services import ( launched_with_other_settings, legacy_pid_refusal_file, refuse_untrusted_tracked_pid, ) try: manager = factory(env) pre = manager.preflight() if not pre.ok: failed = [f"{c['name']}: {c['detail']}" for c in pre.checks if c["status"] == "fail"] return "preflight failed: " + "; ".join(failed) refuse_untrusted_tracked_pid( (manager._read_pid(), manager.pid_file), manager._managed_process_alive, manager._pid_is_stranger, (label, RuntimeError), ) status = manager.status() # A process on an old port is restarted on the configured one (#1361), # which must then be free just as for a fresh launch. record = getattr(manager, "_launch_record", dict)() # only the built-in hosts keep one moves = status.running and launched_with_other_settings( record, getattr(status, "pid", None), {"port": manager.port} ) if (moves or not status.running) and _port_held_by_stranger(manager, held_ports): return f"port {manager.port} is already in use by an unmanaged process" except Exception as exc: # noqa: BLE001 - the start would fail the same way # The stamp-less record from an older pin only warns at start (#990). return None if legacy_pid_refusal_file(exc) is not None else str(exc) return None def _doctor_result( check_id: str, status: str, message: str, *, details: dict | None = None, ) -> dict: return { "id": check_id, "status": status, "message": message, "details": details or {}, } def _resolved_comfyui_model_rows(env: dict) -> list[dict]: """The per-file ComfyUI model rows the container TSV is built from (#754). One source of truth for "what models this stack needs": the same ``comfyui_resolver`` output drives the container init download AND the managed-host provisioner. Returns [] when nothing is selected or the resolver cannot run (a malformed catalog surfaces through its own lint).""" try: from utils.comfyui_resolver import active_comfyui_models, manifest_dict return list(manifest_dict(active_comfyui_models(env)).get("models", [])) except Exception: # noqa: BLE001 — resolver issues have their own surfaces return [] def _resolved_comfyui_custom_nodes(env: dict) -> list: """The resolved ComfyUI custom-node set (#905). One source of truth: the same ``active_custom_nodes`` output the container TSV is built from, delivered to the managed-host provisioner. Returns [] when nothing is declared or the resolver cannot run.""" try: from utils.comfyui_custom_nodes import active_custom_nodes from utils.comfyui_resolver import active_comfyui_models return list(active_custom_nodes(active_comfyui_models(env), env)) except Exception: # noqa: BLE001 — resolver issues have their own surfaces return [] def _doctor_check_compose(starter: "AtlasStarter") -> dict: try: returncode, stdout, stderr, _cmd = starter.docker_manager.validate_compose_config() except ValueError as exc: return _doctor_result( "compose", "fail", f"Compose config validation could not load consumer manifests: {exc}", ) except RuntimeError as exc: return _doctor_result( "compose", "skipped", f"Docker Compose unavailable: {exc}", ) if returncode == 0: return _doctor_result("compose", "pass", "Compose config is valid.") output = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part) summary = _compose_validation_summary(output) return _doctor_result( "compose", "fail", summary or "Compose config validation failed.", details={"returncode": returncode, "output": output}, ) def _consumer_pin_is_auto(key: str, value: str) -> bool: return (key == "BASE_PORT" or key.endswith("_SOURCE")) and value.strip().lower() == "auto" def _consumer_pin_report(consumer_config, env: dict) -> tuple[dict, str]: pins = { key: { "reason": reason, "origin": consumer_config.env_origins[key], "overridden": key in env and not _consumer_pin_is_auto(key, consumer_config.env_overrides[key]) and env[key] != consumer_config.env_overrides[key], } for key, reason in consumer_config.env_reasons.items() } pin_summary = "".join( f" {key}: {pin['reason']} ({pin['origin']})." + (" Consumer pin overridden." if pin["overridden"] else "") for key, pin in pins.items() ) return pins, pin_summary def _doctor_check_consumer_manifests(starter: "AtlasStarter") -> dict: try: consumer_config = starter.config_parser.load_consumer_config() except ValueError as exc: return _doctor_result( "consumer-manifests", "fail", f"Consumer manifest validation failed: {exc}", ) if consumer_config.is_empty: return _doctor_result( "consumer-manifests", "pass", "No consumer manifests configured.", ) names = [consumer.name for consumer in consumer_config.consumers] overlay_count = sum(len(consumer.compose_overlays) for consumer in consumer_config.consumers) pins, pin_summary = _consumer_pin_report(consumer_config, starter.config_parser.parse_env_file()) return _doctor_result( "consumer-manifests", "warn" if any(pin["overridden"] for pin in pins.values()) else "pass", f"{len(names)} consumer manifest(s) valid: {', '.join(names)}." + pin_summary, details={"consumers": names, "compose_overlays": overlay_count, "pins": pins}, ) # `$$` is Compose's escape for a literal `$` (e.g. `$${PORT}` in a shell # healthcheck), so it is consumed first and never read as a reference. _COMPOSE_VAR_RE = re.compile( r"\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}" ) def _doctor_compose_var_refs(text: str) -> list[tuple[str, bool]]: refs: list[tuple[str, bool]] = [] for match in _COMPOSE_VAR_RE.finditer(text): var_name = match.group(1) if var_name is None: # escaped `$$` continue operator = match.group(2) or "" has_default = "-" in operator refs.append((var_name, has_default)) return refs def _doctor_check_overlay_env(starter: "AtlasStarter") -> dict: root = starter.config_parser.root_dir user_dir = root / "services" / "_user" overlays = sorted(user_dir.glob("*/compose.yml")) if user_dir.is_dir() else [] if not overlays: return _doctor_result( "overlay-env", "pass", "No services/_user compose overlays found.", ) env_values = dict(os.environ) env_values.update(starter.config_parser.parse_env_file()) missing: list[dict[str, str]] = [] checked_refs = 0 for overlay in overlays: text = overlay.read_text(encoding="utf-8") for var_name, has_default in _doctor_compose_var_refs(text): checked_refs += 1 if has_default or var_name in env_values: continue missing.append( { "file": str(overlay.relative_to(root)), "variable": var_name, } ) if missing: first = missing[0] return _doctor_result( "overlay-env", "fail", f"{first['file']} references unresolved variable {first['variable']}.", details={"missing": missing, "checked_refs": checked_refs}, ) return _doctor_result( "overlay-env", "pass", f"All {checked_refs} overlay env reference(s) resolve or provide defaults.", details={"overlays": [str(path.relative_to(root)) for path in overlays]}, ) def _resolve_plugin_dirs(starter: "AtlasStarter", env_values: dict | None = None) -> list[Path]: """Resolve BACKEND_PLUGINS_DIR to existing host directories. Relative parts resolve from the bootstrapper root (matching how the compose mount is authored). Nonexistent parts are skipped — this is a best-effort host-side view used by the doctor and the Kong plugin-auth derivation. """ if env_values is None: env_values = starter.config_parser.parse_env_file() raw_path = env_values.get("BACKEND_PLUGINS_DIR", "").strip() dirs: list[Path] = [] for raw_part in raw_path.split(os.pathsep): raw_part = raw_part.strip() if not raw_part: continue plugins_dir = Path(raw_part).expanduser() if not plugins_dir.is_absolute(): plugins_dir = starter.config_parser.root_dir / plugins_dir if plugins_dir.exists(): dirs.append(plugins_dir) return dirs def _doctor_check_plugins(starter: "AtlasStarter") -> dict: env_values = starter.config_parser.parse_env_file() raw_path = env_values.get("BACKEND_PLUGINS_DIR", "").strip() if not raw_path: return _doctor_result( "plugins", "pass", "No BACKEND_PLUGINS_DIR configured.", ) plugin_dirs_to_check: list[Path] = [] for raw_part in raw_path.split(os.pathsep): raw_part = raw_part.strip() if not raw_part: continue plugins_dir = Path(raw_part).expanduser() if not plugins_dir.is_absolute(): plugins_dir = starter.config_parser.root_dir / plugins_dir if not plugins_dir.exists(): return _doctor_result( "plugins", "skipped", f"Plugin directory does not exist: {plugins_dir}", ) plugin_dirs_to_check.append(plugins_dir) problems: list[str] = [] requirement_files = 0 requirement_entries = 0 plugin_names: list[str] = [] for plugins_dir in plugin_dirs_to_check: for req in [plugins_dir / "requirements.txt", *plugins_dir.glob("*/requirements.txt")]: try: if not req.exists(): continue requirement_files += 1 for line in req.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#"): continue requirement_entries += 1 except OSError as exc: problems.append(f"{req} could not be read: {exc}") plugin_names.extend(path.name for path in plugins_dir.iterdir() if path.is_dir()) if problems: return _doctor_result( "plugins", "fail", problems[0], details={"problems": problems}, ) return _doctor_result( "plugins", "pass", f"Plugin directory readable; {len(plugin_names)} plugin dir(s) found.", details={ "plugins": plugin_names, "requirement_files": requirement_files, "requirement_entries": requirement_entries, }, ) def _doctor_check_plugin_manifests(starter: "AtlasStarter") -> dict: """Validate optional plugin.yml manifests + their declared env (#402). Reports malformed manifests and env problems (required-missing, type/enum mismatch) as warnings naming the plugin and var, so a bad manifest surfaces as a startup diagnostic rather than a runtime 500. Secret values are never echoed. Manifest-less plugins are ignored here (the plugins check covers the directory itself). """ from core.plugin_manifest import ( discover_plugin_manifests, validate_plugin_env, ) env_values = starter.config_parser.parse_env_file() plugin_dirs = _resolve_plugin_dirs(starter, env_values) if not plugin_dirs: return _doctor_result( "plugin-manifests", "pass", "No plugin directories to scan for manifests.", ) discovery = discover_plugin_manifests(plugin_dirs) warnings: list[str] = list(discovery.errors) for manifest in discovery.manifests: warnings.extend(validate_plugin_env(manifest, env_values)) names = [m.name for m in discovery.manifests] if warnings: return _doctor_result( "plugin-manifests", "warn", warnings[0], details={"warnings": warnings, "plugins": names}, ) if not names: return _doctor_result( "plugin-manifests", "pass", "No plugin.yml manifests found.", ) return _doctor_result( "plugin-manifests", "pass", f"{len(names)} plugin manifest(s) valid: {', '.join(names)}.", details={"plugins": names}, ) def _doctor_check_ollama_parallel(starter: "AtlasStarter") -> dict: """Fail when a host Ollama daemon is provisioned below the declared need. Only meaningful for ``ollama-localhost``: on the container sources Atlas sets ``OLLAMA_NUM_PARALLEL`` itself, but the host-prereq doctrine means it cannot own a host daemon's environment. Ollama defaults to ONE parallel slot and silently serializes concurrent requests rather than rejecting them, so a consumer that needs 8 gets correct-but-slow behaviour with no signal — which is exactly the failure this check exists to surface (#849). Advisory by construction: an unreadable host config reports ``skipped``, never ``fail``. Warning about a value we could not read would train people to ignore doctor output. """ from services.ollama_localhost import host_parallel_config, parallel_shortfall env_values = starter.config_parser.parse_env_file() source = (env_values.get("LLM_PROVIDER_SOURCE", "") or "").strip() if source != "ollama-localhost": return _doctor_result( "ollama-parallel", "pass", f"LLM_PROVIDER_SOURCE={source or '(unset)'} — Atlas owns the " "parallel-serving env for container sources.", ) raw_min = (env_values.get("OLLAMA_PARALLEL_MIN", "") or "").strip() if not raw_min: return _doctor_result( "ollama-parallel", "pass", "No OLLAMA_PARALLEL_MIN declared — nothing to compare against.", ) try: declared_min = int(raw_min) except ValueError: return _doctor_result( "ollama-parallel", "fail", f"OLLAMA_PARALLEL_MIN={raw_min!r} is not an integer.", ) if declared_min <= 1: return _doctor_result( "ollama-parallel", "pass", f"OLLAMA_PARALLEL_MIN={declared_min} is within Ollama's default.", ) observed = host_parallel_config() shortfall, explanation = parallel_shortfall(declared_min, observed) if shortfall: return _doctor_result("ollama-parallel", "fail", explanation) if observed.get("OLLAMA_NUM_PARALLEL") is None: return _doctor_result("ollama-parallel", "skipped", explanation) return _doctor_result("ollama-parallel", "pass", explanation) def _doctor_check_ollama_residency(starter: "AtlasStarter") -> dict: """Fail when a host Ollama will evict its own working set mid-run. A multi-model ingest (extract + embed + keyword) touches several models in sequence. When OLLAMA_MAX_LOADED_MODELS is below that count, Ollama unloads one to load the next and reloads it moments later — the run crawls with no error anywhere, which is exactly the failure a lint should surface (#798). Advisory like its parallel-serving sibling: an unreadable host config reports ``skipped``, never ``fail``. """ from services.ollama_localhost import ( host_keep_alive, host_parallel_config, residency_shortfall, ) env_values = starter.config_parser.parse_env_file() source = (env_values.get("LLM_PROVIDER_SOURCE", "") or "").strip() if source != "ollama-localhost": return _doctor_result( "ollama-residency", "pass", f"LLM_PROVIDER_SOURCE={source or '(unset)'} — Atlas owns " "OLLAMA_KEEP_ALIVE / OLLAMA_MAX_LOADED_MODELS for container sources.", ) raw_min = (env_values.get("OLLAMA_MODELS_RESIDENT_MIN", "") or "").strip() if not raw_min: return _doctor_result( "ollama-residency", "pass", "No OLLAMA_MODELS_RESIDENT_MIN declared — nothing to compare against.", ) try: declared_min = int(raw_min) except ValueError: return _doctor_result( "ollama-residency", "fail", f"OLLAMA_MODELS_RESIDENT_MIN={raw_min!r} is not an integer.", ) if declared_min <= 1: return _doctor_result( "ollama-residency", "pass", f"OLLAMA_MODELS_RESIDENT_MIN={declared_min} is within Ollama's default.", ) observed = host_parallel_config() shortfall, explanation = residency_shortfall( declared_min, observed, host_keep_alive() ) if shortfall: return _doctor_result("ollama-residency", "fail", explanation) if observed.get("OLLAMA_MAX_LOADED_MODELS") is None: return _doctor_result("ollama-residency", "skipped", explanation) return _doctor_result("ollama-residency", "pass", explanation) def _missing_sidecar_status(sidecar: Path) -> str: return "skipped" if str(sidecar) == "/custom-models.yaml" else "fail" def _doctor_check_model_sidecars(starter: "AtlasStarter") -> dict: env_values = starter.config_parser.parse_env_file() raw_path = env_values.get("COMFYUI_CUSTOM_MODELS_FILE", "").strip() if not raw_path: return _doctor_result( "model-sidecars", "pass", "No COMFYUI_CUSTOM_MODELS_FILE configured.", ) sidecars: list[Path] = [] for raw_part in raw_path.split(os.pathsep): raw_part = raw_part.strip() if not raw_part: continue sidecar = Path(raw_part).expanduser() if not sidecar.is_absolute(): sidecar = starter.config_parser.root_dir / sidecar sidecars.append(sidecar) import yaml parsed = 0 for sidecar in sidecars: if not sidecar.exists(): # The shipped default is a container path, absent on the host; a # path the operator configured that is missing drops its models. return _doctor_result( "model-sidecars", _missing_sidecar_status(sidecar), f"Model sidecar does not exist: {sidecar}", ) try: data = yaml.safe_load(sidecar.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 return _doctor_result( "model-sidecars", "fail", f"Could not parse model sidecar {sidecar}: {exc}", ) if data is not None and not isinstance(data, (dict, list)): return _doctor_result( "model-sidecars", "fail", f"Model sidecar {sidecar} must parse to a mapping or list.", ) parsed += 1 return _doctor_result( "model-sidecars", "pass", f"{parsed} model sidecar file(s) parse.", ) def _doctor_check_unpullable_models(starter: "AtlasStarter") -> dict: """Presence-check declared models for the selected sources (#718 → #754/#757). Container sources are provisioned by init containers; managed host sources are provisioned by Atlas at start (ComfyUI managed-localhost-mps downloads the resolved catalog, ollama-localhost pulls declared tags onto the host daemon). This lint passes when the declared set is present, and warns — naming what's missing and the command that provisions it — when not. Only an unmanaged ComfyUI ``localhost`` install remains hands-off (Atlas never writes into a user-owned tree). """ env = starter.config_parser.parse_env_file() warnings: list[str] = [] llm_source = (env.get("LLM_PROVIDER_SOURCE", "") or "").strip() if llm_source == "ollama-localhost": # #757: Atlas provisions the host daemon now. Presence of the declared # tag set (OLLAMA_USER_MODELS ∪ OLLAMA_CUSTOM_MODELS — the same union # the container pull uses) decides pass vs. actionable warn, naming # each missing tag (the host analogue of the #718 container lint). from services.ollama_localhost import ( declared_models, host_base_url, list_host_tags, ) declared = declared_models(env) if declared: try: present = list_host_tags(host_base_url(env)) except Exception as exc: # noqa: BLE001 — a malformed port/env must # degrade to a warning, never crash the whole doctor run. present = None warnings.append(f"Could not query the host Ollama: {exc}") if present is None: warnings.append( f"Host Ollama at {host_base_url(env)} is not reachable — the " f"{len(declared)} declared model tag(s) will be pulled on the " f"next ./start.sh once the daemon is running (`ollama serve`)." ) else: normalized = {t if ":" in t else f"{t}:latest" for t in declared} missing = sorted(normalized - present) if missing: warnings.append( f"Declared Ollama model(s) missing from the host daemon: " f"{', '.join(missing)}. Atlas pulls these on the next " f"./start.sh (or pull now: `ollama pull " f"{' && ollama pull '.join(missing)}`)." ) comfy_models = (env.get("COMFYUI_USER_MODELS", "") or "").strip() comfy_source = (env.get("COMFYUI_SOURCE", "") or "").strip() if ( comfy_models and comfy_source and not comfy_source.startswith("container") and comfy_source != "disabled" ): if comfy_source == "managed-localhost-mps": # #754: the managed host IS provisioned by Atlas now. Presence of # the resolved file set decides pass vs. actionable warn. try: from services.comfyui_mps_manager import manager_from_env rows = _resolved_comfyui_model_rows(env) if not rows: # A resolver failure yields [] — declared models with an # unresolvable catalog must warn, not silently pass (#754). satisfied, missing = False, [ "declared catalog could not be resolved (see catalog lint)" ] else: satisfied, missing = manager_from_env(env).models_satisfied(rows) except Exception as exc: # noqa: BLE001 - defensive satisfied, missing = False, [f"could not check host tree: {exc}"] if not satisfied: warnings.append( f"COMFYUI_USER_MODELS ({comfy_models}) is declared but the " f"host tree is missing: {', '.join(missing)}. Atlas " f"provisions these on the next `./start.sh` (or run " f"`./start.sh comfyui-mps provision` now)." ) else: warnings.append( f"COMFYUI_USER_MODELS ({comfy_models}) is declared but " f"COMFYUI_SOURCE={comfy_source} reuses a host weight tree Atlas " f"does not manage — the catalog is downloaded for container* " f"sources and provisioned for managed-localhost-mps (#754), but " f"not for an unmanaged localhost install. Provision manually: " f"place the weights in your host ComfyUI models directory." ) # #905: lint consumer-declared custom nodes for the managed host — independent # of COMFYUI_USER_MODELS (a consumer may declare workflow nodes without any # catalog models, e.g. an edit-workflow node). Uses load_custom_nodes (not # active_custom_nodes) so the doctor does NOT resolve the full model catalog # (which would fetch/print the live registry); model-required Atlas nodes are # covered by the models doctor above when COMFYUI_USER_MODELS is declared. if comfy_source == "managed-localhost-mps": try: from utils.comfyui_custom_nodes import load_custom_nodes as _load_nodes declared_nodes = [n for n in _load_nodes(env) if n.from_consumer] if declared_nodes: from services.comfyui_mps_manager import manager_from_env as _node_mgr n_ok, n_missing = _node_mgr(env).nodes_satisfied(declared_nodes) if not n_ok: warnings.append( f"ComfyUI custom nodes are declared but missing on the " f"host: {', '.join(n_missing)}. Atlas provisions these " f"on the next `./start.sh` (or run `./start.sh " f"comfyui-mps provision-nodes` now)." ) except Exception: # noqa: BLE001 - defensive pass if warnings: return _doctor_result( "unpullable-models", "warn", " ".join(warnings), details={"warnings": warnings}, ) return _doctor_result( "unpullable-models", "pass", "No declared-but-unpullable model provisioning for the selected sources.", ) def _doctor_check_litellm_models(starter: "AtlasStarter") -> dict: """Validate consumer-declared LiteLLM model rows (#411). Load-time already resolved api_base against the approved endpoint allowlist and stamped manifest-derived ownership (a parse failure surfaces as a fail here). This check adds the cross-surface validation the triage calls for: each backend-hosted model's first route segment is cross-checked against the declared #402 backend plugin route_prefixes (when any manifests are present), so a model pointing at a route no plugin serves surfaces as a startup diagnostic rather than a dead /v1/models entry. Secrets never appear (rows carry only ``os.environ/`` references). """ from core.consumer_manifest import LITELLM_ENDPOINT_TEMPLATES try: config = starter.config_parser.load_consumer_config() except ValueError as exc: return _doctor_result( "litellm-models", "fail", f"Consumer LiteLLM model validation failed: {exc}", ) if not config.litellm_models: return _doctor_result( "litellm-models", "pass", "No consumer LiteLLM models declared.", ) import urllib.parse backend_host = urllib.parse.urlparse( LITELLM_ENDPOINT_TEMPLATES["ATLAS_BACKEND_INTERNAL"] ).netloc # Declared #402 backend plugin route heads (empty when no manifests present) # plus the built-in backend route prefixes (research/health/… are legitimate # targets even with no plugin manifest, so they must never trip the warning). from core.plugin_manifest import RESERVED_ROUTE_PREFIXES env_values = starter.config_parser.parse_env_file() plugin_dirs = _resolve_plugin_dirs(starter, env_values) plugin_heads: set[str] = set() have_manifests = False if plugin_dirs: from core.plugin_manifest import discover_plugin_manifests discovery = discover_plugin_manifests(plugin_dirs) have_manifests = bool(discovery.manifests) plugin_heads = {m.prefix_head for m in discovery.manifests} warnings: list[str] = [] for model in config.litellm_models: parsed = urllib.parse.urlparse(model.api_base) if parsed.netloc != backend_host: continue # non-backend endpoints have no plugin route to match segments = [seg for seg in parsed.path.split("/") if seg] head = segments[0] if segments else "" # A bare base cannot be cross-checked against a declared route head. # Nonempty built-in prefixes remain valid without plugin manifests. if not head: warnings.append( f"model {model.name!r} (owner {model.consumer}) has a backend " "api_base with no route head; confirm a consumer plugin serves " "its OpenAI endpoint before selecting this model" ) elif ( have_manifests and head not in plugin_heads and head not in RESERVED_ROUTE_PREFIXES ): warnings.append( f"model {model.name!r} (owner {model.consumer}) points at backend " f"route /{head}, which matches no declared plugin route_prefix " f"({', '.join('/' + h for h in sorted(plugin_heads)) or 'none'})" ) owners = sorted({model.consumer for model in config.litellm_models}) names = [model.name for model in config.litellm_models] if warnings: return _doctor_result( "litellm-models", "warn", warnings[0], details={"warnings": warnings, "models": names, "owners": owners}, ) return _doctor_result( "litellm-models", "pass", f"{len(names)} consumer LiteLLM model(s) valid: {', '.join(names)}.", details={"models": names, "owners": owners}, ) def _doctor_check_n8n_workflows(starter: "AtlasStarter") -> dict: """Validate consumer-declared n8n workflows to seed (#412). Load-time already validated the files (JSON parseable, credential-safe, checksum, stable/unique id, non-colliding webhook routes); a parse failure surfaces as a fail here. This check re-confirms each source file still exists and adds an operational signal: an active workflow with declared webhooks but no ``N8N_API_KEY`` will only register its production webhook after an n8n restart (the seed can't activate it live without a key). Secrets never appear. """ try: config = starter.config_parser.load_consumer_config() except ValueError as exc: return _doctor_result( "n8n-workflows", "fail", f"Consumer n8n workflow validation failed: {exc}", ) if not config.n8n_workflows: return _doctor_result( "n8n-workflows", "pass", "No consumer n8n workflows declared.", ) env_values = starter.config_parser.parse_env_file() warnings: list[str] = [] if _n8n_disabled(env_values): warnings.append( f"{len(config.n8n_workflows)} consumer n8n workflow(s) declared but " "N8N_SOURCE is disabled — the seed is skipped until n8n is enabled." ) for wf in config.n8n_workflows: if not wf.source_path.is_file(): warnings.append( f"workflow {wf.id!r} (owner {wf.consumer}) source file is missing: " f"{wf.source_path}" ) has_api_key = bool(env_values.get("N8N_API_KEY", "").strip()) if not has_api_key: # Resolve the imported-active state so the warning fires only for # workflows that will really be active (``fromJson`` reads the file). needs_live = [ wf.id for wf in config.n8n_workflows if wf.webhooks and _n8n_workflow_effective_active(wf) ] if needs_live: warnings.append( "N8N_API_KEY is unset — active workflows with declared webhooks " f"({', '.join(needs_live)}) register their production webhook only " "after an n8n restart (the seed cannot activate them live)." ) ids = [wf.id for wf in config.n8n_workflows] owners = sorted({wf.consumer for wf in config.n8n_workflows}) if warnings: return _doctor_result( "n8n-workflows", "warn", warnings[0], details={"warnings": warnings, "workflows": ids, "owners": owners}, ) return _doctor_result( "n8n-workflows", "pass", f"{len(ids)} consumer n8n workflow(s) valid: {', '.join(ids)}.", details={"workflows": ids, "owners": owners}, ) def _doctor_check_rag_ingestion_profiles(starter: "AtlasStarter") -> dict: """Validate consumer-declared RAG ingestion profiles to register (#413). Load-time already validated the profiles (unique names, corpus path safety, parser/chunker/target schema, collection collisions); a parse failure surfaces as a fail here. This check adds an operational signal: a profile whose vector or graph target has ``on_unavailable: fail`` but whose backend endpoint is disabled by its SOURCE will hard-fail that ingestion at runtime — surface it now. """ try: config = starter.config_parser.load_consumer_config() except ValueError as exc: return _doctor_result( "rag-ingestion-profiles", "fail", f"Consumer RAG ingestion profile validation failed: {exc}", ) if not config.rag_ingestion_profiles: return _doctor_result( "rag-ingestion-profiles", "pass", "No consumer RAG ingestion profiles declared.", ) env_values = starter.config_parser.parse_env_file() # SOURCE var that gates each target backend. Its endpoint (WEAVIATE_URL, # LIGHTRAG_ENDPOINT) is only written by a start, so a fresh .env with the # backend enabled read as disabled (#1391). source_var = {"weaviate": "WEAVIATE_SOURCE", "lightrag": "LIGHTRAG_SOURCE"} warnings: list[str] = [] for profile in config.rag_ingestion_profiles: targets = [ (t.backend, t.on_unavailable) for t in profile.vector_targets ] + [(t.backend, t.on_unavailable) for t in profile.graph_targets] for backend, on_unavailable in targets: var = source_var.get(backend) enabled = _source_enabled(env_values, var) if var else True if on_unavailable == "fail" and not enabled: warnings.append( f"profile {profile.name!r} target {backend} is on_unavailable=fail " f"but {var} is disabled — ingestion will hard-fail until it is enabled." ) names = [p.name for p in config.rag_ingestion_profiles] owners = sorted({p.consumer for p in config.rag_ingestion_profiles}) if warnings: return _doctor_result( "rag-ingestion-profiles", "warn", warnings[0], details={"warnings": warnings, "profiles": names, "owners": owners}, ) return _doctor_result( "rag-ingestion-profiles", "pass", f"{len(names)} consumer RAG ingestion profile(s) valid: {', '.join(names)}.", details={"profiles": names, "owners": owners}, ) def _source_enabled(env_values: dict, source_var: str) -> bool: """Whether ``source_var`` runs its service (anything but disabled/empty).""" return (env_values.get(source_var) or "").strip().lower() not in ("", "disabled") def _doctor_check_lightrag_query_profiles(starter: "AtlasStarter") -> dict: """Validate consumer-declared LightRAG query profiles to register (#414). Load-time already validated each profile (unique namespaced names, supported mode, bounded positive integers, rerank rejection, alias contract); a parse failure surfaces as a fail here. This check adds an operational signal: a profile can only be *served* when LightRAG itself is reachable, so warn when profiles are declared but ``LIGHTRAG_SOURCE`` is disabled (the registry mounts fine, but every flavor would 5xx at query time until LightRAG is enabled). """ try: config = starter.config_parser.load_consumer_config() except ValueError as exc: return _doctor_result( "lightrag-query-profiles", "fail", f"Consumer LightRAG query profile validation failed: {exc}", ) if not config.lightrag_query_profiles: return _doctor_result( "lightrag-query-profiles", "pass", "No consumer LightRAG query profiles declared.", ) names = [p.name for p in config.lightrag_query_profiles] owners = sorted({p.consumer for p in config.lightrag_query_profiles}) aliases = sorted( {p.litellm_alias for p in config.lightrag_query_profiles if p.litellm_alias} ) env_values = starter.config_parser.parse_env_file() if not _source_enabled(env_values, "LIGHTRAG_SOURCE"): return _doctor_result( "lightrag-query-profiles", "warn", f"{len(names)} LightRAG query profile(s) declared but LIGHTRAG_SOURCE is " f"disabled — the registry mounts but flavors cannot be served until LightRAG " f"is enabled.", details={"profiles": names, "owners": owners, "aliases": aliases}, ) return _doctor_result( "lightrag-query-profiles", "pass", f"{len(names)} consumer LightRAG query profile(s) valid: {', '.join(names)}.", details={"profiles": names, "owners": owners, "aliases": aliases}, ) def _doctor_check_lightrag_rerank_adapter(starter: "AtlasStarter") -> dict: """Operational signal for the LightRAG → TEI rerank adapter (#415). The adapter route always exists on the backend, but it only reranks when LightRAG is wired to it. Wiring happens only when the operator opts in (``LIGHTRAG_RERANK_ADAPTER_ENABLED=true``) AND both LightRAG and the TEI reranker are enabled. Warn on the mismatch — flag on but a prerequisite service off — because reranking would silently be a no-op (RERANK_BINDING stays ``null``). """ env_values = starter.config_parser.parse_env_file() flag = (env_values.get("LIGHTRAG_RERANK_ADAPTER_ENABLED", "") or "").strip().lower() == "true" if not flag: return _doctor_result( "lightrag-rerank-adapter", "pass", "LightRAG rerank adapter disabled (default) — LightRAG will not rerank through TEI.", ) lightrag_enabled = (env_values.get("LIGHTRAG_SOURCE", "disabled") or "disabled").strip() != "disabled" tei_enabled = (env_values.get("TEI_RERANKER_SOURCE", "disabled") or "disabled").strip() != "disabled" missing = [] if not lightrag_enabled: missing.append("LIGHTRAG_SOURCE") if not tei_enabled: missing.append("TEI_RERANKER_SOURCE") if missing: return _doctor_result( "lightrag-rerank-adapter", "warn", "LIGHTRAG_RERANK_ADAPTER_ENABLED=true but " f"{' and '.join(missing)} is disabled — reranking will be a no-op " "until both LightRAG and the TEI reranker are enabled.", details={"missing": missing}, ) if not (env_values.get("LIGHTRAG_RERANK_ADAPTER_TOKEN", "") or "").strip(): return _doctor_result( "lightrag-rerank-adapter", "warn", "LightRAG rerank adapter enabled but LIGHTRAG_RERANK_ADAPTER_TOKEN is empty — " "the /lightrag/rerank route will 503 until a token is generated (re-run start).", ) return _doctor_result( "lightrag-rerank-adapter", "pass", "LightRAG rerank adapter enabled and wired to TEI through the backend route.", ) def _lightrag_runtime_env(env_values: dict) -> dict: """The role environment ``services/lightrag/compose.yml`` renders from .env. The base model is the explicit one lightrag-init would take; a model it would pick from LiteLLM's listing stays empty. Only whether a role key is set is kept, so the doctor never holds a key value. """ env = { "LLM_BINDING": env_values.get("LIGHTRAG_LLM_BINDING") or "openai", "LLM_BINDING_HOST": env_values.get("LIGHTRAG_LLM_BINDING_HOST") or "http://litellm:4000/v1", "LLM_MODEL": env_values.get("LIGHTRAG_LLM_MODEL") or env_values.get("LITELLM_DEFAULT_MODEL") or "", } for role in ("EXTRACT", "KEYWORD", "QUERY"): for name in ("LLM_MODEL", "LLM_BINDING", "LLM_BINDING_HOST"): env[f"{role}_{name}"] = env_values.get(f"LIGHTRAG_{role}_{name}", "") env[f"{role}_LLM_BINDING_API_KEY"] = ( "set" if env_values.get(f"LIGHTRAG_{role}_LLM_BINDING_API_KEY") else "" ) return env def _doctor_check_lightrag_role_transport(starter: "AtlasStarter") -> dict: """Name each LightRAG role's effective transport and request defaults (#658). Runs the container's own start-time resolver (``services/lightrag/init/scripts/resolve-role-keys.py``) over the values Compose would pass, so the report matches what LightRAG will use. A role that would inherit a native base binding while its model declares catalog ``request_defaults`` is kept on LiteLLM, which applies them. A role pinned to a native host by its own settings cannot receive them, which warns. No key value is read into the result. """ env_values = starter.config_parser.parse_env_file() if (env_values.get("LIGHTRAG_SOURCE", "disabled") or "disabled").strip() != "container": return _doctor_result( "lightrag-role-transport", "skipped", "LIGHTRAG_SOURCE is not container; no in-stack LightRAG roles to resolve.", ) import importlib.util services = starter.root_dir / "services" spec = importlib.util.spec_from_file_location( "lightrag_role_resolver", services / "lightrag" / "init" / "scripts" / "resolve-role-keys.py", ) resolver = importlib.util.module_from_spec(spec) spec.loader.exec_module(resolver) catalog = resolver.catalog_request_defaults(services / "ollama" / "models.yaml") env = _lightrag_runtime_env(env_values) env.update(resolver.route_roles(env, catalog)) roles = resolver.role_transports(env, catalog) lost = [role for role, transport in roles.items() if transport["unsent_request_defaults"]] return _doctor_result( "lightrag-role-transport", "warn" if lost else "pass", "; ".join(resolver.describe(role, transport) for role, transport in roles.items()) + ".", details={"roles": roles}, ) # A corrupt Redis append-only file (AOF) stops Redis loading it, and every # service that waits on Redis stays at "Created" (#1343). redis-check-aof opens # the files read-write even without --fix, so the probe checks a copy taken # from a read-only mount and never changes the volume. _REDIS_AOF_MANIFEST = "appendonly.aof.manifest" _REDIS_AOF_SCRIPT = ( f"[ -f /data/appendonlydir/{_REDIS_AOF_MANIFEST} ] || exit 3; " "cp -a /data/appendonlydir /tmp/aof && cd /tmp/aof " f'&& echo "manifest-last: $(tail -n 1 {_REDIS_AOF_MANIFEST})" ' f"&& redis-check-aof {_REDIS_AOF_MANIFEST}" ) # Damage Redis refuses to load. A cut-off last command in the newest incr file # is not in this list: with aof-load-truncated (default yes, not overridden in # services/redis/compose.yml) Redis drops the partial command and starts. _REDIS_AOF_FATAL_MARKERS = ("format error", "not sane", "Cannot open file") _REDIS_AOF_MESSAGES = { "running": "Redis is running, so its append-only file loaded; it is " "checked only while Redis is stopped or restarting.", "no-volume": "No Redis data volume yet.", "no-aof": "The Redis data volume holds no append-only file yet.", "ok": "The Redis append-only file is valid.", "truncated": "The last write in the Redis append-only file was cut off. " "Redis loads it anyway (aof-load-truncated) and drops that one command.", } def _redis_aof_names(env_values: dict) -> tuple[str, str, str]: """Container, volume and image names from services/redis/compose.yml.""" project = (env_values.get("PROJECT_NAME") or "").strip() or DEFAULT_PROJECT_NAME image = (env_values.get("REDIS_IMAGE") or "").strip() return f"{project}-redis", f"{project}-redis-data", image def _docker_text(args: list[str], timeout: int) -> tuple[int, str]: """Run ``docker ``; return the exit code and combined output.""" try: proc = subprocess.run( ["docker", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, check=False, ) except (OSError, subprocess.SubprocessError) as exc: return -1, str(exc) return proc.returncode, f"{proc.stdout}{proc.stderr}".strip() def _redis_aof_truncated_tail(output: str, invalid_file: str) -> bool: """True when the only damage is a cut-off command at the end of the last incr file, which Redis loads anyway.""" if any(marker in output for marker in _REDIS_AOF_FATAL_MARKERS): return False last = re.search(r"manifest-last: file (\S+) .*type i", output) return bool(last) and last.group(1) == invalid_file def _redis_aof_classify(code: int, output: str) -> str: """Map a redis-check-aof run to ok, no-aof, truncated, corrupt-base, corrupt or unavailable.""" if code in (0, 3): return "ok" if code == 0 else "no-aof" if code != 1: return "unavailable" if "not sane" in output: return "corrupt-base" invalid = re.search(r"AOF (\S+) is not valid", output) if invalid and _redis_aof_truncated_tail(output, invalid.group(1)): return "truncated" return "corrupt" if invalid or "aborting" in output else "unavailable" def _redis_aof_probe(env_values: dict) -> tuple[str, str]: """Classify the Redis AOF (see ``_redis_aof_classify``, plus running and no-volume), with the checker output.""" container, volume, image = _redis_aof_names(env_values) if not image: return "unavailable", "REDIS_IMAGE is not set in .env." code, out = _docker_text( ["inspect", "-f", "{{.State.Running}} {{.State.Restarting}}", container], 10 ) if code == 0 and out == "true false": return "running", out code, out = _docker_text(["volume", "inspect", volume], 10) if code != 0: return ("no-volume" if "no such volume" in out.lower() else "unavailable"), out # 45 s keeps the probe inside the support bundle's shared check budget. code, out = _docker_text( ["run", "--rm", "--pull", "never", "--network", "none", "-v", f"{volume}:/data:ro", "--entrypoint", "sh", image, "-c", _REDIS_AOF_SCRIPT], 45, ) return _redis_aof_classify(code, out), out def _redis_aof_repair_steps(volume: str, image: str, state: str) -> list[str]: """Backup-first repair; services/redis/README.md documents the same steps.""" backup = ( f'docker run --rm -v {volume}:/data -v "$PWD":/backup {image} ' "tar czf /backup/redis-aof-backup.tgz -C /data appendonlydir" ) if state == "corrupt-base": repair = ( "--fix cannot repair the base snapshot: restore appendonlydir from an " f"earlier backup, or run docker volume rm {volume} to start Redis empty" ) else: repair = ( f"docker run --rm -it -v {volume}:/data -w /data/appendonlydir {image} " f"redis-check-aof --fix {_REDIS_AOF_MANIFEST}" ) return ["./stop.sh", backup, repair, "./start.sh"] def _redis_aof_findings(output: str) -> list[str]: """The checker lines that say where the damage is.""" return [ line.strip() for line in output.splitlines() if (line.startswith(("AOF analyzed:", "0x")) and not line.rstrip().endswith("diff=0")) or "not sane" in line or "Cannot open file" in line ] def _doctor_check_redis_aof(starter: "AtlasStarter") -> dict: """Fail when Redis cannot load its AOF, with the backup-first repair (#1343).""" env_values = starter.config_parser.parse_env_file() _container, volume, image = _redis_aof_names(env_values) state, output = _redis_aof_probe(env_values) if state == "unavailable": return _doctor_result( "redis-aof", "skipped", "Could not check the Redis append-only file: " f"{(output.splitlines() or ['docker unavailable'])[-1]}", ) if not state.startswith("corrupt"): return _doctor_result("redis-aof", "pass", _REDIS_AOF_MESSAGES[state]) steps = _redis_aof_repair_steps(volume, image, state) findings = _redis_aof_findings(output) numbered = "\n".join(f" {index}. {step}" for index, step in enumerate(steps, 1)) where = "\n".join(f" {line}" for line in findings) return _doctor_result( "redis-aof", "fail", f"The Redis append-only file in volume {volume} is corrupt. Redis will " "not load it, so every service that waits on Redis stays at Created.\n" f"{where}\nBack it up first: --fix truncates the file at the first bad " f"byte and drops every write after it.\n{numbered}", details={"volume": volume, "state": state, "findings": findings, "repair": steps}, ) def _doctor_check_endpoints(starter: "AtlasStarter") -> dict: env_values = starter.config_parser.parse_env_file() endpoints = { "COMFYUI_ENDPOINT": env_values.get("COMFYUI_ENDPOINT", ""), "LITELLM_BASE_URL": env_values.get("LITELLM_BASE_URL", ""), "MINIO_ENDPOINT": env_values.get("MINIO_ENDPOINT", ""), "MINIO_PUBLIC_CONSOLE_ENDPOINT": env_values.get("MINIO_PUBLIC_CONSOLE_ENDPOINT", ""), } visible = {key: value for key, value in endpoints.items() if value} if not visible: return _doctor_result( "endpoints", "warn", "No consumer endpoint values are currently resolved in .env.", details={"endpoints": endpoints}, ) message = ", ".join(f"{key}={value}" for key, value in visible.items()) return _doctor_result( "endpoints", "pass", message, details={"endpoints": endpoints}, ) def _doctor_check_submodule_clean(starter: "AtlasStarter") -> dict: try: result = subprocess.run( ["git", "status", "--porcelain", "--untracked-files=no"], cwd=starter.config_parser.root_dir, capture_output=True, text=True, check=False, encoding="utf-8", errors="replace", timeout=10, ) except Exception as exc: # noqa: BLE001 return _doctor_result( "submodule-cleanliness", "skipped", f"Could not inspect git status: {exc}", ) if result.returncode != 0: return _doctor_result( "submodule-cleanliness", "skipped", result.stderr.strip() or "Not a git checkout.", ) dirty = [line for line in result.stdout.splitlines() if line.strip()] if dirty: return _doctor_result( "submodule-cleanliness", "fail", f"{len(dirty)} tracked Atlas file(s) are dirty.", details={"dirty": dirty[:20]}, ) return _doctor_result( "submodule-cleanliness", "pass", "No tracked Atlas files are dirty.", ) def _doctor_check_comfyui_mps(starter: "AtlasStarter") -> dict: """Preflight the Atlas-managed Apple-Silicon/Metal ComfyUI host (#335). Only meaningful when COMFYUI_SOURCE=managed-localhost-mps. The preflight is read-only (no install, no launch) and CI-safe: on a non-Darwin/arm64 host it reports ``fail`` (the source can't run here) with an actionable message; when the source isn't selected the check is skipped entirely. """ env_values = starter.config_parser.parse_env_file() if str(env_values.get("COMFYUI_SOURCE", "")).strip() != "managed-localhost-mps": return _doctor_result( "comfyui-mps", "skipped", "COMFYUI_SOURCE is not managed-localhost-mps.", ) from services.comfyui_mps_manager import manager_from_env manager = manager_from_env(env_values) pre = manager.preflight() fails = [c for c in pre.checks if c["status"] == "fail"] warns = [c for c in pre.checks if c["status"] == "warn"] if fails: return _doctor_result( "comfyui-mps", "fail", "; ".join(f"{c['name']}: {c['detail']}" for c in fails), details=pre.to_dict(), ) status = manager.status() if warns: return _doctor_result( "comfyui-mps", "warn", warns[0]["detail"], details={**pre.to_dict(), "running": status.running, "pid": status.pid}, ) running = "running" if status.running else "not started" return _doctor_result( "comfyui-mps", "pass", f"Managed MPS host preflight passed (host process {running}; " f"port {manager.port}, ref {manager.ref}).", details={**pre.to_dict(), "running": status.running, "pid": status.pid}, ) def _doctor_check_vllm_metal(starter: "AtlasStarter") -> dict: """Preflight the Atlas-managed vLLM Metal host (#379). Only meaningful when VLLM_METAL_SOURCE=managed-localhost. The preflight is read-only (no install, no launch) and CI-safe: on a non-Darwin/arm64 host it reports ``fail`` (the source can't run here) with an actionable message; when the source isn't selected the check is skipped entirely. """ env_values = starter.config_parser.parse_env_file() if str(env_values.get("VLLM_METAL_SOURCE", "")).strip() != "managed-localhost": return _doctor_result( "vllm-metal", "skipped", "VLLM_METAL_SOURCE is not managed-localhost.", ) from services.vllm_metal_manager import manager_from_env manager = manager_from_env(env_values) pre = manager.preflight() fails = [c for c in pre.checks if c["status"] == "fail"] warns = [c for c in pre.checks if c["status"] == "warn"] if fails: return _doctor_result( "vllm-metal", "fail", "; ".join(f"{c['name']}: {c['detail']}" for c in fails), details=pre.to_dict(), ) status = manager.status() if warns: return _doctor_result( "vllm-metal", "warn", warns[0]["detail"], details={**pre.to_dict(), "running": status.running, "pid": status.pid}, ) running = "running" if status.running else "not started" return _doctor_result( "vllm-metal", "pass", f"Managed vLLM Metal host preflight passed (host process {running}; " f"port {manager.port}, model {manager.model}, " f"plugin vllm-metal=={manager.plugin_version}).", details={**pre.to_dict(), "running": status.running, "pid": status.pid}, ) def _scan_env_keys(env_text: str, origin: str = "active env") -> tuple[set[str], set[str]]: """Canonical keys and eligible bare empty occurrences; exports stay occupied.""" existing_keys: set[str] = set() blank_keys: set[str] = set() for index, line in enumerate(env_lines(env_text.removeprefix("\ufeff")), 1): item = _backfill_env_assignment(line, f"{origin}:{index}") if item is not None: key, value, _encoded, exported = item existing_keys.add(key) if value == "" and not exported: blank_keys.add(key) return existing_keys, blank_keys def _parsed_base_port(raw: str) -> Optional[int]: """BASE_PORT as start parses it (strip + int(), so `+64000` / `64_000` count); None for blank, `auto` or unparseable.""" try: return int(raw.strip()) except ValueError: return None def _base_port_problem(raw: str, starter: "AtlasStarter"): """(status, message) for a BASE_PORT start cannot use as written, else None. Mirrors start's own parse (int(), so `+64000` / `64_000` are fine): an unparseable value falls back to the default, an out-of-range one is rejected, and `auto` is resolved by start itself.""" if not raw or raw.lower() == "auto": return None value = _parsed_base_port(raw) if value is None: return ( "warn", f"BASE_PORT {raw!r} is not a number or 'auto'; ./start.sh falls back to " f"{DEFAULT_BASE_PORT}.", ) if not starter.port_manager.validate_base_port(value): return ( "fail", f"BASE_PORT {raw} is outside the usable range; ./start.sh will reject it.", ) return None def _doctor_check_base_port(starter: "AtlasStarter") -> dict: """Warn when a consumer stack squats the default BASE_PORT (63000). ``project_name`` isolates Docker resource names (container/volume/network) but NOT host port bindings, so a consumer that keeps the default BASE_PORT while running under a non-default project collides with a bare atlas checkout on the same host. Set ``BASE_PORT: auto`` in the consumer manifest (durable) or pin a distinct block; ``--base-port auto`` is a one-off. """ try: env = starter.config_parser.parse_env_file() except Exception as exc: # pragma: no cover - defensive return _doctor_result("base-port", "skipped", f"Could not read .env: {exc}") raw = (env.get("BASE_PORT", "") or "").strip() project = starter.config_parser.get_project_name() if raw.lower() == "auto": return _doctor_result( "base-port", "pass", f"BASE_PORT auto / project '{project}': ./start.sh allocates a free " f"non-default block at launch.", details={"base_port": "auto", "project_name": project}, ) try: base_port = int(raw) if raw else DEFAULT_BASE_PORT except ValueError: base_port = DEFAULT_BASE_PORT details = {"base_port": base_port, "project_name": project} problem = _base_port_problem(raw, starter) if problem: return _doctor_result("base-port", problem[0], problem[1], details=details) if base_port == DEFAULT_BASE_PORT and project != DEFAULT_PROJECT_NAME: return _doctor_result( "base-port", "warn", f"Consumer project '{project}' is on the default BASE_PORT " f"{DEFAULT_BASE_PORT}. project_name isolates Docker resources but not " f"host ports, so this stack collides with a bare atlas checkout on the " f"same host. Set BASE_PORT: auto in the consumer manifest, or pin a " f"non-default BASE_PORT (--base-port auto is a one-off).", details=details, ) return _doctor_result( "base-port", "pass", f"BASE_PORT {base_port} / project '{project}' — no default-port squat.", details=details, ) def _doctor_check_auto_sources(starter: "AtlasStarter") -> dict: """Report how each consumer ``_SOURCE: auto`` declaration resolved (#753) — the concrete id now in ``.env`` plus the host capability that matches, so "why did it pick this?" is answerable without reading code. Analogous to the base-port lint for ``BASE_PORT: auto`` (#751).""" try: consumer_config = starter.config_parser.load_consumer_config() env = starter.config_parser.parse_env_file() except Exception as exc: # pragma: no cover - defensive return _doctor_result("auto-sources", "skipped", f"Could not read config: {exc}") declared = sorted( key for key, value in (consumer_config.env_overrides or {}).items() if key.endswith("_SOURCE") and str(value).strip().lower() == "auto" ) if not declared: return _doctor_result( "auto-sources", "pass", "No _SOURCE: auto declarations." ) from services.host_capabilities import probe_host_capabilities from services.manifests import load_manifests capabilities = probe_host_capabilities() matched_caps = [c for c in ("apple_silicon", "nvidia_gpu", "host_ollama") if capabilities.has(c)] by_source_var = { m.sources.var: m for m in load_manifests(starter.root_dir / "services") if m.sources is not None } details: dict = {"host_capabilities": matched_caps} unresolved: list[str] = [] lines: list[str] = [] for key in declared: resolved = (env.get(key, "") or "").strip() manifest = by_source_var.get(key) if manifest is None: unresolved.append(f"{key}: no manifest sources block") continue if not resolved or resolved.lower() == "auto": unresolved.append(f"{key}: not yet resolved (run start or preflight)") continue cap = next( ( p.requires_capability or "terminal fallback" for p in manifest.sources.auto_prefer if p.id == resolved ), "explicit/default", ) details[key] = {"resolved": resolved, "matched": cap} lines.append(f"{key}=auto → {resolved} ({cap})") if unresolved: return _doctor_result( "auto-sources", "warn", "; ".join(unresolved + lines), details=details, ) return _doctor_result("auto-sources", "pass", "; ".join(lines), details=details) def _doctor_check_profile(starter: "AtlasStarter") -> dict: """Report the resolved deployment-profile environment (#755): the active profile (consumer `profile:` default unless a --profile flag overrides at launch), the effective bundle after consumer `profile_overrides:`, and the precedence tier each managed value currently comes from.""" from services.profiles import ( ProfileConfigError, canonical_profile, load_profile_bundles, merge_consumer_profile_overrides, ) try: consumer_config = starter.config_parser.load_consumer_config() env = starter.config_parser.parse_env_file() except Exception as exc: # pragma: no cover - defensive return _doctor_result("profile", "skipped", f"Could not read config: {exc}") try: bundles = merge_consumer_profile_overrides( load_profile_bundles(), getattr(consumer_config, "profile_overrides", None) or {}, ) except ProfileConfigError as exc: return _doctor_result("profile", "fail", str(exc)) declared = getattr(consumer_config, "profile", None) active = _doctor_active_profile(starter, env, declared) applied = (env.get("ATLAS_PROFILE_APPLIED", "") or "").strip() bundle = bundles.get(active) from services.manifests import load_manifests by_name = { m.name: m for m in load_manifests(starter.root_dir / "services") if m.sources is not None } fields: dict = {} if bundle is not None: if bundle.host_bind_ip is not None: current = env.get("HOST_BIND_IP", "") fields["HOST_BIND_IP"] = { "profile_value": bundle.host_bind_ip, "current": current, "tier": "profile" if current == bundle.host_bind_ip else "operator", } for svc, sid in bundle.sources.items(): manifest = by_name.get(svc) if manifest is None: continue var = manifest.sources.var current = (env.get(var, "") or "").strip() tier = ( "profile" if (sid != "auto" and current == sid) else ("auto" if sid == "auto" else "operator") ) fields[var] = {"profile_value": sid, "current": current, "tier": tier} for var, value in bundle.env.items(): current = env.get(var, "") fields[var] = { "profile_value": value, "current": current, "tier": "profile" if current == value else "operator", } details = { "declared_default": declared or "(none — implicit default)", "active": active, "last_applied": applied or "(never)", "fields": fields, } return _doctor_result( "profile", "pass", f"profile={active} (declared: {declared or 'none'}; last applied: " f"{applied or 'never'}); {len(fields)} managed value(s).", details=details, ) def _doctor_check_blender_mcp(starter: "AtlasStarter") -> dict: """Preflight the managed Blender MCP bridge (#759) when selected.""" env = starter.config_parser.parse_env_file() if (env.get("BLENDER_MCP_SOURCE", "") or "").strip() != "managed-localhost": return _doctor_result( "blender-mcp", "pass", "BLENDER_MCP_SOURCE is not managed-localhost." ) from services.blender_mcp_manager import manager_from_env, pool_problems try: result = manager_from_env(env).preflight() problems = pool_problems(env) except Exception as exc: # pragma: no cover - defensive return _doctor_result("blender-mcp", "skipped", f"Could not preflight: {exc}") status = {"ok": "pass", "warn": "warn", "fail": "fail"}.get(result.status, "warn") summary = "; ".join(f"{c['name']}: {c['detail']}" for c in result.checks) if problems: # pool instances no live manager accounts for (#851) status = "fail" if status == "fail" else "warn" summary = "; ".join([summary, *problems]) return _doctor_result("blender-mcp", status, summary, details=result.to_dict()) def _doctor_check_managed_host_services(starter: "AtlasStarter") -> dict: """Preflight every consumer-declared managed host process (#795). Aggregated into one row rather than one row per service: the count is consumer-controlled, and a doctor report that grows unboundedly with a consumer's manifest stops being scannable. """ try: config = starter.config_parser.load_consumer_config() specs = list(getattr(config, "managed_host_services", ())) except Exception as exc: # pragma: no cover - defensive return _doctor_result( "managed-host-services", "skipped", f"Could not load consumer manifests: {exc}" ) if not specs: return _doctor_result( "managed-host-services", "pass", "No managed_host_services declared." ) from services.managed_host import manager_for env = starter.config_parser.parse_env_file() rows, worst = [], "pass" rank = {"pass": 0, "warn": 1, "fail": 2} for spec in sorted(specs, key=lambda item: item.name): try: result = manager_for(spec, env).preflight() except Exception as exc: # pragma: no cover - defensive rows.append({"name": spec.name, "status": "skipped", "detail": str(exc)}) continue mapped = {"ok": "pass", "warn": "warn", "fail": "fail"}.get(result.status, "warn") if rank[mapped] > rank[worst]: worst = mapped rows.append({"name": spec.name, "status": mapped, **result.to_dict()}) summary = "; ".join(f"{row['name']}: {row['status']}" for row in rows) return _doctor_result( "managed-host-services", worst, f"{len(specs)} declared — {summary}", details={"services": rows}, ) # Its result id predates the function name; the bundle needs it for a check # that never returns a result (#1057). _doctor_check_submodule_clean.doctor_id = "submodule-cleanliness" DOCTOR_CHECKS = [ _doctor_check_consumer_manifests, _doctor_check_base_port, _doctor_check_auto_sources, _doctor_check_profile, _doctor_check_compose, _doctor_check_overlay_env, _doctor_check_plugins, _doctor_check_plugin_manifests, _doctor_check_ollama_parallel, _doctor_check_ollama_residency, _doctor_check_model_sidecars, _doctor_check_unpullable_models, _doctor_check_litellm_models, _doctor_check_n8n_workflows, _doctor_check_rag_ingestion_profiles, _doctor_check_lightrag_query_profiles, _doctor_check_lightrag_rerank_adapter, _doctor_check_lightrag_role_transport, _doctor_check_comfyui_mps, _doctor_check_vllm_metal, _doctor_check_blender_mcp, _doctor_check_managed_host_services, _doctor_check_redis_aof, _doctor_check_endpoints, _doctor_check_submodule_clean, ] def _comfyui_models_source_note(source: str) -> Optional[str]: """Why ``--comfyui-models`` will not download anything for ``source``. container-* downloads via comfyui-init and managed-localhost-mps provisions on the host (#754); only localhost and disabled need a note. """ if source == "localhost": return ( "⚠️ --comfyui-models was set with COMFYUI_SOURCE=localhost — the " "selection is published to the backend manifest, but Atlas does not " "download it: place the files in your host ComfyUI models directory." ) if source == "disabled": return ( "⚠️ --comfyui-models was set but COMFYUI_SOURCE=disabled — the " "selection is saved but has no effect until ComfyUI is enabled." ) return None def _invoker_path_list(value: str) -> str: """``_invoker_path`` applied to each entry of an os.pathsep path list.""" return os.pathsep.join( str(_invoker_path(Path(part.strip()))) for part in value.split(os.pathsep) if part.strip() ) def _invoker_path(path: Optional[Path]) -> Optional[Path]: """Resolve a user-supplied path against the directory ./start.sh was run from (ATLAS_INVOKER_CWD); the bootstrapper itself runs elsewhere.""" if path is None: return None path = Path(path).expanduser() if not path.is_absolute(): invoker_cwd = os.environ.get("ATLAS_INVOKER_CWD", "").strip() path = (Path(invoker_cwd).expanduser() if invoker_cwd else Path.cwd()) / path return path.resolve() def _invoker_output_path(path: Optional[Path]) -> Optional[Path]: """``_invoker_path`` for a file Atlas writes: only the parent directory is resolved, so a symlink at the destination is replaced, never followed.""" if path is None: return None requested = Path(path).expanduser() return _invoker_path(requested.parent) / requested.name def _invoker_file_option(ctx, param, value): """Click callback for a file Atlas writes: reject a directory where the file will be written (the invoker's directory), not in this cwd.""" resolved = _invoker_output_path(value) if resolved is not None and resolved.is_dir(): raise click.BadParameter(f"{resolved} is a directory") return value def _invoker_input_file_option(ctx, param, value): """Click callback for a file Atlas reads: reject a directory, resolved against the invoker's directory as the option help says.""" if value and _invoker_path(Path(value)).is_dir(): raise click.BadParameter(f"{value} is a directory") return value def _export_failed_start_bundle(starter: "AtlasStarter", transcript, exit_code) -> None: """Preview, then write, the --support-bundle archive for a failed ``--no-tui`` start (#1057). A bundle problem never masks the start's own failure, so it is reported and swallowed.""" from core import support_bundle as sb echo = lambda line: print(line, file=sys.stderr) # noqa: E731 echo("📦 Start failed; collecting the support bundle (--support-bundle)…") try: options = sb.BundleOptions( context={"command": "start", "interface": "no-tui", "exit_code": exit_code}, logs=(sb.LogSource("transcript.log", text=transcript.text()),), ) sb.export(starter.build_support_bundle(options), starter.support_bundle_path, echo) except Exception as exc: # noqa: BLE001 echo(f"⚠ support bundle not written: {exc}") def _report_tui_exit(rc: int, starter=None, *, cold: bool = False) -> int: """Say what a cancelled Textual run left behind, then pass ``rc`` on. Ctrl+C exits the Textual app with 130 at any point. The alternate screen is gone by then, so without this line nothing in the terminal says that containers Compose already started are still running and that nothing was deleted. Cancelling is never a teardown, and never a deletion (#1032) — except that a cold start's cleanup, once it ran, has removed the volumes. ``starter`` carries what the TUI recorded: whether the launch started and whether the wizard's cold cleanup ran; ``cold`` is a CLI --cold, which cleans before the launch screen. """ stopped = getattr(starter, "stopped_previous_instance", False) cold = cold or getattr(starter, "tui_cold_cleanup_ran", False) started = getattr(starter, "tui_launch_started", True) if getattr(starter, "wizard_declined", False) or (rc == 130 and not started): # Nothing was launched: put .env back as it was before this run (#1044). snapshot = getattr(starter, "launch_env_snapshot", None) restored = snapshot is not None and snapshot.restore() print(launch_cancelled_notice(cold, stopped, env_restored=restored)) elif rc == 130: print(cancel_notice(stopped, cold)) return rc def _run_linear_with_support_bundle(starter: "AtlasStarter", options) -> int: """``run_linear_startup`` under SIGTERM/SIGHUP cleanup: a terminated or disconnected `--no-tui` start stops the command it is running and rolls back the managed hosts it started, as Ctrl+C does; they run in their own session, so they would otherwise outlive it holding GPU memory and ports.""" from core.process_runner import _CommandInterrupted, cleanup_active_processes_on_sigterm with cleanup_active_processes_on_sigterm(): try: return _run_linear_startup_bundled(starter, options) except _CommandInterrupted: starter.rollback_managed_host_processes() raise def _run_linear_startup_bundled(starter: "AtlasStarter", options) -> int: """``run_linear_startup``; with ``--support-bundle`` a failed start also leaves a redacted bundle built from what the run printed (#1057).""" if starter.support_bundle_path is None: return run_linear_startup(starter, options) from core.support_bundle import Transcript transcript = Transcript() try: with transcript.capture(): exit_code = run_linear_startup(starter, options) except Exception: import traceback transcript.write(traceback.format_exc()) _export_failed_start_bundle(starter, transcript, None) raise # Once startup hands over to following the logs, it has succeeded: a # nonzero code there (Ctrl+C is 130) is not a failed start. if exit_code != 0 and not getattr(starter, "startup_reached_log_follow", False): _export_failed_start_bundle(starter, transcript, exit_code) return exit_code def _run_consumer_doctor(starter: "AtlasStarter") -> list[dict]: """Run every check; one that raises becomes a ``fail`` result, so ``--format json`` always prints its JSON and exits non-zero (#1057 records the same exception as ``unavailable`` in a bundle).""" results = [] for check in DOCTOR_CHECKS: try: results.append(check(starter)) except Exception as exc: # noqa: BLE001 - reported, never a traceback check_id = check.__name__.removeprefix("_doctor_check_").replace("_", "-") results.append(_doctor_result( check_id, "fail", f"check raised {type(exc).__name__}: {exc}", )) return results def _print_doctor_text(results: list[dict]) -> None: click.echo("Consumer Doctor") for result in results: status = result["status"].upper() click.echo(f"[{status}] {result['id']}: {result['message']}") def _parse_base_port_option(ctx, param, value): """Accept an integer or the literal ``auto`` for --base-port. ``auto`` is resolved to a concrete free block in ``main`` (needs the topology + live port scan), so here it's passed through as the sentinel string.""" if value is None: return None text = str(value).strip().lower() if text == "auto": return "auto" try: port = int(text) except (TypeError, ValueError): raise click.BadParameter("must be an integer or 'auto'") from core.port_manager import PortManager port_manager = PortManager() if not port_manager.validate_base_port(port): offsets = port_manager.port_offsets() max_port = 65535 - (max(offsets.values()) if offsets else 0) raise click.BadParameter( f"must be between 1024 and {max_port}, or 'auto'" ) return port class AtlasStartGroup(click.Group): """Add terminal JSON for root-command parse failures in ``--json`` mode.""" def main(self, *args, **kwargs): cli_args = kwargs.get("args", args[0] if args else None) standalone_mode = kwargs.get( "standalone_mode", args[3] if len(args) > 3 else True ) raw_args = list(cli_args if cli_args is not None else sys.argv[1:]) startup_json = "--json" in raw_args and not self._has_subcommand(raw_args) if not startup_json: return super().main(*args, **kwargs) delegated_args = list(args) delegated_kwargs = dict(kwargs) if len(delegated_args) > 3: delegated_args[3] = False else: delegated_kwargs["standalone_mode"] = False try: return super().main(*delegated_args, **delegated_kwargs) except click.ClickException as exc: exc.show(file=sys.stderr) if not getattr(exc, "_atlas_json_emitted", False): click.echo( json.dumps({"ok": False, "exit_code": exc.exit_code}) ) if standalone_mode: raise SystemExit(exc.exit_code) from exc raise def _root_options(self) -> dict[str, click.Option]: """Index root options by every accepted spelling.""" return { opt: param for param in self.params if isinstance(param, click.Option) for opt in (*param.opts, *param.secondary_opts) } def _has_subcommand(self, raw_args: list[str]) -> bool: """Find the first positional token while skipping root option values.""" options = self._root_options() index = 0 while index < len(raw_args): token = raw_args[index] if token == "--": return index + 1 < len(raw_args) and raw_args[index + 1] in self.commands if not token.startswith("-"): return token in self.commands name, has_inline_value, _value = token.partition("=") option = options.get(name) consumes_next = ( option is not None and not option.is_flag and not has_inline_value ) index += 2 if consumes_next else 1 return False def _track_suggestions(entered: str, registry) -> list[str]: """Valid track keys close to what the user typed, best first. Prefix and substring matches rank first, so a truncated 'data-en' suggests 'data-eng' before anything fuzzier; difflib fills the rest. """ import difflib entered = entered.strip().lower() if not entered: return [] keys = [track.key for track in registry.tracks] ordered = [key for key in keys if key.startswith(entered) or entered in key] for key in difflib.get_close_matches(entered, keys, n=3, cutoff=0.6): if key not in ordered: ordered.append(key) return ordered[:3] #: Root options harmless before a subcommand: --consumer is exported as #: ATLAS_CONSUMER_MANIFEST before the subcommand runs, and the output/mode #: flags are what consumer scripts habitually pass on every invocation. _SUBCOMMAND_ROOT_OPTIONS = frozenset( {"consumer_manifests", "no_tui", "json_output", "no_splash", "detach"} ) def _reject_root_options_for_subcommand(ctx: click.Context) -> None: """Warn about start options a subcommand silently ignores. Every subcommand builds its own AtlasStarter from .env, so ``./start.sh -p other doctor`` checks the .env project. A warning, not an error: consumer scripts and CI already pass such flags before subcommands, and failing them would break working invocations. """ ignored = sorted( max(param.opts, key=len) for param in ctx.command.params if param.name not in _SUBCOMMAND_ROOT_OPTIONS and ctx.get_parameter_source(param.name) == click.core.ParameterSource.COMMANDLINE ) if ignored: click.echo( f"Warning: {', '.join(ignored)} {'has' if len(ignored) == 1 else 'have'} no " f"effect on '{ctx.invoked_subcommand}'; it reads the project and ports from .env.", err=True, ) def _prompt_for_track(registry, *, max_attempts: int = 5) -> str: """Read a track key from stdin, re-prompting on invalid input (#1184). Empty input selects the displayed default. Non-interactive stdin takes the default without blocking a scripted run. Invalid non-empty input re-prompts with suggestions instead of silently resolving to another workload; after ``max_attempts`` the run exits non-zero rather than guessing which services to disable. """ default_key = registry.tracks[0].key if not sys.stdin.isatty(): print("(non-interactive stdin — using default)", file=sys.stderr) return default_key for attempt in range(max_attempts): try: selected = input().strip() except EOFError: print("(end of input — using default)", file=sys.stderr) return default_key if not selected: return default_key if selected in registry.by_key: return selected suggestions = _track_suggestions(selected, registry) hint = " Did you mean " + " or ".join(suggestions) + "?" if suggestions else "" print(f"Unknown track '{selected}'.{hint}", file=sys.stderr) if attempt == max_attempts - 1: break print( f"Enter a valid track, or press Enter for the default '{default_key}': ", end="", file=sys.stderr, flush=True, ) valid = ", ".join(track.key for track in registry.tracks) print(f"Error: no valid track selected. Available: {valid}.", file=sys.stderr) sys.exit(2) @click.group(invoke_without_command=True, cls=AtlasStartGroup) @click.option('--project', '-p', 'project_name', type=str, default=None, help='Docker Compose project name — the container-family namespace ' '(every container/volume/network is prefixed -…). Persists ' 'to .env as PROJECT_NAME, so a later ./stop.sh tears down exactly ' 'this stack. Defaults to PROJECT_NAME in .env (or "atlas"). Set it ' 'when running Atlas as a submodule so you do not collide with a ' 'base Atlas stack.') @click.option('--consumer', 'consumer_manifests', multiple=True, type=click.Path(exists=False), help='Path to an atlas.consumer.yml manifest in a parent project. ' 'May be passed multiple times. Relative paths resolve from ' 'the directory that invoked start.sh.') @click.option('--base-port', type=str, callback=_parse_base_port_option, help=f'Base port for all services (default: {DEFAULT_BASE_PORT}). ' f'Use "auto" to select the first wholly-free BASE_PORT block — ' f'recommended for submodule consumers so the stack never squats ' f'the default {DEFAULT_BASE_PORT} a bare atlas checkout binds.') @click.option('--cold', is_flag=True, help='Perform cold start with cleanup') @click.option('--setup-hosts', is_flag=True, help='Setup hosts file entries (requires admin/sudo)') @click.option('--skip-hosts', is_flag=True, help='Skip hosts file checks and setup') @click.option('--track', type=str, default=None, help='Pre-select a wizard track — gen-ai-rag, ' 'gen-ai-eng, gen-ai-creative, ml-eng, data-eng, trading, all. ' 'Skips the wizard track-picker. In-track services are ' 'prompted as usual; out-of-track services are disabled. ' 'Use --list-tracks to see members.') @click.option('--list-tracks', is_flag=True, help='Print the available tracks and their service ' 'membership, then exit.') @click.option('--llm-provider-source', type=click.Choice(['ollama-container-cpu', 'ollama-container-gpu', 'ollama-localhost', 'none'], case_sensitive=False), help='Override LLM_PROVIDER_SOURCE (Ollama upstream for the LiteLLM gateway). ' 'Use "none" for no Ollama upstream (vLLM Metal and/or cloud may remain).') @click.option('--cloud-openai-source', type=click.Choice(['enabled', 'disabled'], case_sensitive=False), help='Enable/disable the OpenAI cloud provider in LiteLLM (requires OPENAI_API_KEY).') @click.option('--cloud-anthropic-source', type=click.Choice(['enabled', 'disabled'], case_sensitive=False), help='Enable/disable the Anthropic cloud provider in LiteLLM (requires ANTHROPIC_API_KEY).') @click.option('--cloud-openrouter-source', type=click.Choice(['enabled', 'disabled'], case_sensitive=False), help='Enable/disable the OpenRouter cloud provider in LiteLLM (requires OPENROUTER_API_KEY).') @click.option('--openai-api-key', type=str, default=None, help='OpenAI API key (sk-...). Persists to .env as OPENAI_API_KEY and ' 'implies --cloud-openai-source=enabled.') @click.option('--anthropic-api-key', type=str, default=None, help='Anthropic API key (sk-ant-...). Persists to .env as ANTHROPIC_API_KEY ' 'and implies --cloud-anthropic-source=enabled.') @click.option('--openrouter-api-key', type=str, default=None, help='OpenRouter API key (sk-or-...). Persists to .env as OPENROUTER_API_KEY ' 'and implies --cloud-openrouter-source=enabled.') @click.option('--fal-api-key', type=str, default=None, help='fal.ai API key. Persists to .env as FAL_API_KEY and implies --fal-source=enabled.') @click.option('--openai-models', type=str, default=None, help='Comma-separated OpenAI model names to activate (e.g. "gpt-5,gpt-5-mini,o3"). ' 'Persists to .env as OPENAI_USER_MODELS; litellm-init activates these via model_resolver on the next docker compose up.') @click.option('--anthropic-models', type=str, default=None, help='Comma-separated Anthropic model names to activate. Persists as ANTHROPIC_USER_MODELS.') @click.option('--openrouter-models', type=str, default=None, help='Comma-separated OpenRouter model names to activate. Persists as OPENROUTER_USER_MODELS.') @click.option('--ollama-models', type=str, default=None, help='Comma-separated Ollama model names to activate from the curated catalog. ' 'Persists as OLLAMA_USER_MODELS.') @click.option('--ollama-custom-models', type=str, default=None, help='Comma-separated extra Ollama model names to pull (not in catalog). ' 'Persists as OLLAMA_CUSTOM_MODELS; ollama-pull fetches them at startup.') @click.option('--comfyui-models', help='Comma-separated catalog model names to pull for ComfyUI ' '(e.g. "sd_xl_base_1.0,sdxl-vae,ip-adapter-plus_sdxl_vit-h"). ' 'Overrides wizard selection and existing COMFYUI_USER_MODELS ' 'in .env. Pass "" to clear. Unknown names skip with warning ' '(comfyui-init logs unknown names at start).') @click.option('--comfyui-custom-models-file', type=click.Path(exists=False), callback=_invoker_input_file_option, help='Path to a sidecar custom-models.yaml. Default: ' 'services/comfyui/custom-models.yaml. Override to point at ' 'a file outside the repo (e.g. /etc/atlas/my-models.yaml).') @click.option('--comfyui-source', type=click.Choice(['container-cpu', 'container-gpu', 'localhost', 'managed-localhost-mps', 'disabled'], case_sensitive=False), help='Override COMFYUI_SOURCE') @click.option('--asset-worker-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override ASSET_WORKER_SOURCE — glTF post-processing worker.') @click.option('--asset-baker-source', type=click.Choice(['container-cpu', 'disabled'], case_sensitive=False), help='Override ASSET_BAKER_SOURCE — Blender headless HP→LP bake worker.') @click.option('--fal-source', type=click.Choice(['enabled', 'disabled'], case_sensitive=False), help='Override FAL_SOURCE — cloud media provider for backend generation routes.') @click.option('--weaviate-source', type=click.Choice(['container', 'localhost', 'disabled'], case_sensitive=False), help='Override WEAVIATE_SOURCE') @click.option('--minio-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override MinIO source') @click.option('--n8n-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override N8N_SOURCE') @click.option('--searxng-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override SEARXNG_SOURCE') @click.option('--crawl4ai-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override CRAWL4AI_SOURCE — token-protected browser-backed extraction API.') @click.option('--tika-source', type=click.Choice(['container', 'tika-localhost', 'disabled'], case_sensitive=False), help='Override TIKA_SOURCE — Apache Tika fallback extractor.') @click.option('--llm-graph-builder-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override LLM_GRAPH_BUILDER_SOURCE — Neo4j Labs document-to-graph builder UI/API.') @click.option('--celery-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override CELERY_SOURCE — backend async worker tier with Flower monitor.') @click.option('--supavisor-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override SUPAVISOR_SOURCE — internal Supabase Postgres transaction pooler.') @click.option('--mcp-servers-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override MCP_SERVERS_SOURCE') @click.option('--blender-mcp-source', type=click.Choice(['localhost', 'managed-localhost', 'disabled'], case_sensitive=False), help='Override BLENDER_MCP_SOURCE — host Blender MCP bridge: ' 'localhost (user-run GUI) or managed-localhost ' '(Atlas-provisioned headless, #759).') @click.option('--jupyterhub-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override JUPYTERHUB_SOURCE') @click.option('--open-web-ui-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override OPEN_WEB_UI_SOURCE') @click.option('--local-deep-researcher-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override LOCAL_DEEP_RESEARCHER_SOURCE') @click.option('--stt-provider-source', type=click.Choice(['speaches-container-cpu', 'speaches-container-gpu', 'parakeet-container-gpu', 'parakeet-localhost', 'whisper-cpp-localhost', 'disabled'], case_sensitive=False), help='Override STT_PROVIDER_SOURCE') @click.option('--tts-provider-source', type=click.Choice(['speaches-container-cpu', 'speaches-container-gpu', 'chatterbox-container-gpu', 'chatterbox-localhost', 'disabled'], case_sensitive=False), help='Override TTS_PROVIDER_SOURCE') @click.option('--doc-processor-source', type=click.Choice(['docling-container-gpu', 'docling-localhost', 'disabled'], case_sensitive=False), help='Override DOC_PROCESSOR_SOURCE') @click.option('--openclaw-source', type=click.Choice(['container', 'localhost', 'disabled'], case_sensitive=False), help='Override OPENCLAW_SOURCE') @click.option('--hermes-source', type=click.Choice(['container', 'localhost', 'disabled'], case_sensitive=False), help='Override HERMES_SOURCE') @click.option('--lightrag-source', type=click.Choice(['container', 'localhost', 'disabled'], case_sensitive=False), help='Override LIGHTRAG_SOURCE') @click.option('--tei-reranker-source', type=click.Choice(['container-cpu', 'container-gpu', 'localhost', 'disabled'], case_sensitive=False), help='Override TEI_RERANKER_SOURCE') @click.option('--vllm-metal-source', type=click.Choice(['managed-localhost', 'disabled'], case_sensitive=False), help='Override VLLM_METAL_SOURCE (Apple-silicon Metal managed host)') @click.option('--neo4j-graph-db-source', type=click.Choice(['container', 'localhost', 'disabled'], case_sensitive=False), help='Override NEO4J_GRAPH_DB_SOURCE') @click.option('--multi2vec-clip-source', type=click.Choice(['container-cpu', 'container-gpu', 'disabled'], case_sensitive=False), help='Override MULTI2VEC_CLIP_SOURCE') @click.option('--ray-source', type=click.Choice(['ray-container-cpu', 'ray-container-gpu', 'disabled'], case_sensitive=False), help='Override RAY_SOURCE (Ray distributed-compute cluster).') @click.option('--ray-worker-count', type=int, default=None, help='Override RAY_WORKER_COUNT — number of ray-worker replicas ' 'when --ray-source is ray-container-cpu or ray-container-gpu. ' '0 = head-only single-node mode. Defaults to 2 in .env.example.') @click.option('--prometheus-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override PROMETHEUS_SOURCE — observability scraping stack ' '(prometheus + node-exporter + cAdvisor + postgres/redis exporters).') @click.option('--prometheus-retention-days', type=int, default=None, help='Override PROMETHEUS_RETENTION_DAYS — TSDB retention in days ' '(default 7).') @click.option('--grafana-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override GRAFANA_SOURCE — observability dashboards + alerting UI.') @click.option('--langfuse-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override LANGFUSE_SOURCE — LLM traces and prompt/eval observability.') @click.option('--trueforge-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override TRUEFORGE_SOURCE — TrueForge agent runtime (MCP tools + approvals + schedules).') @click.option('--otel-collector-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override OTEL_COLLECTOR_SOURCE — internal OpenTelemetry ingest.') @click.option('--tempo-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override TEMPO_SOURCE — local trace store for Grafana.') @click.option('--loki-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override LOKI_SOURCE — local log store for Grafana.') @click.option('--spark-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override SPARK_SOURCE — standalone Spark cluster (master + workers + history).') @click.option('--spark-workers', type=int, default=None, help='Override SPARK_WORKER_COUNT — number of spark-worker replicas ' 'when --spark-source is container. Must be in 1-8. ' 'Mirrors --ray-worker-count. Defaults to 2 in .env.example.') @click.option('--zeppelin-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override ZEPPELIN_SOURCE — Spark-first notebook UI (requires Spark).') @click.option('--jenkins-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override JENKINS_SOURCE — Maven Spark app builder with MinIO artifact publishing.') @click.option('--mlflow-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override MLFLOW_SOURCE — experiment tracking with MinIO artifacts.') @click.option('--label-studio-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override LABEL_STUDIO_SOURCE — annotation review UI with MinIO media storage.') @click.option('--verba-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override VERBA_SOURCE — archived Weaviate RAG demo UI.') @click.option('--airflow-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override AIRFLOW_SOURCE — code-defined DAG orchestrator (LocalExecutor + LLM operators).') @click.option('--iceberg-rest-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override ICEBERG_REST_SOURCE — durable Iceberg REST catalog over MinIO.') @click.option('--trino-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override TRINO_SOURCE — SQL query engine over the Iceberg lakehouse.') @click.option('--redpanda-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override REDPANDA_SOURCE — Kafka API streaming broker + console.') @click.option('--backup-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override BACKUP_SOURCE — authorize the on-demand backup/restore runner.') @click.option('--cloudflared-source', type=click.Choice(['container', 'disabled'], case_sensitive=False), help='Override CLOUDFLARED_SOURCE — outbound Cloudflare Tunnel public edge.') @click.option('--no-tui', is_flag=True, help='Disable the Textual wizard/launch UI; use the linear stdout flow ' 'with passthrough docker output. Useful for log capture, ' 'debugging, and terminals that don\'t support the alternate screen buffer.') @click.option('--detach', '--no-follow', 'detach', is_flag=True, default=False, help='Run the start pipeline, wait for compose health gates, print a final ' 'status summary, and exit instead of following docker logs.') @click.option('--json', 'json_output', is_flag=True, default=False, help='With --detach/--no-follow, emit the final status summary as JSON for automation.') @click.option('--no-splash', is_flag=True, default=False, help='Disable the opening splash animation in the wizard.') @click.option('--no-port-migrate', is_flag=True, default=False, help='Skip the chained .env migrations (port-layout v1, URL→PORT v2, ' 'model-set v3, catalog v4, Weaviate backup module v5) for this run. ' 'Version sentinels are NOT stamped, so the migrations run again on ' 'the next start.') @click.option('--profile', type=click.Choice(['default', 'dev', 'prod'], case_sensitive=False), help='Deployment profile (declarative bundles in ' 'bootstrapper/profiles.yml; "dev" aliases "default"). ' 'Both profiles bind service ports to 127.0.0.1 unless HOST_BIND_IP ' 'already holds an operator bind. "prod" also enables log rotation, ' 're-applies observability ON on every start (a source flag ' 'overrides; a hand edit to .env does not stick), and hides ' 'dev-only (localhost) sources. Unset: the ' 'consumer manifest may name its default via `profile:`. ' 'Does not bypass the wizard.') @click.option('--support-bundle', 'support_bundle', type=click.Path(path_type=Path), default=None, callback=_invoker_file_option, help='If the start fails after its preflight checks (Docker/' 'Compose availability, --setup-hosts, legacy sources), ' 'show and then write a redacted support ' 'bundle (.tar.gz) to PATH: doctor checks, the configuration ' 'with the file that set each key, and a log excerpt. Local ' 'only, nothing is sent; redaction is best-effort.') @click.pass_context @json_cli_guard def main(ctx, project_name, consumer_manifests, base_port, track, list_tracks, cold, setup_hosts, skip_hosts, llm_provider_source, cloud_openai_source, cloud_anthropic_source, cloud_openrouter_source, openai_api_key, anthropic_api_key, openrouter_api_key, fal_api_key, openai_models, anthropic_models, openrouter_models, ollama_models, ollama_custom_models, comfyui_models, comfyui_custom_models_file, comfyui_source, asset_worker_source, asset_baker_source, fal_source, weaviate_source, minio_source, n8n_source, searxng_source, crawl4ai_source, tika_source, llm_graph_builder_source, celery_source, supavisor_source, mcp_servers_source, blender_mcp_source, jupyterhub_source, open_web_ui_source, local_deep_researcher_source, stt_provider_source, tts_provider_source, doc_processor_source, openclaw_source, hermes_source, lightrag_source, tei_reranker_source, vllm_metal_source, neo4j_graph_db_source, multi2vec_clip_source, ray_source, ray_worker_count, prometheus_source, prometheus_retention_days, grafana_source, langfuse_source, trueforge_source, otel_collector_source, tempo_source, loki_source, spark_source, spark_workers, zeppelin_source, jenkins_source, mlflow_source, label_studio_source, verba_source, airflow_source, iceberg_rest_source, trino_source, redpanda_source, backup_source, cloudflared_source, no_tui, detach, json_output, no_splash, no_port_migrate, profile, support_bundle): """Start Atlas — the self-hosted engineering platform.""" if consumer_manifests: from core.consumer_manifest import consumer_manifest_cli_paths ctx.with_resource(consumer_manifest_cli_paths(consumer_manifests)) if ctx.invoked_subcommand is not None: _reject_root_options_for_subcommand(ctx) return # ─── Project name (-p / --project) ─────────────────────────────── # Validate + normalize fail-fast, before any work. Persisted to .env # inside setup_env_file so every compose -p and a later ./stop.sh agree. 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"start.sh: {exc}", err=True) raise SystemExit(2) # ─── Base port (--base-port auto) ──────────────────────────────── # Resolve the 'auto' sentinel to a concrete free BASE_PORT block up front # so every downstream path (Textual + linear + setup_env_file) sees a plain # int. auto scans below the ephemeral range and never returns the default, # so a submodule consumer can't silently squat the port a bare atlas binds. base_port_auto = base_port == "auto" if base_port_auto: from core.port_manager import PortManager # The run's ---source flags are not in .env yet; probe with them # so a service they enable is checked and one they disable is not (#1391). chosen = PortManager().auto_base_port( source_overrides=_cli_source_overrides(ctx.params) ) if chosen is None: click.echo( "start.sh: --base-port auto could not find a wholly-free port " "block; pass an explicit --base-port.", err=True) raise SystemExit(2) click.echo(f"start.sh: --base-port auto selected {chosen}.") base_port = chosen # JSON output is only useful for automation, so make it imply the # non-following detached path even if the caller omitted --detach. if json_output: detach = True # ─── Track override warnings ───────────────────────────────────── # Fires when --track is set AND any explicit --*-source flag picks # a service that's out-of-track. Runs BEFORE --list-tracks early # exit so the warning surfaces even when the user listed tracks. if track is not None: try: from tracks import load_tracks as _load_tracks_for_warn from tracks import is_in_track as _is_in_track_for_warn _reg_w = _load_tracks_for_warn() except Exception: # noqa: BLE001 _reg_w = None if _reg_w is not None: _track_w = _reg_w.by_key.get(track) if _track_w is not None and _track_w.services is not None: # Map of Click kwarg → value, restricted to the # source-style flags. Cloud provider toggles # (cloud_openai_source, ...) are intentionally absent — # cloud keys are always prompted (not always running) and # never reach the track skip predicate, so a # --cloud-openai-source flag should never emit a track # warning. _flag_values = { 'llm_provider_source': llm_provider_source, 'comfyui_source': comfyui_source, 'asset_worker_source': asset_worker_source, 'asset_baker_source': asset_baker_source, 'fal_source': fal_source, 'weaviate_source': weaviate_source, 'minio_source': minio_source, 'n8n_source': n8n_source, 'searxng_source': searxng_source, 'crawl4ai_source': crawl4ai_source, 'tika_source': tika_source, 'llm_graph_builder_source': llm_graph_builder_source, 'celery_source': celery_source, 'supavisor_source': supavisor_source, 'mcp_servers_source': mcp_servers_source, 'blender_mcp_source': blender_mcp_source, 'jupyterhub_source': jupyterhub_source, 'open_web_ui_source': open_web_ui_source, 'local_deep_researcher_source': local_deep_researcher_source, 'stt_provider_source': stt_provider_source, 'tts_provider_source': tts_provider_source, 'doc_processor_source': doc_processor_source, 'openclaw_source': openclaw_source, 'hermes_source': hermes_source, 'lightrag_source': lightrag_source, 'tei_reranker_source': tei_reranker_source, 'vllm_metal_source': vllm_metal_source, 'neo4j_graph_db_source': neo4j_graph_db_source, 'multi2vec_clip_source': multi2vec_clip_source, 'ray_source': ray_source, 'prometheus_source': prometheus_source, 'grafana_source': grafana_source, 'langfuse_source': langfuse_source, 'trueforge_source': trueforge_source, 'otel_collector_source': otel_collector_source, 'tempo_source': tempo_source, 'loki_source': loki_source, 'spark_source': spark_source, 'zeppelin_source': zeppelin_source, 'jenkins_source': jenkins_source, 'mlflow_source': mlflow_source, 'label_studio_source': label_studio_source, 'verba_source': verba_source, 'airflow_source': airflow_source, 'iceberg_rest_source': iceberg_rest_source, 'trino_source': trino_source, 'redpanda_source': redpanda_source, 'backup_source': backup_source, 'cloudflared_source': cloudflared_source, } for cli_key, value in _flag_values.items(): if value is None or value == "disabled": continue svc_key = cli_key.removesuffix("_source").replace("_", "-") if _is_in_track_for_warn( _track_w, svc_key, always_on=_reg_w.always_on, ): continue # Look up display name from topology rows for nicer # warning text; fall back to svc_key if no match. derived_var = svc_key.upper().replace("-", "_") + "_SOURCE" display = svc_key try: from services.topology import get_topology as _gt _topo = _gt() for _r in _topo.rows: if _r.source_var == derived_var: display = _r.display_name break except Exception: # noqa: BLE001 pass print( f"[warn] --{cli_key.replace('_', '-')} " f"{value} overrides the {track} track, " f"which excludes {display}. Enabling " f"{display} anyway.", file=sys.stderr, ) # --list-tracks is side-effect-free and runs before any other init # (no Supabase key gen, no env migration). Exits 0. if list_tracks: from tracks import load_tracks, format_track_list try: reg = load_tracks() except Exception as e: # noqa: BLE001 — surface load errors to stderr print(f"Error loading tracks.yml: {e}", file=sys.stderr) sys.exit(2) print(format_track_list(reg)) sys.exit(0) # Validate --track before doing anything else. if track is not None: from tracks import load_tracks try: _track_registry = load_tracks() except Exception as e: # noqa: BLE001 print(f"Error loading tracks.yml: {e}", file=sys.stderr) sys.exit(2) if track not in _track_registry.by_key: valid = ", ".join(t.key for t in _track_registry.tracks) print( f"Error: unknown track '{track}'. Available: {valid}.", file=sys.stderr, ) sys.exit(2) starter = AtlasStarter() starter.support_bundle_path = _invoker_output_path(support_bundle) starter.base_port_auto_chosen = base_port_auto starter._cli_base_port_auto = base_port_auto try: # Consumer manifests (--consumer or ATLAS_CONSUMER_MANIFEST) are user # input. Validate them before cold cleanup, migrations, or any .env # write can mutate the checkout. _validate_consumer_manifests_early(starter, bool(consumer_manifests)) # Resolve the deployment profile: explicit --profile wins; else the # consumer manifest's `profile:` default (#755); else "default". # `dev` is an alias for `default` (services/profiles.py). from services.profiles import canonical_profile as _canonical_profile if profile is None: try: profile = starter.config_parser.load_consumer_config().profile except Exception: # noqa: BLE001 — a malformed manifest surfaces # through the consumer-manifests doctor check, not here. profile = None if profile is not None: # Canonicalize (dev → default) but PRESERVE None: a bare # ./start.sh with no manifest default must keep the wizard's # profile-picker step (which keys off profile is None). profile = _canonical_profile(profile) # Make the resolved profile visible to the _SOURCE:auto resolver # on EVERY path (wizard + TUI-launch call prepare_environment before # the wizard pipeline would otherwise set starter.profile) — a # profile-blind resolution can durably pin a dev-only source that # prod validation then rejects on every start. starter.profile = profile or "default" # Cloud LLM provider keys passed via CLI flags. Persisting to # .env happens later, alongside source overrides — gathered # here so the implied --cloud-*-source toggles are applied # together with the explicit ones. cloud_api_keys: Dict[str, str] = {} if openai_api_key is not None: cloud_api_keys['OPENAI_API_KEY'] = openai_api_key if cloud_openai_source is None: cloud_openai_source = 'enabled' if anthropic_api_key is not None: cloud_api_keys['ANTHROPIC_API_KEY'] = anthropic_api_key if cloud_anthropic_source is None: cloud_anthropic_source = 'enabled' if openrouter_api_key is not None: cloud_api_keys['OPENROUTER_API_KEY'] = openrouter_api_key if cloud_openrouter_source is None: cloud_openrouter_source = 'enabled' if fal_api_key is not None: cloud_api_keys['FAL_API_KEY'] = fal_api_key if fal_source is None: fal_source = 'enabled' # User-selected model lists from CLI flags. litellm-init # consumes these on the next docker compose up via model_resolver # (YAML catalogs + env) to build the active model set. user_model_selections: Dict[str, str] = {} if openai_models is not None: user_model_selections['OPENAI_USER_MODELS'] = openai_models if anthropic_models is not None: user_model_selections['ANTHROPIC_USER_MODELS'] = anthropic_models if openrouter_models is not None: user_model_selections['OPENROUTER_USER_MODELS'] = openrouter_models if ollama_models is not None: user_model_selections['OLLAMA_USER_MODELS'] = ollama_models if ollama_custom_models is not None: user_model_selections['OLLAMA_CUSTOM_MODELS'] = ollama_custom_models if comfyui_models is not None: user_model_selections['COMFYUI_USER_MODELS'] = comfyui_models if comfyui_custom_models_file is not None: # Relative to where ./start.sh was run, like --consumer; stored # absolute so the resolver never depends on the process cwd. user_model_selections['COMFYUI_CUSTOM_MODELS_FILE'] = _invoker_path_list( comfyui_custom_models_file ) # Warn on cloud --*-models flags passed WITHOUT enabling the # provider. model_resolver produces zero active entries for a disabled # provider, so the persisted CSV would be inert. Surface this to # the user instead of silently no-op'ing. The matching key flag # implies enabling above; a bare --openai-models is the case to # warn on. We check the .env-resolved source too — the user may # have already set CLOUD_OPENAI_SOURCE=enabled in .env without # passing --cloud-openai-source on this invocation. try: _existing_env = starter.config_parser.parse_env_file() except Exception: # noqa: BLE001 _existing_env = {} # ─── .env vs .env.example image-pin drift warning ──────────────── # See _detect_env_image_drift() for the rationale. CI is blind to # this class because it tests against .env.example, so the warning # is the only signal a user with a stale .env gets. try: _drift = _detect_env_image_drift( _existing_env, starter.config_parser.env_example_path, ) except Exception: # noqa: BLE001 # Pre-flight warning must never break the start path. _drift = [] if _drift: print( "⚠️ .env image-pin drift vs .env.example " "(CI tests .env.example so this is CI-invisible — " "may break docker build):", file=sys.stderr, ) for _key, _user_val, _example_val in _drift: print( f" {_key}: .env={_user_val!r} → " f".env.example={_example_val!r}", file=sys.stderr, ) print( " Update .env to match (sed -i '' " "'s|^=.*|=|' .env) or accept " "the override if intentional.", file=sys.stderr, ) for _models_flag, _source_kwarg, _source_var in ( (openai_models, cloud_openai_source, 'CLOUD_OPENAI_SOURCE'), (anthropic_models, cloud_anthropic_source, 'CLOUD_ANTHROPIC_SOURCE'), (openrouter_models, cloud_openrouter_source, 'CLOUD_OPENROUTER_SOURCE'), ): if _models_flag is None: continue _effective = (_source_kwarg or _existing_env.get(_source_var, 'disabled') or '').strip().lower() if _effective != 'enabled': _provider = _source_var.removeprefix('CLOUD_').removesuffix('_SOURCE').lower() print( f"⚠️ --{_provider}-models was set but {_source_var}={_effective} — " f"model_resolver produces no active entries for a disabled provider, so the " f"persisted list won't take effect. Pass --{_provider}-api-key, " f"--cloud-{_provider}-source=enabled, or set {_source_var}=enabled " f"in .env.", file=sys.stderr, ) # Warn if user passed --comfyui-models but COMFYUI_SOURCE isn't container. if comfyui_models is not None: _comfyui_source = ( comfyui_source or _existing_env.get('COMFYUI_SOURCE', 'disabled') or '' ).strip().lower() note = _comfyui_models_source_note(_comfyui_source) if note: print(note, file=sys.stderr) # Step 1.6: Apply SOURCE overrides from CLI arguments source_args = { 'llm_provider_source': llm_provider_source, 'cloud_openai_source': cloud_openai_source, 'cloud_anthropic_source': cloud_anthropic_source, 'cloud_openrouter_source': cloud_openrouter_source, 'comfyui_source': comfyui_source, 'asset_worker_source': asset_worker_source, 'asset_baker_source': asset_baker_source, 'fal_source': fal_source, 'weaviate_source': weaviate_source, 'minio_source': minio_source, 'n8n_source': n8n_source, 'searxng_source': searxng_source, 'crawl4ai_source': crawl4ai_source, 'tika_source': tika_source, 'llm_graph_builder_source': llm_graph_builder_source, 'celery_source': celery_source, 'supavisor_source': supavisor_source, 'mcp_servers_source': mcp_servers_source, 'blender_mcp_source': blender_mcp_source, 'jupyterhub_source': jupyterhub_source, 'open_web_ui_source': open_web_ui_source, 'local_deep_researcher_source': local_deep_researcher_source, 'stt_provider_source': stt_provider_source, 'tts_provider_source': tts_provider_source, 'doc_processor_source': doc_processor_source, 'openclaw_source': openclaw_source, 'hermes_source': hermes_source, 'lightrag_source': lightrag_source, 'tei_reranker_source': tei_reranker_source, 'vllm_metal_source': vllm_metal_source, 'neo4j_graph_db_source': neo4j_graph_db_source, 'multi2vec_clip_source': multi2vec_clip_source, 'ray_source': ray_source, 'prometheus_source': prometheus_source, 'grafana_source': grafana_source, 'langfuse_source': langfuse_source, 'trueforge_source': trueforge_source, 'otel_collector_source': otel_collector_source, 'tempo_source': tempo_source, 'loki_source': loki_source, 'spark_source': spark_source, 'zeppelin_source': zeppelin_source, 'jenkins_source': jenkins_source, 'mlflow_source': mlflow_source, 'label_studio_source': label_studio_source, 'verba_source': verba_source, 'airflow_source': airflow_source, 'iceberg_rest_source': iceberg_rest_source, 'trino_source': trino_source, 'redpanda_source': redpanda_source, 'backup_source': backup_source, 'cloudflared_source': cloudflared_source, } # Ray non-SOURCE settings (worker count) get plumbed via # update_env_file the same way the cloud-API keys do. Clamp 0-64 to # match the wizard's SecondaryNumberInput contract (integration.py), # mirroring the --spark-workers guard below. if ray_worker_count is not None: if not 0 <= ray_worker_count <= 64: raise click.UsageError("--ray-worker-count must be in 0-64") user_model_selections['RAY_WORKER_COUNT'] = str(ray_worker_count) # Prometheus retention days — same pattern. if prometheus_retention_days is not None: if not 1 <= prometheus_retention_days <= 365: raise click.UsageError( "--prometheus-retention-days must be in 1-365" ) user_model_selections['PROMETHEUS_RETENTION_DAYS'] = str(prometheus_retention_days) # Spark worker count — same pattern as Ray's worker count: reject an # out-of-range value rather than silently clamping it, so a wrapper # passing a host-derived core count fails loudly instead of running a # different topology than it asked for. Range mirrors the wizard's # SecondaryNumberInput contract. if spark_workers is not None: if not 1 <= spark_workers <= 8: raise click.UsageError("--spark-workers must be in 1-8") user_model_selections['SPARK_WORKER_COUNT'] = str(spark_workers) # Determine if wizard mode — only when NO flags are provided at all. # Both the model-list flags (--openai-models / --ollama-models / etc.) # and the cloud-key flags (--openai-api-key / etc.) count as "non-wizard # intent": presence of either means the user is configuring via CLI # and the wizard would silently overwrite their input. # NOTE: this must be computed BEFORE the track synthesis block so that # `--track ` alone (no --*-source flags) still routes to the wizard. # The synthesis block only writes "disabled" into source_args for # non-wizard paths; the wizard handles off-track disabling itself via # _selections_to_args (Task 9). no_source_flags = all(v is None for v in source_args.values()) no_stack_flags = ( base_port is None and not cold and not setup_hosts and not skip_hosts and not detach and not json_output ) no_model_flags = not user_model_selections no_key_flags = not cloud_api_keys wizard_requested = ( no_source_flags and no_stack_flags and no_model_flags and no_key_flags ) will_run_wizard = ( wizard_requested and not no_tui and sys.stdin.isatty() ) # ─── Track override-set + force-disable synthesis ──────────── # Two outcomes: # 1. `overridden_services`: the set of off-track svc.keys that # were explicitly enabled via a CLI flag. Threaded into the # wizard step builder so their prompts re-appear. # 2. Mirror _selections_to_args (TUI wizard path): force-disable # every off-track configurable service in source_args so # --no-tui and run_launch_flow honor the track without going # through the wizard. Overridden services keep their # CLI-supplied value (flag wins). # Guard: skip the force-disable writes in wizard mode — # the wizard's _selections_to_args already handles them, and # writing "disabled" here would incorrectly cause the wizard to # be skipped (source_args would look non-empty). # #783: SOURCE vars the consumer manifest declares in env.values — # declared intent must survive the track force-disable (a manifest's # MINIO_SOURCE: container was silently reverted to disabled when the # track excluded minio, forcing consumers into workaround flags). consumer_declared_source_keys: frozenset = frozenset() try: _cc = starter.config_parser.load_consumer_config() consumer_declared_source_keys = frozenset( var.lower() for var in (_cc.env_overrides or {}) if var.endswith("_SOURCE") and var.lower() in source_args ) except Exception: # noqa: BLE001 — malformed manifests surface via doctor pass overridden_services: set = set() if track is not None: try: from tracks import load_tracks as _ld from tracks import synthesize_track_source_args as _synth _rg2 = _ld() except Exception as exc: # noqa: BLE001 raise click.ClickException( f"could not load track registry for '{track}': {exc}" ) from exc if _rg2 is not None: try: overridden_services |= _synth( source_args, track_key=track, registry=_rg2, force_disable=not will_run_wizard, consumer_declared=consumer_declared_source_keys, ) except Exception as exc: # noqa: BLE001 raise click.ClickException( f"track '{track}' force-disable synthesis failed: {exc}" ) from exc starter.active_track = track starter.active_track_overrides = frozenset(overridden_services) # Detect legacy `external` source values left in .env from older # versions. These options have been removed # pending a stack-wide authenticated-remote design; users must # switch to `container` or `disabled` (or `none` for LLM_PROVIDER_SOURCE). # Reuses _existing_env parsed above — nothing writes .env in between. _legacy_env = _existing_env _LEGACY_EXTERNAL = { 'COMFYUI_SOURCE': 'external', 'LLM_PROVIDER_SOURCE': 'ollama-external', 'RAY_SOURCE': 'ray-external', } _found = [(k, v) for k, v in _LEGACY_EXTERNAL.items() if (_legacy_env.get(k, '') or '').strip() == v] if _found: print( "\n❌ Legacy `external` source values found in .env:\n" + "\n".join(f" {k}={v}" for k, v in _found) + "\n\n The `external` / `ollama-external` / `ray-external` source " "variants were removed pending a stack-wide authenticated-remote " "design.\n See docs/CHANGELOG.md, v0.1.0 section, \"Removed " "(breaking) — `external` source variants stack-wide\" for\n" " migration. Switch each to `container` (or `disabled` / `none`)\n" " and re-run.\n", file=sys.stderr, ) sys.exit(2) # Step 0: Early hosts setup for CLI --setup-hosts. The wrapper # refuses root, so request elevation only for the hosts-file write. if setup_hosts: starter.banner.console.print("\n [bright_yellow]⚠️ --setup-hosts requires admin privileges.[/bright_yellow]") if not _run_privileged_hosts_setup(): starter.banner.console.print( " [bright_white]Hosts setup did not complete. Re-run[/bright_white] " "[bright_cyan]./start.sh --setup-hosts[/bright_cyan] " "[bright_white]from a terminal that can approve sudo, or add the entries manually.[/bright_white]" ) sys.exit(1) setup_hosts = False # Check dependencies early — silently in wizard mode (wizard clears screen) if not will_run_wizard: if not starter.ensure_dependencies_available(): sys.exit(1) else: if not starter.docker_manager.check_docker_available(): print("❌ Docker is not available. Please install Docker and ensure it's running.") sys.exit(1) compose_ok, compose_message = starter.docker_manager.check_compose_version() if not compose_ok: print(f"❌ {compose_message}") sys.exit(1) if wizard_requested: # Setup .env first so wizard can read current defaults. Capture # it before that first write, so leaving without a launch can put # it back (#1044). starter.launch_env_snapshot = EnvSnapshot.capture(starter.config_parser.env_file_path) if not starter.prepare_environment(cold_start=cold, base_port=base_port, project_name=project_name): sys.exit(1) # Backfill any keys added to .env.example since the user's # .env was last written — run BEFORE the wizard reads it, # otherwise new vars (MinIO image / ports / bucket names, # etc.) won't appear as defaults in the wizard's prompts # and ``docker compose config`` will fail later with # ``variable X not set``. if not starter.backfill_missing_env_vars(): sys.exit(1) # The Textual wizard owns the entire interactive flow when the # terminal can host it. Non-TUI shells (--no-tui, non-TTY, # narrow terminals) skip the wizard and use the user's .env # defaults plus any CLI flags they passed. from ui.term_caps import is_tui_capable as _is_tui_capable if will_run_wizard and _is_tui_capable(no_tui_flag=no_tui): # Single-Textual-app flow: wizard + pipeline + docker # compose log streaming all run inside one App. start.py # exits when the user detaches. from ui.textual.integration import run_setup_flow starter.tui_launch_started = False # set once the wizard launches rc = run_setup_flow( starter.config_parser, starter=starter, no_port_migrate=no_port_migrate, track=track, overridden_services=frozenset(overridden_services), no_splash=no_splash, profile=profile, ) sys.exit(_report_tui_exit(rc, starter, cold=cold)) # No-TUI fallback (spec §6.2 / §8.6): we're in will_run_wizard mode # but is_tui_capable returned False (--no-tui flag or non-TTY / # narrow terminal). The Textual picker won't fire, so resolve the # track on stdin if it wasn't preset — defaulting to gen-ai-rag # (first entry in tracks.yml) on empty input or non-interactive # stdin (CI / pipe). from tracks import load_tracks as _lt from tracks import format_track_list as _ftl try: _reg = _lt() except Exception as exc: # noqa: BLE001 raise click.ClickException( f"could not load track registry: {exc}" ) from exc if track is None and _reg is not None: print(_ftl(_reg), file=sys.stderr) print( "Pick a track (Enter for default 'gen-ai-rag'): ", end="", file=sys.stderr, flush=True, ) # A typo must never silently resolve to a different # workload: the resolved track drives force-disable # synthesis below, so 'data-en' becoming gen-ai-rag would # disable every data-engineering service without the user # ever choosing that (#1184). Re-prompt instead, with a # suggestion. Empty input still takes the displayed # default, and non-interactive stdin still falls back to it # rather than blocking a scripted run. track = _prompt_for_track(_reg) # Force-disable synthesis for the RESOLVED track — whether it was # preset via --track or just prompted above. The early-synthesis # block (search "Track override-set") deliberately skips this when # will_run_wizard is True, on the assumption the Textual wizard's # _selections_to_args handles it — but the wizard is NOT running on # this no-TUI path, so `--no-tui --track ` would otherwise # leave every off-track service at its .env default (track contract # silently violated). Mirrors _selections_to_args; off-track # services the user explicitly flagged keep their value and are # recorded as overrides. No-op for the 'all' track (services is None). if _reg is not None and track is not None: try: from tracks import synthesize_track_source_args as _synth overridden_services |= _synth( source_args, track_key=track, registry=_reg, force_disable=True, consumer_declared=consumer_declared_source_keys, ) except Exception as exc: # noqa: BLE001 raise click.ClickException( f"track '{track}' force-disable synthesis failed: {exc}" ) from exc # The track may have been picked just above; record it (and # its overrides) for the Kong dashboard's track labels, which # were captured before this prompt ran. starter.active_track = track starter.active_track_overrides = frozenset(overridden_services) # CLI-flag mode + TUI capable: skip the wizard but still use the # Textual launch screen, pre-loaded with the user's CLI args. # Falls through to the linear stdout flow only when --no-tui or # the terminal can't host the TUI. This block must run BEFORE # the banner / setup_env_file / apply_source_overrides pipeline # below so its output stays out of the terminal and ends up # inside the log pane. if not no_tui and not detach and not json_output: from ui.term_caps import is_tui_capable as _is_tui_capable if _is_tui_capable(no_tui_flag=no_tui): # Make sure .env exists so the launch screen can build # the Stack overview. starter.launch_env_snapshot = EnvSnapshot.capture(starter.config_parser.env_file_path) if not starter.prepare_environment(cold_start=cold, base_port=base_port, project_name=project_name): sys.exit(1) # Backfill new .env.example keys before the launch # screen renders the Stack overview from .env. if not starter.backfill_missing_env_vars(): sys.exit(1) from ui.textual.integration import run_launch_flow stack_options = { "base_port": base_port, "cold": cold, "setup_hosts": setup_hosts, "skip_hosts": skip_hosts, "launch_confirmed": True, # Forward the resolved deployment profile into the # launch pipeline so wizard_screen sets starter.profile # and calls apply_profile_overrides before compose up. "profile": profile or "default", # Forward any CLI-supplied cloud API keys into the # launch pipeline; the wizard pipeline writes them # to .env via SourceOverrideManager.update_env_file. "cloud_api_keys": cloud_api_keys, # Forward CLI-supplied user model selections (and # any other --x-y --z scalar env-write flags like # COMFYUI_CUSTOM_MODELS_FILE, RAY_WORKER_COUNT, # PROMETHEUS_RETENTION_DAYS, SPARK_WORKER_COUNT) # through the same apply_user_model_selections # pipeline as the wizard's multiselect output. The # wizard splits its dict into cloud/ollama/comfyui # buckets for purely-cosmetic step grouping; here # we forward the entire dict in a fourth catch-all # bucket so no flag is silently dropped. wizard_screen # merges all four buckets into one update_env_file # call so the bucket boundaries are irrelevant for # persistence. "cloud_user_models": { k: v for k, v in user_model_selections.items() if k.endswith("_USER_MODELS") and not k.startswith("OLLAMA_") }, "ollama_user_models": { k: v for k, v in user_model_selections.items() if k.startswith("OLLAMA_") }, "user_env_writes": { k: v for k, v in user_model_selections.items() if not k.endswith("_USER_MODELS") and not k.startswith("OLLAMA_") }, } rc = run_launch_flow( starter.config_parser, starter=starter, source_args=source_args, stack_options=stack_options, no_port_migrate=no_port_migrate, track=track, overridden_services=frozenset(overridden_services), no_splash=no_splash, profile=profile, ) sys.exit(_report_tui_exit(rc, starter, cold=cold)) # Linear (--no-tui / non-TTY) flow from here on — the wizard and # CLI-flag TUI branches above both sys.exit() before this point. exit_code = _run_linear_with_support_bundle( starter, LinearStartupOptions( cold=cold, base_port=base_port, project_name=project_name, source_args=source_args, profile=profile, cloud_api_keys=cloud_api_keys, user_model_selections=user_model_selections, no_port_migrate=no_port_migrate, setup_hosts=setup_hosts, skip_hosts=skip_hosts, track=track, detach=detach, json_output=json_output, no_splash=no_splash, ), ) sys.exit(exit_code) except click.ClickException: # Let click render its own usage/parameter errors (e.g. the # --spark-workers range check) with their conventional exit code # (2 for UsageError) instead of masking them as an "unexpected # error" with exit 1 via the catch-all below. raise except KeyboardInterrupt: starter.rollback_managed_host_processes() print("\n❌ Startup interrupted by user") print(f" {cancel_notice(getattr(starter, 'stopped_previous_instance', False), cold)}") sys.exit(1) except Exception as e: starter.rollback_managed_host_processes() # Anything reaching here is an unexpected bug (click.ClickException # and KeyboardInterrupt are handled above). Emit the full traceback # to stderr so the failure is triageable; the prior handler's bare # str(e) was not enough to locate defects across the ~620-line body. import traceback print(f"\n❌ Unexpected error during startup: {e}", file=sys.stderr) traceback.print_exc() sys.exit(1) @main.group("env") def env_group() -> None: """Headless environment maintenance commands.""" @env_group.command("backfill") def env_backfill_command() -> None: """Add missing .env keys from .env.example without starting services.""" starter = AtlasStarter() env_path = starter.config_parser.env_file_path env_example_path = starter.config_parser.env_example_path if not env_path.exists(): # backfill_missing_env_vars no-ops without a .env. Say so instead of # "No env changes needed"; still exit 0, since documented headless # recipes run this on fresh clones before ./start.sh creates .env. click.echo(f"No env file at {env_path}; nothing to backfill (./start.sh creates it).", err=True) return try: before = _parse_env_values(env_path) if not starter.backfill_missing_env_vars(): raise click.exceptions.Exit(1) after = _parse_env_values(env_path) _print_env_backfill_summary( env_path=env_path, env_example_path=env_example_path, before=before, after=after, ) except UnicodeError: raise click.ClickException(f"Cannot decode {env_path} or {env_example_path} as UTF-8.") from None except ValueError as exc: raise click.ClickException(f"Cannot backfill env files: {exc}") from None @main.group("compose") def compose_group() -> None: """Headless Docker Compose maintenance commands.""" @compose_group.command("validate") def compose_validate_command() -> None: """Validate the assembled Compose config, including user overlays.""" starter = AtlasStarter() # Materialize the consumer manifest's derived env (#451) so overlays that # interpolate ${BACKEND_PLUGINS_DIR} etc. resolve — mirroring a real start. starter.materialize_consumer_env_for_preflight() try: returncode, stdout, stderr, _cmd = starter.docker_manager.validate_compose_config() except (RuntimeError, ValueError) as exc: click.echo(f"Compose config validation failed: {exc}", err=True) raise click.exceptions.Exit(1) from exc if returncode == 0: click.echo("Compose config is valid.") return output = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part) click.echo("Compose config validation failed.", err=True) summary = _compose_validation_summary(output) if summary: click.echo(summary, err=True) if output: click.echo(output, err=True) raise click.exceptions.Exit(returncode) @main.command("doctor") @click.option( "--format", "output_format", type=click.Choice(["text", "json"], case_sensitive=False), default="text", show_default=True, help="Output format for consumer CI.", ) @click.option( "--bundle", "bundle_path", type=click.Path(path_type=Path), default=None, callback=_invoker_file_option, help="Also write a redacted support bundle (.tar.gz) to PATH, after showing " "its full contents. Collection is local, offline and time-bounded; " "redaction is best-effort.", ) @click.option( "--include-unlisted", is_flag=True, help="With --bundle: also include check details and configuration keys " "outside the documented allowlist (still redacted).", ) def doctor_command(output_format: str, bundle_path, include_unlisted: bool) -> None: """Run headless consumer preflight checks without starting services.""" if include_unlisted and bundle_path is None: raise click.UsageError("--include-unlisted needs --bundle PATH") bundle_path = _invoker_output_path(bundle_path) starter = AtlasStarter() # Materialize the consumer manifest's derived env (#451) before the checks # (which validate the assembled compose) so ${BACKEND_PLUGINS_DIR}-style # overlays resolve on a fresh checkout. With --format json, anything a # step prints (e.g. a BASE_PORT: auto re-resolution warning) goes to # stderr so stdout stays one JSON document. from contextlib import nullcontext from core.linear_startup import _pipeline_stdout_to_stderr with _pipeline_stdout_to_stderr() if output_format == "json" else nullcontext(): starter.materialize_consumer_env_for_preflight() bundled = _bundled_doctor_checks(starter, bundle_path, include_unlisted) results = bundled[1][0] if bundled else _run_consumer_doctor(starter) # Only the bundle run reports a check it could not run (raised, timed # out, never started) as ``unavailable``; plain doctor calls it ``fail``. # Both exit non-zero, so --bundle never turns a failing preflight green. ok = not any(result["status"] in ("fail", "unavailable") for result in results) payload = {"ok": ok, "checks": results} if output_format == "json": click.echo(json.dumps(payload, indent=2, sort_keys=True)) else: _print_doctor_text(results) if bundled: options, checks = bundled # The preview goes where it cannot corrupt `--format json` stdout. echo = lambda line: click.echo(line, err=output_format == "json") # noqa: E731 from core.support_bundle import export try: export(starter.build_support_bundle(options, checks), bundle_path, echo) except OSError as exc: raise click.ClickException(f"support bundle not written: {exc}") from exc if not ok: raise click.exceptions.Exit(1) def _bundled_doctor_checks(starter: "AtlasStarter", bundle_path, include_unlisted: bool): """With ``--bundle``, run the checks once, the bundle's way: offline and inside its time budget, recording a check that cannot run as ``unavailable`` (#1057). ``None`` without ``--bundle``.""" if bundle_path is None: return None from core import support_bundle as sb options = sb.BundleOptions( context={"command": "doctor"}, include_unlisted=include_unlisted, ) return options, sb.run_checks(DOCTOR_CHECKS, starter, options.limits) # ─── model capability probes (#1195) ───────────────────────────────── # # Catalog capabilities are declarations. These opt-in probes send one fixed # request per capability through the LiteLLM gateway and judge a concrete # expected answer. Nothing here runs during ./start.sh, and a result never # changes model selection: it is reported and stored, nothing more. PROBE_SUPPORTED, PROBE_UNSUPPORTED, PROBE_UNAVAILABLE = "supported", "unsupported", "unavailable" # Probe kind -> the catalog capability it measures. PROBE_CAPABILITIES = {"tools": "tools", "json": "structured_output", "vision": "vision", "embedding": "embedding"} PROBE_RESULTS_FILE = Path("volumes") / "litellm" / "capability-probes.json" # A 32x32 red square: some vision encoders reject images under 28 px. _RED_SQUARE_PNG = ( "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAJ0lEQVR42u3NsQkA" "AAjAsP7/tF7hIASyp6lTCQQCgUAgEAgEgi/BAjLD/C5w/SM9AAAAAElFTkSuQmCC" ) _UTC_TOOL = { "type": "function", "function": { "name": "get_utc_time", "description": "Return the current UTC time.", "parameters": {"type": "object", "properties": {}}, }, } def gateway_post(base_url: str, key: str): """``post(path, body) -> (status | None, payload)`` against LiteLLM; None when the gateway cannot be reached.""" import requests # noqa: PLC0415 def post(path: str, body: dict): try: response = requests.post( f"{base_url.rstrip('/')}{path}", json=body, headers={"Authorization": f"Bearer {key}"}, timeout=120, ) except requests.RequestException: return None, {} try: return response.status_code, response.json() except ValueError: return response.status_code, {} return post # Statuses that say nothing about the model's capability: a wrong key, an # alias the gateway does not serve, a timeout, a rate limit. _PROBE_INCONCLUSIVE_STATUSES = {401, 403, 404, 408, 429} # LiteLLM answers an alias it does not serve (provider disabled, model not # pulled) with a 400, not a 404; the error text is what identifies it. _UNKNOWN_MODEL_TEXT = re.compile(r"invalid model name|model\b.{0,120}\bnot found|no such model", re.I | re.S) def probe_status_outcome(status, payload=None) -> "str | None": """The outcome an HTTP status already decides, or None to read the reply.""" if status is None or status >= 500 or status in _PROBE_INCONCLUSIVE_STATUSES: return PROBE_UNAVAILABLE if status >= 400 and _UNKNOWN_MODEL_TEXT.search(json.dumps(payload or {})): return PROBE_UNAVAILABLE return PROBE_UNSUPPORTED if status >= 400 else None def _probe_message(post, body: dict): """(outcome the HTTP status already decides, or None; the reply message).""" status, payload = post("/chat/completions", body) verdict = probe_status_outcome(status, payload) if verdict or not isinstance(payload, dict): return verdict or PROBE_UNSUPPORTED, {} choices = payload.get("choices") or [{}] if not isinstance(choices, list): return PROBE_UNSUPPORTED, {} message = choices[0].get("message") if isinstance(choices[0], dict) else None return None, message if isinstance(message, dict) else {} def _reply_text(message: dict) -> str: """The reply as text, without a reasoning block or a code fence.""" content = message.get("content") or "" if isinstance(content, list): # content parts content = " ".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) content = re.sub(r".*?", "", str(content), flags=re.S) return re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", content.strip()) def probe_tools(post, model: str) -> str: """Supported only when the model calls the offered tool.""" verdict, message = _probe_message(post, { "model": model, "tools": [_UTC_TOOL], "messages": [{"role": "user", "content": "What time is it in UTC? Use the get_utc_time tool."}], }) calls = message.get("tool_calls") or [] if not isinstance(calls, list): return verdict or PROBE_UNSUPPORTED names = [call["function"].get("name") for call in calls if isinstance(call, dict) and isinstance(call.get("function"), dict)] return verdict or (PROBE_SUPPORTED if "get_utc_time" in names else PROBE_UNSUPPORTED) def probe_json(post, model: str) -> str: """Supported only when the reply is a JSON object with answer 4.""" verdict, message = _probe_message(post, { "model": model, "response_format": {"type": "json_object"}, "messages": [{"role": "user", "content": 'What is 2+2? Reply only with the JSON object {"answer": }.'}], }) try: parsed = json.loads(_reply_text(message)) answered = isinstance(parsed, dict) and float(parsed.get("answer")) == 4 except (TypeError, ValueError, OverflowError): answered = False return verdict or (PROBE_SUPPORTED if answered else PROBE_UNSUPPORTED) def probe_vision(post, model: str) -> str: """Supported only when the model names the colour of a red square.""" verdict, message = _probe_message(post, {"model": model, "messages": [{"role": "user", "content": [ {"type": "text", "text": "What single colour fills this image? Answer with one word."}, {"type": "image_url", "image_url": {"url": _RED_SQUARE_PNG}}, ]}]}) seen = re.search(r"\bred\b", _reply_text(message).lower()) is not None return verdict or (PROBE_SUPPORTED if seen else PROBE_UNSUPPORTED) def _shared_embedding_probe(): """lightrag-init's embedding probe, the one implementation (#1195).""" import importlib.util # noqa: PLC0415 path = Path(__file__).resolve().parents[1] / "services/lightrag/init/scripts/resolve-models.py" spec = importlib.util.spec_from_file_location("lightrag_resolve_models_probe", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module.probe_embedding_dim def _catalog_entry(alias: str): """The catalog entry a gateway alias names (``ollama/`` or a bare name).""" from utils import llm_catalog # noqa: PLC0415 from utils.cloud_providers import CLOUD_PROVIDERS # noqa: PLC0415 entries = [*llm_catalog.ollama_entries(), *(e for p in CLOUD_PROVIDERS for e in llm_catalog.cloud_entries(p.key))] return next((e for e in entries if alias in (e.name, f"{e.provider}/{e.name}")), None) def probe_identity(alias: str, entry) -> dict: """What a stored result was measured on: model, provider, gateway alias and catalog revision. A result is reused only for an identical identity.""" import dataclasses # noqa: PLC0415 import hashlib # noqa: PLC0415 revision = "uncataloged" if entry is not None: row = json.dumps(dataclasses.asdict(entry), sort_keys=True, default=str) revision = hashlib.sha256(row.encode()).hexdigest()[:12] return { "model": entry.name if entry else alias, "provider": entry.provider if entry else "unknown", "alias": alias, "revision": revision, } def _probe_store(root: Path) -> dict: try: return json.loads((root / PROBE_RESULTS_FILE).read_text(encoding="utf-8")) except (OSError, ValueError): return {} def _declared_kinds(entry) -> list[str]: capabilities = getattr(entry, "capabilities", None) or {} return [kind for kind, capability in PROBE_CAPABILITIES.items() if capabilities.get(capability)] def plan_capability_probes(aliases, kinds, store: dict, refresh: bool) -> list[tuple]: """(alias, kind, identity) still to measure: every requested kind, or the capabilities the catalog declares, minus results stored for the same identity unless ``refresh``.""" plan = [] for alias in aliases: entry = _catalog_entry(alias) identity = probe_identity(alias, entry) stored = store.get(json.dumps(identity, sort_keys=True), {}).get("results", {}) if not kinds and entry is None: print(f" {alias}: not in the catalog, so nothing is declared; pass --kind to probe it") for kind in kinds or _declared_kinds(entry): # `unavailable` measured nothing, so it is never reused. if refresh or stored.get(kind, PROBE_UNAVAILABLE) == PROBE_UNAVAILABLE: plan.append((alias, kind, identity)) return plan def _measure(kind: str, alias: str, gateway: tuple) -> tuple: """(outcome, embedding dimension or None) for one probe.""" base_url, key, post = gateway if kind == "embedding": return _shared_embedding_probe()(base_url, key, alias) probe = {"tools": probe_tools, "json": probe_json, "vision": probe_vision}[kind] return probe(post, alias), None def _record_probe(record: dict, kind: str, outcome: str, dim) -> None: """Store one outcome. ``unavailable`` measured nothing, so it never replaces an earlier real verdict (a --refresh while the gateway is down); a dimension is kept only beside a supported embedding.""" if outcome == PROBE_UNAVAILABLE and record["results"].get(kind) in (PROBE_SUPPORTED, PROBE_UNSUPPORTED): return record["results"][kind] = outcome if kind == "embedding": if dim and outcome == PROBE_SUPPORTED: record["embedding_dim"] = dim else: record.pop("embedding_dim", None) def run_capability_probes(root: Path, gateway: tuple, plan: list, max_requests: int) -> int: """Run ``plan`` (one request per probe) against ``gateway`` = (base_url, key, post) and store the results. Refuses (exit 3) a plan larger than ``max_requests``: probes against a cloud model are billed.""" print(f"Capability probes: {len(plan)} request(s) through {gateway[0]} (cap {max_requests}).") if len(plan) > max_requests: print(f"Refusing: {len(plan)} requests exceed --max-requests {max_requests}.") return 3 store = _probe_store(root) try: for alias, kind, identity in plan: outcome, dim = _measure(kind, alias, gateway) record = store.setdefault(json.dumps(identity, sort_keys=True), {"identity": identity, "results": {}}) _record_probe(record, kind, outcome, dim) finally: if plan: path = root / PROBE_RESULTS_FILE path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n", encoding="utf-8") return 0 def report_capability_probes(root: Path, aliases, kinds) -> int: """Print every stored result for ``aliases`` (measured now or earlier); 1 when a capability the catalog declares measured unsupported.""" store, failed = _probe_store(root), 0 for alias in aliases: entry = _catalog_entry(alias) record = store.get(json.dumps(probe_identity(alias, entry), sort_keys=True), {}) declared = _declared_kinds(entry) for kind in kinds or declared: outcome = record.get("results", {}).get(kind, "not measured") fail = kind in declared and outcome == PROBE_UNSUPPORTED failed += fail shown = kind == "embedding" and outcome == PROBE_SUPPORTED and record.get("embedding_dim") dim = f" ({record['embedding_dim']} dimensions)" if shown else "" print(f" {alias} {kind}: {outcome}{dim}{' FAIL: declared by the catalog' if fail else ''}") return 1 if failed else 0 @main.group("models") def models_group() -> None: """Model commands: opt-in capability probes (#1195).""" @models_group.command("probe") @click.option("--model", "models", multiple=True, help="Gateway model to probe (repeatable). Default: the configured default chat, vision and embedding models.") @click.option("--kind", "kinds", multiple=True, type=click.Choice(sorted(PROBE_CAPABILITIES)), help="Capability to probe (repeatable). Default: what the catalog declares for the model.") @click.option("--max-requests", default=20, show_default=True, type=click.IntRange(min=1), help="Refuse a run that needs more requests; probes against cloud models are billed.") @click.option("--refresh", is_flag=True, help="Measure again even when a stored result matches.") def models_probe_command(models, kinds, max_requests: int, refresh: bool) -> None: """Measure tool calling, JSON output, vision and embedding dimension end to end through LiteLLM. Results are stored in volumes/litellm/capability-probes.json and never change model selection.""" starter = AtlasStarter() env = starter.config_parser.parse_env_file() aliases = list(dict.fromkeys(models or [ env.get(key, "").strip() for key in ("LITELLM_DEFAULT_MODEL", "LITELLM_VISION_MODEL", "LITELLM_EMBEDDING_MODEL") if env.get(key, "").strip() ])) # LiteLLM publishes on HOST_BIND_IP (127.0.0.1 unless set) and LITELLM_PORT. host = (env.get("HOST_BIND_IP") or "127.0.0.1:").rstrip(":") or "127.0.0.1" base_url = f"http://{host}:{env.get('LITELLM_PORT', '')}/v1" key = env.get("LITELLM_MASTER_KEY", "") root = Path(starter.config_parser.root_dir) plan = plan_capability_probes(aliases, list(kinds), _probe_store(root), refresh) refused = run_capability_probes(root, (base_url, key, gateway_post(base_url, key)), plan, max_requests) sys.exit(refused or report_capability_probes(root, aliases, list(kinds))) # ─── Atlas-owned storage: inventory and model cleanup (#1194) ──────── # # Only the volumes below hold models or generated artifacts; every other # declared volume is stateful (databases, workflows, user data) and can never # be part of a deletion set. Cleanup removes single model items, never a # volume: what `./stop.sh --cold` does wholesale is out of scope. MODEL_VOLUMES = {"llm-provider-data": "ollama", "comfyui-models": "comfyui", "comfyui-output": "comfyui"} _COMFYUI_MODELS_DIR = "/opt/ComfyUI/models" _DOCKER_SIZE_UNITS = {"B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12} def declared_project_volumes(root: Path) -> set[str]: """Volume suffixes the compose fragments declare as ``${PROJECT_NAME}-``.""" pattern = re.compile(r"^\s+name:\s*\$\{PROJECT_NAME\}-([A-Za-z0-9_.-]+)\s*$", re.M) return {m for path in (root / "services").glob("*/compose.yml") for m in pattern.findall(path.read_text())} def parse_docker_size(text: str) -> int: """`docker system df` sizes (``1.5GB``, ``12kB``, ``0B``) as bytes.""" match = re.fullmatch(r"\s*([0-9.]+)\s*([A-Za-z]+)\s*", text or "") if not match or match.group(2) not in _DOCKER_SIZE_UNITS: return 0 return int(float(match.group(1)) * _DOCKER_SIZE_UNITS[match.group(2)]) def docker_volume_sizes() -> dict: """Volume name -> bytes, as `docker system df -v` reports them.""" result = subprocess.run( ["docker", "system", "df", "-v", "--format", "{{range .Volumes}}{{.Name}}\t{{.Size}}\n{{end}}"], capture_output=True, text=True, check=False, timeout=120, ) if result.returncode != 0: raise OSError(result.stderr.strip() or f"exit {result.returncode}") rows = (line.split("\t", 1) for line in result.stdout.splitlines() if "\t" in line) return {name: parse_docker_size(size) for name, size in rows} def volume_inventory(project: str, declared: set, sizes: dict) -> list[dict]: """Every Docker volume named for this project, with its size. Ownership comes from the declared names: a project-prefixed volume nothing declares (another project whose name starts the same way, a leftover) is unknown.""" rows = [] for name, size in sorted(sizes.items()): if name.startswith(f"{project}-"): suffix = name[len(project) + 1:] owner = "atlas" if suffix in declared else "unknown" rows.append({"volume": name, "bytes": size, "ownership": owner, "holds": "models/artifacts" if suffix in MODEL_VOLUMES else "state"}) return rows def _retained_comfyui_paths(root: Path) -> set[str]: """``target_dir/filename`` of every row in volumes/comfyui/active-models.tsv. Raises OSError when the file is missing, and ValueError when a row lacks a filename or target_dir: an unknown active set must never read as "nothing is retained".""" lines = (root / "volumes" / "comfyui" / "active-models.tsv").read_text().splitlines() rows = [line.split("\t") for line in lines if line.strip() and not line.startswith("#")] if any(len(row) < 6 or not row[2] or not row[5] for row in rows): raise ValueError("ComfyUI active-model retention is unavailable") return {f"{row[5].strip('/')}/{row[2]}" for row in rows} def _catalog_comfyui_paths() -> set[str]: from utils.comfyui_library import list_curated # noqa: PLC0415 from utils.comfyui_resolver import _derive_filename # noqa: PLC0415 entries = list_curated() files = [f for e in entries for f in (e.files or [])] + [e for e in entries if not e.files] return {f"{(f.target_dir or '').strip('/')}/{_derive_filename(f)}" for f in files} def label_comfyui_files(files: list, root: Path) -> list[dict]: """(relative path, bytes) model files as items: retained when the active set names them, removable when the catalog does, else unknown.""" retained, known = _retained_comfyui_paths(root), _catalog_comfyui_paths() label = lambda path: "retained" if path in retained else ("removable" if path in known else "unknown") # noqa: E731 return [{"kind": "comfyui", "volume": "comfyui-models", "path": path, "bytes": size, "label": label(path)} for path, size in files] _OLLAMA_PREFIXES = ("ollama/", "ollama_chat/") def _routed_ollama_names(env: dict, root: Optional[Path]) -> set: """Ollama models the stack routes besides the selection: the LiteLLM default/vision/embedding settings and the rendered LiteLLM config the running stack uses. `.env` alone let `storage clean` delete the embedding model a still-running stack routed (2026-10-08 run, cycle 9).""" values = [env.get(key, "") for key in ( "LITELLM_DEFAULT_MODEL", "LITELLM_VISION_MODEL", "LITELLM_EMBEDDING_MODEL", "LANGMEM_EMBEDDING_MODEL", )] + _rendered_litellm_models(root) names = {_ollama_model_name(str(value or "").strip()) for value in values} return {name for name in names if name} def _rendered_litellm_models(root: Optional[Path]) -> list: """``litellm_params.model`` of every row in the rendered LiteLLM config; empty when no stack has rendered one yet.""" import yaml # noqa: PLC0415 rendered = (root or Path("/nonexistent")) / "volumes" / "litellm" / "config.yaml" try: model_list = (yaml.safe_load(rendered.read_text(encoding="utf-8")) or {}).get("model_list") or [] return [str((row.get("litellm_params") or {}).get("model") or "") for row in model_list] except FileNotFoundError: return [] except (OSError, yaml.YAMLError, AttributeError): raise OSError("Rendered LiteLLM model inventory is unavailable") from None def _ollama_model_name(value: str) -> str: """``ollama/x`` or ``ollama_chat/x`` → ``x``; anything else → "".""" for prefix in _OLLAMA_PREFIXES: if value.startswith(prefix): return value[len(prefix):] return "" def label_ollama_models(models: list, env: dict, root: Optional[Path] = None) -> list[dict]: """(name, bytes) pulled Ollama models as items: retained when the resolved active set names them, removable when the catalog does, else unknown.""" from utils import llm_catalog # noqa: PLC0415 from utils.model_resolver import _active_ollama # noqa: PLC0415 tag = lambda name: name if ":" in name else f"{name}:latest" # noqa: E731 active = {tag(entry.name) for entry in _active_ollama(env, None)} active |= {tag(name) for name in _routed_ollama_names(env, root)} known = {tag(entry.name) for entry in llm_catalog.ollama_entries()} label = lambda name: "retained" if tag(name) in active else ("removable" if tag(name) in known else "unknown") # noqa: E731 return [{"kind": "ollama", "volume": "llm-provider-data", "path": name, "bytes": size, "label": label(name)} for name, size in models] def deletion_set(items: list, named) -> list[dict]: """Items a cleanup removes: every removable one, plus unknown ones named explicitly. Retained items, and anything outside the model volumes, never are.""" named = set(named) | {f"{name}:latest" for name in named if ":" not in name} return [ item for item in items if item["volume"] in MODEL_VOLUMES and item["label"] != "retained" and (item["label"] == "removable" or item["path"] in named) ] def remove_items(items: list, remove) -> list[dict]: """Remove ``items`` one at a time with ``remove(item)``. Stops at the first failure: each removal is whole, so the inventory taken afterwards is exactly the previous one minus what was removed.""" removed = [] for item in items: try: remove(item) except Exception as exc: # noqa: BLE001 - report and stop print(f" stopped at {item['path']}: {exc}") break removed.append(item) print(f" removed {item['path']} ({item['bytes']} bytes)") return removed def _exec(project: str, service: str, *argv: str): return subprocess.run(["docker", "exec", f"{project}-{service}", *argv], capture_output=True, text=True, check=False, timeout=120) def _ollama_volume_items(env: dict, project: str, root: Optional[Path] = None) -> list[dict]: """Models in the Atlas Ollama container's volume, via that container only: a host daemon (ollama-localhost, or anyone's on the default port) is never queried, so its models can never be listed or removed.""" if not env.get("LLM_PROVIDER_SOURCE", "ollama-container-cpu").strip().startswith("ollama-container"): print(" Ollama does not run in an Atlas container: its models are not managed here.") return [] listing = _exec(project, "ollama", "ollama", "list") if listing.returncode != 0: print(" The Ollama container is not running: its models are not itemized.") return [] rows = [line.split() for line in listing.stdout.splitlines()[1:] if line.strip()] try: return label_ollama_models([(row[0], parse_docker_size("".join(row[2:4]))) for row in rows if len(row) >= 4], env, root) except OSError: print(" Ollama model retention is unavailable: its models are not itemized.") return [] def _comfyui_volume_items(env: dict, root: Path, project: str) -> list[dict]: """Files in the Atlas ComfyUI container's models volume. Itemized only when the active list exists, so nothing in use can read as removable.""" if not env.get("COMFYUI_SOURCE", "container-cpu").strip().startswith("container"): print(" ComfyUI does not run in an Atlas container: its model files are not managed here.") return [] try: _retained_comfyui_paths(root) except OSError: print(" volumes/comfyui/active-models.tsv is missing (run ./start.sh first): ComfyUI files are not itemized.") return [] except ValueError: print(" ComfyUI model retention is unavailable: its model files are not itemized.") return [] listing = _exec(project, "comfyui", "find", _COMFYUI_MODELS_DIR, "-type", "f", "-printf", "%s\t%P\\0") if listing.returncode != 0: print(" The ComfyUI container is not running: its model files are not itemized.") return [] rows = [entry.split("\t", 1) for entry in listing.stdout.split("\0") if "\t" in entry] files = [(path, int(size)) for size, path in rows if size.isdigit() and "\n" not in path] return label_comfyui_files(files, root) def _live_model_items(env: dict, root: Path, project: str) -> list[dict]: """Model items a running stack holds in its own model volumes.""" return _ollama_volume_items(env, project, root) + _comfyui_volume_items(env, root, project) def host_model_directories(env: dict) -> list[dict]: """The declared host model directories with their byte size. Reported only: cleanup never touches a host directory.""" rows, seen = [], set() for var in ("COMFYUI_LOCAL_MODELS_PATH", "COMFYUI_MPS_MODELS_PATH"): raw = (env.get(var) or "").strip() path = Path(raw).expanduser() if raw else None if path is None or path in seen: continue seen.add(path) size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file() and not f.is_symlink()) if path.is_dir() else None rows.append({"variable": var, "path": str(path), "bytes": size}) return rows def _remove_live_item(project: str): def remove(item: dict) -> None: argv = (("ollama", "ollama", "rm", item["path"]) if item["kind"] == "ollama" else ("comfyui", "rm", "-f", "--", f"{_COMFYUI_MODELS_DIR}/{item['path']}")) result = _exec(project, *argv) if result.returncode != 0: raise RuntimeError((result.stderr or result.stdout).strip() or f"exit {result.returncode}") return remove def _storage_context(): """The .env values, repo root and project name for the storage commands. get_project_name() only validates. Compose names containers and volumes from the raw ``${PROJECT_NAME}``, and Atlas keeps a mixed-case value, so the names are built from it, as _redis_aof_names does. """ starter = AtlasStarter() env = starter.config_parser.parse_env_file() root = Path(starter.config_parser.root_dir) starter.config_parser.get_project_name() project = (env.get("PROJECT_NAME") or "").strip() or DEFAULT_PROJECT_NAME return env, root, project @main.group("storage") def storage_group() -> None: """Atlas-owned storage: inventory and model cleanup (#1194).""" @storage_group.command("inventory") def storage_inventory_command() -> None: """List each project volume with its size and ownership, the declared host model directories, and the model items of a running stack labelled retained, removable or unknown.""" env, root, project = _storage_context() try: sizes = docker_volume_sizes() except (OSError, subprocess.SubprocessError) as exc: print(f"docker system df failed: {exc}") sys.exit(1) volumes = volume_inventory(project, declared_project_volumes(root), sizes) for row in volumes: print(f"{row['volume']:<45} {row['bytes']:>14} bytes {row['ownership']:<8} {row['holds']}") print(f"total: {sum(row['bytes'] for row in volumes)} bytes") for row in host_model_directories(env): size = "missing" if row["bytes"] is None else f"{row['bytes']} bytes" print(f"host {row['variable']}: {row['path']} {size}") for item in _live_model_items(env, root, project): print(f" {item['kind']:<8} {item['path']:<60} {item['bytes']:>14} bytes {item['label']}") @storage_group.command("clean") @click.option("--name", "names", multiple=True, help="Also remove this unknown-provenance item (an Ollama model or a ComfyUI model path).") @click.option("--yes", is_flag=True, help="Do not ask before removing the previewed items.") def storage_clean_command(names, yes: bool) -> None: """Remove model items no active selection uses. The exact items and sizes are shown first; retained items, unknown ones not named, and every stateful volume are never touched.""" env, root, project = _storage_context() planned = deletion_set(_live_model_items(env, root, project), names) if not planned: print("Nothing to remove.") return for item in planned: print(f" will remove {item['kind']} {item['path']} ({item['bytes']} bytes)") print(f"total: {sum(item['bytes'] for item in planned)} bytes") if not yes and not click.confirm("Remove these items?", default=False): print("Nothing removed.") return if len(remove_items(planned, _remove_live_item(project))) < len(planned): sys.exit(1) @main.group("endpoints") def endpoints_group() -> None: """Consumer endpoint export commands (stable machine-readable contract).""" def _manifest_base_port_is_unallocated(consumer_config, env) -> bool: """Report whether a manifest ``BASE_PORT: auto`` still has no port block. ``auto`` resolves at bring-up and is then persisted, so a value that is absent, non-numeric, or still ``DEFAULT_BASE_PORT`` means no allocation has happened. That is the same test ``_resolve_auto_base_port_override`` uses to decide a persisted block is not durably ours. """ overrides = getattr(consumer_config, "env_overrides", None) or {} if str(overrides.get("BASE_PORT", "")).strip().lower() != "auto": return False try: return int(str(env.get("BASE_PORT", "")).strip()) == DEFAULT_BASE_PORT except (TypeError, ValueError): return True @endpoints_group.command("export") @click.option( "--format", "output_format", type=click.Choice(["env", "json"], case_sensitive=False), default="env", show_default=True, help="Output format for consumers.", ) @click.option( "--with-secrets", is_flag=True, help="Resolve consumer-scoped credentials (storage keys). Requires --output; " "refused to stdout. Never resolves infra secrets (e.g. the Redis password).", ) @click.option( "--output", "output_path", type=str, default=None, help="Write to PATH instead of stdout (required with --with-secrets).", ) @click.option( "--allow-unresolved", is_flag=True, help="Export even when a manifest BASE_PORT: auto has not been allocated yet. " "The port fields then describe the default block, not this stack's — intended " "for templating and sample files, not for connecting to anything.", ) def endpoints_export_command( output_format: str, with_secrets: bool, output_path: str | None, allow_unresolved: bool, ) -> None: """Export the stable consumer endpoint contract as env or JSON. Emits canonical, distinct container/host/Kong/public endpoints and active SOURCE modes per consumer-relevant service, plus per-consumer storage fields. Secrets are ``${VAR}`` references unless ``--with-secrets`` (which resolves only consumer-scoped credentials and refuses stdout). Deterministic and byte-stable for the same inputs. """ from core.endpoints_contract import build_export, render_env, render_json if with_secrets and not output_path: click.echo( "Refusing to write secrets to stdout; pass --output PATH with " "--with-secrets.", err=True, ) raise click.exceptions.Exit(2) starter = AtlasStarter() env = starter.config_parser.parse_env_file() try: consumer_config = starter.config_parser.load_consumer_config() except (ValueError, OSError) as exc: # ConsumerManifestError / unreadable file raise click.ClickException(f"invalid consumer manifest: {exc}") from exc # A manifest BASE_PORT: auto is allocated at bring-up, and a cold .env still # carries DEFAULT_BASE_PORT until then. Exporting in that state answers a # question that has no answer yet: every port describes the default block # rather than this stack's. Worse on a shared host, where that block may # belong to a different Atlas project and will answer rather than refuse. if not allow_unresolved and _manifest_base_port_is_unallocated( consumer_config, env ): project = starter.config_parser.get_project_name() click.echo( f"atlas: {project} has no allocated port block; BASE_PORT is 'auto' " "so ports are assigned at bring-up.\n" " Start the stack first, or pass --allow-unresolved to export " "placeholder ports anyway.", err=True, ) raise click.exceptions.Exit(3) fields = build_export( env, with_secrets=with_secrets, host_services=getattr(consumer_config, "managed_host_services", ()), ) text = render_json(fields) if output_format.lower() == "json" else render_env(fields) if output_path: # Relative to where ./start.sh was run, like doctor --bundle: the # uv wrapper runs the bootstrapper from bootstrapper/. out = _invoker_output_path(Path(output_path)) # Replace, never follow, a symlink at the destination: the export can # carry secrets (--with-secrets) and must not land on a link target. try: atomic_replace_text(out, text, mode=0o600) except OSError as exc: # a directory or unwritable destination raise click.ClickException(f"could not write {out}: {exc.strerror or exc}") from exc click.echo(f"Wrote {len(fields)} endpoint field(s) to {out}") else: click.echo(text, nl=False) @endpoints_group.command("assert") @click.option( "--require", "required", type=str, default=None, help="Comma/space-separated export field names that MUST be present for the " "current stack; exits non-zero if any is absent. Run this in consumer CI so " "an Atlas field rename/removal fails loudly instead of silently degrading.", ) @click.option( "--format", "output_format", type=click.Choice(["text", "json"], case_sensitive=False), default="text", show_default=True, help="With no --require, list the available export field names.", ) def endpoints_assert_command(required: str | None, output_format: str) -> None: """Assert the consumer endpoint contract — a CI drift gate for submodule consumers. Consumers pin Atlas as a submodule and read specific ``ATLAS_*`` export fields (e.g. ``ATLAS_LITELLM_HOST_ENDPOINT``). Run ``./infra/start.sh endpoints assert --require ATLAS_LITELLM_HOST_ENDPOINT,…`` in consumer CI against the configured stack so a future Atlas rename/removal of a depended-on field fails loudly. With no ``--require``, prints the available field names (``--format json`` for machine parsing). """ from core.endpoints_contract import build_export starter = AtlasStarter() try: env = starter.config_parser.parse_env_file() except Exception: # pragma: no cover - defensive (no .env yet) env = {} try: host_services = starter.config_parser.load_consumer_config().managed_host_services except Exception: # pragma: no cover - defensive (no/!invalid consumer manifest) # `endpoints assert` exists to tell a consumer which fields it can rely # on. A manifest problem must not blank that answer for every OTHER # field, so a failed load degrades to "no declared host services". host_services = () available = sorted({ f.name for f in build_export( env, with_secrets=False, host_services=host_services, ) }) if required: want = [f for f in required.replace(",", " ").split() if f] missing = [f for f in want if f not in available] if missing: click.echo( "endpoints assert: missing required export field(s): " + ", ".join(sorted(missing)) + f".\nAvailable ({len(available)}): {', '.join(available)}", err=True, ) raise click.exceptions.Exit(1) click.echo(f"endpoints assert: all {len(want)} required field(s) present.") return if output_format.lower() == "json": click.echo(json.dumps(available)) else: for name in available: click.echo(name) @main.group("comfyui-mps") def comfyui_mps_group() -> None: """Manage the native Apple-Silicon/Metal (MPS) ComfyUI host (#335). Docker Desktop can't pass Metal into a Linux container, so COMFYUI_SOURCE=managed-localhost-mps runs a native ComfyUI process on the host that containers reach via host.docker.internal. These commands preflight, install/update the pinned checkout + venv, and start/stop/inspect that process. A normal ``./start.sh`` with that source runs install+start automatically; use these for explicit lifecycle control or CI-safe preflight. """ def _comfyui_mps_manager(): from services.comfyui_mps_manager import manager_from_env starter = AtlasStarter() env = starter.config_parser.parse_env_file() return manager_from_env(env) def _stop_managed_host_command(manager, label: str) -> None: """Report manual stop truthfully, including fail-closed PID evidence.""" from services import tracked_process_may_survive _before_pid, was_maybe_alive = tracked_process_may_survive(manager) manager.stop() pid, may_survive = tracked_process_may_survive(manager) if may_survive: detail = f"pid {pid}" if pid is not None else "retained PID evidence" click.echo( f"Could not stop {label}; tracked {detail} may still be alive.", err=True, ) raise click.exceptions.Exit(1) if was_maybe_alive: click.echo("Stopped.") else: click.echo(f"No managed {label} process was running.") @comfyui_mps_group.command("preflight") def comfyui_mps_preflight_command() -> None: """Run the read-only host probe (OS/arch, memory, Torch/MPS). No install.""" manager = _comfyui_mps_manager() result = manager.preflight() for check in result.checks: click.echo(f"[{check['status'].upper()}] {check['name']}: {check['detail']}") click.echo(f"\nPreflight: {result.status.upper()}") if not result.ok: raise click.exceptions.Exit(1) @comfyui_mps_group.command("install") @click.option("--update", is_flag=True, help="Refresh the checkout + reinstall requirements.") def comfyui_mps_install_command(update: bool) -> None: """Idempotently install (or --update) the pinned ComfyUI checkout + venv.""" from services.comfyui_mps_manager import ComfyUiMpsError manager = _comfyui_mps_manager() try: manager.install(update=update) except ComfyUiMpsError as exc: click.echo(f"Install failed: {exc}", err=True) raise click.exceptions.Exit(1) from exc click.echo(f"Installed ComfyUI {manager.ref} into {manager.state_dir}.") @comfyui_mps_group.command("start") def comfyui_mps_start_command() -> None: """Launch the managed host process (idempotent — one process per host).""" from services.comfyui_mps_manager import ComfyUiMpsError manager = _comfyui_mps_manager() try: status = manager.start() except ComfyUiMpsError as exc: click.echo(f"Start failed: {exc}", err=True) raise click.exceptions.Exit(1) from exc click.echo(f"ComfyUI (MPS) running: pid={status.pid} port={status.port}") click.echo(f"Logs: {status.log_file}") @comfyui_mps_group.command("stop") def comfyui_mps_stop_command() -> None: """Stop the managed host process (SIGINT then SIGKILL).""" _stop_managed_host_command(_comfyui_mps_manager(), "ComfyUI (MPS)") @comfyui_mps_group.command("status") def comfyui_mps_status_command() -> None: """Show the managed host process status (running / pid / installed ref).""" manager = _comfyui_mps_manager() status = manager.status() click.echo(json.dumps(status.to_dict(), indent=2)) @comfyui_mps_group.command("health") def comfyui_mps_health_command() -> None: """Probe /system_stats and report reachability + compute device (mps/cpu).""" manager = _comfyui_mps_manager() health = manager.health() click.echo(json.dumps(health, indent=2)) if not health.get("reachable"): raise click.exceptions.Exit(1) def _refusal_exits(label: str, action, error_type): """Run a manager action; its own refusal prints "