diff --git a/docs/cli/configuration.mdx b/docs/cli/configuration.mdx index eb403c55dc..957b675793 100644 --- a/docs/cli/configuration.mdx +++ b/docs/cli/configuration.mdx @@ -13,6 +13,8 @@ The quickest way to connect to an existing deployment is: nemo auth login --base-url https://nmp.example.com ``` +During interactive onboarding, `nemo setup` also offers to connect to a remote deployment when the currently configured platform is unreachable. It verifies and saves the new URL in the active context, then runs the same authentication flow. + To configure a named context: ```bash diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index caae36c381..4a6b0ab135 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -42,12 +42,22 @@ nemo [GLOBAL OPTIONS] COMMAND [ARGS]... ### nemo setup -Set up NeMo Platform: start services, configure a provider, install skills. +Set up NeMo Platform: connect or start services, configure a provider, install skills. -Walks through starting local services, selecting a provider, entering -credentials, registering the provider with the platform, picking a -default model, installing coding agent skills, and optionally deploying -a demo agent. +Uses an already-running platform, starts local services, or connects the +CLI to an existing remote deployment. Then selects and registers an +inference provider, picks a default model, installs coding agent skills, +and optionally deploys a demo agent. + +The active config context remembers the Platform URL. When a remote +deployment is already reachable, setup asks whether to continue with it, +start local services instead, or connect to a different remote URL. + +To override the URL for one run only: + nemo --base-url http://localhost:8080 setup + +To persist a different URL: + nemo config set --base-url http://localhost:8080 Requires an interactive terminal (TTY). In non-interactive contexts (CI, piped input), pass --auto to use environment variables instead. @@ -64,8 +74,10 @@ nemo setup nemo setup --auto nemo setup --auto --start-services --install-skills --deploy-agent nemo setup --auto --start-services --ready-timeout 360 +NMP_BASE_URL=https://nmp.example.com NMP_ACCESS_TOKEN=... nemo setup --auto --no-start-services nemo setup --workspace my-workspace nemo setup --no-install-skills --no-deploy-agent +nemo --base-url http://localhost:8080 setup ``` **Usage:** diff --git a/docs/get-started/setup.mdx b/docs/get-started/setup.mdx index 995d6bdf03..569ab3a63a 100644 --- a/docs/get-started/setup.mdx +++ b/docs/get-started/setup.mdx @@ -74,7 +74,7 @@ nemo setup The wizard walks through each stage: -1. **Start services** — launches the platform locally +1. **Connect to the platform** — uses a running platform, starts services locally, or connects to a remote instance 2. **Choose a provider** — select your model provider and enter your API key 3. **Register the provider** — connects the provider to the platform 4. **Discover models** — finds available models from your provider @@ -84,6 +84,21 @@ The wizard walks through each stage: Each stage is idempotent — you can re-run `nemo setup` at any time to add more providers or update your configuration. +### Connect to an existing deployment + +When the configured platform is not reachable, the setup wizard offers three choices: + +```text +Platform not reachable at http://localhost:8080. Start local services? + 1. Yes, start services now + 2. No, I want to connect to a remote Platform instance + 3. No, I'll start them myself +``` + +Choose the remote option and enter the deployment's base URL. Setup verifies the URL, saves it to the active CLI context, and starts the existing OIDC login flow when the deployment has authentication enabled. It then continues with provider, model, skills, and demo-agent setup. + +This connects the local CLI to an existing deployment; it does not install the platform on a remote host. + ### Auth and OIDC source installs `nemo setup` and `nemo services run` do not create authorization role bindings by themselves. If you enable auth in source config with `auth.enabled: true` and set `auth.admin_email`, run the platform seed task so the bootstrap IAM bindings exist. @@ -183,6 +198,12 @@ nemo setup --auto --start-services --install-skills --deploy-agent export OPENAI_API_KEY=sk-... nemo setup --auto +# Connect to an authenticated remote platform without prompts +export NMP_BASE_URL=https://nmp.example.com +export NMP_ACCESS_TOKEN=... +export OPENAI_API_KEY=sk-... +nemo setup --auto --no-start-services + # Skip skills and agent nemo setup --no-install-skills --no-deploy-agent ``` diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py index 235f2e520c..93ed4f13cb 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py @@ -11,11 +11,13 @@ import asyncio import logging +import os import time from typing import Annotated, cast import httpx import typer +from rich.console import Console from nemo_platform_ext.auth.helpers import ( AuthError, @@ -31,6 +33,7 @@ from nemo_platform_ext.cli.core.context import CLIContext from nemo_platform_ext.cli.core.errors import handle_errors from nemo_platform_ext.cli.core.help_formatter import create_typer_app +from nemo_platform_ext.config.config import Config from nemo_platform_ext.config.models import ConfigParams, Context app = create_typer_app( @@ -156,6 +159,168 @@ def auth_callback(ctx: typer.Context) -> None: typer.echo(ctx.get_help()) +def _login_with_oidc( + cli_context: CLIContext, + *, + no_browser: bool = False, + scope: str | None = None, + username: str | None = None, + password: str | None = None, + selected_context: str | None = None, +) -> bool: + """Authenticate the selected context using the cluster's OIDC configuration. + + Returns ``False`` when cluster authentication is disabled. + """ + from nemo_platform_ext.auth.device_flow import ( + DeviceFlowError, + authenticate_with_device_flow, + authenticate_with_password_grant, + ) + + console = Console() + context = cli_context.get_sdk_context() + base_url = str(context.cluster.base_url).rstrip("/") + + console.print(f"\nDiscovering auth configuration from {base_url}...") + + try: + oidc_config = discover_nmp_config(base_url) + except httpx.HTTPError as exc: + raise AuthError(f"Failed to discover auth configuration: {exc}") from exc + + if not oidc_config.auth_enabled: + console.print("[yellow]Authentication is not enabled on this cluster.[/]") + console.print("You can use the API without authentication.") + return False + + if not oidc_config.token_endpoint: + raise AuthError( + "This cluster does not have OIDC token endpoint configured.\n" + "Use OIDC configuration for device/password login, or for local testing use:\n" + "nemo auth login --unsigned-token --email " + ) + + login_username = username or os.environ.get("NMP_OIDC_USERNAME") + login_password = password or os.environ.get("NMP_OIDC_PASSWORD") + use_password_grant = bool(login_username and login_password) + + if use_password_grant: + if not oidc_config.client_id: + raise AuthError("OIDC client_id is required for password grant.") + elif not oidc_config.device_authorization_endpoint: + raise AuthError( + "This cluster does not support device flow authentication.\n" + "For non-interactive login use: nemo auth login --username --password \n" + "Or set NMP_OIDC_USERNAME and NMP_OIDC_PASSWORD (e.g. in CI)." + ) + + console.print(f"[green]Found OIDC configuration[/] (issuer: {oidc_config.issuer})") + + raw_defaults = oidc_config.default_scopes + default_baseline = " ".join(item for item in raw_defaults.split() if ":" not in item) + if scope: + seen: set[str] = set() + parts: list[str] = [] + for item in default_baseline.split(): + if item not in seen: + seen.add(item) + parts.append(item) + for item in scope.split(): + if item not in seen: + seen.add(item) + parts.append(item) + requested_scopes = " ".join(parts) + else: + requested_scopes = raw_defaults + + scope_prefix = normalize_scope_prefix(oidc_config.scope_prefix) + effective_scope = build_effective_scope(requested_scopes, oidc_config.scope_prefix) + + console.print("\n[bold]Requesting scopes:[/]") + for requested_scope in requested_scopes.split(): + if scope_prefix and (":" in requested_scope or requested_scope.endswith(".default")): + console.print(f" [cyan]{requested_scope}[/] [dim]({scope_prefix}{requested_scope})[/]") + else: + console.print(f" [cyan]{requested_scope}[/]") + console.print() + + if use_password_grant: + if login_username is None or login_password is None: + raise AuthError("Username and password are required for password grant.") + client_id = cast(str, oidc_config.client_id) + try: + token_response = authenticate_with_password_grant( + token_endpoint=oidc_config.token_endpoint, + client_id=client_id, + username=login_username, + password=login_password, + scope=effective_scope, + ) + except DeviceFlowError as exc: + raise AuthError(f"Authentication failed: {exc}") from exc + else: + if oidc_config.device_authorization_endpoint is None: + raise AuthError("This cluster does not support device flow authentication.") + client_id = cast(str, oidc_config.client_id) + try: + token_response = asyncio.run( + authenticate_with_device_flow( + device_authorization_endpoint=oidc_config.device_authorization_endpoint, + token_endpoint=oidc_config.token_endpoint, + client_id=client_id, + scope=effective_scope, + open_browser=not no_browser, + ) + ) + except DeviceFlowError as exc: + raise AuthError(f"Authentication failed: {exc}") from exc + + token = token_response.token_for_nmp + claims = decode_jwt_claims(token) + user_email = claims.get("upn") or claims.get("email") or claims.get("preferred_username") + raw_granted_scopes = claims.get("scp") or claims.get("scope") + granted_scopes: list[str] = [] + if isinstance(raw_granted_scopes, str): + granted_scopes = raw_granted_scopes.split() + elif isinstance(raw_granted_scopes, list): + granted_scopes = [item for item in raw_granted_scopes if isinstance(item, str)] + + validate_requested_scopes_granted(effective_scope, granted_scopes, scope_prefix) + + config_params: ConfigParams = {"access_token": token} + if token_response.refresh_token: + config_params["refresh_token"] = token_response.refresh_token + if selected_context is not None: + config_params["current_context"] = context.context_name + Config.write(config_params, context_name=context.context_name) + + console.print("\n[bold green]Authentication successful![/]") + if user_email: + console.print(f" Logged in as: [cyan]{user_email}[/]") + + if granted_scopes: + display_scopes = [ + item[len(scope_prefix) :] if scope_prefix and item.startswith(scope_prefix) else item + for item in granted_scopes + ] + console.print(f" Granted scopes: [cyan]{' '.join(display_scopes)}[/]") + + if token_response.refresh_token: + console.print(" Refresh token: [green]saved[/] (enables automatic token renewal)") + else: + console.print(" Refresh token: [yellow]not available[/] (add 'offline_access' scope to enable)") + + console.print("\n[bold green]Credentials saved to config file.[/]") + if runtime_token_source := _runtime_token_source_label(): + console.print( + f"[yellow]Warning:[/] {runtime_token_source} is active and will override these saved credentials. " + "Unset the runtime token override to use this login for future commands." + ) + console.print("\n[dim]Run 'nemo workspaces list' to verify your access.[/]") + return True + + @app.command("login") @handle_errors def login( @@ -274,17 +439,6 @@ def login( # Device flow, show code only nemo auth login --no-browser """ - import os - - from rich.console import Console - - from nemo_platform_ext.auth.device_flow import ( - DeviceFlowError, - authenticate_with_device_flow, - authenticate_with_password_grant, - ) - from nemo_platform_ext.config.config import Config - cli_context: CLIContext = ctx.obj selected_context = cli_context.overrides.get("current_context") @@ -376,155 +530,16 @@ def login( console.print("\n[dim]Run 'nemo auth status' to inspect the token.[/]") return - context = cli_context.get_sdk_context() - base_url = str(context.cluster.base_url).rstrip("/") - - console.print(f"\nDiscovering auth configuration from {base_url}...") - - try: - oidc_config = discover_nmp_config(base_url) - except httpx.HTTPError as exc: - raise AuthError(f"Failed to discover auth configuration: {exc}") from exc - - if not oidc_config.auth_enabled: - console.print("[yellow]Authentication is not enabled on this cluster.[/]") - console.print("You can use the API without authentication.") + if not _login_with_oidc( + cli_context, + no_browser=no_browser, + scope=scope, + username=username, + password=password, + selected_context=selected_context, + ): raise typer.Exit(0) - if not oidc_config.token_endpoint: - raise AuthError( - "This cluster does not have OIDC token endpoint configured.\n" - "Use OIDC configuration for device/password login, or for local testing use:\n" - "nemo auth login --unsigned-token --email " - ) - - login_username = username or os.environ.get("NMP_OIDC_USERNAME") - login_password = password or os.environ.get("NMP_OIDC_PASSWORD") - use_password_grant = bool(login_username and login_password) - - if use_password_grant: - if not oidc_config.client_id: - raise AuthError("OIDC client_id is required for password grant.") - else: - if not oidc_config.device_authorization_endpoint: - raise AuthError( - "This cluster does not support device flow authentication.\n" - "For non-interactive login use: nemo auth login --username --password \n" - "Or set NMP_OIDC_USERNAME and NMP_OIDC_PASSWORD (e.g. in CI)." - ) - - console.print(f"[green]Found OIDC configuration[/] (issuer: {oidc_config.issuer})") - - # Use only generic scopes from cluster defaults (exclude platform/custom scopes like platform:read) - # so platform scopes come only from --scope. Merge with --scope if provided. - raw_defaults = oidc_config.default_scopes - default_baseline = " ".join(s for s in raw_defaults.split() if ":" not in s) - if scope: - seen: set[str] = set() - parts: list[str] = [] - for s in default_baseline.split(): - if s not in seen: - seen.add(s) - parts.append(s) - for s in scope.split(): - if s not in seen: - seen.add(s) - parts.append(s) - requested_scopes = " ".join(parts) - else: - requested_scopes = raw_defaults - - scope_prefix = normalize_scope_prefix(oidc_config.scope_prefix) - effective_scope = build_effective_scope(requested_scopes, oidc_config.scope_prefix) - - # Display the scopes being requested - console.print("\n[bold]Requesting scopes:[/]") - for s in requested_scopes.split(): - if scope_prefix and (":" in s or s.endswith(".default")): - console.print(f" [cyan]{s}[/] [dim]({scope_prefix}{s})[/]") - else: - console.print(f" [cyan]{s}[/]") - console.print() - - if use_password_grant: - if login_username is None or login_password is None: - raise AuthError("Username and password are required for password grant.") - client_id = cast(str, oidc_config.client_id) - try: - token_response = authenticate_with_password_grant( - token_endpoint=oidc_config.token_endpoint, - client_id=client_id, - username=login_username, - password=login_password, - scope=effective_scope, - ) - except DeviceFlowError as exc: - raise AuthError(f"Authentication failed: {exc}") from exc - else: - if oidc_config.device_authorization_endpoint is None: - raise AuthError("This cluster does not support device flow authentication.") - client_id = cast(str, oidc_config.client_id) - device_authorization_endpoint = oidc_config.device_authorization_endpoint - try: - token_response = asyncio.run( - authenticate_with_device_flow( - device_authorization_endpoint=device_authorization_endpoint, - token_endpoint=oidc_config.token_endpoint, - client_id=client_id, - scope=effective_scope, - open_browser=not no_browser, - ) - ) - except DeviceFlowError as exc: - raise AuthError(f"Authentication failed: {exc}") from exc - - token = token_response.token_for_nmp - - claims = decode_jwt_claims(token) - user_email = claims.get("upn") or claims.get("email") or claims.get("preferred_username") - raw_granted_scopes = claims.get("scp") or claims.get("scope") - granted_scopes: list[str] = [] - if isinstance(raw_granted_scopes, str): - granted_scopes = raw_granted_scopes.split() - elif isinstance(raw_granted_scopes, list): - granted_scopes = [scope for scope in raw_granted_scopes if isinstance(scope, str)] - - validate_requested_scopes_granted(effective_scope, granted_scopes, scope_prefix) - - config_params: ConfigParams = {"access_token": token} - if token_response.refresh_token: - config_params["refresh_token"] = token_response.refresh_token - if selected_context is not None: - config_params["current_context"] = context.context_name - Config.write(config_params, context_name=context.context_name) - - console.print("\n[bold green]Authentication successful![/]") - if user_email: - console.print(f" Logged in as: [cyan]{user_email}[/]") - - if granted_scopes: - # Normalize scopes by stripping prefix for display - display_scopes = [] - for s in granted_scopes: - if scope_prefix and s.startswith(scope_prefix): - display_scopes.append(s[len(scope_prefix) :]) - else: - display_scopes.append(s) - console.print(f" Granted scopes: [cyan]{' '.join(display_scopes)}[/]") - - if token_response.refresh_token: - console.print(" Refresh token: [green]saved[/] (enables automatic token renewal)") - else: - console.print(" Refresh token: [yellow]not available[/] (add 'offline_access' scope to enable)") - - console.print("\n[bold green]Credentials saved to config file.[/]") - if runtime_token_source := _runtime_token_source_label(): - console.print( - f"[yellow]Warning:[/] {runtime_token_source} is active and will override these saved credentials. " - "Unset the runtime token override to use this login for future commands." - ) - console.print("\n[dim]Run 'nemo workspaces list' to verify your access.[/]") - @app.command("logout") @handle_errors diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py index 98639dbb3b..13d5013840 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py @@ -34,12 +34,22 @@ TopLevelEntry( import_path="nemo_platform_ext.cli.commands.setup:setup_command", help="""\ -Set up NeMo Platform: start services, configure a provider, install skills. +Set up NeMo Platform: connect or start services, configure a provider, install skills. -Walks through starting local services, selecting a provider, entering -credentials, registering the provider with the platform, picking a -default model, installing coding agent skills, and optionally deploying -a demo agent. +Uses an already-running platform, starts local services, or connects the +CLI to an existing remote deployment. Then selects and registers an +inference provider, picks a default model, installs coding agent skills, +and optionally deploys a demo agent. + +The active config context remembers the Platform URL. When a remote +deployment is already reachable, setup asks whether to continue with it, +start local services instead, or connect to a different remote URL. + +To override the URL for one run only: + nemo --base-url http://localhost:8080 setup + +To persist a different URL: + nemo config set --base-url http://localhost:8080 Requires an interactive terminal (TTY). In non-interactive contexts (CI, piped input), pass --auto to use environment variables instead. @@ -54,8 +64,10 @@ nemo setup --auto nemo setup --auto --start-services --install-skills --deploy-agent nemo setup --auto --start-services --ready-timeout 360 + NMP_BASE_URL=https://nmp.example.com NMP_ACCESS_TOKEN=... nemo setup --auto --no-start-services nemo setup --workspace my-workspace - nemo setup --no-install-skills --no-deploy-agent""", + nemo setup --no-install-skills --no-deploy-agent + nemo --base-url http://localhost:8080 setup""", name="setup", panel="Setup", kind="command", diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py index 626d56bd07..5f06f6a4e4 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py @@ -16,10 +16,11 @@ import subprocess import time from dataclasses import dataclass +from enum import StrEnum from importlib.resources import files from importlib.resources.abc import Traversable from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal from urllib.parse import urlparse import httpx @@ -41,8 +42,9 @@ from nemo_platform_ext.cli.commands.skills.registry import get_installer, load_skills from nemo_platform_ext.cli.core.context import CLIContext from nemo_platform_ext.cli.core.errors import handle_errors +from nemo_platform_ext.client.tls import client_verify_from_env from nemo_platform_ext.config.config import Config -from nemo_platform_ext.config.models import ConfigFile, ConfigParams, LocalServicesConfig +from nemo_platform_ext.config.models import DEFAULT_BASE_URL, ConfigFile, ConfigParams, LocalServicesConfig from nemo_platform_ext.local.process import ( check_port_available_for_start, compute_scope, @@ -285,13 +287,25 @@ def _bootstrap_config_if_missing(base_url: str, workspace: str) -> None: Config.write(params) +_PLATFORM_REACHABILITY_PATHS = ("/status", "/cluster-info") + + def _check_platform_reachable(base_url: str, timeout: float = 5.0) -> bool: - """Return True if the platform health endpoint responds.""" - try: - resp = httpx.get(f"{base_url.rstrip('/')}/status", timeout=timeout) - return resp.status_code == 200 - except Exception: - return False + """Return True if a platform health endpoint responds. + + Local ``nemo services run`` publishes ``/status``. Hosted deployments may + only expose ``/cluster-info`` on ingress, so try both. + """ + verify = client_verify_from_env() + root = base_url.rstrip("/") + for path in _PLATFORM_REACHABILITY_PATHS: + try: + resp = httpx.get(f"{root}{path}", timeout=timeout, verify=verify) + if resp.status_code == 200: + return True + except Exception: + continue + return False def _check_platform_reachable_with_retries( @@ -313,18 +327,103 @@ def _check_platform_reachable_with_retries( return False +def _prompt_remote_base_url(*, default_url: str = "") -> str: + """Prompt until the user provides a reachable remote Platform URL.""" + while True: + base_url = prompt_text( + "Enter the remote Platform base URL: ", + default=default_url, + validator=non_empty_validator("Base URL"), + ).strip() + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + console.print(f"{CROSS} Enter a valid HTTP or HTTPS URL.") + continue + + base_url = base_url.rstrip("/") + if _check_platform_reachable_with_retries(base_url): + return base_url + + console.print(f"{CROSS} Unable to connect to NeMo Platform at {base_url}.") + + +def _resolve_setup_workspace(ctx: typer.Context, cli_context: CLIContext, workspace: str) -> str: + """Prefer an explicit ``--workspace``; otherwise keep the active context workspace.""" + from click.core import ParameterSource + + if ctx.get_parameter_source("workspace") != ParameterSource.DEFAULT: + return workspace + return cli_context.get_sdk_context().workspace or workspace + + +def _configure_remote_connection(cli_context: CLIContext, base_url: str, workspace: str) -> None: + """Persist a remote Platform URL in the active CLI context.""" + context_name = cli_context.get_sdk_context().context_name + Config.write( + {"base_url": base_url, "workspace": workspace}, + context_name=context_name, + ) + cli_context.overrides["base_url"] = base_url + cli_context.reset_sdk_context() + + +def _ensure_platform_auth(cli_context: CLIContext) -> None: + """Authenticate the active context when it lacks usable credentials.""" + from nemo_platform_ext.cli.commands.auth import _login_with_oidc, _runtime_token_source_label + + context = cli_context.get_sdk_context() + if runtime_token_source := _runtime_token_source_label(): + console.print(f"{CHECK} Using {runtime_token_source}\n") + return + + authenticated = _login_with_oidc(cli_context, selected_context=context.context_name) + if not authenticated: + Config.write( + {"access_token": None, "refresh_token": None}, + context_name=context.context_name, + ) + cli_context.reset_sdk_context() + + +def _platform_request_headers(cli_context: CLIContext) -> dict[str, str] | None: + """Return authentication headers for direct Platform HTTP requests.""" + context = cli_context.get_sdk_context() + if context.user is None: + return None + headers = context.user.get_client_config().get("default_headers") + if not isinstance(headers, dict): + return None + return {key: value for key, value in headers.items() if isinstance(key, str) and isinstance(value, str)} + + +def _hosted_platform_without_status(base_url: str, *, timeout: float, verify: str | bool) -> bool: + """Return True when ``/cluster-info`` confirms a hosted platform that omits ``/status``.""" + try: + resp = httpx.get(f"{base_url.rstrip('/')}/cluster-info", timeout=timeout, verify=verify) + except Exception: + return False + return resp.status_code == 200 + + def _check_controller_health(base_url: str, timeout: float = 5.0) -> tuple[bool, str]: """Query ``/status`` and assess controller health. Returns ``(True, "")`` when controllers are populated and all healthy. + Returns ``(True, detail)`` when ``/status`` is absent but ``/cluster-info`` confirms a hosted platform. Returns ``(False, detail)`` when unhealthy, unreachable, or empty after retry. If ``controllers.status`` is empty on the first call (startup timing race), waits ``_CONTROLLER_HEALTH_RETRY_DELAY`` seconds and retries once. """ + verify = client_verify_from_env() + root = base_url.rstrip("/") for attempt in range(2): try: - resp = httpx.get(f"{base_url.rstrip('/')}/status", timeout=timeout) + resp = httpx.get(f"{root}/status", timeout=timeout, verify=verify) + if resp.status_code == 404: + if _hosted_platform_without_status(root, timeout=timeout, verify=verify): + return True, "Hosted deployment does not publish /status." + return False, "Unexpected status 404 from /status endpoint." if resp.status_code != 200: return False, f"Unexpected status {resp.status_code} from /status endpoint." data = resp.json() @@ -360,6 +459,13 @@ def _verify_platform_health(base_url: str) -> bool: """ ok, detail = _check_controller_health(base_url) if ok: + if detail: + if "does not publish /status" in detail.lower(): + # Expected for hosted ingress that only exposes /cluster-info. + console.print(f"\n{CHECK} {detail}") + else: + console.print(f"\n{WARN} [yellow]{detail}[/yellow]") + console.print(" Setup may have succeeded, but controller health could not be verified.") return True if "no controllers" in detail.lower(): @@ -626,6 +732,46 @@ def _resolve_services_port(base_url: str) -> int: return parsed.port or 8080 +def _is_local_base_url(base_url: str) -> bool: + """Return whether *base_url* points at the local machine.""" + parsed = urlparse(base_url) + return parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"} + + +def _platform_host_label(base_url: str) -> str: + """Return a human-friendly host label for connection prompts.""" + parsed = urlparse(base_url) + return parsed.hostname or base_url.rstrip("/") + + +class _RemoteConnectionChoice(StrEnum): + """Options offered when a configured remote Platform is already reachable.""" + + CONTINUE = "continue" + START_LOCAL = "local" + CHANGE_REMOTE = "change" + + +def _prompt_reachable_remote_connection(base_url: str) -> Literal["ready", "connect_remote", "start_local"]: + """Ask how to proceed when a configured remote Platform is already reachable.""" + hostname = _platform_host_label(base_url) + action = prompt_choice( + message=f"Platform reachable at {hostname} ({base_url}). What would you like to do?", + options=[ + (_RemoteConnectionChoice.CONTINUE, "Continue with this remote Platform"), + (_RemoteConnectionChoice.START_LOCAL, "Start local services instead"), + (_RemoteConnectionChoice.CHANGE_REMOTE, "Connect to a different remote URL"), + ], + default=_RemoteConnectionChoice.CONTINUE, + ) + if action == _RemoteConnectionChoice.CONTINUE: + console.print(f"{CHECK} Platform already running at {base_url}\n") + return "ready" + if action == _RemoteConnectionChoice.CHANGE_REMOTE: + return "connect_remote" + return "start_local" + + def _start_services_background(base_url: str, data_dir: str | None = None) -> subprocess.Popen: """Launch ``nemo services run`` as a background process. @@ -709,7 +855,7 @@ def _maybe_start_services( auto: bool, start_services: bool | None, timeout: int = _SERVICE_STARTUP_TIMEOUT_SECONDS, -) -> None: +) -> Literal["ready", "connect_remote", "start_local"]: """Start services if requested, restarting if already running. In interactive mode (auto=False), prompts the user if start_services is None. @@ -720,11 +866,19 @@ def _maybe_start_services( (including any newly installed plugins) is picked up. Data lives in SQLite so nothing is lost across restarts. """ + if start_services is True and not _is_local_base_url(base_url): + raise typer.BadParameter( + "--start-services requires a local Platform URL", + param_hint="--start-services", + ) + already_running = _check_platform_reachable(base_url) if already_running and start_services is not True: - console.print(f"{CHECK} Platform already running at {base_url}\n") - return + if _is_local_base_url(base_url) or auto: + console.print(f"{CHECK} Platform already running at {base_url}\n") + return "ready" + return _prompt_reachable_remote_connection(base_url) should_start = start_services if should_start is None: @@ -734,14 +888,20 @@ def _maybe_start_services( console.print(" [cyan]nemo setup --auto --start-services[/cyan]") console.print(" [cyan]nemo services run[/cyan]") raise typer.Exit(1) - should_start = ( - prompt_choice( - message=f"Platform not reachable at {base_url}. Start local services?", - options=[("yes", "Yes, start services now"), ("no", "No, I'll start them myself")], - default="yes", - ) - == "yes" + if not _is_local_base_url(base_url): + return "connect_remote" + action = prompt_choice( + message=f"Platform not reachable at {base_url}. Start local services?", + options=[ + ("yes", "Yes, start services now"), + ("remote", "No, I want to connect to a remote Platform instance"), + ("manual", "No, I'll start them myself"), + ], + default="yes", ) + if action == "remote": + return "connect_remote" + should_start = action == "yes" if not should_start: console.print(f"{CROSS} Cannot reach platform at {base_url}") @@ -789,6 +949,7 @@ def _maybe_start_services( raise typer.Exit(1) console.print(f"{CHECK} Platform running at {base_url} (pid {proc.pid})\n") + return "ready" # --------------------------------------------------------------------------- @@ -1142,11 +1303,12 @@ def _agent_config_path() -> Traversable | None: return None -def _agent_exists(base_url: str, workspace: str) -> bool: +def _agent_exists(base_url: str, workspace: str, headers: dict[str, str] | None = None) -> bool: """Return True if the demo agent already exists on the platform.""" try: resp = httpx.get( f"{base_url.rstrip('/')}/apis/agents/v2/workspaces/{workspace}/agents/{_DEMO_AGENT_NAME}", + headers=headers, timeout=10.0, ) return resp.status_code == 200 @@ -1154,11 +1316,12 @@ def _agent_exists(base_url: str, workspace: str) -> bool: return False -def _agents_api_ready(base_url: str, workspace: str) -> bool: +def _agents_api_ready(base_url: str, workspace: str, headers: dict[str, str] | None = None) -> bool: """Return True if the agents API is responding.""" try: resp = httpx.get( f"{base_url.rstrip('/')}/apis/agents/v2/workspaces/{workspace}/agents", + headers=headers, timeout=3.0, ) return resp.status_code == 200 @@ -1166,19 +1329,26 @@ def _agents_api_ready(base_url: str, workspace: str) -> bool: return False -def _deploy_demo_agent(base_url: str, workspace: str, config_path: Traversable, default_model: str) -> bool: +def _deploy_demo_agent( + base_url: str, + workspace: str, + config_path: Traversable, + default_model: str, + headers: dict[str, str] | None = None, +) -> bool: """Create and deploy the demo calculator agent. Returns True on success.""" # Optional plugin: import here so ``nemo setup`` works without nemo-agents installed. from nemo_agents_plugin.utils import expand_env_vars api_base = base_url.rstrip("/") - if not _agent_exists(base_url, workspace): + if not _agent_exists(base_url, workspace, headers=headers): config_dict = _yaml.safe_load(config_path.read_text(encoding="utf-8")) config_dict = expand_env_vars(config_dict, vars_dict={"NEMO_DEFAULT_MODEL": default_model}) payload = {"name": _DEMO_AGENT_NAME, "description": "Demo calculator agent", "config": config_dict} resp = httpx.post( f"{api_base}/apis/agents/v2/workspaces/{workspace}/agents", + headers=headers, json=payload, timeout=30.0, ) @@ -1189,6 +1359,7 @@ def _deploy_demo_agent(base_url: str, workspace: str, config_path: Traversable, resp = httpx.post( f"{api_base}/apis/agents/v2/workspaces/{workspace}/deployments", + headers=headers, json={"agent": _DEMO_AGENT_NAME}, timeout=30.0, ) @@ -1212,6 +1383,7 @@ def _deploy_demo_agent(base_url: str, workspace: str, config_path: Traversable, try: dep_resp = httpx.get( f"{api_base}/apis/agents/v2/workspaces/{workspace}/deployments/{deployment_name}", + headers=headers, timeout=3.0, ) if dep_resp.status_code == 200: @@ -1235,6 +1407,7 @@ def _maybe_deploy_agent( auto: bool, deploy_agent: bool | None, default_model: str | None = None, + headers: dict[str, str] | None = None, ) -> bool: """Optionally deploy the demo calculator agent. @@ -1289,7 +1462,7 @@ def _maybe_deploy_agent( while time.monotonic() < deadline: elapsed = int(time.monotonic() - start) spinner.update(f"[bold cyan]Waiting for agents API... ({elapsed}s)") - if _agents_api_ready(base_url, workspace): + if _agents_api_ready(base_url, workspace, headers=headers): api_ready = True break _pause(_AGENT_API_READINESS_POLL_INTERVAL) @@ -1299,7 +1472,13 @@ def _maybe_deploy_agent( return False try: - return _deploy_demo_agent(base_url, workspace, config_path, default_model=default_model) + return _deploy_demo_agent( + base_url, + workspace, + config_path, + default_model=default_model, + headers=headers, + ) except Exception as exc: console.print(f" {WARN} Agent deployment failed: {exc}") return False @@ -1373,9 +1552,9 @@ def _register_provider_interactive( default_extra_headers: dict[str, str] | None = None, ) -> None: """Create or update secret + provider for idempotent re-runs.""" - secret_name = f"{provider_name}-api-key" if api_key else None - - if secret_name: + secret_name: str | None = None + if api_key: + secret_name = f"{provider_name}-api-key" if _secret_exists(client, secret_name, workspace): _update_secret(client, secret_name, api_key, workspace) console.print(f" {CHECK} Updated secret '{secret_name}'") @@ -1649,12 +1828,22 @@ def setup_command( ), ] = None, ) -> None: - """Set up NeMo Platform: start services, configure a provider, install skills. + """Set up NeMo Platform: connect or start services, configure a provider, install skills. + + Uses an already-running platform, starts local services, or connects the + CLI to an existing remote deployment. Then selects and registers an + inference provider, picks a default model, installs coding agent skills, + and optionally deploys a demo agent. + + The active config context remembers the Platform URL. When a remote + deployment is already reachable, setup asks whether to continue with it, + start local services instead, or connect to a different remote URL. - Walks through starting local services, selecting a provider, entering - credentials, registering the provider with the platform, picking a - default model, installing coding agent skills, and optionally deploying - a demo agent. + To override the URL for one run only: + nemo --base-url http://localhost:8080 setup + + To persist a different URL: + nemo config set --base-url http://localhost:8080 Requires an interactive terminal (TTY). In non-interactive contexts (CI, piped input), pass --auto to use environment variables instead. @@ -1669,11 +1858,13 @@ def setup_command( nemo setup --auto nemo setup --auto --start-services --install-skills --deploy-agent nemo setup --auto --start-services --ready-timeout 360 + NMP_BASE_URL=https://nmp.example.com NMP_ACCESS_TOKEN=... nemo setup --auto --no-start-services nemo setup --workspace my-workspace nemo setup --no-install-skills --no-deploy-agent + nemo --base-url http://localhost:8080 setup """ cli_context: CLIContext = ctx.obj - base_url = cli_context.get_base_url() + base_url = cli_context.get_base_url() or DEFAULT_BASE_URL console.print("\n[bold cyan]NeMo Platform Setup[/bold cyan]\n") @@ -1684,7 +1875,32 @@ def setup_command( effective_timeout = _SERVICE_STARTUP_TIMEOUT_SECONDS if ready_timeout is None else ready_timeout if effective_timeout <= 0: raise typer.BadParameter("--ready-timeout must be greater than 0", param_hint="--ready-timeout") - _maybe_start_services(base_url, auto, start_services, timeout=effective_timeout) + try: + configured_base_url = base_url + service_result = _maybe_start_services(base_url, auto, start_services, timeout=effective_timeout) + if service_result == "start_local": + context_name = cli_context.get_sdk_context().context_name + _bootstrap_config_if_missing(DEFAULT_BASE_URL, workspace) + Config.write({"base_url": DEFAULT_BASE_URL}, context_name=context_name) + cli_context.overrides["base_url"] = DEFAULT_BASE_URL + cli_context.reset_sdk_context() + base_url = DEFAULT_BASE_URL + service_result = _maybe_start_services( + base_url, + auto, + start_services=True, + timeout=effective_timeout, + ) + if service_result == "connect_remote": + base_url = _prompt_remote_base_url(default_url=configured_base_url) + _bootstrap_config_if_missing(base_url, workspace) + cli_context.reset_sdk_context() + workspace = _resolve_setup_workspace(ctx, cli_context, workspace) + _configure_remote_connection(cli_context, base_url, workspace) + _ensure_platform_auth(cli_context) + except UserCancelled: + console.print(f"\n{WARN} Setup cancelled.") + raise typer.Exit(0) from None if not _check_platform_reachable_with_retries(base_url): console.print(f"\n{CROSS} Cannot reach platform at {base_url}") @@ -1797,7 +2013,14 @@ def _run_auto_mode( skills_scope=skills_scope, skills_from=skills_from, ) - _maybe_deploy_agent(base_url, workspace, auto=True, deploy_agent=deploy_agent, default_model=default_model) + _maybe_deploy_agent( + base_url, + workspace, + auto=True, + deploy_agent=deploy_agent, + default_model=default_model, + headers=_platform_request_headers(cli_context), + ) if _verify_platform_health(base_url): console.print(f"\n{CHECK} [green]Setup complete![/green]") @@ -1882,7 +2105,12 @@ def _run_interactive_mode( console.print("\n[bold]Step 7: Demo agent (optional)[/bold]\n") demo_deployed = _maybe_deploy_agent( - base_url, workspace, auto=False, deploy_agent=deploy_agent, default_model=default_model + base_url, + workspace, + auto=False, + deploy_agent=deploy_agent, + default_model=default_model, + headers=_platform_request_headers(cli_context), ) _print_onboarding(base_url, provider_name, default_model, demo_deployed=demo_deployed) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py b/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py index 7adf87c9a6..341d41924d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/config/config.py @@ -30,6 +30,33 @@ logger = logging.getLogger(__name__) +def _try_secure_chmod_dir(path: Path) -> None: + """Best-effort owner-only permissions on a directory (e.g. ``/tmp`` may reject chmod).""" + try: + os.chmod(path, stat.S_IRWXU) # 700 + except PermissionError: + pass + + +def _write_secure_yaml(path: Path, config_data: dict) -> None: + """Write YAML atomically with owner read/write permissions (600). + + A failure to lock down the file's permissions raises instead of being + swallowed, so credentials are never left readable by other users. + """ + mode = stat.S_IRUSR | stat.S_IWUSR # 600 + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(fd, "w") as f: + fd = -1 + yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) + finally: + if fd >= 0: + os.close(fd) + # Existing files keep prior mode under O_TRUNC; enforce 600 after write. + os.chmod(path, mode) + + @dataclass(frozen=True) class _RuntimeAccessTokenSource: token: str @@ -266,7 +293,7 @@ def save(self, config_path: Path | None = None) -> None: # Ensure parent directory exists with secure permissions (owner-only access) path.parent.mkdir(parents=True, exist_ok=True) - os.chmod(path.parent, stat.S_IRWXU) # 700 + _try_secure_chmod_dir(path.parent) # Serialize with secrets revealed using context config_data = self._config_file.model_dump( @@ -275,11 +302,7 @@ def save(self, config_path: Path | None = None) -> None: context={"include_secrets": True}, ) - with open(path, "w") as f: - yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - - # Set secure file permissions (owner read/write only) - os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 600 + _write_secure_yaml(path, config_data) # Update stored path if we saved to a new location self._config_path = path diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/quickstart/config.py b/packages/nemo_platform_ext/src/nemo_platform_ext/quickstart/config.py index 6a21562158..c7907c08dd 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/quickstart/config.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/quickstart/config.py @@ -18,6 +18,33 @@ from ._registry import image_registry_host from .gpu_config import parse_comma_separated_non_negative_integers + +def _try_secure_chmod_dir(path: Path) -> None: + """Best-effort owner-only permissions on a directory (e.g. ``/tmp`` may reject chmod).""" + try: + os.chmod(path, stat.S_IRWXU) # 700 + except PermissionError: + pass + + +def _write_secure_yaml(path: Path, config_data: dict) -> None: + """Write YAML atomically with owner read/write permissions (600). + + File permission failures remain fatal so credentials are never left world-readable. + """ + mode = stat.S_IRUSR | stat.S_IWUSR # 600 + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(fd, "w") as f: + fd = -1 + yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) + finally: + if fd >= 0: + os.close(fd) + # Existing files keep prior mode under O_TRUNC; enforce 600 after write. + os.chmod(path, mode) + + InferenceProviderType = Literal["nvidia-build", "host-gpu"] # Registry and repo placeholder for SDK-stamped nightly/milestone tags. @@ -228,7 +255,7 @@ def save(self, path: Path | None = None) -> None: # Ensure parent directory exists with secure permissions (owner-only access) config_path.parent.mkdir(parents=True, exist_ok=True) - os.chmod(config_path.parent, stat.S_IRWXU) # 700 + _try_secure_chmod_dir(config_path.parent) # Serialize with secrets revealed config_data = self.model_dump( @@ -246,11 +273,7 @@ def save(self, path: Path | None = None) -> None: if "platform_config_path" in config_data: config_data["platform_config_path"] = str(config_data["platform_config_path"]) - with open(config_path, "w") as f: - yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - - # Set secure file permissions (owner read/write only) - os.chmod(config_path, stat.S_IRUSR | stat.S_IWUSR) # 600 + _write_secure_yaml(config_path, config_data) @classmethod def remove(cls) -> None: diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py index 39d618ae7d..4fc204b1a0 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py @@ -175,7 +175,7 @@ def test_commands_uses_visible_command_order_with_discovered_plugins(self): assert result.exit_code == 0 command_rows = [line for line in result.stdout.splitlines() if line.startswith("| nemo ")] assert command_rows == [ - "| nemo setup | Setup | Set up NeMo Platform: start services, configure a provider, install skills. |", + "| nemo setup | Setup | Set up NeMo Platform: connect or start services, configure a provider, install skills. |", "| nemo services | Setup | Run platform services locally. |", "| nemo skills | Setup | Install AI agent skill files for Nemo. |", "| nemo chat | CLI functions | Start an interactive chat session with a model. |", diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_setup.py b/packages/nemo_platform_ext/tests/cli/commands/test_setup.py index bfc66f71e9..fa128545f1 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_setup.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_setup.py @@ -7,11 +7,16 @@ import logging import sys -from unittest.mock import MagicMock, patch +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch import httpx +import nemo_platform_ext.cli.commands.setup as setup_commands import pytest import typer +from click.core import ParameterSource from click.exceptions import Exit as ClickExit from nemo_platform.resources.inference.providers import ProvidersResource from nemo_platform_ext.cli.commands.setup import ( @@ -56,6 +61,7 @@ _register_provider_interactive, _render_onboarding_card, _resolve_provider_for_url, + _resolve_setup_workspace, _run_interactive_mode, _save_data_dir, _select_default_model, @@ -69,16 +75,22 @@ from nemo_platform_ext.cli.commands.skills import registry as skills_registry from nemo_platform_ext.cli.commands.skills.base import Scope, Skill from nemo_platform_ext.cli.commands.skills.registry import UnsupportedAgentError +from nemo_platform_ext.config.config import Config from nemo_platform_ext.config.models import ( + DEFAULT_BASE_URL, Cluster, ConfigFile, ConfigParams, Context, ContextDefinition, + NoAuthUser, + OAuthUser, ) from nemo_platform_ext.local.process import PortConflict +from nemo_platform_ext.ui.prompts import UserCancelled from nemo_platform_plugin.client.errors import NotFoundError from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest +from pydantic import SecretStr SETUP_MOD = "nemo_platform_ext.cli.commands.setup" @@ -156,20 +168,59 @@ def test_each_known_provider_resolves(self): class TestCheckPlatformReachable: - def test_reachable(self): + def test_reachable_via_status(self): mock_resp = MagicMock() mock_resp.status_code = 200 - with patch("nemo_platform_ext.cli.commands.setup.httpx.get", return_value=mock_resp): + with ( + patch(f"{SETUP_MOD}.client_verify_from_env", return_value="/tmp/custom-ca.pem"), + patch(f"{SETUP_MOD}.httpx.get", return_value=mock_resp) as mock_get, + ): assert _check_platform_reachable("http://localhost:8080") is True + mock_get.assert_called_once_with( + "http://localhost:8080/status", + timeout=5.0, + verify="/tmp/custom-ca.pem", + ) + + def test_reachable_via_cluster_info_when_status_missing(self): + status_resp = MagicMock() + status_resp.status_code = 404 + cluster_resp = MagicMock() + cluster_resp.status_code = 200 + calls: list[tuple[str, object]] = [] + + def _get(url, **kwargs): + calls.append((url, kwargs.get("verify"))) + if url.endswith("/status"): + return status_resp + if url.endswith("/cluster-info"): + return cluster_resp + raise AssertionError(f"unexpected url: {url}") + + with ( + patch(f"{SETUP_MOD}.client_verify_from_env", return_value="/tmp/custom-ca.pem"), + patch(f"{SETUP_MOD}.httpx.get", side_effect=_get), + ): + assert _check_platform_reachable("https://nemo-platform-freeplay.dev.aire.nvidia.com") is True + assert calls == [ + ("https://nemo-platform-freeplay.dev.aire.nvidia.com/status", "/tmp/custom-ca.pem"), + ("https://nemo-platform-freeplay.dev.aire.nvidia.com/cluster-info", "/tmp/custom-ca.pem"), + ] + + def test_unreachable_when_all_probes_fail(self): + mock_resp = MagicMock() + mock_resp.status_code = 404 + with patch(f"{SETUP_MOD}.httpx.get", return_value=mock_resp): + assert _check_platform_reachable("http://localhost:8080") is False def test_unreachable(self): - with patch("nemo_platform_ext.cli.commands.setup.httpx.get", side_effect=Exception("conn refused")): + with patch(f"{SETUP_MOD}.httpx.get", side_effect=Exception("conn refused")): assert _check_platform_reachable("http://localhost:8080") is False - def test_non_200_status(self): + def test_non_200_status_without_cluster_info_fallback(self): mock_resp = MagicMock() mock_resp.status_code = 503 - with patch("nemo_platform_ext.cli.commands.setup.httpx.get", return_value=mock_resp): + with patch(f"{SETUP_MOD}.httpx.get", return_value=mock_resp): assert _check_platform_reachable("http://localhost:8080") is False @@ -545,6 +596,94 @@ def test_skips_when_running_and_explicitly_false(self): with patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True): _maybe_start_services("http://localhost:8080", auto=False, start_services=False) + def test_returns_remote_choice_without_starting_services(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="remote") as mock_prompt, + patch(f"{SETUP_MOD}._start_services_background") as mock_start, + ): + result = _maybe_start_services("http://localhost:8080", auto=False, start_services=None) + + assert result == "connect_remote" + assert mock_prompt.call_args.kwargs["options"] == [ + ("yes", "Yes, start services now"), + ("remote", "No, I want to connect to a remote Platform instance"), + ("manual", "No, I'll start them myself"), + ] + mock_start.assert_not_called() + + def test_start_myself_choice_keeps_existing_exit(self, capsys): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="manual"), + pytest.raises(ClickExit), + ): + _maybe_start_services("http://localhost:8080", auto=False, start_services=None) + + assert "Start the platform first" in capsys.readouterr().err + + def test_unreachable_remote_url_selects_remote_connection_without_local_prompt(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice") as mock_prompt, + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "connect_remote" + mock_prompt.assert_not_called() + + def test_reachable_remote_prompts_continue(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice", return_value="continue") as mock_prompt, + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "ready" + mock_prompt.assert_called_once() + assert ( + mock_prompt.call_args.kwargs["message"] + == "Platform reachable at remote.example.com (https://remote.example.com). What would you like to do?" + ) + + def test_reachable_remote_start_local(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice", return_value="local"), + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "start_local" + + def test_reachable_remote_change_remote(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice", return_value="change"), + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "connect_remote" + + def test_reachable_remote_auto_skips_prompt(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice") as mock_prompt, + ): + result = _maybe_start_services("https://remote.example.com", auto=True, start_services=None) + + assert result == "ready" + mock_prompt.assert_not_called() + + def test_rejects_start_services_for_remote_url(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}._start_services_background") as mock_start, + pytest.raises(typer.BadParameter, match="local Platform URL"), + ): + _maybe_start_services("https://remote.example.com", auto=False, start_services=True) + + mock_start.assert_not_called() + def test_restarts_when_running_and_start_services_true(self): reachable_calls = [True, True, False, True] @@ -622,6 +761,189 @@ def test_allows_start_when_port_free(self, maybe_start_preflight_mocks): maybe_start_preflight_mocks.assert_called_once() +class TestRemoteConnection: + def test_prompts_again_until_platform_is_reachable(self, capsys): + with ( + patch( + f"{SETUP_MOD}.prompt_text", + side_effect=["https://unreachable.example.com", "https://remote.example.com/"], + ), + patch( + f"{SETUP_MOD}._check_platform_reachable_with_retries", + side_effect=[False, True], + ), + ): + base_url = setup_commands._prompt_remote_base_url() + + assert base_url == "https://remote.example.com" + assert "Unable to connect" in capsys.readouterr().err + + def test_persists_remote_url_and_workspace_in_active_context(self, tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + cli_context = MagicMock() + cli_context.overrides = {} + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url="http://localhost:8080"), + user=NoAuthUser(name="default-user"), + workspace="default", + preferences={}, + ) + + setup_commands._configure_remote_connection( + cli_context, + "https://remote.example.com", + "shared-workspace", + ) + + context = Config.load(config_path=config_path).resolve() + assert str(context.cluster.base_url) == "https://remote.example.com/" + assert context.workspace == "shared-workspace" + cli_context.reset_sdk_context.assert_called_once_with() + + def test_updates_selected_context_and_runtime_url_override(self, tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + Config.write({"base_url": "https://default.example.com"}) + Config.write({"base_url": "https://old-dev.example.com"}, context_name="dev") + + cli_context = MagicMock() + cli_context.overrides = { + "current_context": "dev", + "base_url": "https://stale-override.example.com", + } + cli_context.get_sdk_context.return_value = Context( + context_name="dev", + cluster=Cluster(name="dev-cluster", base_url="https://stale-override.example.com"), + user=NoAuthUser(name="dev-user"), + workspace="default", + preferences={}, + ) + + setup_commands._configure_remote_connection( + cli_context, + "https://new-dev.example.com", + "shared-workspace", + ) + + config_file = Config.load(config_path=config_path).get_config_file() + default_cluster = next(cluster for cluster in config_file.clusters if cluster.name == "default-cluster") + dev_cluster = next(cluster for cluster in config_file.clusters if cluster.name == "dev-cluster") + assert str(default_cluster.base_url) == "https://default.example.com/" + assert str(dev_cluster.base_url) == "https://new-dev.example.com/" + assert cli_context.overrides["base_url"] == "https://new-dev.example.com" + + def test_preserves_active_workspace_when_flag_not_explicit(self): + ctx = MagicMock(spec=typer.Context) + ctx.get_parameter_source.return_value = ParameterSource.DEFAULT + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url="http://localhost:8080"), + user=NoAuthUser(name="default-user"), + workspace="team-a", + preferences={}, + ) + + assert _resolve_setup_workspace(ctx, cli_context, "default") == "team-a" + ctx.get_parameter_source.assert_called_once_with("workspace") + + def test_uses_explicit_workspace_flag_over_active_context(self): + ctx = MagicMock(spec=typer.Context) + ctx.get_parameter_source.return_value = ParameterSource.COMMANDLINE + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url="http://localhost:8080"), + user=NoAuthUser(name="default-user"), + workspace="team-a", + preferences={}, + ) + + assert _resolve_setup_workspace(ctx, cli_context, "shared-workspace") == "shared-workspace" + + def test_authenticates_when_context_has_no_credentials(self): + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=NoAuthUser(name="default-user"), + workspace="default", + preferences={}, + ) + + with patch("nemo_platform_ext.cli.commands.auth._login_with_oidc", return_value=True) as mock_login: + setup_commands._ensure_platform_auth(cli_context) + + mock_login.assert_called_once_with(cli_context, selected_context="default") + cli_context.reset_sdk_context.assert_called_once_with() + + def test_reauthenticates_stored_context_credentials_for_new_remote(self): + cli_context = MagicMock() + context = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=OAuthUser(name="default-user", token=SecretStr("token")), + workspace="default", + preferences={}, + ) + cli_context.get_sdk_context.return_value = context + + with patch("nemo_platform_ext.cli.commands.auth._login_with_oidc", return_value=True) as mock_login: + setup_commands._ensure_platform_auth(cli_context) + + mock_login.assert_called_once_with(cli_context, selected_context="default") + cli_context.reset_sdk_context.assert_called_once_with() + + def test_reuses_runtime_access_token_override(self, monkeypatch): + monkeypatch.setenv("NMP_ACCESS_TOKEN", "runtime-token") + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=OAuthUser(name="default-user", token=SecretStr("runtime-token")), + workspace="default", + preferences={}, + ) + + with patch("nemo_platform_ext.cli.commands.auth._login_with_oidc") as mock_login: + setup_commands._ensure_platform_auth(cli_context) + + mock_login.assert_not_called() + + def test_clears_stale_credentials_when_remote_auth_is_disabled(self, tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + monkeypatch.delenv("NMP_ACCESS_TOKEN", raising=False) + Config.write( + { + "base_url": "https://remote.example.com", + "access_token": "old-cluster-token", + } + ) + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Config.load(config_path=config_path).resolve() + + with patch("nemo_platform_ext.cli.commands.auth._login_with_oidc", return_value=False): + setup_commands._ensure_platform_auth(cli_context) + + assert isinstance(Config.load(config_path=config_path).resolve().user, NoAuthUser) + cli_context.reset_sdk_context.assert_called_once_with() + + def test_platform_request_headers_include_context_token(self): + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=OAuthUser(name="default-user", token=SecretStr("remote-token")), + workspace="default", + preferences={}, + ) + + assert setup_commands._platform_request_headers(cli_context) == {"Authorization": "Bearer remote-token"} + + class TestLocalDataDirHelpers: """Tests for the XDG-default data-dir helpers used by `nemo setup`.""" @@ -2253,6 +2575,88 @@ def test_prompt_passes_hint(self): assert "lowercase" in hint.lower() +# --------------------------------------------------------------------------- +# setup_command entry-path helpers +# --------------------------------------------------------------------------- + + +def _make_setup_command_ctx( + *, + base_url: str = "http://localhost:8080", + workspace: str = "default", + workspace_source: ParameterSource = ParameterSource.DEFAULT, +) -> tuple[MagicMock, MagicMock]: + """Build a typer Context + CLIContext pair for invoking ``setup_command``.""" + ctx = MagicMock(spec=typer.Context) + ctx.get_parameter_source.return_value = workspace_source + cli_context = MagicMock() + cli_context.overrides = {} + cli_context.get_base_url.return_value = base_url + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url=base_url), + user=NoAuthUser(name="default-user"), + workspace=workspace, + preferences={}, + ) + cli_context.get_client.return_value.workspaces.retrieve.return_value = MagicMock() + ctx.obj = cli_context + return ctx, cli_context + + +@contextmanager +def _patch_setup_command( + *, + interactive: bool = True, + maybe_start_services: object | None = None, + remote_url: str | None = None, + include_config_write: bool = False, + auto_mode: bool = False, +) -> Iterator[SimpleNamespace]: + """Patch the common ``setup_command`` entry path and yield named mocks. + + When *remote_url* is set, also patches the connect-remote branch helpers and + defaults ``_maybe_start_services`` to return ``\"connect_remote\"``. + """ + maybe_start_kwargs: dict[str, object] = {} + if maybe_start_services is None: + if remote_url is not None: + maybe_start_kwargs["return_value"] = "connect_remote" + elif isinstance(maybe_start_services, list): + maybe_start_kwargs["side_effect"] = maybe_start_services + elif isinstance(maybe_start_services, MagicMock): + maybe_start_kwargs["new"] = maybe_start_services + else: + maybe_start_kwargs["return_value"] = maybe_start_services + + with ExitStack() as stack: + mocks = SimpleNamespace( + is_interactive=stack.enter_context(patch(f"{SETUP_MOD}.is_interactive", return_value=interactive)), + maybe_start_services=stack.enter_context(patch(f"{SETUP_MOD}._maybe_start_services", **maybe_start_kwargs)), + check_reachable=stack.enter_context( + patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True) + ), + bootstrap=stack.enter_context(patch(f"{SETUP_MOD}._bootstrap_config_if_missing")), + run_interactive=stack.enter_context(patch(f"{SETUP_MOD}._run_interactive_mode")), + prompt_remote=None, + configure_remote=None, + ensure_auth=None, + config_write=None, + run_auto=None, + ) + if remote_url is not None: + mocks.prompt_remote = stack.enter_context( + patch(f"{SETUP_MOD}._prompt_remote_base_url", return_value=remote_url) + ) + mocks.configure_remote = stack.enter_context(patch(f"{SETUP_MOD}._configure_remote_connection")) + mocks.ensure_auth = stack.enter_context(patch(f"{SETUP_MOD}._ensure_platform_auth")) + if include_config_write: + mocks.config_write = stack.enter_context(patch(f"{SETUP_MOD}.Config.write")) + if auto_mode: + mocks.run_auto = stack.enter_context(patch(f"{SETUP_MOD}._run_auto_mode")) + yield mocks + + # --------------------------------------------------------------------------- # Non-TTY early exit guard # --------------------------------------------------------------------------- @@ -2261,41 +2665,91 @@ def test_prompt_passes_hint(self): class TestNonTtyEarlyExit: """setup_command must exit(1) when stdin is not a TTY and --auto is not passed.""" - def _invoke(self, *, auto: bool = False): - """Invoke setup_command with a minimal mock context.""" - ctx = MagicMock(spec=typer.Context) - cli_context = MagicMock() - cli_context.get_base_url.return_value = "http://localhost:8080" - ctx.obj = cli_context - setup_command(ctx, auto=auto) - def test_exits_when_non_tty_without_auto(self): + ctx, _cli_context = _make_setup_command_ctx() with ( - patch(f"{SETUP_MOD}.is_interactive", return_value=False), + _patch_setup_command(interactive=False), pytest.raises(typer.Exit) as exc_info, ): - self._invoke(auto=False) + setup_command(ctx, auto=False) assert exc_info.value.exit_code == 1 def test_proceeds_when_non_tty_with_auto(self): - with ( - patch(f"{SETUP_MOD}.is_interactive", return_value=False), - patch(f"{SETUP_MOD}._maybe_start_services"), - patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True), - patch(f"{SETUP_MOD}._bootstrap_config_if_missing"), - patch(f"{SETUP_MOD}._run_auto_mode"), - ): - self._invoke(auto=True) + ctx, _cli_context = _make_setup_command_ctx() + with _patch_setup_command(interactive=False, auto_mode=True): + setup_command(ctx, auto=True) def test_proceeds_when_tty_without_auto(self): + ctx, _cli_context = _make_setup_command_ctx() + with _patch_setup_command(): + setup_command(ctx, auto=False) + + def test_cancelling_initial_connection_prompt_exits_cleanly(self, capsys): + ctx, _cli_context = _make_setup_command_ctx() with ( - patch(f"{SETUP_MOD}.is_interactive", return_value=True), - patch(f"{SETUP_MOD}._maybe_start_services"), - patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True), - patch(f"{SETUP_MOD}._bootstrap_config_if_missing"), - patch(f"{SETUP_MOD}._run_interactive_mode"), + _patch_setup_command(maybe_start_services=MagicMock(side_effect=UserCancelled)), + pytest.raises(typer.Exit) as exc_info, ): - self._invoke(auto=False) + setup_command(ctx, auto=False) + + assert exc_info.value.exit_code == 0 + assert "Setup cancelled" in capsys.readouterr().err + + +class TestSetupCommandRemoteFlow: + def test_remote_choice_connects_before_continuing_setup(self): + ctx, cli_context = _make_setup_command_ctx() + with _patch_setup_command(remote_url="https://remote.example.com") as mocks: + setup_command(ctx) + + mocks.prompt_remote.assert_called_once_with(default_url="http://localhost:8080") + mocks.configure_remote.assert_called_once_with(cli_context, "https://remote.example.com", "default") + mocks.ensure_auth.assert_called_once_with(cli_context) + assert mocks.run_interactive.call_args.args[3] == "https://remote.example.com" + + def test_remote_choice_preserves_active_workspace_when_flag_omitted(self): + ctx, cli_context = _make_setup_command_ctx(workspace="team-a") + with _patch_setup_command(remote_url="https://remote.example.com") as mocks: + setup_command(ctx) + + mocks.configure_remote.assert_called_once_with(cli_context, "https://remote.example.com", "team-a") + assert mocks.bootstrap.call_args_list == [ + call("https://remote.example.com", "default"), + call("https://remote.example.com", "team-a"), + ] + assert mocks.run_interactive.call_args.args[2] == "team-a" + + def test_remote_choice_uses_explicit_workspace_flag(self): + ctx, cli_context = _make_setup_command_ctx( + workspace="team-a", + workspace_source=ParameterSource.COMMANDLINE, + ) + with _patch_setup_command(remote_url="https://remote.example.com") as mocks: + setup_command(ctx, workspace="shared-workspace") + + mocks.configure_remote.assert_called_once_with(cli_context, "https://remote.example.com", "shared-workspace") + assert mocks.run_interactive.call_args.args[2] == "shared-workspace" + + def test_start_local_persists_default_url_and_starts_services(self): + ctx, cli_context = _make_setup_command_ctx(base_url="https://remote.example.com") + with _patch_setup_command( + maybe_start_services=["start_local", "ready"], + include_config_write=True, + ) as mocks: + setup_command(ctx) + + assert mocks.maybe_start_services.call_count == 2 + assert mocks.maybe_start_services.call_args_list[1] == call( + DEFAULT_BASE_URL, + False, + start_services=True, + timeout=_SERVICE_STARTUP_TIMEOUT_SECONDS, + ) + mocks.bootstrap.assert_any_call(DEFAULT_BASE_URL, "default") + mocks.config_write.assert_called_once_with({"base_url": DEFAULT_BASE_URL}, context_name="default") + assert cli_context.overrides["base_url"] == DEFAULT_BASE_URL + cli_context.reset_sdk_context.assert_called() + assert mocks.run_interactive.call_args.args[3] == DEFAULT_BASE_URL # --------------------------------------------------------------------------- @@ -2369,6 +2823,42 @@ def test_non_200_response(self): ok, _ = _check_controller_health("http://localhost:8080") assert ok is False + def test_status_not_published_on_hosted_deployment(self): + status_resp = MagicMock() + status_resp.status_code = 404 + cluster_resp = MagicMock() + cluster_resp.status_code = 200 + + def _get(url, **kwargs): + if url.endswith("/status"): + return status_resp + if url.endswith("/cluster-info"): + return cluster_resp + raise AssertionError(f"unexpected url: {url}") + + with patch(f"{SETUP_MOD}.httpx.get", side_effect=_get): + ok, msg = _check_controller_health("https://nemo-platform-freeplay.dev.aire.nvidia.com") + assert ok is True + assert "does not publish /status" in msg + + def test_status_404_without_cluster_info_is_unhealthy(self): + status_resp = MagicMock() + status_resp.status_code = 404 + cluster_resp = MagicMock() + cluster_resp.status_code = 404 + + def _get(url, **kwargs): + if url.endswith("/status"): + return status_resp + if url.endswith("/cluster-info"): + return cluster_resp + raise AssertionError(f"unexpected url: {url}") + + with patch(f"{SETUP_MOD}.httpx.get", side_effect=_get): + ok, msg = _check_controller_health("http://localhost:8080") + assert ok is False + assert "404" in msg + def test_invalid_json_response(self): resp = MagicMock() resp.status_code = 200 @@ -2406,6 +2896,21 @@ def test_healthy_returns_true(self): result = _verify_platform_health("http://localhost:8080") assert result is True + def test_missing_status_endpoint_returns_true_with_info(self): + with ( + patch( + f"{SETUP_MOD}._check_controller_health", + return_value=(True, "Hosted deployment does not publish /status."), + ), + patch(f"{SETUP_MOD}.console") as mock_console, + ): + result = _verify_platform_health("https://nemo-platform-freeplay.dev.aire.nvidia.com") + assert result is True + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "does not publish /status" in printed + assert "could not be verified" not in printed + assert "yellow" not in printed.lower() + def test_unhealthy_prints_red_error(self): with ( patch(f"{SETUP_MOD}._check_controller_health", return_value=(False, "models_controller unhealthy")), diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_setup_cli.py b/packages/nemo_platform_ext/tests/cli/commands/test_setup_cli.py new file mode 100644 index 0000000000..9ffb653682 --- /dev/null +++ b/packages/nemo_platform_ext/tests/cli/commands/test_setup_cli.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CLI integration tests for ``nemo setup`` connection selection.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from nemo_platform_ext.cli.app import app +from nemo_platform_ext.cli.core.context import CLIContext +from nemo_platform_ext.config.config import Config +from typer.testing import CliRunner + +SETUP_MOD = "nemo_platform_ext.cli.commands.setup" + + +def test_remote_choice_retries_and_persists_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.touch() + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + client = MagicMock() + client.workspaces.retrieve.return_value = MagicMock() + + with ( + patch(f"{SETUP_MOD}.is_interactive", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="remote"), + patch( + f"{SETUP_MOD}.prompt_text", + side_effect=["https://unreachable.example.com", "https://remote.example.com/"], + ), + patch( + f"{SETUP_MOD}._check_platform_reachable_with_retries", + side_effect=[False, True, True], + ), + patch(f"{SETUP_MOD}._ensure_platform_auth") as ensure_auth, + patch(f"{SETUP_MOD}._start_services_background") as start_services, + patch.object(CLIContext, "get_client", return_value=client), + patch(f"{SETUP_MOD}._run_interactive_mode") as run_interactive, + ): + result = CliRunner().invoke(app, ["setup"]) + + assert result.exit_code == 0, result.output + assert "Unable to connect to NeMo Platform at https://unreachable.example.com" in result.output + context = Config.load(config_path=config_path).resolve() + assert str(context.cluster.base_url) == "https://remote.example.com/" + start_services.assert_not_called() + ensure_auth.assert_called_once() + assert run_interactive.call_args.args[3] == "https://remote.example.com" + + +def test_local_choice_starts_services_and_keeps_local_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.touch() + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + client = MagicMock() + client.workspaces.retrieve.return_value = MagicMock() + process = MagicMock(pid=1234) + + with ( + patch(f"{SETUP_MOD}.is_interactive", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="yes"), + patch(f"{SETUP_MOD}._prompt_data_dir", return_value="/tmp/nemo-demo"), + patch(f"{SETUP_MOD}.importlib.util.find_spec", return_value=MagicMock()), + patch(f"{SETUP_MOD}._ensure_port_available_for_start"), + patch(f"{SETUP_MOD}._start_services_background", return_value=process) as start_services, + patch(f"{SETUP_MOD}._wait_for_platform", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True), + patch.object(CLIContext, "get_client", return_value=client), + patch(f"{SETUP_MOD}._run_interactive_mode") as run_interactive, + ): + result = CliRunner().invoke(app, ["setup"]) + + assert result.exit_code == 0, result.output + context = Config.load(config_path=config_path).resolve() + assert str(context.cluster.base_url) == "http://localhost:8080/" + start_services.assert_called_once() + assert run_interactive.call_args.args[3].rstrip("/") == "http://localhost:8080" + + +def test_start_myself_exits_without_starting_or_mutating_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.touch() + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + with ( + patch(f"{SETUP_MOD}.is_interactive", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="manual"), + patch(f"{SETUP_MOD}._start_services_background") as start_services, + ): + result = CliRunner().invoke(app, ["setup"]) + + assert result.exit_code == 1 + assert "Start the platform first" in result.output + assert config_path.read_text() == "" + start_services.assert_not_called() diff --git a/packages/nemo_platform_ext/tests/cli/test_app.py b/packages/nemo_platform_ext/tests/cli/test_app.py index 55b1bf0da6..dad582eaa2 100644 --- a/packages/nemo_platform_ext/tests/cli/test_app.py +++ b/packages/nemo_platform_ext/tests/cli/test_app.py @@ -53,7 +53,7 @@ def test_help_includes_getting_started(): assert "nemo docs --list" in result.stdout assert "nemo services run --help" in result.stdout # Help panel truncates long command descriptions; match the visible prefix. - assert "Set up NeMo Platform: start services" in result.stdout + assert "Set up NeMo Platform: connect or start services" in result.stdout assert "--help, -h" in result.stdout assert "nemo auth login --base-url" not in result.stdout assert "nemo quickstart configure" not in result.stdout diff --git a/packages/nemo_platform_ext/tests/config/test_config.py b/packages/nemo_platform_ext/tests/config/test_config.py index 652c879681..49bc1ae069 100644 --- a/packages/nemo_platform_ext/tests/config/test_config.py +++ b/packages/nemo_platform_ext/tests/config/test_config.py @@ -3,7 +3,9 @@ """Unit tests for the config module.""" +import os from pathlib import Path +from unittest.mock import patch import pytest import yaml @@ -865,6 +867,49 @@ def test_save_creates_file_with_600_permissions(self, tmp_path: Path): file_mode = config_path.stat().st_mode & 0o777 assert file_mode == 0o600, f"Expected 600, got {oct(file_mode)}" + def test_save_tolerates_unowned_parent_directory(self, tmp_path: Path): + """Saving under a world-writable parent (e.g. /tmp) should not fail on dir chmod.""" + config_path = tmp_path / "config.yaml" + + real_chmod = os.chmod + + def chmod_side_effect(path, mode): + if Path(path) == tmp_path: + raise PermissionError("Operation not permitted") + real_chmod(path, mode) + + with patch("nemo_platform_ext.config.config.os.chmod", side_effect=chmod_side_effect): + Config.write( + {"base_url": "http://test.example.com"}, + context_name="default", + config_path=config_path, + ) + + assert config_path.exists() + file_mode = config_path.stat().st_mode & 0o777 + assert file_mode == 0o600, f"Expected 600, got {oct(file_mode)}" + + def test_save_raises_when_file_chmod_fails(self, tmp_path: Path): + """Credential file permission failures must remain fatal.""" + config_path = tmp_path / "config.yaml" + + real_chmod = os.chmod + + def chmod_side_effect(path, mode): + if Path(path) == config_path: + raise PermissionError("Operation not permitted") + real_chmod(path, mode) + + with ( + patch("nemo_platform_ext.config.config.os.chmod", side_effect=chmod_side_effect), + pytest.raises(PermissionError), + ): + Config.write( + {"base_url": "http://test.example.com"}, + context_name="default", + config_path=config_path, + ) + class TestUserTypeDiscriminator: """Test User type discriminator and deserialization.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py index 4bf510bb51..b8b04cff48 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py @@ -11,11 +11,13 @@ import asyncio import logging +import os import time from typing import Annotated, cast import httpx import typer +from rich.console import Console from nemo_platform.auth.helpers import ( AuthError, @@ -31,6 +33,7 @@ from nemo_platform.cli.core.context import CLIContext from nemo_platform.cli.core.errors import handle_errors from nemo_platform.cli.core.help_formatter import create_typer_app +from nemo_platform.config.config import Config from nemo_platform.config.models import ConfigParams, Context app = create_typer_app( @@ -156,6 +159,168 @@ def auth_callback(ctx: typer.Context) -> None: typer.echo(ctx.get_help()) +def _login_with_oidc( + cli_context: CLIContext, + *, + no_browser: bool = False, + scope: str | None = None, + username: str | None = None, + password: str | None = None, + selected_context: str | None = None, +) -> bool: + """Authenticate the selected context using the cluster's OIDC configuration. + + Returns ``False`` when cluster authentication is disabled. + """ + from nemo_platform.auth.device_flow import ( + DeviceFlowError, + authenticate_with_device_flow, + authenticate_with_password_grant, + ) + + console = Console() + context = cli_context.get_sdk_context() + base_url = str(context.cluster.base_url).rstrip("/") + + console.print(f"\nDiscovering auth configuration from {base_url}...") + + try: + oidc_config = discover_nmp_config(base_url) + except httpx.HTTPError as exc: + raise AuthError(f"Failed to discover auth configuration: {exc}") from exc + + if not oidc_config.auth_enabled: + console.print("[yellow]Authentication is not enabled on this cluster.[/]") + console.print("You can use the API without authentication.") + return False + + if not oidc_config.token_endpoint: + raise AuthError( + "This cluster does not have OIDC token endpoint configured.\n" + "Use OIDC configuration for device/password login, or for local testing use:\n" + "nemo auth login --unsigned-token --email " + ) + + login_username = username or os.environ.get("NMP_OIDC_USERNAME") + login_password = password or os.environ.get("NMP_OIDC_PASSWORD") + use_password_grant = bool(login_username and login_password) + + if use_password_grant: + if not oidc_config.client_id: + raise AuthError("OIDC client_id is required for password grant.") + elif not oidc_config.device_authorization_endpoint: + raise AuthError( + "This cluster does not support device flow authentication.\n" + "For non-interactive login use: nemo auth login --username --password \n" + "Or set NMP_OIDC_USERNAME and NMP_OIDC_PASSWORD (e.g. in CI)." + ) + + console.print(f"[green]Found OIDC configuration[/] (issuer: {oidc_config.issuer})") + + raw_defaults = oidc_config.default_scopes + default_baseline = " ".join(item for item in raw_defaults.split() if ":" not in item) + if scope: + seen: set[str] = set() + parts: list[str] = [] + for item in default_baseline.split(): + if item not in seen: + seen.add(item) + parts.append(item) + for item in scope.split(): + if item not in seen: + seen.add(item) + parts.append(item) + requested_scopes = " ".join(parts) + else: + requested_scopes = raw_defaults + + scope_prefix = normalize_scope_prefix(oidc_config.scope_prefix) + effective_scope = build_effective_scope(requested_scopes, oidc_config.scope_prefix) + + console.print("\n[bold]Requesting scopes:[/]") + for requested_scope in requested_scopes.split(): + if scope_prefix and (":" in requested_scope or requested_scope.endswith(".default")): + console.print(f" [cyan]{requested_scope}[/] [dim]({scope_prefix}{requested_scope})[/]") + else: + console.print(f" [cyan]{requested_scope}[/]") + console.print() + + if use_password_grant: + if login_username is None or login_password is None: + raise AuthError("Username and password are required for password grant.") + client_id = cast(str, oidc_config.client_id) + try: + token_response = authenticate_with_password_grant( + token_endpoint=oidc_config.token_endpoint, + client_id=client_id, + username=login_username, + password=login_password, + scope=effective_scope, + ) + except DeviceFlowError as exc: + raise AuthError(f"Authentication failed: {exc}") from exc + else: + if oidc_config.device_authorization_endpoint is None: + raise AuthError("This cluster does not support device flow authentication.") + client_id = cast(str, oidc_config.client_id) + try: + token_response = asyncio.run( + authenticate_with_device_flow( + device_authorization_endpoint=oidc_config.device_authorization_endpoint, + token_endpoint=oidc_config.token_endpoint, + client_id=client_id, + scope=effective_scope, + open_browser=not no_browser, + ) + ) + except DeviceFlowError as exc: + raise AuthError(f"Authentication failed: {exc}") from exc + + token = token_response.token_for_nmp + claims = decode_jwt_claims(token) + user_email = claims.get("upn") or claims.get("email") or claims.get("preferred_username") + raw_granted_scopes = claims.get("scp") or claims.get("scope") + granted_scopes: list[str] = [] + if isinstance(raw_granted_scopes, str): + granted_scopes = raw_granted_scopes.split() + elif isinstance(raw_granted_scopes, list): + granted_scopes = [item for item in raw_granted_scopes if isinstance(item, str)] + + validate_requested_scopes_granted(effective_scope, granted_scopes, scope_prefix) + + config_params: ConfigParams = {"access_token": token} + if token_response.refresh_token: + config_params["refresh_token"] = token_response.refresh_token + if selected_context is not None: + config_params["current_context"] = context.context_name + Config.write(config_params, context_name=context.context_name) + + console.print("\n[bold green]Authentication successful![/]") + if user_email: + console.print(f" Logged in as: [cyan]{user_email}[/]") + + if granted_scopes: + display_scopes = [ + item[len(scope_prefix) :] if scope_prefix and item.startswith(scope_prefix) else item + for item in granted_scopes + ] + console.print(f" Granted scopes: [cyan]{' '.join(display_scopes)}[/]") + + if token_response.refresh_token: + console.print(" Refresh token: [green]saved[/] (enables automatic token renewal)") + else: + console.print(" Refresh token: [yellow]not available[/] (add 'offline_access' scope to enable)") + + console.print("\n[bold green]Credentials saved to config file.[/]") + if runtime_token_source := _runtime_token_source_label(): + console.print( + f"[yellow]Warning:[/] {runtime_token_source} is active and will override these saved credentials. " + "Unset the runtime token override to use this login for future commands." + ) + console.print("\n[dim]Run 'nemo workspaces list' to verify your access.[/]") + return True + + @app.command("login") @handle_errors def login( @@ -274,17 +439,6 @@ def login( # Device flow, show code only nemo auth login --no-browser """ - import os - - from rich.console import Console - - from nemo_platform.auth.device_flow import ( - DeviceFlowError, - authenticate_with_device_flow, - authenticate_with_password_grant, - ) - from nemo_platform.config.config import Config - cli_context: CLIContext = ctx.obj selected_context = cli_context.overrides.get("current_context") @@ -376,155 +530,16 @@ def login( console.print("\n[dim]Run 'nemo auth status' to inspect the token.[/]") return - context = cli_context.get_sdk_context() - base_url = str(context.cluster.base_url).rstrip("/") - - console.print(f"\nDiscovering auth configuration from {base_url}...") - - try: - oidc_config = discover_nmp_config(base_url) - except httpx.HTTPError as exc: - raise AuthError(f"Failed to discover auth configuration: {exc}") from exc - - if not oidc_config.auth_enabled: - console.print("[yellow]Authentication is not enabled on this cluster.[/]") - console.print("You can use the API without authentication.") + if not _login_with_oidc( + cli_context, + no_browser=no_browser, + scope=scope, + username=username, + password=password, + selected_context=selected_context, + ): raise typer.Exit(0) - if not oidc_config.token_endpoint: - raise AuthError( - "This cluster does not have OIDC token endpoint configured.\n" - "Use OIDC configuration for device/password login, or for local testing use:\n" - "nemo auth login --unsigned-token --email " - ) - - login_username = username or os.environ.get("NMP_OIDC_USERNAME") - login_password = password or os.environ.get("NMP_OIDC_PASSWORD") - use_password_grant = bool(login_username and login_password) - - if use_password_grant: - if not oidc_config.client_id: - raise AuthError("OIDC client_id is required for password grant.") - else: - if not oidc_config.device_authorization_endpoint: - raise AuthError( - "This cluster does not support device flow authentication.\n" - "For non-interactive login use: nemo auth login --username --password \n" - "Or set NMP_OIDC_USERNAME and NMP_OIDC_PASSWORD (e.g. in CI)." - ) - - console.print(f"[green]Found OIDC configuration[/] (issuer: {oidc_config.issuer})") - - # Use only generic scopes from cluster defaults (exclude platform/custom scopes like platform:read) - # so platform scopes come only from --scope. Merge with --scope if provided. - raw_defaults = oidc_config.default_scopes - default_baseline = " ".join(s for s in raw_defaults.split() if ":" not in s) - if scope: - seen: set[str] = set() - parts: list[str] = [] - for s in default_baseline.split(): - if s not in seen: - seen.add(s) - parts.append(s) - for s in scope.split(): - if s not in seen: - seen.add(s) - parts.append(s) - requested_scopes = " ".join(parts) - else: - requested_scopes = raw_defaults - - scope_prefix = normalize_scope_prefix(oidc_config.scope_prefix) - effective_scope = build_effective_scope(requested_scopes, oidc_config.scope_prefix) - - # Display the scopes being requested - console.print("\n[bold]Requesting scopes:[/]") - for s in requested_scopes.split(): - if scope_prefix and (":" in s or s.endswith(".default")): - console.print(f" [cyan]{s}[/] [dim]({scope_prefix}{s})[/]") - else: - console.print(f" [cyan]{s}[/]") - console.print() - - if use_password_grant: - if login_username is None or login_password is None: - raise AuthError("Username and password are required for password grant.") - client_id = cast(str, oidc_config.client_id) - try: - token_response = authenticate_with_password_grant( - token_endpoint=oidc_config.token_endpoint, - client_id=client_id, - username=login_username, - password=login_password, - scope=effective_scope, - ) - except DeviceFlowError as exc: - raise AuthError(f"Authentication failed: {exc}") from exc - else: - if oidc_config.device_authorization_endpoint is None: - raise AuthError("This cluster does not support device flow authentication.") - client_id = cast(str, oidc_config.client_id) - device_authorization_endpoint = oidc_config.device_authorization_endpoint - try: - token_response = asyncio.run( - authenticate_with_device_flow( - device_authorization_endpoint=device_authorization_endpoint, - token_endpoint=oidc_config.token_endpoint, - client_id=client_id, - scope=effective_scope, - open_browser=not no_browser, - ) - ) - except DeviceFlowError as exc: - raise AuthError(f"Authentication failed: {exc}") from exc - - token = token_response.token_for_nmp - - claims = decode_jwt_claims(token) - user_email = claims.get("upn") or claims.get("email") or claims.get("preferred_username") - raw_granted_scopes = claims.get("scp") or claims.get("scope") - granted_scopes: list[str] = [] - if isinstance(raw_granted_scopes, str): - granted_scopes = raw_granted_scopes.split() - elif isinstance(raw_granted_scopes, list): - granted_scopes = [scope for scope in raw_granted_scopes if isinstance(scope, str)] - - validate_requested_scopes_granted(effective_scope, granted_scopes, scope_prefix) - - config_params: ConfigParams = {"access_token": token} - if token_response.refresh_token: - config_params["refresh_token"] = token_response.refresh_token - if selected_context is not None: - config_params["current_context"] = context.context_name - Config.write(config_params, context_name=context.context_name) - - console.print("\n[bold green]Authentication successful![/]") - if user_email: - console.print(f" Logged in as: [cyan]{user_email}[/]") - - if granted_scopes: - # Normalize scopes by stripping prefix for display - display_scopes = [] - for s in granted_scopes: - if scope_prefix and s.startswith(scope_prefix): - display_scopes.append(s[len(scope_prefix) :]) - else: - display_scopes.append(s) - console.print(f" Granted scopes: [cyan]{' '.join(display_scopes)}[/]") - - if token_response.refresh_token: - console.print(" Refresh token: [green]saved[/] (enables automatic token renewal)") - else: - console.print(" Refresh token: [yellow]not available[/] (add 'offline_access' scope to enable)") - - console.print("\n[bold green]Credentials saved to config file.[/]") - if runtime_token_source := _runtime_token_source_label(): - console.print( - f"[yellow]Warning:[/] {runtime_token_source} is active and will override these saved credentials. " - "Unset the runtime token override to use this login for future commands." - ) - console.print("\n[dim]Run 'nemo workspaces list' to verify your access.[/]") - @app.command("logout") @handle_errors diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py index 75ab429697..2e078d4fc2 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py @@ -34,12 +34,22 @@ TopLevelEntry( import_path="nemo_platform.cli.commands.setup:setup_command", help="""\ -Set up NeMo Platform: start services, configure a provider, install skills. +Set up NeMo Platform: connect or start services, configure a provider, install skills. -Walks through starting local services, selecting a provider, entering -credentials, registering the provider with the platform, picking a -default model, installing coding agent skills, and optionally deploying -a demo agent. +Uses an already-running platform, starts local services, or connects the +CLI to an existing remote deployment. Then selects and registers an +inference provider, picks a default model, installs coding agent skills, +and optionally deploys a demo agent. + +The active config context remembers the Platform URL. When a remote +deployment is already reachable, setup asks whether to continue with it, +start local services instead, or connect to a different remote URL. + +To override the URL for one run only: + nemo --base-url http://localhost:8080 setup + +To persist a different URL: + nemo config set --base-url http://localhost:8080 Requires an interactive terminal (TTY). In non-interactive contexts (CI, piped input), pass --auto to use environment variables instead. @@ -54,8 +64,10 @@ nemo setup --auto nemo setup --auto --start-services --install-skills --deploy-agent nemo setup --auto --start-services --ready-timeout 360 + NMP_BASE_URL=https://nmp.example.com NMP_ACCESS_TOKEN=... nemo setup --auto --no-start-services nemo setup --workspace my-workspace - nemo setup --no-install-skills --no-deploy-agent""", + nemo setup --no-install-skills --no-deploy-agent + nemo --base-url http://localhost:8080 setup""", name="setup", panel="Setup", kind="command", diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py index 78849e4181..d5d6b32ab9 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py @@ -16,10 +16,11 @@ import subprocess import time from dataclasses import dataclass +from enum import StrEnum from importlib.resources import files from importlib.resources.abc import Traversable from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal from urllib.parse import urlparse import httpx @@ -41,8 +42,9 @@ from nemo_platform.cli.commands.skills.registry import get_installer, load_skills from nemo_platform.cli.core.context import CLIContext from nemo_platform.cli.core.errors import handle_errors +from nemo_platform.client.tls import client_verify_from_env from nemo_platform.config.config import Config -from nemo_platform.config.models import ConfigFile, ConfigParams, LocalServicesConfig +from nemo_platform.config.models import DEFAULT_BASE_URL, ConfigFile, ConfigParams, LocalServicesConfig from nemo_platform.local.process import ( check_port_available_for_start, compute_scope, @@ -285,13 +287,25 @@ def _bootstrap_config_if_missing(base_url: str, workspace: str) -> None: Config.write(params) +_PLATFORM_REACHABILITY_PATHS = ("/status", "/cluster-info") + + def _check_platform_reachable(base_url: str, timeout: float = 5.0) -> bool: - """Return True if the platform health endpoint responds.""" - try: - resp = httpx.get(f"{base_url.rstrip('/')}/status", timeout=timeout) - return resp.status_code == 200 - except Exception: - return False + """Return True if a platform health endpoint responds. + + Local ``nemo services run`` publishes ``/status``. Hosted deployments may + only expose ``/cluster-info`` on ingress, so try both. + """ + verify = client_verify_from_env() + root = base_url.rstrip("/") + for path in _PLATFORM_REACHABILITY_PATHS: + try: + resp = httpx.get(f"{root}{path}", timeout=timeout, verify=verify) + if resp.status_code == 200: + return True + except Exception: + continue + return False def _check_platform_reachable_with_retries( @@ -313,18 +327,103 @@ def _check_platform_reachable_with_retries( return False +def _prompt_remote_base_url(*, default_url: str = "") -> str: + """Prompt until the user provides a reachable remote Platform URL.""" + while True: + base_url = prompt_text( + "Enter the remote Platform base URL: ", + default=default_url, + validator=non_empty_validator("Base URL"), + ).strip() + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + console.print(f"{CROSS} Enter a valid HTTP or HTTPS URL.") + continue + + base_url = base_url.rstrip("/") + if _check_platform_reachable_with_retries(base_url): + return base_url + + console.print(f"{CROSS} Unable to connect to NeMo Platform at {base_url}.") + + +def _resolve_setup_workspace(ctx: typer.Context, cli_context: CLIContext, workspace: str) -> str: + """Prefer an explicit ``--workspace``; otherwise keep the active context workspace.""" + from click.core import ParameterSource + + if ctx.get_parameter_source("workspace") != ParameterSource.DEFAULT: + return workspace + return cli_context.get_sdk_context().workspace or workspace + + +def _configure_remote_connection(cli_context: CLIContext, base_url: str, workspace: str) -> None: + """Persist a remote Platform URL in the active CLI context.""" + context_name = cli_context.get_sdk_context().context_name + Config.write( + {"base_url": base_url, "workspace": workspace}, + context_name=context_name, + ) + cli_context.overrides["base_url"] = base_url + cli_context.reset_sdk_context() + + +def _ensure_platform_auth(cli_context: CLIContext) -> None: + """Authenticate the active context when it lacks usable credentials.""" + from nemo_platform.cli.commands.auth import _login_with_oidc, _runtime_token_source_label + + context = cli_context.get_sdk_context() + if runtime_token_source := _runtime_token_source_label(): + console.print(f"{CHECK} Using {runtime_token_source}\n") + return + + authenticated = _login_with_oidc(cli_context, selected_context=context.context_name) + if not authenticated: + Config.write( + {"access_token": None, "refresh_token": None}, + context_name=context.context_name, + ) + cli_context.reset_sdk_context() + + +def _platform_request_headers(cli_context: CLIContext) -> dict[str, str] | None: + """Return authentication headers for direct Platform HTTP requests.""" + context = cli_context.get_sdk_context() + if context.user is None: + return None + headers = context.user.get_client_config().get("default_headers") + if not isinstance(headers, dict): + return None + return {key: value for key, value in headers.items() if isinstance(key, str) and isinstance(value, str)} + + +def _hosted_platform_without_status(base_url: str, *, timeout: float, verify: str | bool) -> bool: + """Return True when ``/cluster-info`` confirms a hosted platform that omits ``/status``.""" + try: + resp = httpx.get(f"{base_url.rstrip('/')}/cluster-info", timeout=timeout, verify=verify) + except Exception: + return False + return resp.status_code == 200 + + def _check_controller_health(base_url: str, timeout: float = 5.0) -> tuple[bool, str]: """Query ``/status`` and assess controller health. Returns ``(True, "")`` when controllers are populated and all healthy. + Returns ``(True, detail)`` when ``/status`` is absent but ``/cluster-info`` confirms a hosted platform. Returns ``(False, detail)`` when unhealthy, unreachable, or empty after retry. If ``controllers.status`` is empty on the first call (startup timing race), waits ``_CONTROLLER_HEALTH_RETRY_DELAY`` seconds and retries once. """ + verify = client_verify_from_env() + root = base_url.rstrip("/") for attempt in range(2): try: - resp = httpx.get(f"{base_url.rstrip('/')}/status", timeout=timeout) + resp = httpx.get(f"{root}/status", timeout=timeout, verify=verify) + if resp.status_code == 404: + if _hosted_platform_without_status(root, timeout=timeout, verify=verify): + return True, "Hosted deployment does not publish /status." + return False, "Unexpected status 404 from /status endpoint." if resp.status_code != 200: return False, f"Unexpected status {resp.status_code} from /status endpoint." data = resp.json() @@ -360,6 +459,13 @@ def _verify_platform_health(base_url: str) -> bool: """ ok, detail = _check_controller_health(base_url) if ok: + if detail: + if "does not publish /status" in detail.lower(): + # Expected for hosted ingress that only exposes /cluster-info. + console.print(f"\n{CHECK} {detail}") + else: + console.print(f"\n{WARN} [yellow]{detail}[/yellow]") + console.print(" Setup may have succeeded, but controller health could not be verified.") return True if "no controllers" in detail.lower(): @@ -626,6 +732,46 @@ def _resolve_services_port(base_url: str) -> int: return parsed.port or 8080 +def _is_local_base_url(base_url: str) -> bool: + """Return whether *base_url* points at the local machine.""" + parsed = urlparse(base_url) + return parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"} + + +def _platform_host_label(base_url: str) -> str: + """Return a human-friendly host label for connection prompts.""" + parsed = urlparse(base_url) + return parsed.hostname or base_url.rstrip("/") + + +class _RemoteConnectionChoice(StrEnum): + """Options offered when a configured remote Platform is already reachable.""" + + CONTINUE = "continue" + START_LOCAL = "local" + CHANGE_REMOTE = "change" + + +def _prompt_reachable_remote_connection(base_url: str) -> Literal["ready", "connect_remote", "start_local"]: + """Ask how to proceed when a configured remote Platform is already reachable.""" + hostname = _platform_host_label(base_url) + action = prompt_choice( + message=f"Platform reachable at {hostname} ({base_url}). What would you like to do?", + options=[ + (_RemoteConnectionChoice.CONTINUE, "Continue with this remote Platform"), + (_RemoteConnectionChoice.START_LOCAL, "Start local services instead"), + (_RemoteConnectionChoice.CHANGE_REMOTE, "Connect to a different remote URL"), + ], + default=_RemoteConnectionChoice.CONTINUE, + ) + if action == _RemoteConnectionChoice.CONTINUE: + console.print(f"{CHECK} Platform already running at {base_url}\n") + return "ready" + if action == _RemoteConnectionChoice.CHANGE_REMOTE: + return "connect_remote" + return "start_local" + + def _start_services_background(base_url: str, data_dir: str | None = None) -> subprocess.Popen: """Launch ``nemo services run`` as a background process. @@ -709,7 +855,7 @@ def _maybe_start_services( auto: bool, start_services: bool | None, timeout: int = _SERVICE_STARTUP_TIMEOUT_SECONDS, -) -> None: +) -> Literal["ready", "connect_remote", "start_local"]: """Start services if requested, restarting if already running. In interactive mode (auto=False), prompts the user if start_services is None. @@ -720,11 +866,19 @@ def _maybe_start_services( (including any newly installed plugins) is picked up. Data lives in SQLite so nothing is lost across restarts. """ + if start_services is True and not _is_local_base_url(base_url): + raise typer.BadParameter( + "--start-services requires a local Platform URL", + param_hint="--start-services", + ) + already_running = _check_platform_reachable(base_url) if already_running and start_services is not True: - console.print(f"{CHECK} Platform already running at {base_url}\n") - return + if _is_local_base_url(base_url) or auto: + console.print(f"{CHECK} Platform already running at {base_url}\n") + return "ready" + return _prompt_reachable_remote_connection(base_url) should_start = start_services if should_start is None: @@ -734,14 +888,20 @@ def _maybe_start_services( console.print(" [cyan]nemo setup --auto --start-services[/cyan]") console.print(" [cyan]nemo services run[/cyan]") raise typer.Exit(1) - should_start = ( - prompt_choice( - message=f"Platform not reachable at {base_url}. Start local services?", - options=[("yes", "Yes, start services now"), ("no", "No, I'll start them myself")], - default="yes", - ) - == "yes" + if not _is_local_base_url(base_url): + return "connect_remote" + action = prompt_choice( + message=f"Platform not reachable at {base_url}. Start local services?", + options=[ + ("yes", "Yes, start services now"), + ("remote", "No, I want to connect to a remote Platform instance"), + ("manual", "No, I'll start them myself"), + ], + default="yes", ) + if action == "remote": + return "connect_remote" + should_start = action == "yes" if not should_start: console.print(f"{CROSS} Cannot reach platform at {base_url}") @@ -789,6 +949,7 @@ def _maybe_start_services( raise typer.Exit(1) console.print(f"{CHECK} Platform running at {base_url} (pid {proc.pid})\n") + return "ready" # --------------------------------------------------------------------------- @@ -1142,11 +1303,12 @@ def _agent_config_path() -> Traversable | None: return None -def _agent_exists(base_url: str, workspace: str) -> bool: +def _agent_exists(base_url: str, workspace: str, headers: dict[str, str] | None = None) -> bool: """Return True if the demo agent already exists on the platform.""" try: resp = httpx.get( f"{base_url.rstrip('/')}/apis/agents/v2/workspaces/{workspace}/agents/{_DEMO_AGENT_NAME}", + headers=headers, timeout=10.0, ) return resp.status_code == 200 @@ -1154,11 +1316,12 @@ def _agent_exists(base_url: str, workspace: str) -> bool: return False -def _agents_api_ready(base_url: str, workspace: str) -> bool: +def _agents_api_ready(base_url: str, workspace: str, headers: dict[str, str] | None = None) -> bool: """Return True if the agents API is responding.""" try: resp = httpx.get( f"{base_url.rstrip('/')}/apis/agents/v2/workspaces/{workspace}/agents", + headers=headers, timeout=3.0, ) return resp.status_code == 200 @@ -1166,19 +1329,26 @@ def _agents_api_ready(base_url: str, workspace: str) -> bool: return False -def _deploy_demo_agent(base_url: str, workspace: str, config_path: Traversable, default_model: str) -> bool: +def _deploy_demo_agent( + base_url: str, + workspace: str, + config_path: Traversable, + default_model: str, + headers: dict[str, str] | None = None, +) -> bool: """Create and deploy the demo calculator agent. Returns True on success.""" # Optional plugin: import here so ``nemo setup`` works without nemo-agents installed. from nemo_agents_plugin.utils import expand_env_vars api_base = base_url.rstrip("/") - if not _agent_exists(base_url, workspace): + if not _agent_exists(base_url, workspace, headers=headers): config_dict = _yaml.safe_load(config_path.read_text(encoding="utf-8")) config_dict = expand_env_vars(config_dict, vars_dict={"NEMO_DEFAULT_MODEL": default_model}) payload = {"name": _DEMO_AGENT_NAME, "description": "Demo calculator agent", "config": config_dict} resp = httpx.post( f"{api_base}/apis/agents/v2/workspaces/{workspace}/agents", + headers=headers, json=payload, timeout=30.0, ) @@ -1189,6 +1359,7 @@ def _deploy_demo_agent(base_url: str, workspace: str, config_path: Traversable, resp = httpx.post( f"{api_base}/apis/agents/v2/workspaces/{workspace}/deployments", + headers=headers, json={"agent": _DEMO_AGENT_NAME}, timeout=30.0, ) @@ -1212,6 +1383,7 @@ def _deploy_demo_agent(base_url: str, workspace: str, config_path: Traversable, try: dep_resp = httpx.get( f"{api_base}/apis/agents/v2/workspaces/{workspace}/deployments/{deployment_name}", + headers=headers, timeout=3.0, ) if dep_resp.status_code == 200: @@ -1235,6 +1407,7 @@ def _maybe_deploy_agent( auto: bool, deploy_agent: bool | None, default_model: str | None = None, + headers: dict[str, str] | None = None, ) -> bool: """Optionally deploy the demo calculator agent. @@ -1289,7 +1462,7 @@ def _maybe_deploy_agent( while time.monotonic() < deadline: elapsed = int(time.monotonic() - start) spinner.update(f"[bold cyan]Waiting for agents API... ({elapsed}s)") - if _agents_api_ready(base_url, workspace): + if _agents_api_ready(base_url, workspace, headers=headers): api_ready = True break _pause(_AGENT_API_READINESS_POLL_INTERVAL) @@ -1299,7 +1472,13 @@ def _maybe_deploy_agent( return False try: - return _deploy_demo_agent(base_url, workspace, config_path, default_model=default_model) + return _deploy_demo_agent( + base_url, + workspace, + config_path, + default_model=default_model, + headers=headers, + ) except Exception as exc: console.print(f" {WARN} Agent deployment failed: {exc}") return False @@ -1373,9 +1552,9 @@ def _register_provider_interactive( default_extra_headers: dict[str, str] | None = None, ) -> None: """Create or update secret + provider for idempotent re-runs.""" - secret_name = f"{provider_name}-api-key" if api_key else None - - if secret_name: + secret_name: str | None = None + if api_key: + secret_name = f"{provider_name}-api-key" if _secret_exists(client, secret_name, workspace): _update_secret(client, secret_name, api_key, workspace) console.print(f" {CHECK} Updated secret '{secret_name}'") @@ -1649,12 +1828,22 @@ def setup_command( ), ] = None, ) -> None: - """Set up NeMo Platform: start services, configure a provider, install skills. + """Set up NeMo Platform: connect or start services, configure a provider, install skills. + + Uses an already-running platform, starts local services, or connects the + CLI to an existing remote deployment. Then selects and registers an + inference provider, picks a default model, installs coding agent skills, + and optionally deploys a demo agent. + + The active config context remembers the Platform URL. When a remote + deployment is already reachable, setup asks whether to continue with it, + start local services instead, or connect to a different remote URL. - Walks through starting local services, selecting a provider, entering - credentials, registering the provider with the platform, picking a - default model, installing coding agent skills, and optionally deploying - a demo agent. + To override the URL for one run only: + nemo --base-url http://localhost:8080 setup + + To persist a different URL: + nemo config set --base-url http://localhost:8080 Requires an interactive terminal (TTY). In non-interactive contexts (CI, piped input), pass --auto to use environment variables instead. @@ -1669,11 +1858,13 @@ def setup_command( nemo setup --auto nemo setup --auto --start-services --install-skills --deploy-agent nemo setup --auto --start-services --ready-timeout 360 + NMP_BASE_URL=https://nmp.example.com NMP_ACCESS_TOKEN=... nemo setup --auto --no-start-services nemo setup --workspace my-workspace nemo setup --no-install-skills --no-deploy-agent + nemo --base-url http://localhost:8080 setup """ cli_context: CLIContext = ctx.obj - base_url = cli_context.get_base_url() + base_url = cli_context.get_base_url() or DEFAULT_BASE_URL console.print("\n[bold cyan]NeMo Platform Setup[/bold cyan]\n") @@ -1684,7 +1875,32 @@ def setup_command( effective_timeout = _SERVICE_STARTUP_TIMEOUT_SECONDS if ready_timeout is None else ready_timeout if effective_timeout <= 0: raise typer.BadParameter("--ready-timeout must be greater than 0", param_hint="--ready-timeout") - _maybe_start_services(base_url, auto, start_services, timeout=effective_timeout) + try: + configured_base_url = base_url + service_result = _maybe_start_services(base_url, auto, start_services, timeout=effective_timeout) + if service_result == "start_local": + context_name = cli_context.get_sdk_context().context_name + _bootstrap_config_if_missing(DEFAULT_BASE_URL, workspace) + Config.write({"base_url": DEFAULT_BASE_URL}, context_name=context_name) + cli_context.overrides["base_url"] = DEFAULT_BASE_URL + cli_context.reset_sdk_context() + base_url = DEFAULT_BASE_URL + service_result = _maybe_start_services( + base_url, + auto, + start_services=True, + timeout=effective_timeout, + ) + if service_result == "connect_remote": + base_url = _prompt_remote_base_url(default_url=configured_base_url) + _bootstrap_config_if_missing(base_url, workspace) + cli_context.reset_sdk_context() + workspace = _resolve_setup_workspace(ctx, cli_context, workspace) + _configure_remote_connection(cli_context, base_url, workspace) + _ensure_platform_auth(cli_context) + except UserCancelled: + console.print(f"\n{WARN} Setup cancelled.") + raise typer.Exit(0) from None if not _check_platform_reachable_with_retries(base_url): console.print(f"\n{CROSS} Cannot reach platform at {base_url}") @@ -1797,7 +2013,14 @@ def _run_auto_mode( skills_scope=skills_scope, skills_from=skills_from, ) - _maybe_deploy_agent(base_url, workspace, auto=True, deploy_agent=deploy_agent, default_model=default_model) + _maybe_deploy_agent( + base_url, + workspace, + auto=True, + deploy_agent=deploy_agent, + default_model=default_model, + headers=_platform_request_headers(cli_context), + ) if _verify_platform_health(base_url): console.print(f"\n{CHECK} [green]Setup complete![/green]") @@ -1882,7 +2105,12 @@ def _run_interactive_mode( console.print("\n[bold]Step 7: Demo agent (optional)[/bold]\n") demo_deployed = _maybe_deploy_agent( - base_url, workspace, auto=False, deploy_agent=deploy_agent, default_model=default_model + base_url, + workspace, + auto=False, + deploy_agent=deploy_agent, + default_model=default_model, + headers=_platform_request_headers(cli_context), ) _print_onboarding(base_url, provider_name, default_model, demo_deployed=demo_deployed) diff --git a/sdk/python/nemo-platform/src/nemo_platform/config/config.py b/sdk/python/nemo-platform/src/nemo_platform/config/config.py index f136ec9019..8c5f3e4b20 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/config/config.py +++ b/sdk/python/nemo-platform/src/nemo_platform/config/config.py @@ -30,6 +30,33 @@ logger = logging.getLogger(__name__) +def _try_secure_chmod_dir(path: Path) -> None: + """Best-effort owner-only permissions on a directory (e.g. ``/tmp`` may reject chmod).""" + try: + os.chmod(path, stat.S_IRWXU) # 700 + except PermissionError: + pass + + +def _write_secure_yaml(path: Path, config_data: dict) -> None: + """Write YAML atomically with owner read/write permissions (600). + + A failure to lock down the file's permissions raises instead of being + swallowed, so credentials are never left readable by other users. + """ + mode = stat.S_IRUSR | stat.S_IWUSR # 600 + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(fd, "w") as f: + fd = -1 + yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) + finally: + if fd >= 0: + os.close(fd) + # Existing files keep prior mode under O_TRUNC; enforce 600 after write. + os.chmod(path, mode) + + @dataclass(frozen=True) class _RuntimeAccessTokenSource: token: str @@ -266,7 +293,7 @@ def save(self, config_path: Path | None = None) -> None: # Ensure parent directory exists with secure permissions (owner-only access) path.parent.mkdir(parents=True, exist_ok=True) - os.chmod(path.parent, stat.S_IRWXU) # 700 + _try_secure_chmod_dir(path.parent) # Serialize with secrets revealed using context config_data = self._config_file.model_dump( @@ -275,11 +302,7 @@ def save(self, config_path: Path | None = None) -> None: context={"include_secrets": True}, ) - with open(path, "w") as f: - yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - - # Set secure file permissions (owner read/write only) - os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 600 + _write_secure_yaml(path, config_data) # Update stored path if we saved to a new location self._config_path = path diff --git a/sdk/python/nemo-platform/src/nemo_platform/quickstart/config.py b/sdk/python/nemo-platform/src/nemo_platform/quickstart/config.py index 6a21562158..c7907c08dd 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/quickstart/config.py +++ b/sdk/python/nemo-platform/src/nemo_platform/quickstart/config.py @@ -18,6 +18,33 @@ from ._registry import image_registry_host from .gpu_config import parse_comma_separated_non_negative_integers + +def _try_secure_chmod_dir(path: Path) -> None: + """Best-effort owner-only permissions on a directory (e.g. ``/tmp`` may reject chmod).""" + try: + os.chmod(path, stat.S_IRWXU) # 700 + except PermissionError: + pass + + +def _write_secure_yaml(path: Path, config_data: dict) -> None: + """Write YAML atomically with owner read/write permissions (600). + + File permission failures remain fatal so credentials are never left world-readable. + """ + mode = stat.S_IRUSR | stat.S_IWUSR # 600 + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(fd, "w") as f: + fd = -1 + yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) + finally: + if fd >= 0: + os.close(fd) + # Existing files keep prior mode under O_TRUNC; enforce 600 after write. + os.chmod(path, mode) + + InferenceProviderType = Literal["nvidia-build", "host-gpu"] # Registry and repo placeholder for SDK-stamped nightly/milestone tags. @@ -228,7 +255,7 @@ def save(self, path: Path | None = None) -> None: # Ensure parent directory exists with secure permissions (owner-only access) config_path.parent.mkdir(parents=True, exist_ok=True) - os.chmod(config_path.parent, stat.S_IRWXU) # 700 + _try_secure_chmod_dir(config_path.parent) # Serialize with secrets revealed config_data = self.model_dump( @@ -246,11 +273,7 @@ def save(self, path: Path | None = None) -> None: if "platform_config_path" in config_data: config_data["platform_config_path"] = str(config_data["platform_config_path"]) - with open(config_path, "w") as f: - yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - - # Set secure file permissions (owner read/write only) - os.chmod(config_path, stat.S_IRUSR | stat.S_IWUSR) # 600 + _write_secure_yaml(config_path, config_data) @classmethod def remove(cls) -> None: diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py index 6060cfb346..5ed300825e 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py @@ -175,7 +175,7 @@ def test_commands_uses_visible_command_order_with_discovered_plugins(self): assert result.exit_code == 0 command_rows = [line for line in result.stdout.splitlines() if line.startswith("| nemo ")] assert command_rows == [ - "| nemo setup | Setup | Set up NeMo Platform: start services, configure a provider, install skills. |", + "| nemo setup | Setup | Set up NeMo Platform: connect or start services, configure a provider, install skills. |", "| nemo services | Setup | Run platform services locally. |", "| nemo skills | Setup | Install AI agent skill files for Nemo. |", "| nemo chat | CLI functions | Start an interactive chat session with a model. |", diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py index 84a99ee90d..038bdba8fc 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py @@ -7,11 +7,16 @@ import logging import sys -from unittest.mock import MagicMock, patch +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch import httpx +import nemo_platform.cli.commands.setup as setup_commands import pytest import typer +from click.core import ParameterSource from click.exceptions import Exit as ClickExit from nemo_platform.resources.inference.providers import ProvidersResource from nemo_platform.cli.commands.setup import ( @@ -56,6 +61,7 @@ _register_provider_interactive, _render_onboarding_card, _resolve_provider_for_url, + _resolve_setup_workspace, _run_interactive_mode, _save_data_dir, _select_default_model, @@ -69,16 +75,22 @@ from nemo_platform.cli.commands.skills import registry as skills_registry from nemo_platform.cli.commands.skills.base import Scope, Skill from nemo_platform.cli.commands.skills.registry import UnsupportedAgentError +from nemo_platform.config.config import Config from nemo_platform.config.models import ( + DEFAULT_BASE_URL, Cluster, ConfigFile, ConfigParams, Context, ContextDefinition, + NoAuthUser, + OAuthUser, ) from nemo_platform.local.process import PortConflict +from nemo_platform.ui.prompts import UserCancelled from nemo_platform_plugin.client.errors import NotFoundError from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest +from pydantic import SecretStr SETUP_MOD = "nemo_platform.cli.commands.setup" @@ -156,20 +168,59 @@ def test_each_known_provider_resolves(self): class TestCheckPlatformReachable: - def test_reachable(self): + def test_reachable_via_status(self): mock_resp = MagicMock() mock_resp.status_code = 200 - with patch("nemo_platform.cli.commands.setup.httpx.get", return_value=mock_resp): + with ( + patch(f"{SETUP_MOD}.client_verify_from_env", return_value="/tmp/custom-ca.pem"), + patch(f"{SETUP_MOD}.httpx.get", return_value=mock_resp) as mock_get, + ): assert _check_platform_reachable("http://localhost:8080") is True + mock_get.assert_called_once_with( + "http://localhost:8080/status", + timeout=5.0, + verify="/tmp/custom-ca.pem", + ) + + def test_reachable_via_cluster_info_when_status_missing(self): + status_resp = MagicMock() + status_resp.status_code = 404 + cluster_resp = MagicMock() + cluster_resp.status_code = 200 + calls: list[tuple[str, object]] = [] + + def _get(url, **kwargs): + calls.append((url, kwargs.get("verify"))) + if url.endswith("/status"): + return status_resp + if url.endswith("/cluster-info"): + return cluster_resp + raise AssertionError(f"unexpected url: {url}") + + with ( + patch(f"{SETUP_MOD}.client_verify_from_env", return_value="/tmp/custom-ca.pem"), + patch(f"{SETUP_MOD}.httpx.get", side_effect=_get), + ): + assert _check_platform_reachable("https://nemo-platform-freeplay.dev.aire.nvidia.com") is True + assert calls == [ + ("https://nemo-platform-freeplay.dev.aire.nvidia.com/status", "/tmp/custom-ca.pem"), + ("https://nemo-platform-freeplay.dev.aire.nvidia.com/cluster-info", "/tmp/custom-ca.pem"), + ] + + def test_unreachable_when_all_probes_fail(self): + mock_resp = MagicMock() + mock_resp.status_code = 404 + with patch(f"{SETUP_MOD}.httpx.get", return_value=mock_resp): + assert _check_platform_reachable("http://localhost:8080") is False def test_unreachable(self): - with patch("nemo_platform.cli.commands.setup.httpx.get", side_effect=Exception("conn refused")): + with patch(f"{SETUP_MOD}.httpx.get", side_effect=Exception("conn refused")): assert _check_platform_reachable("http://localhost:8080") is False - def test_non_200_status(self): + def test_non_200_status_without_cluster_info_fallback(self): mock_resp = MagicMock() mock_resp.status_code = 503 - with patch("nemo_platform.cli.commands.setup.httpx.get", return_value=mock_resp): + with patch(f"{SETUP_MOD}.httpx.get", return_value=mock_resp): assert _check_platform_reachable("http://localhost:8080") is False @@ -545,6 +596,94 @@ def test_skips_when_running_and_explicitly_false(self): with patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True): _maybe_start_services("http://localhost:8080", auto=False, start_services=False) + def test_returns_remote_choice_without_starting_services(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="remote") as mock_prompt, + patch(f"{SETUP_MOD}._start_services_background") as mock_start, + ): + result = _maybe_start_services("http://localhost:8080", auto=False, start_services=None) + + assert result == "connect_remote" + assert mock_prompt.call_args.kwargs["options"] == [ + ("yes", "Yes, start services now"), + ("remote", "No, I want to connect to a remote Platform instance"), + ("manual", "No, I'll start them myself"), + ] + mock_start.assert_not_called() + + def test_start_myself_choice_keeps_existing_exit(self, capsys): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="manual"), + pytest.raises(ClickExit), + ): + _maybe_start_services("http://localhost:8080", auto=False, start_services=None) + + assert "Start the platform first" in capsys.readouterr().err + + def test_unreachable_remote_url_selects_remote_connection_without_local_prompt(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice") as mock_prompt, + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "connect_remote" + mock_prompt.assert_not_called() + + def test_reachable_remote_prompts_continue(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice", return_value="continue") as mock_prompt, + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "ready" + mock_prompt.assert_called_once() + assert ( + mock_prompt.call_args.kwargs["message"] + == "Platform reachable at remote.example.com (https://remote.example.com). What would you like to do?" + ) + + def test_reachable_remote_start_local(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice", return_value="local"), + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "start_local" + + def test_reachable_remote_change_remote(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice", return_value="change"), + ): + result = _maybe_start_services("https://remote.example.com", auto=False, start_services=None) + + assert result == "connect_remote" + + def test_reachable_remote_auto_skips_prompt(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=True), + patch(f"{SETUP_MOD}.prompt_choice") as mock_prompt, + ): + result = _maybe_start_services("https://remote.example.com", auto=True, start_services=None) + + assert result == "ready" + mock_prompt.assert_not_called() + + def test_rejects_start_services_for_remote_url(self): + with ( + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}._start_services_background") as mock_start, + pytest.raises(typer.BadParameter, match="local Platform URL"), + ): + _maybe_start_services("https://remote.example.com", auto=False, start_services=True) + + mock_start.assert_not_called() + def test_restarts_when_running_and_start_services_true(self): reachable_calls = [True, True, False, True] @@ -622,6 +761,189 @@ def test_allows_start_when_port_free(self, maybe_start_preflight_mocks): maybe_start_preflight_mocks.assert_called_once() +class TestRemoteConnection: + def test_prompts_again_until_platform_is_reachable(self, capsys): + with ( + patch( + f"{SETUP_MOD}.prompt_text", + side_effect=["https://unreachable.example.com", "https://remote.example.com/"], + ), + patch( + f"{SETUP_MOD}._check_platform_reachable_with_retries", + side_effect=[False, True], + ), + ): + base_url = setup_commands._prompt_remote_base_url() + + assert base_url == "https://remote.example.com" + assert "Unable to connect" in capsys.readouterr().err + + def test_persists_remote_url_and_workspace_in_active_context(self, tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + cli_context = MagicMock() + cli_context.overrides = {} + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url="http://localhost:8080"), + user=NoAuthUser(name="default-user"), + workspace="default", + preferences={}, + ) + + setup_commands._configure_remote_connection( + cli_context, + "https://remote.example.com", + "shared-workspace", + ) + + context = Config.load(config_path=config_path).resolve() + assert str(context.cluster.base_url) == "https://remote.example.com/" + assert context.workspace == "shared-workspace" + cli_context.reset_sdk_context.assert_called_once_with() + + def test_updates_selected_context_and_runtime_url_override(self, tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + Config.write({"base_url": "https://default.example.com"}) + Config.write({"base_url": "https://old-dev.example.com"}, context_name="dev") + + cli_context = MagicMock() + cli_context.overrides = { + "current_context": "dev", + "base_url": "https://stale-override.example.com", + } + cli_context.get_sdk_context.return_value = Context( + context_name="dev", + cluster=Cluster(name="dev-cluster", base_url="https://stale-override.example.com"), + user=NoAuthUser(name="dev-user"), + workspace="default", + preferences={}, + ) + + setup_commands._configure_remote_connection( + cli_context, + "https://new-dev.example.com", + "shared-workspace", + ) + + config_file = Config.load(config_path=config_path).get_config_file() + default_cluster = next(cluster for cluster in config_file.clusters if cluster.name == "default-cluster") + dev_cluster = next(cluster for cluster in config_file.clusters if cluster.name == "dev-cluster") + assert str(default_cluster.base_url) == "https://default.example.com/" + assert str(dev_cluster.base_url) == "https://new-dev.example.com/" + assert cli_context.overrides["base_url"] == "https://new-dev.example.com" + + def test_preserves_active_workspace_when_flag_not_explicit(self): + ctx = MagicMock(spec=typer.Context) + ctx.get_parameter_source.return_value = ParameterSource.DEFAULT + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url="http://localhost:8080"), + user=NoAuthUser(name="default-user"), + workspace="team-a", + preferences={}, + ) + + assert _resolve_setup_workspace(ctx, cli_context, "default") == "team-a" + ctx.get_parameter_source.assert_called_once_with("workspace") + + def test_uses_explicit_workspace_flag_over_active_context(self): + ctx = MagicMock(spec=typer.Context) + ctx.get_parameter_source.return_value = ParameterSource.COMMANDLINE + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url="http://localhost:8080"), + user=NoAuthUser(name="default-user"), + workspace="team-a", + preferences={}, + ) + + assert _resolve_setup_workspace(ctx, cli_context, "shared-workspace") == "shared-workspace" + + def test_authenticates_when_context_has_no_credentials(self): + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=NoAuthUser(name="default-user"), + workspace="default", + preferences={}, + ) + + with patch("nemo_platform.cli.commands.auth._login_with_oidc", return_value=True) as mock_login: + setup_commands._ensure_platform_auth(cli_context) + + mock_login.assert_called_once_with(cli_context, selected_context="default") + cli_context.reset_sdk_context.assert_called_once_with() + + def test_reauthenticates_stored_context_credentials_for_new_remote(self): + cli_context = MagicMock() + context = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=OAuthUser(name="default-user", token=SecretStr("token")), + workspace="default", + preferences={}, + ) + cli_context.get_sdk_context.return_value = context + + with patch("nemo_platform.cli.commands.auth._login_with_oidc", return_value=True) as mock_login: + setup_commands._ensure_platform_auth(cli_context) + + mock_login.assert_called_once_with(cli_context, selected_context="default") + cli_context.reset_sdk_context.assert_called_once_with() + + def test_reuses_runtime_access_token_override(self, monkeypatch): + monkeypatch.setenv("NMP_ACCESS_TOKEN", "runtime-token") + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=OAuthUser(name="default-user", token=SecretStr("runtime-token")), + workspace="default", + preferences={}, + ) + + with patch("nemo_platform.cli.commands.auth._login_with_oidc") as mock_login: + setup_commands._ensure_platform_auth(cli_context) + + mock_login.assert_not_called() + + def test_clears_stale_credentials_when_remote_auth_is_disabled(self, tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + monkeypatch.delenv("NMP_ACCESS_TOKEN", raising=False) + Config.write( + { + "base_url": "https://remote.example.com", + "access_token": "old-cluster-token", + } + ) + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Config.load(config_path=config_path).resolve() + + with patch("nemo_platform.cli.commands.auth._login_with_oidc", return_value=False): + setup_commands._ensure_platform_auth(cli_context) + + assert isinstance(Config.load(config_path=config_path).resolve().user, NoAuthUser) + cli_context.reset_sdk_context.assert_called_once_with() + + def test_platform_request_headers_include_context_token(self): + cli_context = MagicMock() + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="remote", base_url="https://remote.example.com"), + user=OAuthUser(name="default-user", token=SecretStr("remote-token")), + workspace="default", + preferences={}, + ) + + assert setup_commands._platform_request_headers(cli_context) == {"Authorization": "Bearer remote-token"} + + class TestLocalDataDirHelpers: """Tests for the XDG-default data-dir helpers used by `nemo setup`.""" @@ -2253,6 +2575,88 @@ def test_prompt_passes_hint(self): assert "lowercase" in hint.lower() +# --------------------------------------------------------------------------- +# setup_command entry-path helpers +# --------------------------------------------------------------------------- + + +def _make_setup_command_ctx( + *, + base_url: str = "http://localhost:8080", + workspace: str = "default", + workspace_source: ParameterSource = ParameterSource.DEFAULT, +) -> tuple[MagicMock, MagicMock]: + """Build a typer Context + CLIContext pair for invoking ``setup_command``.""" + ctx = MagicMock(spec=typer.Context) + ctx.get_parameter_source.return_value = workspace_source + cli_context = MagicMock() + cli_context.overrides = {} + cli_context.get_base_url.return_value = base_url + cli_context.get_sdk_context.return_value = Context( + context_name="default", + cluster=Cluster(name="default-cluster", base_url=base_url), + user=NoAuthUser(name="default-user"), + workspace=workspace, + preferences={}, + ) + cli_context.get_client.return_value.workspaces.retrieve.return_value = MagicMock() + ctx.obj = cli_context + return ctx, cli_context + + +@contextmanager +def _patch_setup_command( + *, + interactive: bool = True, + maybe_start_services: object | None = None, + remote_url: str | None = None, + include_config_write: bool = False, + auto_mode: bool = False, +) -> Iterator[SimpleNamespace]: + """Patch the common ``setup_command`` entry path and yield named mocks. + + When *remote_url* is set, also patches the connect-remote branch helpers and + defaults ``_maybe_start_services`` to return ``\"connect_remote\"``. + """ + maybe_start_kwargs: dict[str, object] = {} + if maybe_start_services is None: + if remote_url is not None: + maybe_start_kwargs["return_value"] = "connect_remote" + elif isinstance(maybe_start_services, list): + maybe_start_kwargs["side_effect"] = maybe_start_services + elif isinstance(maybe_start_services, MagicMock): + maybe_start_kwargs["new"] = maybe_start_services + else: + maybe_start_kwargs["return_value"] = maybe_start_services + + with ExitStack() as stack: + mocks = SimpleNamespace( + is_interactive=stack.enter_context(patch(f"{SETUP_MOD}.is_interactive", return_value=interactive)), + maybe_start_services=stack.enter_context(patch(f"{SETUP_MOD}._maybe_start_services", **maybe_start_kwargs)), + check_reachable=stack.enter_context( + patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True) + ), + bootstrap=stack.enter_context(patch(f"{SETUP_MOD}._bootstrap_config_if_missing")), + run_interactive=stack.enter_context(patch(f"{SETUP_MOD}._run_interactive_mode")), + prompt_remote=None, + configure_remote=None, + ensure_auth=None, + config_write=None, + run_auto=None, + ) + if remote_url is not None: + mocks.prompt_remote = stack.enter_context( + patch(f"{SETUP_MOD}._prompt_remote_base_url", return_value=remote_url) + ) + mocks.configure_remote = stack.enter_context(patch(f"{SETUP_MOD}._configure_remote_connection")) + mocks.ensure_auth = stack.enter_context(patch(f"{SETUP_MOD}._ensure_platform_auth")) + if include_config_write: + mocks.config_write = stack.enter_context(patch(f"{SETUP_MOD}.Config.write")) + if auto_mode: + mocks.run_auto = stack.enter_context(patch(f"{SETUP_MOD}._run_auto_mode")) + yield mocks + + # --------------------------------------------------------------------------- # Non-TTY early exit guard # --------------------------------------------------------------------------- @@ -2261,41 +2665,91 @@ def test_prompt_passes_hint(self): class TestNonTtyEarlyExit: """setup_command must exit(1) when stdin is not a TTY and --auto is not passed.""" - def _invoke(self, *, auto: bool = False): - """Invoke setup_command with a minimal mock context.""" - ctx = MagicMock(spec=typer.Context) - cli_context = MagicMock() - cli_context.get_base_url.return_value = "http://localhost:8080" - ctx.obj = cli_context - setup_command(ctx, auto=auto) - def test_exits_when_non_tty_without_auto(self): + ctx, _cli_context = _make_setup_command_ctx() with ( - patch(f"{SETUP_MOD}.is_interactive", return_value=False), + _patch_setup_command(interactive=False), pytest.raises(typer.Exit) as exc_info, ): - self._invoke(auto=False) + setup_command(ctx, auto=False) assert exc_info.value.exit_code == 1 def test_proceeds_when_non_tty_with_auto(self): - with ( - patch(f"{SETUP_MOD}.is_interactive", return_value=False), - patch(f"{SETUP_MOD}._maybe_start_services"), - patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True), - patch(f"{SETUP_MOD}._bootstrap_config_if_missing"), - patch(f"{SETUP_MOD}._run_auto_mode"), - ): - self._invoke(auto=True) + ctx, _cli_context = _make_setup_command_ctx() + with _patch_setup_command(interactive=False, auto_mode=True): + setup_command(ctx, auto=True) def test_proceeds_when_tty_without_auto(self): + ctx, _cli_context = _make_setup_command_ctx() + with _patch_setup_command(): + setup_command(ctx, auto=False) + + def test_cancelling_initial_connection_prompt_exits_cleanly(self, capsys): + ctx, _cli_context = _make_setup_command_ctx() with ( - patch(f"{SETUP_MOD}.is_interactive", return_value=True), - patch(f"{SETUP_MOD}._maybe_start_services"), - patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True), - patch(f"{SETUP_MOD}._bootstrap_config_if_missing"), - patch(f"{SETUP_MOD}._run_interactive_mode"), + _patch_setup_command(maybe_start_services=MagicMock(side_effect=UserCancelled)), + pytest.raises(typer.Exit) as exc_info, ): - self._invoke(auto=False) + setup_command(ctx, auto=False) + + assert exc_info.value.exit_code == 0 + assert "Setup cancelled" in capsys.readouterr().err + + +class TestSetupCommandRemoteFlow: + def test_remote_choice_connects_before_continuing_setup(self): + ctx, cli_context = _make_setup_command_ctx() + with _patch_setup_command(remote_url="https://remote.example.com") as mocks: + setup_command(ctx) + + mocks.prompt_remote.assert_called_once_with(default_url="http://localhost:8080") + mocks.configure_remote.assert_called_once_with(cli_context, "https://remote.example.com", "default") + mocks.ensure_auth.assert_called_once_with(cli_context) + assert mocks.run_interactive.call_args.args[3] == "https://remote.example.com" + + def test_remote_choice_preserves_active_workspace_when_flag_omitted(self): + ctx, cli_context = _make_setup_command_ctx(workspace="team-a") + with _patch_setup_command(remote_url="https://remote.example.com") as mocks: + setup_command(ctx) + + mocks.configure_remote.assert_called_once_with(cli_context, "https://remote.example.com", "team-a") + assert mocks.bootstrap.call_args_list == [ + call("https://remote.example.com", "default"), + call("https://remote.example.com", "team-a"), + ] + assert mocks.run_interactive.call_args.args[2] == "team-a" + + def test_remote_choice_uses_explicit_workspace_flag(self): + ctx, cli_context = _make_setup_command_ctx( + workspace="team-a", + workspace_source=ParameterSource.COMMANDLINE, + ) + with _patch_setup_command(remote_url="https://remote.example.com") as mocks: + setup_command(ctx, workspace="shared-workspace") + + mocks.configure_remote.assert_called_once_with(cli_context, "https://remote.example.com", "shared-workspace") + assert mocks.run_interactive.call_args.args[2] == "shared-workspace" + + def test_start_local_persists_default_url_and_starts_services(self): + ctx, cli_context = _make_setup_command_ctx(base_url="https://remote.example.com") + with _patch_setup_command( + maybe_start_services=["start_local", "ready"], + include_config_write=True, + ) as mocks: + setup_command(ctx) + + assert mocks.maybe_start_services.call_count == 2 + assert mocks.maybe_start_services.call_args_list[1] == call( + DEFAULT_BASE_URL, + False, + start_services=True, + timeout=_SERVICE_STARTUP_TIMEOUT_SECONDS, + ) + mocks.bootstrap.assert_any_call(DEFAULT_BASE_URL, "default") + mocks.config_write.assert_called_once_with({"base_url": DEFAULT_BASE_URL}, context_name="default") + assert cli_context.overrides["base_url"] == DEFAULT_BASE_URL + cli_context.reset_sdk_context.assert_called() + assert mocks.run_interactive.call_args.args[3] == DEFAULT_BASE_URL # --------------------------------------------------------------------------- @@ -2369,6 +2823,42 @@ def test_non_200_response(self): ok, _ = _check_controller_health("http://localhost:8080") assert ok is False + def test_status_not_published_on_hosted_deployment(self): + status_resp = MagicMock() + status_resp.status_code = 404 + cluster_resp = MagicMock() + cluster_resp.status_code = 200 + + def _get(url, **kwargs): + if url.endswith("/status"): + return status_resp + if url.endswith("/cluster-info"): + return cluster_resp + raise AssertionError(f"unexpected url: {url}") + + with patch(f"{SETUP_MOD}.httpx.get", side_effect=_get): + ok, msg = _check_controller_health("https://nemo-platform-freeplay.dev.aire.nvidia.com") + assert ok is True + assert "does not publish /status" in msg + + def test_status_404_without_cluster_info_is_unhealthy(self): + status_resp = MagicMock() + status_resp.status_code = 404 + cluster_resp = MagicMock() + cluster_resp.status_code = 404 + + def _get(url, **kwargs): + if url.endswith("/status"): + return status_resp + if url.endswith("/cluster-info"): + return cluster_resp + raise AssertionError(f"unexpected url: {url}") + + with patch(f"{SETUP_MOD}.httpx.get", side_effect=_get): + ok, msg = _check_controller_health("http://localhost:8080") + assert ok is False + assert "404" in msg + def test_invalid_json_response(self): resp = MagicMock() resp.status_code = 200 @@ -2406,6 +2896,21 @@ def test_healthy_returns_true(self): result = _verify_platform_health("http://localhost:8080") assert result is True + def test_missing_status_endpoint_returns_true_with_info(self): + with ( + patch( + f"{SETUP_MOD}._check_controller_health", + return_value=(True, "Hosted deployment does not publish /status."), + ), + patch(f"{SETUP_MOD}.console") as mock_console, + ): + result = _verify_platform_health("https://nemo-platform-freeplay.dev.aire.nvidia.com") + assert result is True + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "does not publish /status" in printed + assert "could not be verified" not in printed + assert "yellow" not in printed.lower() + def test_unhealthy_prints_red_error(self): with ( patch(f"{SETUP_MOD}._check_controller_health", return_value=(False, "models_controller unhealthy")), diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup_cli.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup_cli.py new file mode 100644 index 0000000000..c63bd9fdfb --- /dev/null +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup_cli.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CLI integration tests for ``nemo setup`` connection selection.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from nemo_platform.cli.app import app +from nemo_platform.cli.core.context import CLIContext +from nemo_platform.config.config import Config +from typer.testing import CliRunner + +SETUP_MOD = "nemo_platform.cli.commands.setup" + + +def test_remote_choice_retries_and_persists_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.touch() + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + client = MagicMock() + client.workspaces.retrieve.return_value = MagicMock() + + with ( + patch(f"{SETUP_MOD}.is_interactive", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="remote"), + patch( + f"{SETUP_MOD}.prompt_text", + side_effect=["https://unreachable.example.com", "https://remote.example.com/"], + ), + patch( + f"{SETUP_MOD}._check_platform_reachable_with_retries", + side_effect=[False, True, True], + ), + patch(f"{SETUP_MOD}._ensure_platform_auth") as ensure_auth, + patch(f"{SETUP_MOD}._start_services_background") as start_services, + patch.object(CLIContext, "get_client", return_value=client), + patch(f"{SETUP_MOD}._run_interactive_mode") as run_interactive, + ): + result = CliRunner().invoke(app, ["setup"]) + + assert result.exit_code == 0, result.output + assert "Unable to connect to NeMo Platform at https://unreachable.example.com" in result.output + context = Config.load(config_path=config_path).resolve() + assert str(context.cluster.base_url) == "https://remote.example.com/" + start_services.assert_not_called() + ensure_auth.assert_called_once() + assert run_interactive.call_args.args[3] == "https://remote.example.com" + + +def test_local_choice_starts_services_and_keeps_local_connection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.touch() + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + client = MagicMock() + client.workspaces.retrieve.return_value = MagicMock() + process = MagicMock(pid=1234) + + with ( + patch(f"{SETUP_MOD}.is_interactive", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="yes"), + patch(f"{SETUP_MOD}._prompt_data_dir", return_value="/tmp/nemo-demo"), + patch(f"{SETUP_MOD}.importlib.util.find_spec", return_value=MagicMock()), + patch(f"{SETUP_MOD}._ensure_port_available_for_start"), + patch(f"{SETUP_MOD}._start_services_background", return_value=process) as start_services, + patch(f"{SETUP_MOD}._wait_for_platform", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable_with_retries", return_value=True), + patch.object(CLIContext, "get_client", return_value=client), + patch(f"{SETUP_MOD}._run_interactive_mode") as run_interactive, + ): + result = CliRunner().invoke(app, ["setup"]) + + assert result.exit_code == 0, result.output + context = Config.load(config_path=config_path).resolve() + assert str(context.cluster.base_url) == "http://localhost:8080/" + start_services.assert_called_once() + assert run_interactive.call_args.args[3].rstrip("/") == "http://localhost:8080" + + +def test_start_myself_exits_without_starting_or_mutating_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.touch() + monkeypatch.setenv("NMP_CONFIG_FILE", str(config_path)) + + with ( + patch(f"{SETUP_MOD}.is_interactive", return_value=True), + patch(f"{SETUP_MOD}._check_platform_reachable", return_value=False), + patch(f"{SETUP_MOD}.prompt_choice", return_value="manual"), + patch(f"{SETUP_MOD}._start_services_background") as start_services, + ): + result = CliRunner().invoke(app, ["setup"]) + + assert result.exit_code == 1 + assert "Start the platform first" in result.output + assert config_path.read_text() == "" + start_services.assert_not_called() diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py index be8ef844d4..b1d074e9f8 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py @@ -53,7 +53,7 @@ def test_help_includes_getting_started(): assert "nemo docs --list" in result.stdout assert "nemo services run --help" in result.stdout # Help panel truncates long command descriptions; match the visible prefix. - assert "Set up NeMo Platform: start services" in result.stdout + assert "Set up NeMo Platform: connect or start services" in result.stdout assert "--help, -h" in result.stdout assert "nemo auth login --base-url" not in result.stdout assert "nemo quickstart configure" not in result.stdout diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py index 5af80018c9..47391148e1 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/config/test_config.py @@ -3,7 +3,9 @@ """Unit tests for the config module.""" +import os from pathlib import Path +from unittest.mock import patch import pytest import yaml @@ -865,6 +867,49 @@ def test_save_creates_file_with_600_permissions(self, tmp_path: Path): file_mode = config_path.stat().st_mode & 0o777 assert file_mode == 0o600, f"Expected 600, got {oct(file_mode)}" + def test_save_tolerates_unowned_parent_directory(self, tmp_path: Path): + """Saving under a world-writable parent (e.g. /tmp) should not fail on dir chmod.""" + config_path = tmp_path / "config.yaml" + + real_chmod = os.chmod + + def chmod_side_effect(path, mode): + if Path(path) == tmp_path: + raise PermissionError("Operation not permitted") + real_chmod(path, mode) + + with patch("nemo_platform.config.config.os.chmod", side_effect=chmod_side_effect): + Config.write( + {"base_url": "http://test.example.com"}, + context_name="default", + config_path=config_path, + ) + + assert config_path.exists() + file_mode = config_path.stat().st_mode & 0o777 + assert file_mode == 0o600, f"Expected 600, got {oct(file_mode)}" + + def test_save_raises_when_file_chmod_fails(self, tmp_path: Path): + """Credential file permission failures must remain fatal.""" + config_path = tmp_path / "config.yaml" + + real_chmod = os.chmod + + def chmod_side_effect(path, mode): + if Path(path) == config_path: + raise PermissionError("Operation not permitted") + real_chmod(path, mode) + + with ( + patch("nemo_platform.config.config.os.chmod", side_effect=chmod_side_effect), + pytest.raises(PermissionError), + ): + Config.write( + {"base_url": "http://test.example.com"}, + context_name="default", + config_path=config_path, + ) + class TestUserTypeDiscriminator: """Test User type discriminator and deserialization."""