diff --git a/api/install.sh b/api/install.sh index 67785c1da7..8663736d66 100755 --- a/api/install.sh +++ b/api/install.sh @@ -593,6 +593,18 @@ multiuser_setup() { echo "Installing SkyPilot with Kubernetes support..." uv pip install --python "${GENERAL_UV_ENV_DIR}/bin/python" "skypilot[kubernetes]==0.12.1" + # Nebius AI Cloud CLI (used by the Nebius compute provider; default install path ~/.nebius/bin/nebius). + if command -v curl &> /dev/null; then + if [[ -x "${HOME}/.nebius/bin/nebius" ]]; then + ohai "Nebius CLI already installed at ${HOME}/.nebius/bin/nebius" + else + ohai "Installing Nebius CLI..." + curl -sSL https://storage.eu-north1.nebius.cloud/cli/install.sh | bash + fi + else + warn "curl is not available; skipping Nebius CLI install. Install manually if you use Nebius providers." + fi + echo "Multiuser setup complete." } diff --git a/api/pyproject.toml b/api/pyproject.toml index 19a772d63b..65d82f7744 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "fastapi==0.125.0", "fastapi-users[sqlalchemy,oauth]==15.0.5", "httpx-oauth==0.16.1", + "nebius==0.3.65", "packaging==26.2", "psutil==7.2.2", "pydantic>=2.13.4,<3.0", diff --git a/api/test/compute_providers/test_nebius_subnet.py b/api/test/compute_providers/test_nebius_subnet.py new file mode 100644 index 0000000000..8687261f25 --- /dev/null +++ b/api/test/compute_providers/test_nebius_subnet.py @@ -0,0 +1,71 @@ +"""Tests for Nebius automatic subnet resolution.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from transformerlab.compute_providers.models import ClusterConfig +from transformerlab.compute_providers.nebius import NEBIUS_AUTO_SUBNET_NAME, NebiusProvider + + +def test_resolve_subnet_uses_configured_id() -> None: + p = NebiusProvider(team_id="t1", parent_id="proj", subnet_id="subnet-fixed") + assert p._resolve_subnet_id_for_launch() == "subnet-fixed" + + +def test_resolve_subnet_reuses_existing_list() -> None: + p = NebiusProvider(team_id="t1", parent_id="proj-a", subnet_id=None) + p._run_nebius = MagicMock(return_value={"subnets": [{"metadata": {"id": "sn-1", "name": "s1"}}]}) + assert p._resolve_subnet_id_for_launch() == "sn-1" + p._run_nebius.assert_called_once() + assert p._resolved_subnet_id == "sn-1" + + +def test_resolve_subnet_creates_network_then_subnet() -> None: + p = NebiusProvider(team_id="t1", parent_id="proj-b", subnet_id=None) + subnet_list_returns: list = [[], []] + + def fake_run(args: list, stdin_json=None, timeout=120): + joined = " ".join(args) + if "subnet list" in joined: + return {"subnets": subnet_list_returns.pop(0)} + if "network list" in joined: + return {"networks": []} + if "create-default" in joined: + return {"metadata": {"id": "net-new"}} + if "subnet create" in joined: + return {"metadata": {"id": "sn-created"}} + raise AssertionError(f"unexpected nebius call: {joined}") + + p._run_nebius = MagicMock(side_effect=fake_run) + assert p._resolve_subnet_id_for_launch() == "sn-created" + create_calls = [c for c in p._run_nebius.call_args_list if "subnet create" in " ".join(c[0][0])] + assert len(create_calls) == 1 + create_args = create_calls[0][0][0] + assert NEBIUS_AUTO_SUBNET_NAME in create_args + + +def test_resolve_subnet_requires_parent_when_no_subnet() -> None: + p = NebiusProvider(team_id="t1", parent_id=None, subnet_id=None) + with pytest.raises(ValueError, match="parent_id"): + p._resolve_subnet_id_for_launch() + + +def test_cloud_init_includes_self_termination_trap() -> None: + p = NebiusProvider(team_id="t1") + user_data = p._build_cloud_init("cluster-a", ClusterConfig(run="echo hello"), public_key="ssh-ed25519 AAAATEST") + assert "_tfl_self_terminate" in user_data + assert "trap _tfl_self_terminate EXIT" in user_data + + +def test_cloud_init_self_termination_fallbacks() -> None: + p = NebiusProvider(team_id="t1") + user_data = p._build_cloud_init("cluster-a", ClusterConfig(run="echo hello"), public_key="ssh-ed25519 AAAATEST") + assert "_tfl_self_delete_instance" in user_data + assert "_tfl_ensure_nebius_cli" in user_data + assert "https://storage.eu-north1.nebius.cloud/cli/install.sh | bash" in user_data + assert 'export PATH="$HOME/.nebius/bin:$PATH"' in user_data + assert 'nebius compute instance delete --id "$_iid" --async' in user_data + assert "shutdown -h now || poweroff || true" in user_data diff --git a/api/transformerlab/compute_providers/config.py b/api/transformerlab/compute_providers/config.py index b487c33567..ce9276fc17 100644 --- a/api/transformerlab/compute_providers/config.py +++ b/api/transformerlab/compute_providers/config.py @@ -56,6 +56,16 @@ class ComputeProviderConfig(BaseModel): service_account_json: Optional[Dict[str, Any]] = None service_account_email: Optional[str] = None + # Nebius-specific config + nebius_profile: Optional[str] = None # Nebius CLI profile name + nebius_config_path: Optional[str] = None # Provider-scoped Nebius CLI config path + parent_id: Optional[str] = None # Nebius project/parent ID + subnet_id: Optional[str] = None # Nebius VPC subnet ID + default_platform: Optional[str] = None # e.g. "gpu-h100-sxm" or "cpu-d3" + default_preset: Optional[str] = None # e.g. "1gpu-16vcpu-200gb" + boot_image_family: Optional[str] = None # e.g. "ubuntu24.04-cuda13.0" + disk_size_gib: Optional[int] = None + # Additional provider-specific config extra_config: Dict[str, Any] = Field(default_factory=dict) @@ -265,6 +275,29 @@ def create_compute_provider(config: ComputeProviderConfig) -> "ComputeProvider": team_id=config.team_id, extra_config=config.extra_config, ) + elif config.type == "nebius": + from .nebius import NebiusProvider + + if not config.team_id: + raise ValueError("Nebius provider requires team_id in config") + if not config.subnet_id and not config.parent_id: + raise ValueError( + "Nebius provider requires parent_id (Nebius project) for automatic default network/subnet, " + "or subnet_id if you use your own VPC subnet" + ) + return NebiusProvider( + team_id=config.team_id, + parent_id=config.parent_id, + subnet_id=config.subnet_id, + profile=config.nebius_profile, + config_path=config.nebius_config_path, + default_platform=config.default_platform, + default_preset=config.default_preset, + boot_image_family=config.boot_image_family, + disk_size_gib=config.disk_size_gib, + ssh_user=config.ssh_user, + extra_config=config.extra_config, + ) elif config.type == "vastai": from .vastai import VastAIProvider diff --git a/api/transformerlab/compute_providers/nebius.py b/api/transformerlab/compute_providers/nebius.py new file mode 100644 index 0000000000..5241c0d14c --- /dev/null +++ b/api/transformerlab/compute_providers/nebius.py @@ -0,0 +1,711 @@ +"""Nebius AI Cloud compute provider implementation. + +This provider uses the Nebius CLI as the supported control-plane client. The +Nebius docs show VM creation via `nebius compute instance create --format json -` +with a JSON instance spec, cloud-init user data, managed boot disks, and public +network interfaces. Keeping the integration on top of the CLI avoids vendoring +Nebius generated gRPC clients while still following the documented API shape. +""" + +from __future__ import annotations + +import asyncio +import io +import json +import logging +import re +import shlex +import subprocess +from typing import Any, Dict, List, Optional, Union + +from transformerlab.services.nebius_cli_resolve import nebius_cli_argv_prefix, nebius_cli_available +from transformerlab.services.ssh_key_service import get_org_ssh_private_key +from transformerlab.shared.ssh_policy import get_add_if_verified_policy + +from .base import ComputeProvider +from .models import ClusterConfig, ClusterState, ClusterStatus, JobConfig, JobInfo, ResourceInfo + +logger = logging.getLogger(__name__) + +NEBIUS_RUN_LOGS_PATH = "/workspace/run_logs.txt" +DEFAULT_NEBIUS_USER = "tflab" +NEBIUS_AUTO_SUBNET_NAME = "transformerlab-default" + + +_NEBIUS_STATE_TO_CLUSTER_STATE: Dict[str, ClusterState] = { + "CREATING": ClusterState.INIT, + "STARTING": ClusterState.INIT, + "RUNNING": ClusterState.UP, + "UPDATING": ClusterState.UP, + "STOPPING": ClusterState.STOPPED, + "STOPPED": ClusterState.STOPPED, + "DELETING": ClusterState.DOWN, + "DELETED": ClusterState.DOWN, + "FAILED": ClusterState.FAILED, + "ERROR": ClusterState.FAILED, +} + + +_GPU_PLATFORM_PRESET_MAP: Dict[tuple[str, int], tuple[str, str]] = { + ("B300", 1): ("gpu-b300-sxm", "1gpu-24vcpu-346gb"), + ("B300", 8): ("gpu-b300-sxm", "8gpu-192vcpu-2768gb"), + ("B200", 1): ("gpu-b200-sxm", "1gpu-20vcpu-224gb"), + ("B200", 8): ("gpu-b200-sxm", "8gpu-160vcpu-1792gb"), + ("H200", 1): ("gpu-h200-sxm", "1gpu-16vcpu-200gb"), + ("H200", 8): ("gpu-h200-sxm", "8gpu-128vcpu-1600gb"), + ("H100", 1): ("gpu-h100-sxm", "1gpu-16vcpu-200gb"), + ("H100", 8): ("gpu-h100-sxm", "8gpu-128vcpu-1600gb"), + ("RTX6000", 1): ("gpu-rtx6000", "1gpu-24vcpu-218gb"), + ("RTXPRO6000", 1): ("gpu-rtx6000", "1gpu-24vcpu-218gb"), + ("RTX6000", 8): ("gpu-rtx6000", "8gpu-192vcpu-1744gb"), + ("RTXPRO6000", 8): ("gpu-rtx6000", "8gpu-192vcpu-1744gb"), + ("L40S", 1): ("gpu-l40s-d", "1gpu-16vcpu-96gb"), + ("L40S", 2): ("gpu-l40s-d", "2gpu-64vcpu-384gb"), + ("L40S", 4): ("gpu-l40s-d", "4gpu-128vcpu-768gb"), +} + +_CPU_PRESET_OPTIONS: List[tuple[int, int, str]] = [ + (4, 16, "4vcpu-16gb"), + (8, 32, "8vcpu-32gb"), + (16, 64, "16vcpu-64gb"), + (32, 128, "32vcpu-128gb"), + (48, 192, "48vcpu-192gb"), + (64, 256, "64vcpu-256gb"), + (96, 384, "96vcpu-384gb"), + (128, 512, "128vcpu-512gb"), + (160, 640, "160vcpu-640gb"), + (192, 768, "192vcpu-768gb"), + (224, 896, "224vcpu-896gb"), + (256, 1024, "256vcpu-1024gb"), +] + + +def _parse_memory_gb(memory: Union[int, float, str, None]) -> float: + if memory is None: + return 0.0 + if isinstance(memory, (int, float)): + return float(memory) + stripped = str(memory).strip().upper() + for suffix in ("GIB", "GB", "G", "MIB", "MB", "M"): + if stripped.endswith(suffix): + value = stripped[: -len(suffix)].strip() + try: + parsed = float(value) + except ValueError: + return 0.0 + return parsed / 1024.0 if suffix in ("MIB", "MB", "M") else parsed + try: + return float(stripped) + except ValueError: + return 0.0 + + +def _resolve_cpu_preset(cpus: Union[int, str, None], memory: Union[int, float, str, None]) -> str: + requested_cpus = int(cpus) if cpus else 0 + requested_memory = _parse_memory_gb(memory) + for preset_cpus, preset_memory, preset in _CPU_PRESET_OPTIONS: + if preset_cpus >= requested_cpus and preset_memory >= requested_memory: + return preset + raise ValueError( + f"No Nebius CPU preset found for cpus={requested_cpus}, memory={requested_memory}GB. " + "Maximum mapped preset: 256 vCPUs, 1024 GB memory." + ) + + +def _resolve_gpu_platform_preset(accelerators: str) -> tuple[str, str]: + parts = accelerators.strip().split(":") + accelerator_type = re.sub(r"[^A-Za-z0-9]", "", parts[0].upper()) + count = int(parts[1].strip()) if len(parts) > 1 else 1 + key = (accelerator_type, count) + if key not in _GPU_PLATFORM_PRESET_MAP: + valid = sorted(f"{gpu}:{count}" for gpu, count in _GPU_PLATFORM_PRESET_MAP) + raise ValueError(f"Unsupported Nebius accelerator spec '{accelerators}'. Valid options: {', '.join(valid)}") + return _GPU_PLATFORM_PRESET_MAP[key] + + +def _nested_get(data: Dict[str, Any], path: List[Union[str, int]]) -> Any: + current: Any = data + for key in path: + if isinstance(key, int): + if not isinstance(current, list) or len(current) <= key: + return None + current = current[key] + else: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +def _extract_resource_id(data: Dict[str, Any]) -> Optional[str]: + for path in (["metadata", "id"], ["id"]): + value = _nested_get(data, list(path)) + if value: + return str(value) + return None + + +def _extract_public_ip(instance: Dict[str, Any]) -> Optional[str]: + interfaces = _nested_get(instance, ["status", "network_interfaces"]) + if not isinstance(interfaces, list): + return None + for interface in interfaces: + if not isinstance(interface, dict): + continue + public_ip = interface.get("public_ip_address") or {} + if isinstance(public_ip, dict): + address = public_ip.get("address") or public_ip.get("ip") + if address: + return str(address).split("/")[0] + return None + + +def _ssh_read_file(host: str, username: str, key_bytes: bytes, remote_path: str, tail_lines: int = 500) -> str: + import paramiko + + pkey = None + key_file = io.StringIO(key_bytes.decode("utf-8")) + for key_class in (paramiko.Ed25519Key, paramiko.RSAKey): + try: + pkey = key_class.from_private_key(key_file) + break + except Exception: + key_file.seek(0) + + if pkey is None: + return "Failed to load SSH key." + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(get_add_if_verified_policy()) + try: + ssh.connect(hostname=host, port=22, username=username, pkey=pkey, timeout=15, banner_timeout=15) + cmd = f"tail -n {tail_lines} {remote_path} 2>/dev/null || echo 'No log file yet.'" + _, stdout, _ = ssh.exec_command(cmd, timeout=10) + return stdout.read().decode("utf-8", errors="replace").strip() or "No output yet." + except Exception as exc: + return f"SSH failed: {exc}" + finally: + ssh.close() + + +class NebiusProvider(ComputeProvider): + """Compute provider that launches Nebius AI Cloud VMs with cloud-init.""" + + def __init__( + self, + team_id: str, + parent_id: Optional[str] = None, + subnet_id: Optional[str] = None, + profile: Optional[str] = None, + config_path: Optional[str] = None, + default_platform: Optional[str] = None, + default_preset: Optional[str] = None, + boot_image_family: Optional[str] = None, + disk_size_gib: Optional[int] = None, + ssh_user: Optional[str] = None, + extra_config: Optional[Dict[str, Any]] = None, + ) -> None: + self.team_id = team_id + self.parent_id = parent_id + self.subnet_id = subnet_id + self.profile = profile + self.config_path = config_path + self.default_platform = default_platform + self.default_preset = default_preset + self.boot_image_family = boot_image_family + self.disk_size_gib = disk_size_gib or 200 + self.ssh_user = ssh_user or DEFAULT_NEBIUS_USER + self.extra_config = extra_config or {} + # Filled on first launch when subnet_id is omitted (automatic VPC setup). + self._resolved_subnet_id: Optional[str] = None + + def _base_cmd(self) -> List[str]: + cmd = list(nebius_cli_argv_prefix()) + if self.config_path: + cmd.extend(["--config", self.config_path]) + if self.profile: + cmd.extend(["--profile", self.profile]) + return cmd + + def _run_nebius(self, args: List[str], stdin_json: Optional[Dict[str, Any]] = None, timeout: int = 120) -> Any: + if not nebius_cli_available(): + raise RuntimeError( + "Nebius CLI is not available in this Python environment. " + "Install API dependencies (package `nebius` in api/pyproject.toml) and run the API with that venv." + ) + + cmd = self._base_cmd() + args + input_text = json.dumps(stdin_json) if stdin_json is not None else None + proc = subprocess.run( + cmd, + input=input_text, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + if proc.returncode != 0: + stderr = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError(f"Nebius CLI command failed ({' '.join(shlex.quote(c) for c in cmd)}): {stderr}") + output = (proc.stdout or "").strip() + if not output: + return {} + try: + return json.loads(output) + except json.JSONDecodeError: + return output + + def check(self) -> tuple[bool, str | None]: + try: + args = ["profile", "current", "--format", "json"] + self._run_nebius(args, timeout=30) + + return True, None + except Exception as exc: + reason = f"Nebius provider check failed: {exc}" + logger.warning(reason) + return False, reason + + def _resolve_platform_preset(self, config: ClusterConfig) -> tuple[str, str]: + provider_config = config.provider_config or {} + platform = provider_config.get("platform") or provider_config.get("resources_platform") + preset = provider_config.get("preset") or provider_config.get("resources_preset") + if platform and preset: + return str(platform), str(preset) + if self.default_platform and self.default_preset: + return self.default_platform, self.default_preset + if config.instance_type: + if ":" in config.instance_type: + parsed_platform, parsed_preset = config.instance_type.split(":", 1) + return parsed_platform, parsed_preset + if self.default_platform: + return self.default_platform, config.instance_type + if config.accelerators: + return _resolve_gpu_platform_preset(config.accelerators) + return "cpu-d3", _resolve_cpu_preset(config.cpus, config.memory) + + def _image_family_for_platform(self, platform: str) -> str: + if self.boot_image_family: + return self.boot_image_family + if platform.startswith("gpu-"): + return "ubuntu24.04-cuda13.0" + return "ubuntu24.04-driverless" + + def _build_cloud_init(self, cluster_name: str, config: ClusterConfig, public_key: str) -> str: + env_exports_lines = [] + for key, value in config.env_vars.items(): + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + raise ValueError(f"Invalid environment variable name: {key!r}") + env_exports_lines.append(f"export {key}={shlex.quote(str(value))}") + env_exports = "\n".join(env_exports_lines) + setup_block = config.setup or "" + run_cmd = config.run or "" + parent_id_env = shlex.quote(self.parent_id) if self.parent_id else "" + script = f"""set -eo pipefail +# Best-effort self-delete on EXIT (success or crash) via Nebius control plane. +# Fallback is guest shutdown if delete is unavailable. +TFL_CLUSTER_NAME={shlex.quote(cluster_name)} +TFL_NEBIUS_PARENT_ID={parent_id_env} + +_tfl_self_delete_instance() {{ + local _iid="" + + _tfl_ensure_nebius_cli() {{ + if command -v nebius >/dev/null 2>&1; then + return 0 + fi + # Best effort: install Nebius CLI using the same installer used by api/install.sh. + if command -v curl >/dev/null 2>&1; then + curl -sSL https://storage.eu-north1.nebius.cloud/cli/install.sh | bash >/dev/null 2>&1 || true + export PATH="$HOME/.nebius/bin:$PATH" + fi + command -v nebius >/dev/null 2>&1 + }} + + # Try common metadata endpoints first. + _iid=$(curl -sf http://169.254.169.254/latest/meta-data/instance-id 2>/dev/null || true) + if [ -z "$_iid" ]; then + _iid=$(curl -sf -H "Metadata-Flavor: Google" \ + "http://169.254.169.254/computeMetadata/v1/instance/id" 2>/dev/null || true) + fi + + # Fallback: resolve id by cluster name when parent_id and CLI are available. + if [ -z "$_iid" ] && [ -n "$TFL_NEBIUS_PARENT_ID" ] && _tfl_ensure_nebius_cli; then + _iid=$(nebius compute instance list --parent-id "$TFL_NEBIUS_PARENT_ID" --format json 2>/dev/null | \ + python3 -c 'import json,sys; data=json.load(sys.stdin); items=(data.get("items") or data.get("instances") or []); \ +name="'$TFL_CLUSTER_NAME'"; print(next((str((i.get("metadata") or {{}}).get("id") or i.get("id") or "") for i in items if ((i.get("metadata") or {{}}).get("name") or i.get("name"))==name), ""))' \ + 2>/dev/null || true) + fi + + if [ -n "$_iid" ] && _tfl_ensure_nebius_cli; then + nebius compute instance delete --id "$_iid" --async >/dev/null 2>&1 || true + fi + return 0 +}} + +_tfl_self_terminate() {{ + _tfl_self_delete_instance || true + sync || true + shutdown -h now || poweroff || true + return 0 +}} +trap _tfl_self_terminate EXIT + +mkdir -p /workspace +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-venv python3-pip curl ca-certificates >/dev/null 2>&1 +python3 -m venv /opt/transformerlab-venv +export PATH="/opt/transformerlab-venv/bin:$PATH" +curl -LsSf https://astral.sh/uv/install.sh | sh +export PATH="$HOME/.local/bin:/root/.local/bin:/home/{self.ssh_user}/.local/bin:$PATH" +if [ -x /root/.local/bin/uv ]; then cp /root/.local/bin/uv /usr/local/bin/uv && chmod +x /usr/local/bin/uv; fi +if [ -x /root/.local/bin/uvx ]; then cp /root/.local/bin/uvx /usr/local/bin/uvx && chmod +x /usr/local/bin/uvx; fi +{env_exports} +{setup_block} +set +e +({run_cmd}) 2>&1 | tee {NEBIUS_RUN_LOGS_PATH} +_exit_code=${{PIPESTATUS[0]}} +set -e +exit $_exit_code +""" + indented_script = "\n".join(f" {line}" for line in script.splitlines()) + return f"""#cloud-config +users: + - name: {self.ssh_user} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - {public_key.strip()} +write_files: + - path: /opt/transformerlab-run.sh + permissions: '0755' + content: | +{indented_script} +runcmd: + - [ bash, -lc, /opt/transformerlab-run.sh ] +""" + + @staticmethod + def _extract_list_response(response: Any) -> List[Dict[str, Any]]: + if isinstance(response, list): + return [item for item in response if isinstance(item, dict)] + if isinstance(response, dict): + for key in ("items", "instances", "resources", "networks", "subnets"): + value = response.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + def _find_instance_by_cluster_name(self, cluster_name: str) -> Optional[Dict[str, Any]]: + args = ["compute", "instance", "list", "--format", "json"] + if self.parent_id: + args.extend(["--parent-id", self.parent_id]) + response = self._run_nebius(args, timeout=60) + instances = self._extract_list_response(response) + for instance in instances: + name = _nested_get(instance, ["metadata", "name"]) or instance.get("name") + if name == cluster_name: + return instance + return None + + def _get_instance(self, instance_id: str) -> Dict[str, Any]: + return self._run_nebius(["compute", "instance", "get", "--id", instance_id, "--format", "json"], timeout=60) + + def _subnet_id_from_resource(self, subnet: Dict[str, Any]) -> Optional[str]: + return _extract_resource_id(subnet) + + def _list_subnets_for_parent(self, parent_id: str) -> List[Dict[str, Any]]: + response = self._run_nebius( + ["vpc", "subnet", "list", "--parent-id", parent_id, "--format", "json", "--all"], + timeout=120, + ) + return self._extract_list_response(response) + + def _list_networks_for_parent(self, parent_id: str) -> List[Dict[str, Any]]: + response = self._run_nebius( + ["vpc", "network", "list", "--parent-id", parent_id, "--format", "json", "--all"], + timeout=120, + ) + return self._extract_list_response(response) + + def _ensure_network(self, parent_id: str) -> str: + networks = self._list_networks_for_parent(parent_id) + if networks: + net_id = _extract_resource_id(networks[0]) + if net_id: + return net_id + try: + created = self._run_nebius( + ["vpc", "network", "create-default", "--parent-id", parent_id, "--format", "json"], + timeout=180, + ) + except RuntimeError as first_exc: + # Another process may have created the default network; re-list. + logger.info("Nebius create-default network failed (%s); re-listing networks", first_exc) + networks = self._list_networks_for_parent(parent_id) + if networks: + net_id = _extract_resource_id(networks[0]) + if net_id: + return net_id + raise + if isinstance(created, dict): + net_id = _extract_resource_id(created) + if net_id: + return net_id + networks = self._list_networks_for_parent(parent_id) + if not networks: + raise RuntimeError("Nebius vpc network create-default did not return a network id and list is still empty.") + net_id = _extract_resource_id(networks[0]) + if not net_id: + raise RuntimeError("Could not read network id from Nebius after create-default.") + return net_id + + def _create_subnet(self, parent_id: str, network_id: str) -> str: + response = self._run_nebius( + [ + "vpc", + "subnet", + "create", + "--network-id", + network_id, + "--parent-id", + parent_id, + "--name", + NEBIUS_AUTO_SUBNET_NAME, + "--ipv4-private-pools-use-network-pools", + "true", + "--ipv4-public-pools-use-network-pools", + "true", + "--format", + "json", + ], + timeout=180, + ) + if isinstance(response, dict): + sid = _extract_resource_id(response) + if sid: + return sid + subnets = self._list_subnets_for_parent(parent_id) + for sn in subnets: + name = _nested_get(sn, ["metadata", "name"]) or sn.get("name") + if name == NEBIUS_AUTO_SUBNET_NAME: + sid = self._subnet_id_from_resource(sn) + if sid: + return sid + raise RuntimeError(f"Nebius subnet create did not return a subnet id: {response!r}") + + def _resolve_subnet_id_for_launch(self) -> str: + """Return configured subnet_id or ensure a project subnet via default network + subnet.""" + if self.subnet_id: + return self.subnet_id + if self._resolved_subnet_id: + return self._resolved_subnet_id + if not self.parent_id: + raise ValueError( + "Nebius provider needs parent_id (Nebius project) to create a default network/subnet automatically, " + "or set subnet_id in the provider config." + ) + parent_id = self.parent_id.strip() + subnets = self._list_subnets_for_parent(parent_id) + if subnets: + sid = self._subnet_id_from_resource(subnets[0]) + if sid: + self._resolved_subnet_id = sid + logger.info("Nebius using existing subnet %s under project %s", sid, parent_id) + return sid + network_id = self._ensure_network(parent_id) + subnets = self._list_subnets_for_parent(parent_id) + if subnets: + sid = self._subnet_id_from_resource(subnets[0]) + if sid: + self._resolved_subnet_id = sid + logger.info("Nebius using subnet %s after ensuring network %s", sid, network_id) + return sid + sid = self._create_subnet(parent_id, network_id) + self._resolved_subnet_id = sid + logger.info("Nebius created subnet %s on network %s", sid, network_id) + return sid + + def launch_cluster(self, cluster_name: str, config: ClusterConfig) -> Dict[str, Any]: + from transformerlab.services.ssh_key_service import get_or_create_org_ssh_key_pair, get_org_ssh_public_key + + subnet_id = self._resolve_subnet_id_for_launch() + + async def _ensure_and_get_public_key() -> str: + await get_or_create_org_ssh_key_pair(self.team_id) + return await get_org_ssh_public_key(self.team_id) + + public_key = asyncio.run(_ensure_and_get_public_key()) + platform, preset = self._resolve_platform_preset(config) + image_family = self._image_family_for_platform(platform) + cloud_init_user_data = self._build_cloud_init(cluster_name, config, public_key) + + metadata: Dict[str, Any] = {"name": cluster_name} + if self.parent_id: + metadata["parent_id"] = self.parent_id + + spec: Dict[str, Any] = { + "stopped": False, + "cloud_init_user_data": cloud_init_user_data, + "resources": {"platform": platform, "preset": preset}, + "recovery_policy": "FAIL", + "boot_disk": { + "attach_mode": "READ_WRITE", + "managed_disk": { + "name": f"{cluster_name}-boot", + "spec": { + "type": "NETWORK_SSD", + "size_gibibytes": int(config.disk_size or self.disk_size_gib), + "block_size_bytes": 4096, + "source_image_family": {"image_family": image_family}, + }, + }, + }, + "network_interfaces": [{"name": "eth0", "subnet_id": subnet_id, "ip_address": {}, "public_ip_address": {}}], + } + # Note: do NOT set parent_id on source_image_family — public image families are + # not scoped to the project and Nebius will reject the request if project ID is used. + if config.use_spot: + spec["preemptible"] = {"on_preemption": "STOP"} + if self.extra_config.get("service_account_id"): + spec["service_account_id"] = self.extra_config["service_account_id"] + if config.provider_config.get("gpu_cluster_id") or self.extra_config.get("gpu_cluster_id"): + spec["gpu_cluster"] = { + "id": config.provider_config.get("gpu_cluster_id") or self.extra_config["gpu_cluster_id"] + } + + payload = {"metadata": metadata, "spec": spec} + args = ["compute", "instance", "create", "--format", "json"] + if self.parent_id: + args.extend(["--parent-id", self.parent_id]) + args.append("-") + response = self._run_nebius(args, stdin_json=payload, timeout=300) + instance_id = _extract_resource_id(response) if isinstance(response, dict) else None + if not instance_id: + instance = self._find_instance_by_cluster_name(cluster_name) + instance_id = _extract_resource_id(instance or {}) + if not instance_id: + raise RuntimeError(f"Nebius instance was created but its ID could not be determined: {response}") + return {"instance_id": instance_id, "request_id": instance_id, "platform": platform, "preset": preset} + + def stop_cluster(self, cluster_name: str) -> Dict[str, Any]: + instance = self._find_instance_by_cluster_name(cluster_name) + if not instance: + return {"status": "error", "message": f"Instance '{cluster_name}' not found", "cluster_name": cluster_name} + instance_id = _extract_resource_id(instance) + if not instance_id: + return {"status": "error", "message": "Instance ID not found", "cluster_name": cluster_name} + try: + self._run_nebius(["compute", "instance", "delete", "--id", instance_id, "--format", "json"], timeout=180) + return {"status": "success", "message": f"Instance '{cluster_name}' deleted", "instance_id": instance_id} + except Exception as exc: + return {"status": "error", "message": str(exc), "cluster_name": cluster_name, "instance_id": instance_id} + + def get_cluster_status(self, cluster_name: str) -> ClusterStatus: + instance = self._find_instance_by_cluster_name(cluster_name) + if not instance: + return ClusterStatus( + cluster_name=cluster_name, state=ClusterState.UNKNOWN, status_message="Instance not found" + ) + state_raw = ( + _nested_get(instance, ["status", "state"]) + or _nested_get(instance, ["status", "status"]) + or instance.get("state") + or "UNKNOWN" + ) + state_text = str(state_raw).upper() + state = _NEBIUS_STATE_TO_CLUSTER_STATE.get(state_text, ClusterState.UNKNOWN) + return ClusterStatus( + cluster_name=cluster_name, + state=state, + status_message=state_text, + provider_data=instance, + ) + + def get_job_logs( + self, + cluster_name: str, + job_id: Union[str, int], + tail_lines: Optional[int] = None, + follow: bool = False, + ) -> str: + instance = self._find_instance_by_cluster_name(cluster_name) + if not instance: + return f"Instance '{cluster_name}' not found." + public_ip = _extract_public_ip(instance) + if not public_ip: + return "Instance has no public IP yet (still starting)." + key_bytes = asyncio.run(get_org_ssh_private_key(self.team_id)) + return _ssh_read_file(public_ip, self.ssh_user, key_bytes, NEBIUS_RUN_LOGS_PATH, tail_lines or 500) + + def list_clusters(self) -> List[ClusterStatus]: + args = ["compute", "instance", "list", "--format", "json"] + if self.parent_id: + args.extend(["--parent-id", self.parent_id]) + response = self._run_nebius(args, timeout=60) + instances = self._extract_list_response(response) + statuses: List[ClusterStatus] = [] + for instance in instances: + cluster_name = str( + _nested_get(instance, ["metadata", "name"]) or _extract_resource_id(instance) or "unknown" + ) + state_text = str(_nested_get(instance, ["status", "state"]) or instance.get("state") or "UNKNOWN").upper() + statuses.append( + ClusterStatus( + cluster_name=cluster_name, + state=_NEBIUS_STATE_TO_CLUSTER_STATE.get(state_text, ClusterState.UNKNOWN), + status_message=state_text, + provider_data=instance, + ) + ) + return statuses + + def get_cluster_resources(self, cluster_name: str) -> ResourceInfo: + instance = self._find_instance_by_cluster_name(cluster_name) + return ResourceInfo(cluster_name=cluster_name, gpus=[], num_nodes=1, provider_data=instance or {}) + + def get_clusters_detailed(self) -> List[Dict[str, Any]]: + detailed = [] + for status in self.list_clusters(): + state_str = status.state.name if hasattr(status.state, "name") else str(status.state) + resources = (status.provider_data or {}).get("spec", {}).get("resources", {}) + detailed.append( + { + "cluster_id": status.cluster_name, + "cluster_name": status.cluster_name, + "backend_type": "Nebius Compute", + "cloud_provider": "Nebius", + "elastic_enabled": True, + "max_nodes": 1, + "head_node_ip": _extract_public_ip(status.provider_data or {}), + "nodes": [ + { + "node_name": status.cluster_name, + "is_fixed": False, + "is_active": state_str.upper() in ("UP", "INIT"), + "state": state_str.upper(), + "reason": status.status_message or state_str, + "resources": { + "platform": resources.get("platform"), + "preset": resources.get("preset"), + "cpus_total": 0, + "cpus_allocated": 0, + "gpus": {}, + "gpus_free": {}, + "memory_gb_total": 0, + "memory_gb_allocated": 0, + }, + } + ], + } + ) + return detailed + + def submit_job(self, cluster_name: str, job_config: JobConfig) -> Dict[str, Any]: + raise NotImplementedError("Nebius provider uses cloud-init/tfl-remote-trap for job dispatch") + + def list_jobs(self, cluster_name: str) -> List[JobInfo]: + raise NotImplementedError("Nebius provider uses cloud-init/tfl-remote-trap for job dispatch") + + def cancel_job(self, cluster_name: str, job_id: Union[str, int]) -> Dict[str, Any]: + raise NotImplementedError("Nebius provider uses cloud-init/tfl-remote-trap for job dispatch") diff --git a/api/transformerlab/routers/compute_provider/providers.py b/api/transformerlab/routers/compute_provider/providers.py index aa876ca94c..2fb11c2eeb 100644 --- a/api/transformerlab/routers/compute_provider/providers.py +++ b/api/transformerlab/routers/compute_provider/providers.py @@ -7,6 +7,7 @@ from transformerlab.shared.models.user_model import get_async_session from transformerlab.routers.auth import require_team_owner, get_user_and_team from transformerlab.services.compute_provider import team_provider_endpoints +from transformerlab.services.nebius_credentials_service import write_nebius_service_account_credentials from transformerlab.services.compute_provider.launch_credentials import ( parse_gcp_service_account_json, write_aws_credentials_to_profile, @@ -115,6 +116,12 @@ class AwsCredentialsRequest(BaseModel): secret_access_key: str +class NebiusCredentialsRequest(BaseModel): + service_account_id: str + public_key_id: str + private_key: str + + class GcpCredentialsRequest(BaseModel): service_account_json: str @@ -143,6 +150,58 @@ async def set_aws_credentials( return {"status": "ok", "profile": profile} +@router.post("/{provider_id}/nebius/credentials") +async def set_nebius_credentials( + provider_id: str, + body: NebiusCredentialsRequest, + owner_info=Depends(require_team_owner), + session: AsyncSession = Depends(get_async_session), +): + """Write service-account credentials for a Nebius provider to a provider-scoped CLI config.""" + provider = await get_team_provider(session, owner_info["team_id"], provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + if provider.type != "nebius": + raise HTTPException(status_code=400, detail="Provider is not of type 'nebius'") + + config = json.loads(provider.config) if isinstance(provider.config, str) else (provider.config or {}) + profile = config.get("nebius_profile") + config_path = config.get("nebius_config_path") + parent_id = config.get("parent_id") + if not profile: + from transformerlab.services.nebius_credentials_service import build_nebius_profile_name + + profile = build_nebius_profile_name(owner_info["team_id"], provider_id) + config["nebius_profile"] = profile + if not config_path: + from transformerlab.services.nebius_credentials_service import get_nebius_cli_config_path + + config_path = get_nebius_cli_config_path(owner_info["team_id"], provider_id) + config["nebius_config_path"] = config_path + + actual_config_path = write_nebius_service_account_credentials( + team_id=owner_info["team_id"], + provider_id=provider_id, + profile_name=profile, + parent_id=parent_id, + service_account_id=body.service_account_id, + public_key_id=body.public_key_id, + private_key=body.private_key, + ) + config["nebius_config_path"] = actual_config_path + config["team_id"] = owner_info["team_id"] + await update_team_provider( + session=session, + provider=provider, + name=None, + config=config, + disabled=None, + is_default=None, + ) + await cache.invalidate("providers") + return {"status": "ok", "profile": profile, "config_path": actual_config_path} + + @router.post("/{provider_id}/gcp/credentials") async def set_gcp_credentials( provider_id: str, diff --git a/api/transformerlab/schemas/compute_providers.py b/api/transformerlab/schemas/compute_providers.py index 3f416877e7..6d76cfad85 100644 --- a/api/transformerlab/schemas/compute_providers.py +++ b/api/transformerlab/schemas/compute_providers.py @@ -36,8 +36,18 @@ class ProviderConfigBase(BaseModel): ssh_key_path: Optional[str] = None ssh_port: int = 22 - # AWS/GCP-specific config - region: Optional[str] = None # AWS region (e.g. "us-east-1") or GCP region (e.g. "us-central1") + # AWS/GCP region config + region: Optional[str] = None # e.g. AWS "us-east-1", GCP "us-central1" + + # Nebius-specific config + nebius_profile: Optional[str] = None # Nebius CLI profile name + nebius_config_path: Optional[str] = None # Provider-scoped Nebius CLI config path + parent_id: Optional[str] = None # Nebius project/parent ID + subnet_id: Optional[str] = None # Nebius VPC subnet ID + default_platform: Optional[str] = None # e.g. "gpu-h100-sxm" or "cpu-d3" + default_preset: Optional[str] = None # e.g. "1gpu-16vcpu-200gb" + boot_image_family: Optional[str] = None # e.g. "ubuntu24.04-cuda13.0" + disk_size_gib: Optional[int] = None # GCP-specific config project_id: Optional[str] = None diff --git a/api/transformerlab/services/compute_provider/team_provider_endpoints.py b/api/transformerlab/services/compute_provider/team_provider_endpoints.py index 5f197ab367..38e7d325de 100644 --- a/api/transformerlab/services/compute_provider/team_provider_endpoints.py +++ b/api/transformerlab/services/compute_provider/team_provider_endpoints.py @@ -15,6 +15,10 @@ get_provider_setup_status_path, run_local_provider_setup_background, ) +from transformerlab.services.nebius_credentials_service import ( + build_nebius_profile_name, + get_nebius_cli_config_path, +) from transformerlab.services.provider_service import ( _local_providers_disabled, build_aws_profile_name, @@ -86,6 +90,7 @@ async def create_provider_for_team( ProviderType.DSTACK, ProviderType.AZURE, ProviderType.AWS, + ProviderType.NEBIUS, ProviderType.VASTAI, ProviderType.GCP, ] @@ -112,7 +117,7 @@ async def create_provider_for_team( config_dict.setdefault("azure_resource_group", f"transformerlab-{team_id}") # Auto-inject team_id for cloud providers that require team scoping. - if provider_data.type in (ProviderType.AWS, ProviderType.AZURE, ProviderType.GCP): + if provider_data.type in (ProviderType.AWS, ProviderType.AZURE, ProviderType.GCP, ProviderType.NEBIUS): config_dict["team_id"] = team_id provider = await create_team_provider( @@ -140,6 +145,28 @@ async def create_provider_for_team( is_default=None, ) + if provider.type == ProviderType.NEBIUS.value: + provider_config = dict(provider.config or {}) + changed = False + if not provider_config.get("nebius_profile"): + provider_config["nebius_profile"] = build_nebius_profile_name(team_id, str(provider.id)) + changed = True + if not provider_config.get("nebius_config_path"): + provider_config["nebius_config_path"] = get_nebius_cli_config_path(team_id, str(provider.id)) + changed = True + if provider_config.get("team_id") != team_id: + provider_config["team_id"] = team_id + changed = True + if changed: + provider = await update_team_provider( + session=session, + provider=provider, + name=None, + config=provider_config, + disabled=None, + is_default=None, + ) + await cache.invalidate("providers") if provider.type == ProviderType.LOCAL.value: @@ -218,7 +245,12 @@ async def update_provider_for_team( if new_config.get("api_key") == "***": new_config.pop("api_key", None) update_config = {**existing_config, **new_config} - if provider.type in (ProviderType.AWS.value, ProviderType.GCP.value): + if provider.type in ( + ProviderType.AWS.value, + ProviderType.AZURE.value, + ProviderType.GCP.value, + ProviderType.NEBIUS.value, + ): update_config["team_id"] = team_id update_disabled = provider_data.disabled if provider_data.disabled is not None else None diff --git a/api/transformerlab/services/nebius_cli_resolve.py b/api/transformerlab/services/nebius_cli_resolve.py new file mode 100644 index 0000000000..6924c5a443 --- /dev/null +++ b/api/transformerlab/services/nebius_cli_resolve.py @@ -0,0 +1,89 @@ +"""Locate the Nebius CLI for subprocess use (same venv as the API process).""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import sys +import tempfile +from importlib.metadata import entry_points +from pathlib import Path +from typing import List + + +def _known_nebius_locations() -> List[Path]: + home = Path.home() + return [ + home / ".nebius" / "bin" / "nebius", + home / ".local" / "bin" / "nebius", + ] + + +def _nebius_console_script_registered() -> bool: + """True if the installed nebius distribution exposes a `nebius` console script.""" + try: + return bool(tuple(entry_points().select(group="console_scripts", name="nebius"))) + except ValueError: + return False + + +def _entrypoint_wrapper_script() -> Path: + """Small executable script that runs the setuptools console_scripts entry (no `python -m nebius`).""" + key = hashlib.sha256(str(Path(sys.executable).resolve()).encode()).hexdigest()[:16] + path = Path(tempfile.gettempdir()) / f"transformerlab-nebius-cli-{key}.py" + shebang = f"#!{sys.executable}\n" + body = """# Generated by Transformer Lab — runs Nebius setuptools console_scripts entry. +import sys +from importlib.metadata import entry_points + +if __name__ == "__main__": + eps = list(entry_points().select(group="console_scripts", name="nebius")) + if not eps: + print( + "Nebius is installed but no console_scripts entry named 'nebius' was found.", + file=sys.stderr, + ) + sys.exit(2) + sys.exit(eps[0].load()()) +""" + content = shebang + body + if not path.is_file() or path.read_text(encoding="utf-8") != content: + path.write_text(content, encoding="utf-8") + os.chmod(path, 0o700) + return path + + +def nebius_cli_argv_prefix() -> List[str]: + """Return argv prefix to invoke Nebius (venv binary, PATH, or entry-point wrapper).""" + bin_dir = Path(sys.executable).resolve().parent + for name in ("nebius", "nebius.exe"): + candidate = bin_dir / name + if candidate.is_file(): + return [str(candidate)] + for candidate in _known_nebius_locations(): + if candidate.is_file(): + return [str(candidate)] + resolved = shutil.which("nebius") + if resolved: + return [resolved] + if _nebius_console_script_registered(): + return [str(_entrypoint_wrapper_script())] + raise RuntimeError( + "Nebius CLI could not be resolved: no venv script, no PATH binary, and no console_scripts " + "entry named 'nebius'. Install the `nebius` package in this environment." + ) + + +def nebius_cli_available() -> bool: + """True if we can invoke Nebius (venv binary, PATH, or setuptools console_scripts).""" + bin_dir = Path(sys.executable).resolve().parent + for name in ("nebius", "nebius.exe"): + if (bin_dir / name).is_file(): + return True + for candidate in _known_nebius_locations(): + if candidate.is_file(): + return True + if shutil.which("nebius"): + return True + return _nebius_console_script_registered() diff --git a/api/transformerlab/services/nebius_credentials_service.py b/api/transformerlab/services/nebius_credentials_service.py new file mode 100644 index 0000000000..96526053fa --- /dev/null +++ b/api/transformerlab/services/nebius_credentials_service.py @@ -0,0 +1,131 @@ +"""Helpers for team/provider-scoped Nebius CLI credentials.""" + +from __future__ import annotations + +import os +import re +import stat +import subprocess +from typing import Optional + +from lab.dirs import HOME_DIR + +from transformerlab.services.nebius_cli_resolve import nebius_cli_argv_prefix, nebius_cli_available + +NEBIUS_CREDENTIALS_ROOT = os.path.normpath(os.path.join(HOME_DIR, "nebius_credentials")) +DEFAULT_NEBIUS_ENDPOINT = "api.nebius.cloud" + + +def _sanitize_path_component(name: str, value: str) -> str: + if not value or not isinstance(value, str): + raise ValueError(f"Invalid {name} for Nebius credentials path") + if not re.fullmatch(r"[A-Za-z0-9_-]+", value): + raise ValueError(f"Invalid {name} for Nebius credentials path") + return value + + +def _ensure_under_nebius_root(path: str) -> str: + normalized = os.path.normpath(path) + common = os.path.commonpath([NEBIUS_CREDENTIALS_ROOT, normalized]) + if common != NEBIUS_CREDENTIALS_ROOT: + raise ValueError("Invalid path for Nebius credentials") + return normalized + + +def get_nebius_credentials_dir(team_id: str, provider_id: str) -> str: + safe_team_id = _sanitize_path_component("team_id", team_id) + safe_provider_id = _sanitize_path_component("provider_id", provider_id) + return _ensure_under_nebius_root(os.path.join(NEBIUS_CREDENTIALS_ROOT, safe_team_id, safe_provider_id)) + + +def get_nebius_cli_config_path(team_id: str, provider_id: str) -> str: + return _ensure_under_nebius_root(os.path.join(get_nebius_credentials_dir(team_id, provider_id), "config.yaml")) + + +def get_nebius_private_key_path(team_id: str, provider_id: str) -> str: + return _ensure_under_nebius_root(os.path.join(get_nebius_credentials_dir(team_id, provider_id), "private_key.pem")) + + +def build_nebius_profile_name(team_id: str, provider_id: str) -> str: + def compact(value: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9_-]", "-", str(value).lower()) + normalized = re.sub(r"-+", "-", normalized).strip("-") + return (normalized[:8].rstrip("-") or "id") if len(normalized) > 8 else (normalized or "id") + + return f"tlab-nebius-{compact(team_id)}-{compact(provider_id)}" + + +def write_nebius_service_account_credentials( + *, + team_id: str, + provider_id: str, + profile_name: str, + parent_id: Optional[str], + service_account_id: str, + public_key_id: str, + private_key: str, + endpoint: str = DEFAULT_NEBIUS_ENDPOINT, +) -> str: + """Persist credentials and create/update a provider-scoped Nebius CLI profile. + + Returns the Nebius CLI config path. The resulting config and private key are + stored under HOME_DIR/nebius_credentials/// so multiple + providers in the same org never share a profile namespace or key file. + """ + + if not service_account_id.strip(): + raise ValueError("Nebius service_account_id is required") + if not public_key_id.strip(): + raise ValueError("Nebius public_key_id is required") + if not private_key.strip(): + raise ValueError("Nebius private_key is required") + if not nebius_cli_available(): + raise RuntimeError( + "Nebius CLI is not available in this Python environment. " + "Install the API dependencies (e.g. `nebius` is listed in api/pyproject.toml) " + "and run the API with that same interpreter/venv." + ) + + credentials_dir = get_nebius_credentials_dir(team_id, provider_id) + os.makedirs(credentials_dir, exist_ok=True) + os.chmod(credentials_dir, stat.S_IRWXU) + + private_key_path = get_nebius_private_key_path(team_id, provider_id) + key_content = private_key.strip() + "\n" + with open(private_key_path, "w", encoding="utf-8") as f: + f.write(key_content) + os.chmod(private_key_path, stat.S_IRUSR | stat.S_IWUSR) + + config_path = get_nebius_cli_config_path(team_id, provider_id) + temp_config_path = _ensure_under_nebius_root(f"{config_path}.tmp") + if os.path.exists(temp_config_path): + os.remove(temp_config_path) + cmd = nebius_cli_argv_prefix() + [ + "--config", + temp_config_path, + "profile", + "create", + profile_name, + "--endpoint", + endpoint, + "--service-account-id", + service_account_id.strip(), + "--public-key-id", + public_key_id.strip(), + "--private-key-file-path", + private_key_path, + ] + if parent_id: + cmd.extend(["--parent-id", parent_id.strip()]) + + proc = subprocess.run(cmd, text=True, capture_output=True, timeout=60, check=False) + if proc.returncode != 0: + stderr = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError(f"Failed to configure Nebius CLI profile: {stderr}") + + if os.path.exists(temp_config_path): + os.replace(temp_config_path, config_path) + if os.path.exists(config_path): + os.chmod(config_path, stat.S_IRUSR | stat.S_IWUSR) + + return config_path diff --git a/api/transformerlab/services/provider_service.py b/api/transformerlab/services/provider_service.py index 08d2480628..b3002c8093 100644 --- a/api/transformerlab/services/provider_service.py +++ b/api/transformerlab/services/provider_service.py @@ -295,6 +295,14 @@ def db_record_to_provider_config( credentials_path=config_dict.get("credentials_path"), service_account_json=config_dict.get("service_account_json"), service_account_email=config_dict.get("service_account_email"), + nebius_profile=config_dict.get("nebius_profile"), + nebius_config_path=config_dict.get("nebius_config_path"), + parent_id=config_dict.get("parent_id"), + subnet_id=config_dict.get("subnet_id"), + default_platform=config_dict.get("default_platform"), + default_preset=config_dict.get("default_preset"), + boot_image_family=config_dict.get("boot_image_family"), + disk_size_gib=config_dict.get("disk_size_gib"), extra_config=extra_config, # Azure-specific config azure_subscription_id=config_dict.get("azure_subscription_id"), @@ -303,10 +311,16 @@ def db_record_to_provider_config( azure_client_secret=config_dict.get("azure_client_secret"), azure_location=config_dict.get("azure_location"), azure_resource_group=config_dict.get("azure_resource_group"), - # team_id: config value (or record fallback) for AWS/Azure; None otherwise. + # team_id: config value (or record fallback) for AWS/GCP/Azure/Nebius; None otherwise. team_id=( config_dict.get("team_id") or record.team_id - if record.type in {ProviderType.AWS.value, ProviderType.GCP.value, ProviderType.AZURE.value} + if record.type + in { + ProviderType.AWS.value, + ProviderType.GCP.value, + ProviderType.AZURE.value, + ProviderType.NEBIUS.value, + } else None ), ) diff --git a/api/transformerlab/services/remote_job_status_service.py b/api/transformerlab/services/remote_job_status_service.py index 6d58e4278d..0f688cd5e9 100644 --- a/api/transformerlab/services/remote_job_status_service.py +++ b/api/transformerlab/services/remote_job_status_service.py @@ -256,11 +256,12 @@ async def _check_job_via_provider( ProviderType.LOCAL.value, ProviderType.RUNPOD.value, ProviderType.AWS.value, + ProviderType.NEBIUS.value, ProviderType.AZURE.value, ProviderType.GCP.value, ProviderType.VASTAI.value, ): - # LOCAL/RUNPOD/AWS/GCP: cluster state directly represents job lifecycle. + # LOCAL/RUNPOD/AWS/GCP/Azure/Nebius/Vast.ai: cluster state directly represents job lifecycle. if provider_type == ProviderType.LOCAL.value and job_data.get("workspace_dir"): if hasattr(provider_instance, "extra_config"): provider_instance.extra_config["workspace_dir"] = job_data["workspace_dir"] @@ -326,6 +327,12 @@ async def _check_job_via_provider( # If the user requested stop, treat this as terminal STOPPED so # the job does not remain stuck in STOPPING. cluster_state = ClusterState.STOPPED + elif ( + provider_type == ProviderType.NEBIUS.value + and job_status == JobStatus.STOPPING.value + and (cluster_state == ClusterState.UNKNOWN or status_message == "Instance not found") + ): + cluster_state = ClusterState.STOPPED elif ( provider_type == ProviderType.VASTAI.value and job_status == JobStatus.STOPPING.value diff --git a/api/transformerlab/shared/models/models.py b/api/transformerlab/shared/models/models.py index 75d941980f..b16e9627b5 100644 --- a/api/transformerlab/shared/models/models.py +++ b/api/transformerlab/shared/models/models.py @@ -134,6 +134,7 @@ class ProviderType(str, enum.Enum): DSTACK = "dstack" AZURE = "azure" AWS = "aws" + NEBIUS = "nebius" VASTAI = "vastai" GCP = "gcp" diff --git a/docs/task-execution/04-compute-providers.md b/docs/task-execution/04-compute-providers.md index 8f5245949e..46282b5a7c 100644 --- a/docs/task-execution/04-compute-providers.md +++ b/docs/task-execution/04-compute-providers.md @@ -59,12 +59,13 @@ launch_cluster(cluster_name, config) Submits to HPC clusters running the SLURM workload manager. Two modes: -| Mode | Connection | Job submission | -|------|-----------|----------------| -| **SSH** | paramiko SSH connection | Generate SBATCH script, submit via `sbatch` | -| **REST** | SLURM REST API (slurmrestd) | POST to `/slurm/v0.0.40/job/submit` | +| Mode | Connection | Job submission | +| -------- | --------------------------- | ------------------------------------------- | +| **SSH** | paramiko SSH connection | Generate SBATCH script, submit via `sbatch` | +| **REST** | SLURM REST API (slurmrestd) | POST to `/slurm/v0.0.40/job/submit` | ### SSH mode flow + 1. Connect via SSH (key or password auth) 2. Upload task files via SFTP 3. Generate SBATCH script with resource requests, env vars, setup, and run command @@ -81,12 +82,14 @@ Submits to HPC clusters running the SLURM workload manager. Two modes: Uses the [SkyPilot](https://skypilot.readthedocs.io/) SDK to launch on multiple clouds (AWS, GCP, Azure, etc.). ### Features + - Supports spot/preemptible instances - Auto-stop idle clusters (configurable timeout) - Cloud credential passthrough - Multi-cloud resource optimization ### Flow + 1. Build SkyPilot task YAML from ClusterConfig 2. Call `sky.launch()` or `sky.exec()` via SDK 3. Status tracked via `sky.status()` and `tfl-remote-trap` @@ -100,6 +103,7 @@ Uses the [SkyPilot](https://skypilot.readthedocs.io/) SDK to launch on multiple Integration with RunPod cloud GPU platform. ### Flow + 1. Create pod via RunPod API 2. Wait for pod to be ready 3. SSH into pod for log fetching @@ -107,6 +111,30 @@ Integration with RunPod cloud GPU platform. --- +## Nebius Provider + +**Source:** `api/transformerlab/compute_providers/nebius.py` + +Launches ephemeral Nebius AI Cloud Compute VMs using the documented Nebius CLI JSON API (`nebius compute instance create --format json -`). The provider creates a managed boot disk, injects org SSH keys via cloud-init, runs the task through `tfl-remote-trap`, and shuts the VM down after the command completes. + +Required provider config: + +- `subnet_id`: Nebius VPC subnet for the VM network interface. +- `team_id`: injected automatically by the API. + +Common optional config: + +- `nebius_profile`: Nebius CLI profile name. Auto-generated per provider when omitted. +- `nebius_config_path`: provider-scoped Nebius CLI config path. Auto-generated per provider when omitted. +- `parent_id`: Nebius project/parent ID; passed to profile creation and CLI operations when present. +- `default_platform` / `default_preset`: e.g. `gpu-h100-sxm` / `1gpu-16vcpu-200gb`. +- `boot_image_family`: defaults to `ubuntu24.04-cuda13.0` for GPU platforms and `ubuntu24.04-driverless` for CPU. +- `disk_size_gib`: managed boot disk size; defaults to 200 GiB. + +Credentials are stored per team and provider under `HOME_DIR/nebius_credentials///` with `0600` file permissions. This allows multiple Nebius providers in one org (for example, different projects, subnets, or service accounts) without sharing a Nebius CLI profile or private key file. + +--- + ## Provider interface All providers implement these methods: @@ -128,12 +156,13 @@ class ComputeProvider(ABC): ## Key source files -| File | Role | -|------|------| -| `api/transformerlab/compute_providers/base.py` | Abstract base class | -| `api/transformerlab/compute_providers/models.py` | ClusterConfig, ClusterStatus, etc. | -| `api/transformerlab/compute_providers/local.py` | Local subprocess execution | -| `api/transformerlab/compute_providers/slurm.py` | SLURM SSH/REST integration | -| `api/transformerlab/compute_providers/skypilot.py` | SkyPilot multi-cloud | -| `api/transformerlab/compute_providers/runpod.py` | RunPod cloud GPUs | -| `api/transformerlab/services/provider_service.py` | Provider lookup and instantiation | +| File | Role | +| -------------------------------------------------- | ---------------------------------- | +| `api/transformerlab/compute_providers/base.py` | Abstract base class | +| `api/transformerlab/compute_providers/models.py` | ClusterConfig, ClusterStatus, etc. | +| `api/transformerlab/compute_providers/local.py` | Local subprocess execution | +| `api/transformerlab/compute_providers/slurm.py` | SLURM SSH/REST integration | +| `api/transformerlab/compute_providers/skypilot.py` | SkyPilot multi-cloud | +| `api/transformerlab/compute_providers/runpod.py` | RunPod cloud GPUs | +| `api/transformerlab/compute_providers/nebius.py` | Nebius AI Cloud VMs via Nebius CLI | +| `api/transformerlab/services/provider_service.py` | Provider lookup and instantiation | diff --git a/src/renderer/components/Team/ProviderDetailsModal.tsx b/src/renderer/components/Team/ProviderDetailsModal.tsx index feb35f9d34..631d7a4309 100644 --- a/src/renderer/components/Team/ProviderDetailsModal.tsx +++ b/src/renderer/components/Team/ProviderDetailsModal.tsx @@ -38,6 +38,7 @@ import AwsProviderFields from './providerForms/AwsProviderFields'; import GcpProviderFields from './providerForms/GcpProviderFields'; import AzureProviderFields from './providerForms/AzureProviderFields'; import LocalProviderFields from './providerForms/LocalProviderFields'; +import NebiusProviderFields from './providerForms/NebiusProviderFields'; interface ProviderDetailsModalProps { open: boolean; @@ -80,6 +81,10 @@ const DEFAULT_CONFIGS = { }`, aws: `{ "region": "us-east-1" +}`, + nebius: `{ + "parent_id": "", + "subnet_id": "" }`, vastai: `{}`, gcp: `{ @@ -87,6 +92,17 @@ const DEFAULT_CONFIGS = { }`, } as const; +/** Nebius config keys edited via structured form (rest is preserved as passthrough). */ +const NEBIUS_MANAGED_CONFIG_KEYS = [ + 'parent_id', + 'subnet_id', + 'region', + 'default_platform', + 'default_preset', + 'boot_image_family', + 'disk_size_gib', +] as const; + const DEFAULT_SUPPORTED_ACCELERATORS: Record = { skypilot: ['NVIDIA'], slurm: ['NVIDIA'], @@ -95,6 +111,7 @@ const DEFAULT_SUPPORTED_ACCELERATORS: Record = { local: ['AppleSilicon', 'cpu'], azure: ['NVIDIA'], aws: ['NVIDIA'], + nebius: ['NVIDIA'], vastai: ['NVIDIA'], gcp: ['NVIDIA'], }; @@ -166,6 +183,20 @@ export default function ProviderDetailsModal({ const [awsAccessKeyId, setAwsAccessKeyId] = useState(''); const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(''); + // Nebius-specific credential fields. Non-sensitive Nebius settings stay in the JSON config. + const [nebiusServiceAccountId, setNebiusServiceAccountId] = useState(''); + const [nebiusPublicKeyId, setNebiusPublicKeyId] = useState(''); + const [nebiusPrivateKey, setNebiusPrivateKey] = useState(''); + const [nebiusParentId, setNebiusParentId] = useState(''); + const [nebiusSubnetId, setNebiusSubnetId] = useState(''); + const [nebiusDefaultPlatform, setNebiusDefaultPlatform] = useState(''); + const [nebiusDefaultPreset, setNebiusDefaultPreset] = useState(''); + const [nebiusBootImageFamily, setNebiusBootImageFamily] = useState(''); + const [nebiusDiskSizeGib, setNebiusDiskSizeGib] = useState(''); + const [nebiusPassthrough, setNebiusPassthrough] = useState< + Record + >({}); + // Vast.ai-specific form fields const [vastAiApiKey, setVastAiApiKey] = useState(''); const [vastAiApiKeyChanged, setVastAiApiKeyChanged] = useState(false); @@ -213,6 +244,11 @@ export default function ProviderDetailsModal({ label: 'AWS (beta)', description: 'Launch and manage compute on AWS.', }, + { + value: 'nebius', + label: 'Nebius (beta)', + description: 'Launch and manage VMs on Nebius AI Cloud.', + }, { value: 'vastai', label: 'Vast.ai (beta)', @@ -509,6 +545,86 @@ export default function ProviderDetailsModal({ return configObj; }, [awsRegion, supportedAccelerators]); + const parseNebiusConfig = (configObj: Record) => { + if (!configObj || typeof configObj !== 'object') { + return; + } + setNebiusParentId( + configObj.parent_id != null ? String(configObj.parent_id) : '', + ); + setNebiusSubnetId( + configObj.subnet_id != null ? String(configObj.subnet_id) : '', + ); + setNebiusDefaultPlatform( + configObj.default_platform != null + ? String(configObj.default_platform) + : '', + ); + setNebiusDefaultPreset( + configObj.default_preset != null ? String(configObj.default_preset) : '', + ); + setNebiusBootImageFamily( + configObj.boot_image_family != null + ? String(configObj.boot_image_family) + : '', + ); + setNebiusDiskSizeGib( + configObj.disk_size_gib != null ? String(configObj.disk_size_gib) : '', + ); + }; + + const buildNebiusConfig = useCallback(() => { + const configObj: Record = { ...nebiusPassthrough }; + if (nebiusParentId.trim()) { + configObj.parent_id = nebiusParentId.trim(); + } else { + delete configObj.parent_id; + } + if (nebiusSubnetId.trim()) { + configObj.subnet_id = nebiusSubnetId.trim(); + } else { + delete configObj.subnet_id; + } + if (nebiusDefaultPlatform.trim()) { + configObj.default_platform = nebiusDefaultPlatform.trim(); + } else { + delete configObj.default_platform; + } + if (nebiusDefaultPreset.trim()) { + configObj.default_preset = nebiusDefaultPreset.trim(); + } else { + delete configObj.default_preset; + } + if (nebiusBootImageFamily.trim()) { + configObj.boot_image_family = nebiusBootImageFamily.trim(); + } else { + delete configObj.boot_image_family; + } + if (nebiusDiskSizeGib.trim()) { + const parsed = parseInt(nebiusDiskSizeGib.trim(), 10); + if (!Number.isNaN(parsed)) { + configObj.disk_size_gib = parsed; + } else { + delete configObj.disk_size_gib; + } + } else { + delete configObj.disk_size_gib; + } + if (supportedAccelerators.length > 0) { + configObj.supported_accelerators = supportedAccelerators; + } + return configObj; + }, [ + nebiusPassthrough, + nebiusParentId, + nebiusSubnetId, + nebiusDefaultPlatform, + nebiusDefaultPreset, + nebiusBootImageFamily, + nebiusDiskSizeGib, + supportedAccelerators, + ]); + const parseVastAiConfig = (configObj: any) => { if (configObj && typeof configObj === 'object') { setVastAiApiKey( @@ -615,13 +731,23 @@ export default function ProviderDetailsModal({ if (providerData.type === 'aws') { parseAwsConfig(rawConfigObj); } - if (providerData.type === 'vastai') { - parseVastAiConfig(rawConfigObj); - } - if (providerData.type === 'gcp') { - parseGcpConfig(rawConfigObj); + if (providerData.type === 'nebius') { + parseNebiusConfig(rawConfigObj); + const passthrough: Record = { ...rawConfigObj }; + NEBIUS_MANAGED_CONFIG_KEYS.forEach((k) => { + delete passthrough[k]; + }); + setNebiusPassthrough(passthrough); + setConfig('{}'); + } else { + if (providerData.type === 'vastai') { + parseVastAiConfig(rawConfigObj); + } + if (providerData.type === 'gcp') { + parseGcpConfig(rawConfigObj); + } + setConfig(JSON.stringify(rawConfigObj, null, 2)); } - setConfig(JSON.stringify(rawConfigObj, null, 2)); } else if (!providerId) { // Reset form when in "add" mode (no providerId) setName(''); @@ -664,6 +790,16 @@ export default function ProviderDetailsModal({ setAwsRegion('us-east-1'); setAwsAccessKeyId(''); setAwsSecretAccessKey(''); + setNebiusServiceAccountId(''); + setNebiusPublicKeyId(''); + setNebiusPrivateKey(''); + setNebiusParentId(''); + setNebiusSubnetId(''); + setNebiusDefaultPlatform(''); + setNebiusDefaultPreset(''); + setNebiusBootImageFamily(''); + setNebiusDiskSizeGib(''); + setNebiusPassthrough({}); setVastAiApiKey(''); setVastAiApiKeyChanged(false); setGcpRegion('us-central1'); @@ -718,6 +854,16 @@ export default function ProviderDetailsModal({ setAwsRegion('us-east-1'); setAwsAccessKeyId(''); setAwsSecretAccessKey(''); + setNebiusServiceAccountId(''); + setNebiusPublicKeyId(''); + setNebiusPrivateKey(''); + setNebiusParentId(''); + setNebiusSubnetId(''); + setNebiusDefaultPlatform(''); + setNebiusDefaultPreset(''); + setNebiusBootImageFamily(''); + setNebiusDiskSizeGib(''); + setNebiusPassthrough({}); setVastAiApiKey(''); setVastAiApiKeyChanged(false); setGcpRegion('us-central1'); @@ -823,6 +969,15 @@ export default function ProviderDetailsModal({ // Ignore parse errors } } + if (type === 'nebius') { + try { + const configObj = JSON.parse(DEFAULT_CONFIGS.nebius); + parseNebiusConfig(configObj); + } catch (e) { + // Ignore parse errors + } + setNebiusPassthrough({}); + } if (type === 'vastai') { try { const configObj = JSON.parse(defaultConfig); @@ -880,6 +1035,10 @@ export default function ProviderDetailsModal({ const configObj = buildGcpConfig(); setConfig(JSON.stringify(configObj, null, 2)); } + if (type === 'nebius') { + const configObj = buildNebiusConfig(); + setConfig(JSON.stringify(configObj, null, 2)); + } } }, [ buildSlurmConfig, @@ -890,6 +1049,7 @@ export default function ProviderDetailsModal({ buildAwsConfig, buildVastAiConfig, buildGcpConfig, + buildNebiusConfig, type, providerId, ]); @@ -1021,6 +1181,28 @@ export default function ProviderDetailsModal({ ); } + function saveNebiusCredentials( + providerIdToSave: string, + serviceAccountId: string, + publicKeyId: string, + privateKey: string, + ) { + return fetchWithAuth( + Endpoints.ComputeProvider.NebiusCredentials(providerIdToSave), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + service_account_id: serviceAccountId, + public_key_id: publicKeyId, + private_key: privateKey, + }), + }, + ); + } + function saveGcpCredentials( providerIdToSave: string, serviceAccountJson: string, @@ -1063,6 +1245,8 @@ export default function ProviderDetailsModal({ parsedConfig = buildAzureConfig(); } else if (type === 'aws') { parsedConfig = buildAwsConfig(); + } else if (type === 'nebius') { + parsedConfig = buildNebiusConfig(); } else if (type === 'vastai') { parsedConfig = buildVastAiConfig(); } else if (type === 'gcp') { @@ -1104,6 +1288,46 @@ export default function ProviderDetailsModal({ return; } + const trimmedNebiusServiceAccountId = nebiusServiceAccountId.trim(); + const trimmedNebiusPublicKeyId = nebiusPublicKeyId.trim(); + const trimmedNebiusPrivateKey = nebiusPrivateKey.trim(); + const nebiusCredValues = [ + trimmedNebiusServiceAccountId, + trimmedNebiusPublicKeyId, + trimmedNebiusPrivateKey, + ]; + const hasAnyNebiusCreds = nebiusCredValues.some(Boolean); + const hasAllNebiusCreds = nebiusCredValues.every(Boolean); + if (type === 'nebius' && hasAnyNebiusCreds && !hasAllNebiusCreds) { + addNotification({ + type: 'danger', + message: + 'Enter Nebius Service Account ID, Public Key ID, and Private Key, or leave all credential fields blank.', + }); + return; + } + if (type === 'nebius' && !providerId && !hasAllNebiusCreds) { + addNotification({ + type: 'danger', + message: + 'Nebius service-account credentials are required when creating a Nebius provider.', + }); + return; + } + + if ( + type === 'nebius' && + !nebiusSubnetId.trim() && + !nebiusParentId.trim() + ) { + addNotification({ + type: 'danger', + message: + 'Nebius needs a project (parent) ID so the API can create a default VPC network and subnet automatically, or paste an existing subnet ID instead.', + }); + return; + } + const trimmedGcpServiceAccountJson = gcpServiceAccountJson.trim(); const trimmedGcpRegion = gcpRegion.trim(); if (type === 'gcp' && !trimmedGcpRegion) { @@ -1184,6 +1408,42 @@ export default function ProviderDetailsModal({ } } + if (type === 'nebius' && hasAllNebiusCreds) { + if (!savedProviderId) { + addNotification({ + type: 'danger', + message: + 'Provider was saved, but could not determine provider ID to save Nebius credentials.', + }); + return; + } + const nebiusCredsResponse = await saveNebiusCredentials( + savedProviderId, + trimmedNebiusServiceAccountId, + trimmedNebiusPublicKeyId, + trimmedNebiusPrivateKey, + ); + if (!nebiusCredsResponse.ok) { + const errorData = await nebiusCredsResponse + .json() + .catch(() => ({})); + addNotification({ + type: 'danger', + message: + (errorData && (errorData.detail || errorData.message)) || + 'Provider was saved, but saving Nebius credentials failed. Open the provider and try again.', + }); + // The provider record was created server-side but credentials + // weren't written. Close the modal so the parent refreshes and the + // user can re-open the partial provider in edit mode instead of + // getting a name-collision on a second submit. + setName(''); + setConfig(''); + onClose(); + return; + } + } + if (type === 'gcp' && trimmedGcpServiceAccountJson) { if (!savedProviderId) { addNotification({ @@ -1512,6 +1772,30 @@ export default function ProviderDetailsModal({ /> )} + {type === 'nebius' && ( + + )} + {type === 'gcp' && ( void; + nebiusPublicKeyId: string; + setNebiusPublicKeyId: (value: string) => void; + nebiusPrivateKey: string; + setNebiusPrivateKey: (value: string) => void; + nebiusParentId: string; + setNebiusParentId: (value: string) => void; + nebiusSubnetId: string; + setNebiusSubnetId: (value: string) => void; + nebiusDefaultPlatform: string; + setNebiusDefaultPlatform: (value: string) => void; + nebiusDefaultPreset: string; + setNebiusDefaultPreset: (value: string) => void; + nebiusBootImageFamily: string; + setNebiusBootImageFamily: (value: string) => void; + nebiusDiskSizeGib: string; + setNebiusDiskSizeGib: (value: string) => void; + providerId?: string; +} + +export default function NebiusProviderFields({ + nebiusServiceAccountId, + setNebiusServiceAccountId, + nebiusPublicKeyId, + setNebiusPublicKeyId, + nebiusPrivateKey, + setNebiusPrivateKey, + nebiusParentId, + setNebiusParentId, + nebiusSubnetId, + setNebiusSubnetId, + nebiusDefaultPlatform, + setNebiusDefaultPlatform, + nebiusDefaultPreset, + setNebiusDefaultPreset, + nebiusBootImageFamily, + setNebiusBootImageFamily, + nebiusDiskSizeGib, + setNebiusDiskSizeGib, + providerId = undefined, +}: NebiusProviderFieldsProps) { + return ( + + + Nebius CLI auth uses a service account key pair (not an + uploaded public key file in this form): + + + Create a private key locally. + + Upload the matching public .pem to the + Nebius console for that service account. + + + Copy the Public key ID the console shows after + registering the public key. + + + Paste the private key below — the public key stays + only on Nebius. + + + Use your Nebius service account resource id as the{' '} + Service account ID. + + + + If your console only offers short "access key" style secrets, + use the key-pair flow documented for the Nebius CLI{' '} + profile create command instead. + + + + Service Account ID + + setNebiusServiceAccountId(event.currentTarget.value) + } + placeholder="serviceaccount-..." + fullWidth + /> + + + Public Key ID + setNebiusPublicKeyId(event.currentTarget.value)} + placeholder="ID shown after you register the public key in Nebius" + fullWidth + /> + + + Private Key +