""" Databricks profile onboarding for Omnigent. The internal-beta onboarding configures a fixed set of Databricks profiles in ``~/.databrickscfg`` (the specific workspaces live in :mod:`omnigent.onboarding.internal_beta`, which is excluded from the public OSS build). This module provides the generic profile machinery: - Discovers existing profiles via ``databricks auth profiles`` - Silently aliases existing profiles that already point at the right workspace under a different name (the OAuth token cache is host-keyed, so an alias inherits the login) - Walks the user through ``databricks auth login`` for whatever's left - Strips ``DATABRICKS_*`` env vars that would shadow profile lookup so stale tokens from a previous workspace can't override the requested profile No persistent state: source of truth is ``~/.databrickscfg`` itself, so every invocation re-derives "what needs to happen" from one filesystem read. """ from __future__ import annotations import configparser import json import os import shutil import subprocess import sys import time from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from urllib.parse import urlparse if TYPE_CHECKING: from rich.console import Console SKIP_ENV_VAR = "OMNIGENT_SKIP_ONBOARD" CONFLICTING_ENV_VARS: tuple[str, ...] = ( "DATABRICKS_HOST", "DATABRICKS_TOKEN", "DATABRICKS_CONFIG_PROFILE", "DATABRICKS_AUTH_TYPE", "DATABRICKS_CLIENT_ID", "DATABRICKS_CLIENT_SECRET", "DATABRICKS_USERNAME", "DATABRICKS_PASSWORD", ) _CONFLICTING_ENV_VARS = CONFLICTING_ENV_VARS @dataclass(frozen=True) class ProfileSpec: """ One Databricks profile Omnigent depends on. :param name: Profile name written to ``~/.databrickscfg``, e.g. ``"oss"``. :param host: Workspace URL the profile points at. :param purpose: One-line description shown during onboarding. :param is_model_gateway: Whether this workspace serves the Unity AI gateway that ucode configures coding harnesses against. Only gateway workspaces are passed to ``ucode configure``; MCP-only workspaces (e.g. Jira / Confluence) still get a Databricks profile for MCP auth but have no role in model serving, so configuring ucode against them is wasted work. """ name: str host: str purpose: str is_model_gateway: bool # ``DEFAULT_PROFILES`` (the internal-beta workspace hosts) lives in # :mod:`omnigent.onboarding.internal_beta`, which is excluded from the public # OSS build. The functions below import it lazily so this module stays free of # internal hostnames and imports cleanly in the OSS build (where the only setup # path is ``--no-internal-beta``, which never touches DEFAULT_PROFILES). # ── Env-var hygiene ──────────────────────────────────── def detect_conflicting_env_vars() -> list[str]: """ Return the names of set ``DATABRICKS_*`` env vars (catalog order). These shadow profile lookup in the SDK (env beats profile beats defaults). Empty-string values are treated as unset. :returns: e.g. ``["DATABRICKS_TOKEN"]``. """ return [v for v in _CONFLICTING_ENV_VARS if os.environ.get(v)] def find_databricks_cli() -> str | None: """Locate the ``databricks`` binary on ``$PATH``. :returns: Absolute path, or ``None`` if not found. """ return shutil.which("databricks") def _existing_profile_hosts() -> dict[str, str]: """ Return ``{profile_name: host}`` from ``~/.databrickscfg``. Shells out to ``databricks auth profiles --output json``. Returns ``{}`` on any failure so callers can treat "no CLI" / "no config" / "bad config" identically. :returns: Mapping of profile name to workspace host. """ cli = find_databricks_cli() if cli is None: return {} try: result = subprocess.run( [cli, "auth", "profiles", "--output", "json"], capture_output=True, text=True, check=False, timeout=10.0, ) except (OSError, subprocess.TimeoutExpired): return {} if result.returncode != 0: return {} try: payload = json.loads(result.stdout) except json.JSONDecodeError: return {} profiles = payload.get("profiles") if isinstance(payload, dict) else None if not isinstance(profiles, list): return {} out: dict[str, str] = {} for entry in profiles: if ( isinstance(entry, dict) and isinstance(entry.get("name"), str) and isinstance(entry.get("host"), str) ): out[entry["name"]] = entry["host"] return out def _host_matches(a: str, b: str) -> bool: """Compare two workspace hosts ignoring a trailing slash.""" return a.rstrip("/") == b.rstrip("/") # ── Aliasing ─────────────────────────────────────────── def _alias_source_for(host: str, target: str, existing: dict[str, str]) -> str | None: """ Find an existing profile pointing at ``host`` we can alias from. The Databricks CLI keys its OAuth token cache by host, so two profile sections pointing at the same workspace share the login — aliasing avoids a redundant browser OAuth dance. :param host: Target workspace URL. :param target: The profile name we're trying to create. :param existing: Output of :func:`_existing_profile_hosts`. :returns: A source profile name, or ``None`` if no match. """ for name, existing_host in existing.items(): if name != target and _host_matches(existing_host, host): return name return None def _databrickscfg_path() -> Path: """Return the active Databricks config file path. Honors ``DATABRICKS_CONFIG_FILE`` so callers that want to operate on a temporary copy (e.g. during ``omnigent setup``) can redirect all direct configparser writes without touching the user's real file. """ env = os.environ.get("DATABRICKS_CONFIG_FILE") return Path(env).expanduser() if env else Path.home() / ".databrickscfg" def _alias_profile(source: str, target: str) -> None: """ Copy the ``[source]`` section of ``~/.databrickscfg`` to ``[target]``. Atomic write via tempfile + rename. Raises if ``source`` isn't in the file (which would be a programmer error — callers must check that ``source`` exists first). :param source: Existing profile name to copy from. :param target: New profile name to create. """ path = _databrickscfg_path() cfg = configparser.ConfigParser() cfg.read(path) if source not in cfg: raise ValueError(f"alias source {source!r} not in {path}") cfg[target] = dict(cfg[source]) tmp = path.with_name(path.name + ".write") with tmp.open("w") as f: cfg.write(f) tmp.replace(path) def _remove_profile_section(name: str) -> bool: """ Remove ``[name]`` from ``~/.databrickscfg``. Atomic write. Necessary before ``databricks auth login --host X --profile name`` when ``name`` already exists with a different host — the CLI refuses the host change otherwise. :param name: Profile section to drop, e.g. ``"oss"``. :returns: ``True`` if the section existed and was removed. """ path = _databrickscfg_path() if not path.exists(): return False cfg = configparser.ConfigParser() cfg.read(path) if name not in cfg: return False cfg.remove_section(name) tmp = path.with_name(path.name + ".write") with tmp.open("w") as f: cfg.write(f) tmp.replace(path) return True # ── Compute actions ─────────────────────────────────── @dataclass(frozen=True) class _Actions: """ What :func:`maybe_run_onboarding` needs to do for the current state. :param ready: Profiles already correctly configured. :param aliasable: Tuples of ``(source_profile, target_spec)`` we can alias without OAuth. :param oauth: Profiles that need a fresh ``databricks auth login``. :param wrong_host: Profiles where the alpha name (e.g. ``oss``) exists but points at the wrong workspace. We can't auto-alias these without clobbering the user's existing config, so they need explicit confirmation before we overwrite. """ ready: tuple[ProfileSpec, ...] aliasable: tuple[tuple[str, ProfileSpec], ...] oauth: tuple[ProfileSpec, ...] wrong_host: tuple[tuple[ProfileSpec, str], ...] def _compute_actions(existing: dict[str, str]) -> _Actions: """Classify each :data:`DEFAULT_PROFILES` entry by what it needs. :param existing: Current ``{profile: host}`` from the CLI. :returns: Bucketed actions. """ # Lazy import: the internal-beta workspace list is excluded from the OSS # build. This function is only reached via ``setup --internal-beta``. from omnigent.onboarding.internal_beta import DEFAULT_PROFILES ready: list[ProfileSpec] = [] aliasable: list[tuple[str, ProfileSpec]] = [] oauth: list[ProfileSpec] = [] wrong_host: list[tuple[ProfileSpec, str]] = [] for spec in DEFAULT_PROFILES: host = existing.get(spec.name) if host is not None and _host_matches(host, spec.host): ready.append(spec) continue if host is not None: # Profile name matches but the host doesn't — needs an # explicit "overwrite or skip" decision from the user. wrong_host.append((spec, host)) continue source = _alias_source_for(spec.host, spec.name, existing) if source is not None: aliasable.append((source, spec)) else: oauth.append(spec) return _Actions(tuple(ready), tuple(aliasable), tuple(oauth), tuple(wrong_host)) # ── OAuth login ─────────────────────────────────────── _OAUTH_BETWEEN_LOGIN_SLEEP_SECONDS = 1.0 def _login_profile(cli: str, spec: ProfileSpec, console: Console) -> bool: """Run ``databricks auth login`` for a single profile. Sleeps briefly after each successful login so the IdP's session-scoped OAuth state drains before the next call's listener opens on the same ``localhost:8020`` redirect URI — without this pause, Okta SSO can deliver a previous flow's callback to the current listener and trip ``state mismatch in 3-legged-OAuth flow``. :param cli: Absolute path to the ``databricks`` binary. :param spec: Profile to set up. :param console: Rich console for surrounding messages. :returns: ``True`` on success. """ console.print(f"\n [bold]→ {spec.name}[/bold] [dim]{spec.purpose}[/dim]") console.print(f" [dim]host: {spec.host}[/dim]") try: result = subprocess.run( [cli, "auth", "login", "--host", spec.host, "--profile", spec.name], check=False, ) except KeyboardInterrupt: console.print(f" [yellow]cancelled {spec.name}[/yellow]") return False if result.returncode != 0: console.print(f" [red]failed (exit {result.returncode})[/red]") return False console.print(f" [green]✓ {spec.name}[/green]") time.sleep(_OAUTH_BETWEEN_LOGIN_SLEEP_SECONDS) return True # ── Top-level entry points ──────────────────────────── def _apply_silent_aliases(aliasable: tuple[tuple[str, ProfileSpec], ...], console: Console) -> int: """Create aliases for every entry; return how many landed.""" n = 0 for source, spec in aliasable: try: _alias_profile(source, spec.name) except (ValueError, OSError) as exc: console.print(f" [yellow]could not alias {spec.name} from {source}: {exc}[/yellow]") continue n += 1 return n def run_onboarding() -> bool: """ Interactive Databricks profile setup flow. 1. Bail if ``databricks`` CLI is missing. 2. Silently alias any same-host profiles. 3. For the remaining gaps, run ``databricks auth login``. :returns: ``True`` if all three profiles are configured at the end. """ from rich.console import Console from rich.panel import Panel # Lazy import: internal-beta workspace list, excluded from the OSS build. from omnigent.onboarding.internal_beta import DEFAULT_PROFILES console = Console() if os.environ.get(SKIP_ENV_VAR): console.print(f" [dim]{SKIP_ENV_VAR} set; skipping.[/dim]") return False cli = find_databricks_cli() if cli is None: installer = ( "curl -fsSL " "https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh " "| sh" ) console.print( f"\n [bold red]`databricks` CLI not on PATH.[/bold red]\n" f" Install with:\n [cyan]{installer}[/cyan]\n" ) return False console.print( Panel( "[bold]Omnigent onboarding[/bold]\n\n" "Setting up the Databricks profiles Omnigent needs " f"({', '.join(f'`{s.name}`' for s in DEFAULT_PROFILES)}).", border_style="cyan", padding=(1, 2), ) ) existing = _existing_profile_hosts() actions = _compute_actions(existing) if actions.aliasable: n = _apply_silent_aliases(actions.aliasable, console) if n: console.print(f"\n [green]✓ aliased {n} existing profile(s)[/green]") existing = _existing_profile_hosts() actions = _compute_actions(existing) for spec, current_host in actions.wrong_host: _handle_wrong_host(cli, spec, current_host, console) if actions.wrong_host: existing = _existing_profile_hosts() actions = _compute_actions(existing) if not actions.oauth and not actions.wrong_host: console.print("\n [bold green]all profiles ready.[/bold green]\n") return True for spec in actions.oauth: _login_profile(cli, spec, console) actions = _compute_actions(_existing_profile_hosts()) done = not actions.oauth and not actions.wrong_host if done: console.print("\n [bold green]✓ onboarding complete.[/bold green]\n") else: names = [s.name for s in actions.oauth] + [s.name for s, _ in actions.wrong_host] console.print(f"\n [yellow]still missing: {', '.join(names)}[/yellow]\n") return done def _handle_wrong_host(cli: str, spec: ProfileSpec, current_host: str, console: Console) -> None: """ Prompt the user about a profile whose name matches an alpha slot but points at a different workspace. Three escapes: overwrite (run ``databricks auth login`` for the alpha host, which the CLI handles by replacing the section), skip (leave the user's profile alone — they'll need to rename it manually and re-run), or default-skip on non-TTY / EOF. :param cli: Absolute path to the ``databricks`` binary. :param spec: The alpha profile spec we wanted to set up. :param current_host: Where the user's existing same-name profile currently points. :param console: Rich console for the surrounding messaging. """ console.print( f"\n [yellow]! `{spec.name}` exists but points at the wrong workspace:[/yellow]\n" f" current: {current_host}\n" f" expected: {spec.host}\n" f" [dim]Overwriting will REPLACE the existing `{spec.name}` section in " f"~/.databrickscfg. To keep your existing one, decline below and rename it " f"first (e.g. `databricks auth login --profile {spec.name}-personal " f"--host {current_host}`).[/dim]" ) if not sys.stdin.isatty(): console.print( f" [dim](non-TTY — skipping. Rename your existing `{spec.name}` and re-run.)[/dim]" ) return try: answer = input(f" Overwrite `{spec.name}`? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): console.print() return if answer not in ("y", "yes"): console.print(f" [dim]Skipped `{spec.name}`.[/dim]") return # The Databricks CLI refuses ``--host X --profile NAME`` when NAME already # exists with a different host ("conflicts with --host"). Drop the existing # section first so the login starts from a clean slate. _remove_profile_section(spec.name) _login_profile(cli, spec, console) def _derive_workspace_profile_name(workspace_url: str, existing: dict[str, str]) -> str: """Pick the ``~/.databrickscfg`` profile name to use for a workspace URL. Resolution order. Reusing an *existing* login is the priority — the whole point of the flow is to avoid extra browser sign-in windows — so an already-authenticated profile for this host wins even over the bundled canonical name: 1. An existing profile in ``~/.databrickscfg`` already pointing at this host — reuse its name, so the host-keyed OAuth token cache is reused and no second ``databricks auth login`` is triggered. This wins even for the bundled OSS host: a user already logged in under their own profile name is never re-sent through a fresh ``--profile oss`` login. 2. A bundled :data:`DEFAULT_PROFILES` spec whose host matches — use its canonical name (e.g. the OSS gateway URL → ``"oss"``) when the user has no existing profile for it, lining up with the routing fallbacks in :func:`omnigent.onboarding.databricks_config.get_workspace_url_for_profile`. 3. Otherwise derive a readable name from the workspace host's first DNS label, e.g. ``https://my-ws.cloud.databricks.com`` → ``"my-ws"``. :param workspace_url: The workspace URL, trailing slash already stripped, e.g. ``"https://my-ws.cloud.databricks.com"``. :param existing: ``{profile_name: host}`` from :func:`_existing_profile_hosts`. :returns: The profile section name to create or reuse, e.g. ``"oss"`` or ``"my-ws"``. """ for name, host in existing.items(): if _host_matches(host, workspace_url): return name # Lazy import: internal-beta workspace list, intentionally absent from # the OSS build — without it there are no bundled canonical names and # the derived-label fallback below applies. try: from omnigent.onboarding.internal_beta import DEFAULT_PROFILES except ModuleNotFoundError: DEFAULT_PROFILES = () for spec in DEFAULT_PROFILES: if _host_matches(spec.host, workspace_url): return spec.name netloc = urlparse(workspace_url).netloc # The first DNS label is the most human-recognizable handle; fall back # to the full netloc, then a constant, so we never return an empty name. first_label = netloc.split(".")[0] if netloc else "" return first_label or netloc or "databricks" def login_databricks_workspace(workspace_url: str, *, console: Console | None = None) -> str: """Ensure ``~/.databrickscfg`` has an authed profile for *workspace_url*. Runs ``databricks auth login --host --profile `` for a single workspace and returns the profile name, so the caller can persist a ``kind: databricks`` provider keyed on it. This is the only place Omnigent triggers a Databricks CLI login: it fires solely when a user explicitly adds a Databricks provider in ``omnigent setup --no-internal-beta``, never on a bare ``omnigent run``. Idempotent: when a profile already points at this host (the OAuth token cache is host-keyed, so the login is still valid), it is reused without a fresh browser sign-in. If the derived profile name already exists but points at a *different* host, that stale section is dropped first — the CLI refuses ``--host`` against a name already bound to another host. :param workspace_url: The Databricks workspace URL to authenticate, e.g. ``"https://my-workspace.cloud.databricks.com"``. A trailing slash is stripped. :param console: Rich console for progress output; a fresh :class:`~rich.console.Console` is created when ``None``. :returns: The ``~/.databrickscfg`` profile name now authenticated for the workspace, e.g. ``"oss"``. :raises click.ClickException: If the ``databricks`` CLI is not on PATH, or the login subprocess fails. """ import click from rich.console import Console from omnigent.onboarding.databricks_config import normalize_workspace_url console = console or Console() workspace_url = normalize_workspace_url(workspace_url) cli = find_databricks_cli() if cli is None: installer = ( "curl -fsSL " "https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh " "| sh" ) raise click.ClickException( "`databricks` CLI not on PATH — required to authenticate the workspace.\n" f" Install with:\n {installer}" ) existing = _existing_profile_hosts() profile = _derive_workspace_profile_name(workspace_url, existing) current_host = existing.get(profile) if current_host is not None and _host_matches(current_host, workspace_url): console.print(f" [green]✓ using existing Databricks profile `{profile}`[/green]") return profile if current_host is not None: # Name collides with a profile bound to a different host; the CLI # won't rebind --host onto an existing name, so drop it first. _remove_profile_section(profile) spec = ProfileSpec( name=profile, host=workspace_url, purpose="Databricks model serving (Unity AI Gateway)", # The user is adding this workspace specifically for model serving, so # it's a gateway workspace. is_model_gateway=True, ) if not _login_profile(cli, spec, console): raise click.ClickException( f"`databricks auth login` failed for {workspace_url}; see the output above." ) return profile def maybe_run_onboarding() -> None: """ Pre-flight onboarding for ``omnigent run``. Fast path on every call: read ``~/.databrickscfg``, classify profiles, and: - All three configured → no-op. - Some same-host profiles exist under different names → silently alias, print one-line note, no prompt. - Some still need OAuth → ``Y/n`` prompt; on ``Y`` delegate to :func:`run_onboarding`. Skipped when ``$OMNIGENT_SKIP_ONBOARD`` is set or stdin isn't a TTY (CI / piped input — can't safely launch OAuth). """ if os.environ.get(SKIP_ENV_VAR): return if not sys.stdin.isatty(): return existing = _existing_profile_hosts() actions = _compute_actions(existing) if not actions.aliasable and not actions.oauth and not actions.wrong_host: return from rich.console import Console console = Console() if actions.aliasable: n = _apply_silent_aliases(actions.aliasable, console) if n: console.print( f"omnigent: aliased {n} existing profile(s) " f"to Omnigent names: " f"{', '.join(spec.name for _, spec in actions.aliasable)}" ) existing = _existing_profile_hosts() actions = _compute_actions(existing) if not actions.oauth and not actions.wrong_host: return missing_parts = [s.name for s in actions.oauth] missing_parts += [f"{s.name} (wrong host)" for s, _ in actions.wrong_host] console.print( f"\n [yellow]Omnigent needs Databricks profiles: {', '.join(missing_parts)}[/yellow]" ) try: answer = input(" Run onboarding now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): console.print() return if answer in ("", "y", "yes"): run_onboarding() else: console.print( " [dim]run `omnigent setup --internal-beta` when ready " f"(or `{SKIP_ENV_VAR}=1` to silence).[/dim]" )