2165 lines
76 KiB
Python
2165 lines
76 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""Idempotent vLLM deployment script for AMD ROCm GPUs.
|
|
|
|
Deploys vLLM behind nginx with self-signed TLS on Fedora, CentOS Stream, or openEuler.
|
|
vLLM binds to ::1 (IPv4 loopback fallback) on port 8000, internal only;
|
|
nginx proxies :443 → the loopback upstream with streaming (SSE) support.
|
|
The endpoint requires an API key, delivered to the service via a 0600
|
|
EnvironmentFile; nginx→vLLM proxying is allowed through SELinux on
|
|
enforcing systems. Fedora gets ROCm from its native repositories;
|
|
CentOS Stream and openEuler fall back to AMD's amdgpu-install (unsupported by AMD).
|
|
|
|
No external dependencies — stdlib and system tools only. Every operation
|
|
checks current state before acting; running the script repeatedly produces
|
|
the same result with no errors and no duplicate work.
|
|
|
|
Usage:
|
|
vllm-deploy.py # interactive model selection
|
|
vllm-deploy.py --model Qwen/Qwen3.6-35B-A3B # deploy specific model
|
|
vllm-deploy.py --model Qwen/Qwen3.6-35B-A3B --dry-run # preview
|
|
vllm-deploy.py --uninstall # tear down
|
|
vllm-deploy.py --version
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
BOLD = "\033[1m"
|
|
RED = "\033[31m"
|
|
GREEN = "\033[32m"
|
|
YELLOW = "\033[33m"
|
|
DIM = "\033[2m"
|
|
RESET = "\033[0m"
|
|
VERSION = "1.2.1"
|
|
|
|
SUPPORTED_OS: dict[str, str] = {
|
|
"fedora": "Fedora",
|
|
"centos": "CentOS Stream",
|
|
"openeuler": "openEuler",
|
|
}
|
|
|
|
# Tools this script itself needs (lspci, curl fetches, openssl for TLS).
|
|
DNF_PACKAGES: list[str] = ["pciutils", "curl", "openssl"]
|
|
|
|
# Fedora ships ROCm natively (maintained by the Fedora ROCm SIG) — preferred
|
|
# over AMD's amdgpu-install, which does not support Fedora.
|
|
FEDORA_ROCM_PACKAGES: list[str] = [
|
|
"rocm-hip",
|
|
"rocm-opencl",
|
|
"rocm-runtime",
|
|
"rocm-smi",
|
|
"rocminfo",
|
|
]
|
|
|
|
# Default models for interactive selection when --model is omitted.
|
|
# All IDs verified to exist (ungated) on Hugging Face as of 2026-07.
|
|
DEFAULT_MODELS: list[str] = [
|
|
"deepseek-ai/DeepSeek-V4-Pro",
|
|
"deepseek-ai/DeepSeek-V4-Flash",
|
|
"MiniMaxAI/MiniMax-M3",
|
|
"Qwen/Qwen3.6-35B-A3B",
|
|
"Qwen/Qwen3.6-27B",
|
|
]
|
|
|
|
# vLLM ROCm wheel resolution, per the official vLLM ROCm install docs:
|
|
# https://docs.vllm.ai/en/stable/getting_started/installation/gpu/
|
|
# The newest pin is scraped from the index at runtime; the constants below
|
|
# are the fallback (current stable example from the vLLM documentation).
|
|
WHEELS_INDEX = "https://wheels.vllm.ai/rocm"
|
|
FALLBACK_VLLM_VERSION = "0.25.0"
|
|
FALLBACK_ROCM_VARIANT = "rocm723"
|
|
|
|
AMDGPU_INSTALL_URL = "https://repo.radeon.com/amdgpu-install/latest/rhel"
|
|
|
|
# HuggingFace model IDs look like "org/name". The strict pattern also keeps
|
|
# systemd specifier characters (%) and whitespace out of unit files.
|
|
MODEL_ID_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress helpers — write to stderr so stdout stays clean
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _status(msg: str) -> None:
|
|
"""Print a progress message to stderr — stays on the same line until ``_status_done``."""
|
|
print(f" {msg}...", end="", flush=True, file=sys.stderr)
|
|
|
|
|
|
def _status_done(msg: str = "done") -> None:
|
|
"""Complete the current progress line on stderr."""
|
|
print(f" {msg}", file=sys.stderr)
|
|
|
|
|
|
def _info(msg: str) -> None:
|
|
"""Print an informational line to stderr."""
|
|
print(f" {DIM}{msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
def _warn(msg: str) -> None:
|
|
"""Print a warning to stderr."""
|
|
print(f" {YELLOW}⚠ {msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
def _ok(msg: str) -> None:
|
|
"""Print a success message to stderr."""
|
|
print(f" {GREEN}✓ {msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
def _fail(msg: str) -> None:
|
|
"""Print a failure message to stderr."""
|
|
print(f" {RED}✗ {msg}{RESET}", file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Low-level command runner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def run(
|
|
cmd: list[str],
|
|
timeout: int = 300,
|
|
check: bool = False,
|
|
env: dict[str, str] | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Execute a command and return the CompletedProcess.
|
|
|
|
Sets LANG=C for reliable English-language output parsing.
|
|
Raises subprocess.CalledProcessError when *check* is True and the
|
|
command exits non-zero.
|
|
"""
|
|
merged_env = {**os.environ, "LANG": "C"}
|
|
if env:
|
|
merged_env.update(env)
|
|
return subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=timeout, env=merged_env, check=check
|
|
)
|
|
|
|
|
|
def _find_uv() -> str | None:
|
|
"""Locate the uv binary: PATH first, then well-known install locations."""
|
|
uv = shutil.which("uv")
|
|
if uv:
|
|
return uv
|
|
candidates = (
|
|
"/usr/local/bin/uv",
|
|
"/root/.local/bin/uv",
|
|
os.path.expanduser("~/.local/bin/uv"),
|
|
)
|
|
for candidate in candidates:
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _fetch_text(url: str, timeout: int = 30) -> str:
|
|
"""Fetch a URL as text via curl -fsSL; return '' on failure (with a warning)."""
|
|
curl = shutil.which("curl")
|
|
if curl is None:
|
|
_warn("curl not found — cannot fetch remote version information")
|
|
return ""
|
|
try:
|
|
result = run([curl, "-fsSL", url], timeout=timeout)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
_warn(f"Failed to fetch {url}: {exc}")
|
|
return ""
|
|
if result.returncode != 0:
|
|
_warn(f"Failed to fetch {url} (curl exit {result.returncode})")
|
|
return ""
|
|
return result.stdout
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_os_release() -> dict[str, str]:
|
|
"""Parse /etc/os-release into a dict of KEY=VALUE pairs."""
|
|
result: dict[str, str] = {}
|
|
try:
|
|
with open("/etc/os-release", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
value = value.strip().strip('"').strip("'")
|
|
result[key] = value
|
|
except OSError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def detect_os() -> tuple[str, str, str]:
|
|
"""Detect the operating system.
|
|
|
|
Returns a tuple of (os_id, display_name, version_id).
|
|
Exits with an error message when the OS is not supported.
|
|
"""
|
|
release = parse_os_release()
|
|
os_id = release.get("ID", "").lower()
|
|
version_id = release.get("VERSION_ID", "unknown")
|
|
|
|
if os_id not in SUPPORTED_OS:
|
|
print(
|
|
f"{RED}{BOLD}Error:{RESET} Unsupported operating system: "
|
|
f"'{release.get('ID', 'unknown')}' (detected from /etc/os-release).",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f" Supported systems: {', '.join(SUPPORTED_OS.values())}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
return os_id, SUPPORTED_OS[os_id], version_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Root check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_root() -> None:
|
|
"""Exit with a clear message if not running as root (UID 0)."""
|
|
if os.geteuid() != 0:
|
|
print(
|
|
f"{RED}{BOLD}Error:{RESET} This script must be run as root (UID 0).",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f" Current UID: {os.geteuid()}. Try: sudo python3 vllm-deploy.py --model ...",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Idempotency helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _file_exists(path: str) -> bool:
|
|
"""Return True if the path exists as a file."""
|
|
return os.path.isfile(path)
|
|
|
|
|
|
def _dir_exists(path: str) -> bool:
|
|
"""Return True if the path exists as a directory."""
|
|
return os.path.isdir(path)
|
|
|
|
|
|
def _user_exists(username: str) -> bool:
|
|
"""Return True if the system user exists."""
|
|
result = run(["id", username])
|
|
return result.returncode == 0
|
|
|
|
|
|
def _group_exists(groupname: str) -> bool:
|
|
"""Return True if the system group exists."""
|
|
result = run(["getent", "group", groupname])
|
|
return result.returncode == 0
|
|
|
|
|
|
def _rpm_installed(pkg: str) -> bool:
|
|
"""Return True if the RPM package is already installed."""
|
|
result = run(["rpm", "-q", pkg])
|
|
return result.returncode == 0
|
|
|
|
|
|
def _systemctl_is_active(service: str) -> bool:
|
|
"""Return True if the systemd service is active."""
|
|
result = run(["systemctl", "is-active", "--quiet", service])
|
|
return result.returncode == 0
|
|
|
|
|
|
def _systemctl_is_enabled(service: str) -> bool:
|
|
"""Return True if the systemd service is enabled."""
|
|
result = run(["systemctl", "is-enabled", "--quiet", service])
|
|
return result.returncode == 0
|
|
|
|
|
|
def _map_to_el_major(os_id: str, version_id: str) -> str:
|
|
"""Map an OS version to the closest RHEL major ("8"/"9") for AMDGPU repos.
|
|
|
|
CentOS Stream VERSION_ID is already the RHEL major (e.g. "9", "10");
|
|
openEuler uses its own numbering (≥24 → "9", otherwise "8").
|
|
"""
|
|
if os_id.startswith("centos"):
|
|
try:
|
|
return str(int(version_id.split(".")[0]))
|
|
except (ValueError, IndexError):
|
|
return "9"
|
|
try:
|
|
major_ver = int(version_id.split(".")[0])
|
|
except (ValueError, IndexError):
|
|
major_ver = 0
|
|
return "9" if major_ver >= 24 else "8"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GPU, ROCm and wheel-pin resolution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _detect_amd_gpus() -> list[str]:
|
|
"""Return AMD/ATI GPU descriptions from lspci -nn (empty list when none).
|
|
|
|
Only VGA/3D/Display controller class lines with the AMD/ATI vendor ID
|
|
(1002) match — AMD chipset, audio and USB lines found on AMD-CPU systems
|
|
are correctly ignored.
|
|
"""
|
|
lspci = shutil.which("lspci")
|
|
if lspci is None:
|
|
_fail("lspci not found — pciutils should have been installed in the dependencies step")
|
|
sys.exit(1)
|
|
result = run([lspci, "-nn"], timeout=30)
|
|
if result.returncode != 0:
|
|
_fail(f"lspci -nn failed: {result.stderr.strip()}")
|
|
sys.exit(1)
|
|
|
|
gpu_classes = ("VGA compatible controller", "3D controller", "Display controller")
|
|
gpus: list[str] = []
|
|
for line in result.stdout.split("\n"):
|
|
if "[1002:" in line and any(cls in line for cls in gpu_classes):
|
|
gpus.append(line.split(": ", 1)[-1].strip() if ": " in line else line.strip())
|
|
return gpus
|
|
|
|
|
|
def _detect_rocm_version() -> str | None:
|
|
"""Return the installed ROCm version (e.g. '7.1.0'), or None if not found."""
|
|
info_file = "/opt/rocm/.info/version"
|
|
if _file_exists(info_file):
|
|
try:
|
|
with open(info_file, encoding="utf-8") as fh:
|
|
version = fh.read().strip().split("-")[0]
|
|
if version:
|
|
return version
|
|
except OSError:
|
|
pass
|
|
# Fedora native packages install into /usr (no /opt/rocm).
|
|
result = run(["rpm", "-q", "--qf", "%{VERSION}", "rocm-runtime"], timeout=30)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return result.stdout.strip()
|
|
smi = shutil.which("rocm-smi")
|
|
if smi:
|
|
result = run([smi, "--version"], timeout=30)
|
|
match = re.search(r"(\d+\.\d+(?:\.\d+)?)", result.stdout + result.stderr)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def _variant_to_version(variant: str) -> str:
|
|
"""Map a wheel ROCm variant ('rocm700', 'rocm723') to a dotted version."""
|
|
digits = variant.removeprefix("rocm")
|
|
return ".".join(digits) # '700' -> '7.0.0', '723' -> '7.2.3'
|
|
|
|
|
|
def _check_rocm_compat(rocm_version: str | None, variant: str) -> None:
|
|
"""Warn when the installed ROCm major version differs from the wheel's target."""
|
|
if rocm_version is None:
|
|
_warn("Could not determine the ROCm version — cannot verify wheel compatibility")
|
|
return
|
|
required = _variant_to_version(variant)
|
|
if rocm_version.split(".")[0] != required.split(".")[0]:
|
|
_warn(
|
|
f"ROCm {rocm_version} is installed but the vLLM wheel targets ROCm {required} "
|
|
f"({variant}) — runtime errors are likely; align the versions"
|
|
)
|
|
|
|
|
|
def _resolve_vllm_pin() -> tuple[str, str, str]:
|
|
"""Resolve (version, rocm_variant, index_url) for the newest stable ROCm wheel.
|
|
|
|
Scrapes the vLLM wheel index as recommended by the vLLM documentation;
|
|
falls back to FALLBACK_VLLM_VERSION/FALLBACK_ROCM_VARIANT when the index
|
|
is unreachable or unparsable.
|
|
"""
|
|
listing = _fetch_text(f"{WHEELS_INDEX}/vllm")
|
|
candidates = re.findall(r"vllm-(\d+\.\d+\.\d+)\+(rocm\d+)-", listing)
|
|
if not candidates:
|
|
if listing:
|
|
_warn("Could not parse the vLLM wheel index — using the fallback pin")
|
|
return (
|
|
FALLBACK_VLLM_VERSION,
|
|
FALLBACK_ROCM_VARIANT,
|
|
f"{WHEELS_INDEX}/{FALLBACK_VLLM_VERSION}/{FALLBACK_ROCM_VARIANT}",
|
|
)
|
|
version, variant = max(candidates, key=lambda c: ([int(p) for p in c[0].split(".")], c[1]))
|
|
return version, variant, f"{WHEELS_INDEX}/{version}/{variant}"
|
|
|
|
|
|
def _hf_model_status(model: str) -> str:
|
|
"""Probe Hugging Face for the model: 'ok', 'gated', 'missing', or 'unknown'.
|
|
|
|
Only a definitive 404 ('missing') is fatal; network problems yield
|
|
'unknown' so offline environments are not blocked.
|
|
"""
|
|
url = f"https://huggingface.co/api/models/{model}"
|
|
request = urllib.request.Request(url, headers={"User-Agent": f"vllm-deploy/{VERSION}"})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
payload = json.loads(response.read().decode("utf-8", "replace"))
|
|
return "gated" if payload.get("gated") not in (False, None) else "ok"
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return "missing"
|
|
if exc.code in (401, 403):
|
|
return "gated"
|
|
return "unknown"
|
|
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
return "unknown"
|
|
|
|
|
|
def _detect_loopback() -> tuple[str, str]:
|
|
"""Return (vllm_bind_host, nginx_upstream_host) — IPv6 ::1 first.
|
|
|
|
Falls back to 127.0.0.1 on kernels with IPv6 disabled
|
|
(net.ipv6.conf.all.disable_ipv6=1), where binding ::1 would fail.
|
|
"""
|
|
if os.path.exists("/proc/net/if_inet6"):
|
|
return "::1", "[::1]"
|
|
return "127.0.0.1", "127.0.0.1"
|
|
|
|
|
|
def _cert_san() -> str:
|
|
"""Build a subjectAltName value from the host FQDN and primary IPv4 (if resolvable)."""
|
|
fqdn = socket.getfqdn()
|
|
hostname = socket.gethostname()
|
|
entries = [f"DNS:{fqdn}"]
|
|
if hostname and hostname != fqdn:
|
|
entries.append(f"DNS:{hostname}")
|
|
try:
|
|
addr = socket.gethostbyname(fqdn)
|
|
if addr and not addr.startswith("127."):
|
|
entries.append(f"IP:{addr}")
|
|
except OSError:
|
|
pass
|
|
return ",".join(entries)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. System dependencies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def install_system_deps(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Install required system packages via dnf. Idempotent."""
|
|
results: dict[str, Any] = {"installed": [], "skipped": [], "failed": []}
|
|
|
|
missing: list[str] = []
|
|
for pkg in DNF_PACKAGES:
|
|
if _rpm_installed(pkg):
|
|
results["skipped"].append(pkg)
|
|
else:
|
|
missing.append(pkg)
|
|
|
|
if not missing:
|
|
_info(f"All {len(DNF_PACKAGES)} packages already installed")
|
|
else:
|
|
_status(f"Installing {len(missing)} package(s): {', '.join(missing)}")
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
results["installed"] = missing
|
|
else:
|
|
result = run(["dnf", "install", "-y", *missing], timeout=600)
|
|
if result.returncode == 0:
|
|
results["installed"] = missing
|
|
_status_done("ok")
|
|
else:
|
|
_status_done("failed")
|
|
results["failed"] = missing
|
|
if result.stderr:
|
|
_info(f"dnf stderr: {result.stderr.strip()}")
|
|
|
|
# Install uv: dnf first (packaged in Fedora), standalone installer as
|
|
# fallback. Never pip-install into the system Python.
|
|
_status("Checking uv")
|
|
uv = _find_uv()
|
|
if uv is not None:
|
|
_status_done("already installed")
|
|
results["uv"] = uv
|
|
elif dry_run:
|
|
_status_done("would install via dnf")
|
|
results["uv"] = "dry run"
|
|
else:
|
|
uv_dnf = run(["dnf", "install", "-y", "uv"], timeout=300)
|
|
uv = _find_uv() if uv_dnf.returncode == 0 else None
|
|
if uv is not None:
|
|
_status_done(f"installed via dnf ({uv})")
|
|
results["uv"] = uv
|
|
else:
|
|
_info("uv not in distro repos — trying the official standalone installer")
|
|
installer = run(
|
|
["sh", "-c", "curl -fsSL https://astral.sh/uv/install.sh | sh"],
|
|
timeout=300,
|
|
)
|
|
uv = _find_uv() if installer.returncode == 0 else None
|
|
if uv is not None:
|
|
_status_done(f"installed via installer ({uv})")
|
|
results["uv"] = uv
|
|
else:
|
|
_status_done("failed")
|
|
results["uv"] = None
|
|
_fail("uv could not be installed — vLLM venv creation will fail")
|
|
|
|
# Packages expected from server-setup.py (warning only, not installed here).
|
|
required_server_packages: dict[str, str] = {
|
|
"nginx": "reverse proxy",
|
|
"git": "source control",
|
|
"python3": "Python runtime",
|
|
"python3-devel": "Python development headers",
|
|
}
|
|
for pkg, purpose in required_server_packages.items():
|
|
if not _rpm_installed(pkg):
|
|
_warn(f"{pkg} ({purpose}) is not installed — run server-setup.py first")
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Pre-flight checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _install_rocm_fedora(dry_run: bool) -> None:
|
|
"""Install ROCm from Fedora's native repositories (Fedora ROCm SIG)."""
|
|
_status("Installing ROCm from Fedora repositories (this may take several minutes)")
|
|
missing = [pkg for pkg in FEDORA_ROCM_PACKAGES if not _rpm_installed(pkg)]
|
|
if not missing:
|
|
_status_done("already installed")
|
|
return
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
_info(f"Would run: dnf install -y {' '.join(missing)}")
|
|
return
|
|
result = run(["dnf", "install", "-y", *missing], timeout=1800)
|
|
if result.returncode != 0:
|
|
_status_done("failed")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} Failed to install ROCm packages.",
|
|
file=sys.stderr,
|
|
)
|
|
if result.stderr:
|
|
_info(f"dnf stderr: {result.stderr.strip()}")
|
|
sys.exit(1)
|
|
_status_done("ok")
|
|
|
|
|
|
def _install_rocm_openeuler(os_id: str, version_id: str, dry_run: bool) -> None:
|
|
"""Install ROCm on CentOS Stream / openEuler via AMD's amdgpu-install script.
|
|
|
|
AMD does not list CentOS Stream or openEuler as supported ROCm platforms —
|
|
the RHEL packages are installed strictly on a best-effort basis and may
|
|
fail. The RHEL point-release directory and RPM filename are resolved
|
|
dynamically from the repository (nothing is hardcoded).
|
|
"""
|
|
_warn(
|
|
"AMD does not support ROCm on CentOS Stream / openEuler — "
|
|
"amdgpu-install targets RHEL. Continuing best-effort; this may fail "
|
|
"or produce a broken install."
|
|
)
|
|
el_major = _map_to_el_major(os_id, version_id)
|
|
|
|
_status(f"Resolving latest amdgpu-install release for el{el_major}")
|
|
listing = _fetch_text(f"{AMDGPU_INSTALL_URL}/")
|
|
rhel_dirs = [
|
|
d for d in re.findall(r'href="(\d+\.\d+)/"', listing) if d.startswith(f"{el_major}.")
|
|
]
|
|
rhel_version = max(rhel_dirs, key=lambda v: [int(p) for p in v.split(".")], default=None)
|
|
rpm_filename = ""
|
|
if rhel_version:
|
|
rpm_listing = _fetch_text(f"{AMDGPU_INSTALL_URL}/{rhel_version}/")
|
|
match = re.search(rf"amdgpu-install-[\d.]+-\d+\.el{el_major}\.noarch\.rpm", rpm_listing)
|
|
if match:
|
|
rpm_filename = match.group(0)
|
|
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
if rpm_filename:
|
|
_info(f"Would fetch: {AMDGPU_INSTALL_URL}/{rhel_version}/{rpm_filename}")
|
|
else:
|
|
_warn(f"Could not resolve the latest RPM — check {AMDGPU_INSTALL_URL}/")
|
|
_info("Would run: amdgpu-install -y --usecase=rocm")
|
|
return
|
|
|
|
if not rpm_filename or rhel_version is None:
|
|
_status_done("failed")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} Could not resolve the latest amdgpu-install "
|
|
f"RPM under {AMDGPU_INSTALL_URL}/.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
rpm_url = f"{AMDGPU_INSTALL_URL}/{rhel_version}/{rpm_filename}"
|
|
ver_match = re.match(r"amdgpu-install-([\d.]+)", rpm_filename)
|
|
detected = ver_match.group(1) if ver_match else "latest"
|
|
_status_done(f"amdgpu-install {detected} (rhel {rhel_version})")
|
|
|
|
_status(f"Installing amdgpu-install {detected}")
|
|
install_result = run(["dnf", "install", "-y", rpm_url], timeout=300)
|
|
if install_result.returncode != 0:
|
|
_status_done("failed")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} Failed to install amdgpu-install.",
|
|
file=sys.stderr,
|
|
)
|
|
print(f" URL: {rpm_url}", file=sys.stderr)
|
|
if install_result.stderr:
|
|
_info(f"dnf stderr: {install_result.stderr.strip()}")
|
|
sys.exit(1)
|
|
_status_done("ok")
|
|
|
|
_status("Installing ROCm stack (this may take several minutes)")
|
|
rocm_result = run(["amdgpu-install", "-y", "--usecase=rocm"], timeout=1800)
|
|
if rocm_result.returncode != 0:
|
|
_status_done("failed")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} amdgpu-install failed.",
|
|
file=sys.stderr,
|
|
)
|
|
if rocm_result.stderr:
|
|
_info(f"stderr: {rocm_result.stderr.strip()}")
|
|
sys.exit(1)
|
|
_status_done("ok")
|
|
|
|
|
|
def preflight_checks(os_id: str, version_id: str, dry_run: bool = False) -> dict[str, Any]:
|
|
"""Run pre-flight checks: root, GPU hardware, wheel pin, ROCm.
|
|
|
|
Returns a dict with check results (including the resolved vLLM pin).
|
|
Exits with a clear error on any hard failure.
|
|
"""
|
|
results: dict[str, Any] = {}
|
|
|
|
# Root (idempotent no-op — main() already checked; keeps the status line).
|
|
_status("Checking root privileges")
|
|
check_root()
|
|
_status_done("root")
|
|
|
|
# OS (already detected by main for the header).
|
|
_status("Detecting operating system")
|
|
_status_done(f"{SUPPORTED_OS[os_id]} {version_id}")
|
|
results["os_id"] = os_id
|
|
results["os_display"] = SUPPORTED_OS[os_id]
|
|
results["version_id"] = version_id
|
|
|
|
# AMD GPU hardware — class + vendor filtered, so AMD chipsets never match.
|
|
_status("Checking GPU hardware")
|
|
gpus = _detect_amd_gpus()
|
|
if not gpus:
|
|
_status_done("not found")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} No AMD GPU detected in this system — "
|
|
"vLLM requires an AMD GPU with ROCm support.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
if len(gpus) == 1:
|
|
_status_done(gpus[0])
|
|
else:
|
|
_status_done(f"{gpus[0]} (+{len(gpus) - 1} more)")
|
|
results["gpu_models"] = gpus
|
|
results["gpu_count"] = len(gpus)
|
|
|
|
# Resolve the vLLM ROCm wheel pin (also used for the ROCm version check).
|
|
_status("Resolving vLLM ROCm wheel version")
|
|
vllm_version, rocm_variant, wheel_index = _resolve_vllm_pin()
|
|
_status_done(f"vllm=={vllm_version}+{rocm_variant}")
|
|
results["vllm_version"] = vllm_version
|
|
results["rocm_variant"] = rocm_variant
|
|
results["wheel_index"] = wheel_index
|
|
|
|
# ROCm
|
|
_status("Checking ROCm installation")
|
|
rocm_version = _detect_rocm_version()
|
|
rocm_present = (
|
|
rocm_version is not None or _dir_exists("/opt/rocm") or shutil.which("rocm-smi") is not None
|
|
)
|
|
if rocm_present:
|
|
_status_done(f"present ({rocm_version or 'unknown version'})")
|
|
results["rocm"] = True
|
|
_check_rocm_compat(rocm_version, rocm_variant)
|
|
else:
|
|
_status_done("not found")
|
|
if os_id == "fedora":
|
|
_install_rocm_fedora(dry_run)
|
|
else:
|
|
_install_rocm_openeuler(os_id, version_id, dry_run)
|
|
results["rocm"] = True
|
|
|
|
# GPU verification via rocm-smi.
|
|
_status("Verifying GPU via rocm-smi")
|
|
if dry_run and not rocm_present:
|
|
_status_done("would verify")
|
|
_info("Would run: rocm-smi --showproductname")
|
|
return results
|
|
smi = shutil.which("rocm-smi")
|
|
if smi is None and _file_exists("/opt/rocm/bin/rocm-smi"):
|
|
smi = "/opt/rocm/bin/rocm-smi"
|
|
if smi is None:
|
|
_status_done("rocm-smi not found")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} rocm-smi not found — ROCm installation "
|
|
"appears incomplete.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
gpu_result = run([smi, "--showproductname"], timeout=30)
|
|
if gpu_result.returncode != 0:
|
|
_status_done("no GPU detected")
|
|
print(
|
|
f"\n {RED}{BOLD}Error:{RESET} No AMD GPU detected via rocm-smi.",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
" ROCm installed but no compatible GPU found.",
|
|
file=sys.stderr,
|
|
)
|
|
if gpu_result.stderr:
|
|
print(f" rocm-smi stderr: {gpu_result.stderr.strip()}", file=sys.stderr)
|
|
sys.exit(1)
|
|
_status_done("available")
|
|
# Count unique GPU[n] indices — rocm-smi prints several lines per GPU.
|
|
indices = set(re.findall(r"GPU\[(\d+)\]", gpu_result.stdout))
|
|
if indices:
|
|
results["gpu_count"] = len(indices)
|
|
results["gpu_info"] = gpu_result.stdout.strip()
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Create vllm system user
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_vllm_user(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Create the vllm system user and add to GPU groups. Idempotent."""
|
|
results: dict[str, Any] = {}
|
|
|
|
_status("Checking vllm system user")
|
|
if _user_exists("vllm"):
|
|
_status_done("already exists")
|
|
results["user_exists"] = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("would create")
|
|
results["user_created"] = "dry run"
|
|
else:
|
|
result = run(
|
|
["useradd", "-r", "-s", "/sbin/nologin", "-d", "/opt/vllm", "vllm"],
|
|
timeout=30,
|
|
)
|
|
if result.returncode == 0:
|
|
_status_done("created")
|
|
results["user_created"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"useradd failed: {result.stderr.strip()}")
|
|
results["user_created"] = False
|
|
return results
|
|
|
|
# Add to render and video groups for GPU access.
|
|
for group in ("render", "video"):
|
|
_status(f"Adding vllm to {group} group")
|
|
if not _group_exists(group):
|
|
_status_done("group does not exist")
|
|
_warn(f"Group '{group}' not found — GPU access may be limited")
|
|
continue
|
|
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
continue
|
|
|
|
result = run(["usermod", "-aG", group, "vllm"], timeout=30)
|
|
if result.returncode == 0:
|
|
_status_done("ok")
|
|
else:
|
|
_status_done("failed")
|
|
_warn(f"Failed to add vllm to {group}: {result.stderr.strip()}")
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Python venv with uv + vLLM for ROCm
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _chown_vllm(path: str, recursive: bool = False) -> None:
|
|
"""chown a path to vllm:vllm, warning (not crashing) on failure."""
|
|
cmd = ["chown"] + (["-R"] if recursive else []) + ["vllm:vllm", path]
|
|
result = run(cmd, timeout=60)
|
|
if result.returncode != 0:
|
|
_warn(f"chown failed for {path}: {result.stderr.strip()}")
|
|
|
|
|
|
def _installed_vllm_version(uv: str | None, venv_dir: str) -> str | None:
|
|
"""Return the vLLM version installed in the venv, or None if unknown."""
|
|
if uv is None:
|
|
return None
|
|
result = run([uv, "pip", "show", "--python", f"{venv_dir}/bin/python", "vllm"], timeout=60)
|
|
if result.returncode != 0:
|
|
return None
|
|
for line in result.stdout.split("\n"):
|
|
if line.startswith("Version:"):
|
|
return line.split(":", 1)[1].strip()
|
|
return None
|
|
|
|
|
|
def setup_venv(
|
|
vllm_version: str,
|
|
rocm_variant: str,
|
|
index_url: str,
|
|
venv_dir: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Create Python venv and install vLLM for ROCm via uv. Idempotent."""
|
|
results: dict[str, Any] = {}
|
|
|
|
# Ensure /opt/vllm exists and is owned by vllm.
|
|
_status("Ensuring /opt/vllm directory")
|
|
if not _dir_exists("/opt/vllm"):
|
|
if dry_run:
|
|
_status_done("would create")
|
|
else:
|
|
os.makedirs("/opt/vllm", exist_ok=True)
|
|
_chown_vllm("/opt/vllm")
|
|
_status_done("created")
|
|
else:
|
|
_status_done("exists")
|
|
|
|
# Create venv if missing. Stable ROCm wheels support Python 3.13 (uv downloads it automatically).
|
|
_status("Creating Python venv")
|
|
uv = _find_uv()
|
|
if _dir_exists(venv_dir):
|
|
_status_done("already exists")
|
|
results["venv_exists"] = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
results["venv_created"] = "dry run"
|
|
else:
|
|
if uv is None:
|
|
_status_done("uv not found")
|
|
_fail("uv is not installed — cannot create the venv")
|
|
results["venv_created"] = False
|
|
return results
|
|
venv_result = run([uv, "venv", "--python", "3.13", venv_dir], timeout=300)
|
|
if venv_result.returncode == 0:
|
|
_status_done("created")
|
|
results["venv_created"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"uv venv failed: {venv_result.stderr.strip()}")
|
|
results["venv_created"] = False
|
|
return results
|
|
|
|
if not dry_run and _dir_exists(venv_dir):
|
|
_chown_vllm(venv_dir, recursive=True)
|
|
|
|
# Idempotency: uv venvs have no pip — check for the vllm entry point and
|
|
# query the installed version via uv instead.
|
|
vllm_bin = f"{venv_dir}/bin/vllm"
|
|
_status("Checking vLLM installation")
|
|
if os.path.isfile(vllm_bin):
|
|
installed = _installed_vllm_version(uv, venv_dir)
|
|
_status_done(f"already installed (v{installed})" if installed else "already installed")
|
|
results["vllm_installed"] = True
|
|
results["vllm_version"] = installed or "unknown"
|
|
if installed and installed != vllm_version:
|
|
_warn(
|
|
f"Installed vLLM {installed} differs from the resolved pin {vllm_version} "
|
|
"— keeping the installed version (uninstall first to switch)"
|
|
)
|
|
return results
|
|
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
_info(
|
|
f"Would run: uv pip install --python {venv_dir}/bin/python "
|
|
f"vllm=={vllm_version}+{rocm_variant} --extra-index-url {index_url}"
|
|
)
|
|
results["vllm_installed"] = "dry run"
|
|
return results
|
|
|
|
if uv is None:
|
|
_status_done("uv not found")
|
|
_fail("uv is not installed — cannot install vLLM")
|
|
results["vllm_installed"] = False
|
|
return results
|
|
|
|
_status(f"Installing vllm=={vllm_version}+{rocm_variant} (this may take several minutes)")
|
|
install_result = run(
|
|
[
|
|
uv,
|
|
"pip",
|
|
"install",
|
|
"--python",
|
|
f"{venv_dir}/bin/python",
|
|
f"vllm=={vllm_version}+{rocm_variant}",
|
|
"--extra-index-url",
|
|
index_url,
|
|
],
|
|
timeout=1800, # 30 min — vLLM is a large package
|
|
)
|
|
if install_result.returncode == 0:
|
|
_status_done("ok")
|
|
results["vllm_installed"] = True
|
|
results["vllm_version"] = vllm_version
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"vLLM install failed: {install_result.stderr.strip()[-500:]}")
|
|
results["vllm_installed"] = False
|
|
|
|
if not dry_run and _dir_exists(venv_dir):
|
|
_chown_vllm(venv_dir, recursive=True)
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. TLS certificate (self-signed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_tls(cert_dir: str, dry_run: bool = False) -> dict[str, Any]:
|
|
"""Generate a self-signed TLS certificate (with SAN). Idempotent."""
|
|
results: dict[str, Any] = {}
|
|
key_path = os.path.join(cert_dir, "vllm.key")
|
|
crt_path = os.path.join(cert_dir, "vllm.crt")
|
|
|
|
_status("Checking TLS certificate")
|
|
if _file_exists(crt_path) and _file_exists(key_path):
|
|
_status_done("already exists")
|
|
results["cert_exists"] = True
|
|
return results
|
|
|
|
if dry_run:
|
|
_status_done("would generate")
|
|
results["cert_created"] = "dry run"
|
|
return results
|
|
|
|
openssl = shutil.which("openssl")
|
|
if openssl is None:
|
|
_status_done("openssl not found")
|
|
_fail("openssl is not installed — cannot generate a certificate")
|
|
results["cert_created"] = False
|
|
return results
|
|
|
|
if not _dir_exists(cert_dir):
|
|
os.makedirs(cert_dir, exist_ok=True)
|
|
|
|
_status("Generating self-signed certificate")
|
|
result = run(
|
|
[
|
|
openssl,
|
|
"req",
|
|
"-x509",
|
|
"-nodes",
|
|
"-days",
|
|
"365",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-keyout",
|
|
key_path,
|
|
"-out",
|
|
crt_path,
|
|
"-subj",
|
|
f"/CN={socket.getfqdn()}",
|
|
"-addext",
|
|
f"subjectAltName={_cert_san()}",
|
|
],
|
|
timeout=30,
|
|
)
|
|
if result.returncode == 0:
|
|
os.chmod(key_path, 0o600)
|
|
os.chmod(crt_path, 0o644)
|
|
_status_done("generated")
|
|
results["cert_created"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"openssl failed: {result.stderr.strip()}")
|
|
results["cert_created"] = False
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. API key (EnvironmentFile for the systemd unit)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_api_key(
|
|
service_name: str,
|
|
api_key: str | None,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Store the vLLM API key in a 0600 EnvironmentFile. Idempotent.
|
|
|
|
The key comes from --api-key when given, otherwise it is generated once
|
|
and reused on subsequent runs. systemd (PID 1, root) reads the file, so
|
|
0600 root:vllm is sufficient — the service user never opens it.
|
|
"""
|
|
results: dict[str, Any] = {}
|
|
env_path = f"/etc/sysconfig/{service_name}"
|
|
|
|
current = ""
|
|
if _file_exists(env_path):
|
|
try:
|
|
with open(env_path, encoding="utf-8") as fh:
|
|
current = fh.read()
|
|
except OSError:
|
|
current = ""
|
|
|
|
_status("Checking API key environment file")
|
|
if api_key is None and "VLLM_API_KEY=" in current:
|
|
_status_done("already present — reusing existing key")
|
|
results["env_file"] = "exists"
|
|
return results
|
|
|
|
desired = f"VLLM_API_KEY={api_key}\n" if api_key else ""
|
|
if api_key is not None and current == desired:
|
|
_status_done("already configured")
|
|
results["env_file"] = "exists"
|
|
return results
|
|
|
|
if dry_run:
|
|
_status_done("would update" if current else "would create")
|
|
results["env_file"] = "dry run"
|
|
return results
|
|
|
|
key = api_key if api_key is not None else secrets.token_urlsafe(32)
|
|
try:
|
|
with open(env_path, "w", encoding="utf-8") as fh:
|
|
fh.write(f"VLLM_API_KEY={key}\n")
|
|
except OSError as exc:
|
|
_status_done("failed")
|
|
_fail(f"Could not write {env_path}: {exc}")
|
|
results["env_file"] = "failed"
|
|
return results
|
|
os.chmod(env_path, 0o600)
|
|
result = run(["chown", "root:vllm", env_path], timeout=30)
|
|
if result.returncode != 0:
|
|
_warn(f"chown failed for {env_path}: {result.stderr.strip()}")
|
|
_status_done("updated" if current else "created")
|
|
results["env_file"] = "updated" if current else "created"
|
|
if api_key is None:
|
|
results["generated_key"] = key
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. SELinux
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_selinux(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Allow nginx→vLLM proxying on SELinux-enforcing systems. Idempotent.
|
|
|
|
http_port_t covers 80/81/443/488/8008/8009/8443/9000 — not vLLM's port —
|
|
so on enforcing systems nginx needs httpd_can_network_connect.
|
|
"""
|
|
results: dict[str, Any] = {}
|
|
|
|
_status("Checking SELinux status")
|
|
getenforce = shutil.which("getenforce")
|
|
if getenforce is None:
|
|
_status_done("not installed — skipping")
|
|
results["selinux"] = "absent"
|
|
return results
|
|
mode_result = run([getenforce], timeout=10)
|
|
mode = mode_result.stdout.strip().lower()
|
|
if mode != "enforcing":
|
|
_status_done(f"{mode} — skipping")
|
|
results["selinux"] = mode
|
|
return results
|
|
_status_done("enforcing")
|
|
|
|
_status("Checking httpd_can_network_connect boolean")
|
|
getsebool = shutil.which("getsebool")
|
|
setsebool = shutil.which("setsebool")
|
|
if getsebool is None or setsebool is None:
|
|
_status_done("tools missing")
|
|
_warn("getsebool/setsebool not found — nginx proxying may be blocked (502)")
|
|
results["selinux"] = "failed"
|
|
return results
|
|
|
|
current = run([getsebool, "httpd_can_network_connect"], timeout=10)
|
|
if current.returncode == 0 and "--> on" in current.stdout:
|
|
_status_done("already on")
|
|
results["selinux"] = "on"
|
|
return results
|
|
|
|
if dry_run:
|
|
_status_done("would set on")
|
|
results["selinux"] = "dry run"
|
|
return results
|
|
|
|
# -P persists across reboots; the policy rebuild can take a while.
|
|
set_result = run([setsebool, "-P", "httpd_can_network_connect=1"], timeout=180)
|
|
if set_result.returncode == 0:
|
|
_status_done("set on")
|
|
results["selinux"] = "on"
|
|
else:
|
|
_status_done("failed")
|
|
_warn(f"setsebool failed: {set_result.stderr.strip()}")
|
|
results["selinux"] = "failed"
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. nginx configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _nginx_conf_content(port: int, upstream_host: str, cert_dir: str) -> str:
|
|
"""Return the nginx vllm server block content.
|
|
|
|
HTTP/1.1 + ``Connection ""`` + ``proxy_buffering off`` are required for
|
|
vLLM's SSE streaming — nginx's defaults (HTTP/1.0, buffering on) would
|
|
hold a whole streamed completion until generation finishes.
|
|
"""
|
|
return f"""server {{
|
|
listen [::]:443 ssl;
|
|
listen 443 ssl;
|
|
server_name _;
|
|
|
|
ssl_certificate {cert_dir}/vllm.crt;
|
|
ssl_certificate_key {cert_dir}/vllm.key;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
|
|
client_max_body_size 50m;
|
|
|
|
location / {{
|
|
proxy_pass http://{upstream_host}:{port};
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Connection "";
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_buffering off;
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
}}
|
|
}}
|
|
"""
|
|
|
|
|
|
def setup_nginx(
|
|
port: int,
|
|
upstream_host: str,
|
|
cert_dir: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Configure nginx as reverse proxy for vLLM. Idempotent."""
|
|
results: dict[str, Any] = {}
|
|
conf_path = "/etc/nginx/conf.d/vllm.conf"
|
|
desired_content = _nginx_conf_content(port, upstream_host, cert_dir)
|
|
|
|
# Write nginx config if missing or different.
|
|
_status("Checking nginx configuration")
|
|
if _file_exists(conf_path):
|
|
try:
|
|
with open(conf_path, encoding="utf-8") as fh:
|
|
current = fh.read()
|
|
except OSError:
|
|
current = ""
|
|
if current.strip() == desired_content.strip():
|
|
_status_done("already configured")
|
|
results["nginx_configured"] = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("would update")
|
|
results["nginx_configured"] = "dry run"
|
|
else:
|
|
with open(conf_path, "w", encoding="utf-8") as fh:
|
|
fh.write(desired_content)
|
|
_status_done("updated")
|
|
results["nginx_configured"] = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("would create")
|
|
results["nginx_configured"] = "dry run"
|
|
else:
|
|
with open(conf_path, "w", encoding="utf-8") as fh:
|
|
fh.write(desired_content)
|
|
_status_done("created")
|
|
results["nginx_configured"] = True
|
|
|
|
# Ensure nginx is enabled and running (handles the enabled-but-stopped case).
|
|
_status("Ensuring nginx service is enabled and running")
|
|
nginx_running = _systemctl_is_active("nginx")
|
|
if _systemctl_is_enabled("nginx") and nginx_running:
|
|
_status_done("running")
|
|
results["nginx_running"] = True
|
|
elif dry_run:
|
|
_status_done("dry run")
|
|
results["nginx_running"] = "dry run"
|
|
else:
|
|
if not _systemctl_is_enabled("nginx"):
|
|
start_result = run(["systemctl", "enable", "--now", "nginx"], timeout=30)
|
|
else:
|
|
start_result = run(["systemctl", "start", "nginx"], timeout=30)
|
|
if start_result.returncode == 0:
|
|
_status_done("enabled and started")
|
|
results["nginx_running"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_warn(f"Could not start nginx: {start_result.stderr.strip()}")
|
|
results["nginx_running"] = False
|
|
|
|
# Test and reload the configuration (only possible when nginx is running).
|
|
_status("Reloading nginx configuration")
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
results["nginx_reloaded"] = "dry run"
|
|
elif not results.get("nginx_running"):
|
|
_status_done("skipped — nginx not running")
|
|
results["nginx_reloaded"] = False
|
|
else:
|
|
test_result = run(["nginx", "-t"], timeout=30)
|
|
if test_result.returncode != 0:
|
|
_status_done("config test failed")
|
|
_fail(f"nginx -t failed: {test_result.stderr.strip()}")
|
|
results["nginx_reloaded"] = False
|
|
else:
|
|
reload_result = run(["systemctl", "reload", "nginx"], timeout=30)
|
|
if reload_result.returncode == 0:
|
|
_status_done("reloaded")
|
|
results["nginx_reloaded"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"nginx reload failed: {reload_result.stderr.strip()}")
|
|
results["nginx_reloaded"] = False
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. systemd service
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _systemd_content(
|
|
model: str,
|
|
port: int,
|
|
host: str,
|
|
tensor_parallel: int,
|
|
max_model_len: int,
|
|
gpu_memory_utilization: float,
|
|
venv_dir: str,
|
|
env_file: str,
|
|
) -> str:
|
|
"""Return the systemd unit file content.
|
|
|
|
The API key is substituted by systemd from the EnvironmentFile
|
|
(${VLLM_API_KEY}) — the key never appears in the unit file itself.
|
|
"""
|
|
return f"""[Unit]
|
|
Description=vLLM Inference Server ({model})
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=vllm
|
|
Group=vllm
|
|
WorkingDirectory=/opt/vllm
|
|
Environment=PYTHONUNBUFFERED=1
|
|
EnvironmentFile={env_file}
|
|
ExecStart={venv_dir}/bin/vllm serve {model} \\
|
|
--host {host} \\
|
|
--port {port} \\
|
|
--api-key ${{VLLM_API_KEY}} \\
|
|
--tensor-parallel-size {tensor_parallel} \\
|
|
--max-model-len {max_model_len} \\
|
|
--gpu-memory-utilization {gpu_memory_utilization}
|
|
Restart=on-failure
|
|
RestartSec=10
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
"""
|
|
|
|
|
|
def setup_systemd(
|
|
model: str,
|
|
port: int,
|
|
host: str,
|
|
tensor_parallel: int,
|
|
max_model_len: int,
|
|
gpu_memory_utilization: float,
|
|
venv_dir: str,
|
|
service_name: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Create and enable the vLLM systemd service. Idempotent.
|
|
|
|
When the unit file changed and the service is already active, it is
|
|
restarted (try-restart) so config changes actually take effect.
|
|
"""
|
|
results: dict[str, Any] = {}
|
|
unit_path = f"/etc/systemd/system/{service_name}.service"
|
|
env_file = f"/etc/sysconfig/{service_name}"
|
|
desired_content = _systemd_content(
|
|
model,
|
|
port,
|
|
host,
|
|
tensor_parallel,
|
|
max_model_len,
|
|
gpu_memory_utilization,
|
|
venv_dir,
|
|
env_file,
|
|
)
|
|
|
|
# Write unit file if missing or different.
|
|
unit_changed = False
|
|
_status(f"Checking {service_name}.service unit file")
|
|
if _file_exists(unit_path):
|
|
try:
|
|
with open(unit_path, encoding="utf-8") as fh:
|
|
current = fh.read()
|
|
except OSError:
|
|
current = ""
|
|
if current.strip() == desired_content.strip():
|
|
_status_done("already configured")
|
|
results["unit_configured"] = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("would update")
|
|
results["unit_configured"] = "dry run"
|
|
else:
|
|
with open(unit_path, "w", encoding="utf-8") as fh:
|
|
fh.write(desired_content)
|
|
_status_done("updated")
|
|
results["unit_configured"] = True
|
|
unit_changed = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("would create")
|
|
results["unit_configured"] = "dry run"
|
|
else:
|
|
with open(unit_path, "w", encoding="utf-8") as fh:
|
|
fh.write(desired_content)
|
|
_status_done("created")
|
|
results["unit_configured"] = True
|
|
unit_changed = True
|
|
results["unit_changed"] = unit_changed
|
|
|
|
# systemctl daemon-reload.
|
|
_status("Reloading systemd daemon")
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
run(["systemctl", "daemon-reload"], timeout=30)
|
|
_status_done("ok")
|
|
|
|
# Enable the service.
|
|
_status(f"Enabling {service_name} service")
|
|
if _systemctl_is_enabled(service_name):
|
|
_status_done("already enabled")
|
|
results["service_enabled"] = True
|
|
else:
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
results["service_enabled"] = "dry run"
|
|
else:
|
|
enable_result = run(["systemctl", "enable", service_name], timeout=30)
|
|
if enable_result.returncode == 0:
|
|
_status_done("enabled")
|
|
results["service_enabled"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(f"systemctl enable failed: {enable_result.stderr.strip()}")
|
|
results["service_enabled"] = False
|
|
|
|
# Start the service — or restart it when the unit changed underneath it.
|
|
_status(f"Starting {service_name} service")
|
|
is_active = _systemctl_is_active(service_name)
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
results["service_started"] = "dry run"
|
|
elif is_active and unit_changed:
|
|
restart_result = run(["systemctl", "try-restart", service_name], timeout=60)
|
|
if restart_result.returncode == 0:
|
|
_status_done("restarted (unit changed)")
|
|
results["service_started"] = "restarted"
|
|
else:
|
|
_status_done("failed")
|
|
_fail(
|
|
f"systemctl try-restart failed: {restart_result.stderr.strip()} "
|
|
f"(check logs with: journalctl -u {service_name} -f)"
|
|
)
|
|
results["service_started"] = False
|
|
elif is_active:
|
|
_status_done("already running")
|
|
results["service_started"] = True
|
|
else:
|
|
start_result = run(["systemctl", "start", service_name], timeout=30)
|
|
if start_result.returncode == 0:
|
|
_status_done("started")
|
|
results["service_started"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_fail(
|
|
f"systemctl start failed: {start_result.stderr.strip()} "
|
|
f"(check logs with: journalctl -u {service_name} -f)"
|
|
)
|
|
results["service_started"] = False
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. Firewall
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def setup_firewall(dry_run: bool = False) -> dict[str, Any]:
|
|
"""Open HTTPS port (443) in firewalld. Idempotent."""
|
|
results: dict[str, Any] = {}
|
|
|
|
_status("Checking firewalld")
|
|
fw_result = run(["systemctl", "is-active", "--quiet", "firewalld"], timeout=10)
|
|
if fw_result.returncode != 0:
|
|
_status_done("not running — skipping")
|
|
results["firewall_active"] = False
|
|
return results
|
|
|
|
_status_done("active")
|
|
|
|
# Check if https service is already allowed.
|
|
_status("Checking HTTPS firewall rule")
|
|
list_result = run(["firewall-cmd", "--permanent", "--list-services"], timeout=30)
|
|
if list_result.returncode == 0 and "https" in list_result.stdout.split():
|
|
_status_done("already allowed")
|
|
results["firewall_configured"] = True
|
|
return results
|
|
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
results["firewall_configured"] = "dry run"
|
|
return results
|
|
|
|
# Add https service.
|
|
add_result = run(["firewall-cmd", "--permanent", "--add-service=https"], timeout=30)
|
|
if add_result.returncode != 0:
|
|
_status_done("failed")
|
|
_warn(f"firewall-cmd failed: {add_result.stderr.strip()}")
|
|
results["firewall_configured"] = False
|
|
return results
|
|
|
|
# Reload to apply — a failed reload leaves the rule inactive.
|
|
reload_result = run(["firewall-cmd", "--reload"], timeout=30)
|
|
if reload_result.returncode == 0:
|
|
_status_done("added (permanent)")
|
|
results["firewall_configured"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_warn(
|
|
f"firewall-cmd --reload failed: {reload_result.stderr.strip()} "
|
|
"— rule stored permanently but not active"
|
|
)
|
|
results["firewall_configured"] = False
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. Uninstall
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def uninstall(
|
|
venv_dir: str,
|
|
service_name: str,
|
|
cert_dir: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Tear down the vLLM deployment. Idempotent.
|
|
|
|
Deliberately kept (documented at the end): /opt/vllm with the
|
|
HuggingFace cache, the ROCm stack, the firewalld https rule and the
|
|
SELinux boolean.
|
|
"""
|
|
results: dict[str, Any] = {}
|
|
|
|
# Stop and disable the systemd service.
|
|
_status(f"Stopping {service_name} service")
|
|
if _systemctl_is_active(service_name) or _systemctl_is_enabled(service_name):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
run(["systemctl", "stop", service_name], timeout=60)
|
|
run(["systemctl", "disable", service_name], timeout=30)
|
|
_status_done("stopped and disabled")
|
|
results["service_stopped"] = True
|
|
else:
|
|
_status_done("not running")
|
|
results["service_not_found"] = True
|
|
|
|
# Remove systemd unit file.
|
|
unit_path = f"/etc/systemd/system/{service_name}.service"
|
|
_status(f"Removing {service_name}.service unit file")
|
|
if _file_exists(unit_path):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
os.remove(unit_path)
|
|
run(["systemctl", "daemon-reload"], timeout=30)
|
|
_status_done("removed")
|
|
results["unit_removed"] = True
|
|
else:
|
|
_status_done("not present")
|
|
results["unit_not_found"] = True
|
|
|
|
# Remove the API key environment file.
|
|
env_path = f"/etc/sysconfig/{service_name}"
|
|
_status("Removing API key environment file")
|
|
if _file_exists(env_path):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
os.remove(env_path)
|
|
_status_done("removed")
|
|
results["env_removed"] = True
|
|
else:
|
|
_status_done("not present")
|
|
results["env_not_found"] = True
|
|
|
|
# Remove nginx config.
|
|
nginx_conf = "/etc/nginx/conf.d/vllm.conf"
|
|
_status("Removing nginx vllm configuration")
|
|
if _file_exists(nginx_conf):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
os.remove(nginx_conf)
|
|
run(["systemctl", "reload", "nginx"], timeout=30)
|
|
_status_done("removed and nginx reloaded")
|
|
results["nginx_removed"] = True
|
|
else:
|
|
_status_done("not present")
|
|
results["nginx_not_found"] = True
|
|
|
|
# Remove TLS certificates.
|
|
_status("Removing TLS certificates")
|
|
if _dir_exists(cert_dir):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
# Remove individual files first, then the directory.
|
|
for fname in ("vllm.key", "vllm.crt"):
|
|
fpath = os.path.join(cert_dir, fname)
|
|
if _file_exists(fpath):
|
|
os.remove(fpath)
|
|
try:
|
|
os.rmdir(cert_dir)
|
|
except OSError:
|
|
pass
|
|
_status_done("removed")
|
|
results["certs_removed"] = True
|
|
else:
|
|
_status_done("not present")
|
|
results["certs_not_found"] = True
|
|
|
|
# Remove venv.
|
|
_status(f"Removing venv at {venv_dir}")
|
|
if _dir_exists(venv_dir):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
shutil.rmtree(venv_dir)
|
|
_status_done("removed")
|
|
results["venv_removed"] = True
|
|
else:
|
|
_status_done("not present")
|
|
results["venv_not_found"] = True
|
|
|
|
# Remove vllm user.
|
|
_status("Removing vllm system user")
|
|
if _user_exists("vllm"):
|
|
if dry_run:
|
|
_status_done("dry run")
|
|
else:
|
|
userdel_result = run(["userdel", "vllm"], timeout=30)
|
|
if userdel_result.returncode == 0:
|
|
_status_done("removed")
|
|
results["user_removed"] = True
|
|
else:
|
|
_status_done("failed")
|
|
_warn(f"userdel failed: {userdel_result.stderr.strip()}")
|
|
results["user_removed"] = False
|
|
else:
|
|
_status_done("not present")
|
|
results["user_not_found"] = True
|
|
|
|
# What deliberately persists after uninstall.
|
|
print(file=sys.stderr)
|
|
_info("Kept on the system (remove manually if unwanted):")
|
|
_info(" /opt/vllm — HuggingFace cache with downloaded model weights")
|
|
_info(" ROCm stack — via dnf or amdgpu-uninstall")
|
|
_info(" firewalld 'https' rule and the SELinux httpd_can_network_connect boolean")
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary(
|
|
model: str,
|
|
port: int,
|
|
service_name: str,
|
|
os_display: str,
|
|
version_id: str,
|
|
results: dict[str, Any],
|
|
warnings: list[str],
|
|
elapsed: float,
|
|
dry_run: bool = False,
|
|
uninstall_mode: bool = False,
|
|
bind_host: str = "",
|
|
generated_api_key: str | None = None,
|
|
) -> None:
|
|
"""Print a final summary of all sections to stderr."""
|
|
mode = "Uninstall" if uninstall_mode else ("Dry Run" if dry_run else "Setup")
|
|
print(f"\n{BOLD}── {mode} Summary ──{RESET}", file=sys.stderr)
|
|
print(f" OS: {os_display} {version_id}", file=sys.stderr)
|
|
|
|
if uninstall_mode:
|
|
# Uninstall summary.
|
|
ur = results.get("uninstall", {})
|
|
for key, label in [
|
|
("service_stopped", "vLLM service stopped"),
|
|
("unit_removed", "systemd unit removed"),
|
|
("env_removed", "API key environment file removed"),
|
|
("nginx_removed", "nginx config removed"),
|
|
("certs_removed", "TLS certificates removed"),
|
|
("venv_removed", "Python venv removed"),
|
|
("user_removed", "vLLM user removed"),
|
|
]:
|
|
if key in ur:
|
|
_ok(label)
|
|
for key, label in [
|
|
("service_not_found", "vLLM service: already absent"),
|
|
("unit_not_found", "systemd unit: already absent"),
|
|
("env_not_found", "API key environment file: already absent"),
|
|
("nginx_not_found", "nginx config: already absent"),
|
|
("certs_not_found", "TLS certs: already absent"),
|
|
("venv_not_found", "Python venv: already absent"),
|
|
("user_not_found", "vLLM user: already absent"),
|
|
]:
|
|
if key in ur:
|
|
_info(label)
|
|
else:
|
|
# Deploy summary.
|
|
_ok(f"Model: {model}")
|
|
|
|
sd = results.get("system_deps", {})
|
|
installed = len(sd.get("installed", []))
|
|
skipped = len(sd.get("skipped", []))
|
|
failed_pkgs = len(sd.get("failed", []))
|
|
if dry_run:
|
|
_ok(f"System packages: {skipped} present, {installed} would be installed")
|
|
else:
|
|
_ok(f"System packages: {skipped} already present, {installed} installed")
|
|
if failed_pkgs:
|
|
_fail(f" {failed_pkgs} package(s) failed to install")
|
|
|
|
vu = results.get("vllm_user", {})
|
|
if vu.get("user_created") is True or vu.get("user_exists"):
|
|
_ok("vLLM system user: configured")
|
|
elif vu.get("user_created") == "dry run":
|
|
_info("vLLM system user: would create")
|
|
|
|
venv = results.get("venv", {})
|
|
if venv.get("vllm_installed") is True:
|
|
ver = venv.get("vllm_version", "")
|
|
label = f"vLLM installed ({ver})" if ver else "vLLM installed"
|
|
_ok(label)
|
|
elif venv.get("vllm_installed") == "dry run":
|
|
pf = results.get("preflight", {})
|
|
_info(
|
|
f"vLLM: would install vllm=={pf.get('vllm_version', '?')}"
|
|
f"+{pf.get('rocm_variant', '?')}"
|
|
)
|
|
elif venv.get("venv_exists"):
|
|
_warn("vLLM: venv exists but vLLM not installed")
|
|
|
|
tls = results.get("tls", {})
|
|
if tls.get("cert_exists") or tls.get("cert_created") is True:
|
|
_ok("TLS certificate: configured (self-signed, 365-day validity)")
|
|
elif tls.get("cert_created") == "dry run":
|
|
_info("TLS: would generate self-signed certificate (SAN from host FQDN)")
|
|
|
|
ak = results.get("api_key", {})
|
|
env_state = ak.get("env_file")
|
|
if generated_api_key:
|
|
_ok(f"API key: stored in /etc/sysconfig/{service_name} (0600 root:vllm)")
|
|
print(
|
|
f" {YELLOW}{BOLD}API key (shown once — store it securely): "
|
|
f"{generated_api_key}{RESET}",
|
|
file=sys.stderr,
|
|
)
|
|
elif env_state == "exists":
|
|
_ok("API key: existing key reused")
|
|
elif env_state == "updated":
|
|
_ok("API key: updated from --api-key")
|
|
elif env_state == "created":
|
|
_ok(f"API key: stored in /etc/sysconfig/{service_name} (0600 root:vllm)")
|
|
elif env_state == "dry run":
|
|
_info(f"API key: would generate/store in /etc/sysconfig/{service_name} (0600)")
|
|
elif env_state == "failed":
|
|
_fail("API key: could not write the environment file")
|
|
|
|
se = results.get("selinux", {}).get("selinux")
|
|
if se == "on":
|
|
_ok("SELinux: httpd_can_network_connect on")
|
|
elif se == "dry run":
|
|
_info("SELinux: would set httpd_can_network_connect=1")
|
|
elif se == "failed":
|
|
_fail("SELinux: failed to set httpd_can_network_connect")
|
|
elif se in ("permissive", "disabled"):
|
|
_info(f"SELinux: {se} — skipped")
|
|
elif se == "absent":
|
|
_info("SELinux: not installed — skipped")
|
|
|
|
ng = results.get("nginx", {})
|
|
if ng.get("nginx_configured") is True and ng.get("nginx_reloaded") is True:
|
|
_ok("nginx: configured and reloaded")
|
|
elif ng.get("nginx_configured") is True:
|
|
_fail("nginx: config written but reload failed")
|
|
elif ng.get("nginx_configured") == "dry run":
|
|
_info("nginx: would write config and reload")
|
|
|
|
svc = results.get("systemd", {})
|
|
started = svc.get("service_started")
|
|
disp = f"[{bind_host}]:{port}" if ":" in bind_host else f"{bind_host}:{port}"
|
|
if started is True or started == "restarted":
|
|
verb = "restarted with updated unit" if started == "restarted" else "running"
|
|
_ok(f"systemd: {service_name} {verb} on {disp}")
|
|
_info("First load takes minutes — verify: curl -fk https://localhost/health (retry)")
|
|
elif started is False:
|
|
_fail(f"systemd: {service_name} failed to start")
|
|
elif started == "dry run":
|
|
_info(f"systemd: would enable and start {service_name}")
|
|
|
|
fw = results.get("firewall", {})
|
|
if fw.get("firewall_active") is False:
|
|
_info("Firewall: firewalld not running — skipped")
|
|
elif fw.get("firewall_configured") is True:
|
|
_ok("Firewall: HTTPS (443) allowed")
|
|
elif fw.get("firewall_configured") is False:
|
|
_fail("Firewall: failed to allow HTTPS (443)")
|
|
elif fw.get("firewall_configured") == "dry run":
|
|
_info("Firewall: would allow HTTPS (443)")
|
|
|
|
if warnings:
|
|
print(file=sys.stderr)
|
|
for w in warnings:
|
|
_warn(w)
|
|
|
|
print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
print(f" {BOLD}Total time: {elapsed:.1f}s{RESET}", file=sys.stderr)
|
|
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
"""Parse command-line arguments (no interaction — see _prompt_model)."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Idempotent vLLM deployment for AMD ROCm GPUs",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--model",
|
|
type=str,
|
|
required=False,
|
|
default=None,
|
|
help="HuggingFace model ID (if omitted, shows interactive menu)",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=8000,
|
|
help="vLLM internal port (default: 8000)",
|
|
)
|
|
parser.add_argument(
|
|
"--tensor-parallel",
|
|
type=int,
|
|
default=1,
|
|
dest="tensor_parallel",
|
|
help="Number of GPUs for tensor parallelism (default: 1)",
|
|
)
|
|
parser.add_argument(
|
|
"--max-model-len",
|
|
type=int,
|
|
default=4096,
|
|
dest="max_model_len",
|
|
help="Maximum context length (default: 4096)",
|
|
)
|
|
parser.add_argument(
|
|
"--gpu-memory-utilization",
|
|
type=float,
|
|
default=0.90,
|
|
dest="gpu_memory_utilization",
|
|
help="GPU memory fraction (default: 0.90)",
|
|
)
|
|
parser.add_argument(
|
|
"--venv-dir",
|
|
type=str,
|
|
default="/opt/vllm/venv",
|
|
dest="venv_dir",
|
|
help="Path to Python venv (default: /opt/vllm/venv)",
|
|
)
|
|
parser.add_argument(
|
|
"--service-name",
|
|
type=str,
|
|
default="vllm",
|
|
dest="service_name",
|
|
help="systemd service name (default: vllm)",
|
|
)
|
|
parser.add_argument(
|
|
"--cert-dir",
|
|
type=str,
|
|
default="/etc/ssl/vllm",
|
|
dest="cert_dir",
|
|
help="TLS certificate directory (default: /etc/ssl/vllm)",
|
|
)
|
|
parser.add_argument(
|
|
"--api-key",
|
|
type=str,
|
|
default=None,
|
|
dest="api_key",
|
|
help="API key for the endpoint (default: generate and store in /etc/sysconfig)",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Preview without making changes",
|
|
)
|
|
parser.add_argument(
|
|
"--uninstall",
|
|
action="store_true",
|
|
help="Tear down service, venv, nginx config, and certificates",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version=f"%(prog)s {VERSION}",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _prompt_model(args: argparse.Namespace) -> None:
|
|
"""Show the interactive model selection menu; set args.model."""
|
|
print(f"\n{BOLD}Select a model to deploy:{RESET}\n", file=sys.stderr)
|
|
for i, name in enumerate(DEFAULT_MODELS, 1):
|
|
print(f" {GREEN}{i}{RESET}. {name}", file=sys.stderr)
|
|
print(f" {DIM}{len(DEFAULT_MODELS) + 1}. Enter custom model ID{RESET}", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
try:
|
|
choice = input(f" Choice [{GREEN}1{RESET}]: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nCancelled.", file=sys.stderr)
|
|
sys.exit(130)
|
|
if not choice:
|
|
choice = "1"
|
|
try:
|
|
idx = int(choice)
|
|
except ValueError:
|
|
print(f"{RED}Invalid input.{RESET}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if 1 <= idx <= len(DEFAULT_MODELS):
|
|
args.model = DEFAULT_MODELS[idx - 1]
|
|
elif idx == len(DEFAULT_MODELS) + 1:
|
|
try:
|
|
args.model = input(" Model ID: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nCancelled.", file=sys.stderr)
|
|
sys.exit(130)
|
|
if not args.model:
|
|
print(f"{RED}Model ID cannot be empty.{RESET}", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
print(f"{RED}Invalid choice.{RESET}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _validate_args(args: argparse.Namespace) -> None:
|
|
"""Validate CLI arguments; exit with a clear message on invalid input."""
|
|
if not re.match(r"^[A-Za-z0-9_.@-]+$", args.service_name):
|
|
_fail(f"Invalid --service-name: {args.service_name!r}")
|
|
sys.exit(1)
|
|
if args.uninstall:
|
|
return
|
|
if not MODEL_ID_RE.match(args.model or ""):
|
|
_fail(
|
|
f"Invalid model ID: {args.model!r} — expected 'org/name' "
|
|
"(letters, digits, dot, dash, underscore only)"
|
|
)
|
|
sys.exit(1)
|
|
if not 1 <= args.port <= 65535 or args.port == 443:
|
|
_fail(f"Invalid --port {args.port} — must be 1-65535 and not 443 (nginx)")
|
|
sys.exit(1)
|
|
if not 0.0 < args.gpu_memory_utilization <= 1.0:
|
|
_fail(f"Invalid --gpu-memory-utilization {args.gpu_memory_utilization} — must be in (0, 1]")
|
|
sys.exit(1)
|
|
if args.max_model_len <= 0:
|
|
_fail(f"Invalid --max-model-len {args.max_model_len} — must be positive")
|
|
sys.exit(1)
|
|
if args.tensor_parallel <= 0:
|
|
_fail(f"Invalid --tensor-parallel {args.tensor_parallel} — must be positive")
|
|
sys.exit(1)
|
|
if args.api_key is not None and (not args.api_key or any(ch.isspace() for ch in args.api_key)):
|
|
_fail("Invalid --api-key — must be non-empty and contain no whitespace")
|
|
sys.exit(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point — parse args, run pre-flight checks, deploy or uninstall."""
|
|
start_time = time.monotonic()
|
|
warnings: list[str] = []
|
|
results: dict[str, Any] = {}
|
|
failures: list[str] = []
|
|
|
|
# parse_args first: --help/--version must work without root privileges.
|
|
args = parse_args()
|
|
check_root()
|
|
if not args.uninstall and not args.model:
|
|
_prompt_model(args)
|
|
_validate_args(args)
|
|
os_id, os_display, version_id = detect_os()
|
|
|
|
# Header.
|
|
print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
print(
|
|
f"{BOLD} vLLM Deploy v{VERSION} — {os_display} {version_id} (ROCm){RESET}",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
|
|
if args.dry_run:
|
|
print(
|
|
f"\n {YELLOW}{BOLD}DRY RUN — no changes will be made{RESET}",
|
|
file=sys.stderr,
|
|
)
|
|
if args.uninstall and not args.dry_run:
|
|
print(
|
|
f"\n {YELLOW}{BOLD}UNINSTALL MODE — all vLLM components will be removed{RESET}",
|
|
file=sys.stderr,
|
|
)
|
|
print(file=sys.stderr)
|
|
|
|
# ── Uninstall path ──
|
|
if args.uninstall:
|
|
print(f"\n{BOLD}── Uninstall ──{RESET}", file=sys.stderr)
|
|
results["uninstall"] = uninstall(
|
|
venv_dir=args.venv_dir,
|
|
service_name=args.service_name,
|
|
cert_dir=args.cert_dir,
|
|
dry_run=args.dry_run,
|
|
)
|
|
elapsed = time.monotonic() - start_time
|
|
print_summary(
|
|
model="",
|
|
port=args.port,
|
|
service_name=args.service_name,
|
|
os_display=os_display,
|
|
version_id=version_id,
|
|
results=results,
|
|
warnings=warnings,
|
|
elapsed=elapsed,
|
|
dry_run=args.dry_run,
|
|
uninstall_mode=True,
|
|
)
|
|
if results["uninstall"].get("user_removed") is False:
|
|
sys.exit(1)
|
|
return
|
|
|
|
# ── Deploy path ──
|
|
bind_host, upstream_host = _detect_loopback()
|
|
|
|
try:
|
|
# 1. System dependencies (before preflight: provides lspci and curl).
|
|
print(f"\n{BOLD}── System Dependencies ──{RESET}", file=sys.stderr)
|
|
results["system_deps"] = install_system_deps(dry_run=args.dry_run)
|
|
failed = results["system_deps"].get("failed", [])
|
|
if failed:
|
|
failures.append(f"failed to install packages: {', '.join(failed)}")
|
|
|
|
# 2. Pre-flight checks.
|
|
print(f"\n{BOLD}── Pre-flight Checks ──{RESET}", file=sys.stderr)
|
|
preflight = preflight_checks(os_id=os_id, version_id=version_id, dry_run=args.dry_run)
|
|
results["preflight"] = preflight
|
|
gpu_count = preflight.get("gpu_count", 0)
|
|
_info(f"GPU count: {gpu_count}")
|
|
if gpu_count and gpu_count < args.tensor_parallel:
|
|
warnings.append(
|
|
f"--tensor-parallel={args.tensor_parallel} but only {gpu_count} GPU(s) detected"
|
|
)
|
|
|
|
# 3. vLLM system user.
|
|
print(f"\n{BOLD}── System User ──{RESET}", file=sys.stderr)
|
|
results["vllm_user"] = setup_vllm_user(dry_run=args.dry_run)
|
|
if results["vllm_user"].get("user_created") is False:
|
|
_fail("Could not create the vllm system user — cannot continue")
|
|
sys.exit(1)
|
|
|
|
# 4. Python venv + vLLM (fatal on failure — nothing works without it).
|
|
print(f"\n{BOLD}── Python Environment ──{RESET}", file=sys.stderr)
|
|
results["venv"] = setup_venv(
|
|
vllm_version=preflight["vllm_version"],
|
|
rocm_variant=preflight["rocm_variant"],
|
|
index_url=preflight["wheel_index"],
|
|
venv_dir=args.venv_dir,
|
|
dry_run=args.dry_run,
|
|
)
|
|
if (
|
|
results["venv"].get("venv_created") is False
|
|
or results["venv"].get("vllm_installed") is False
|
|
):
|
|
_fail("Python environment setup failed — cannot continue")
|
|
sys.exit(1)
|
|
|
|
# 5. TLS certificate (fatal — nginx cannot start without it).
|
|
print(f"\n{BOLD}── TLS Certificate ──{RESET}", file=sys.stderr)
|
|
results["tls"] = setup_tls(cert_dir=args.cert_dir, dry_run=args.dry_run)
|
|
if results["tls"].get("cert_created") is False:
|
|
_fail("TLS certificate setup failed — cannot continue")
|
|
sys.exit(1)
|
|
|
|
# 6. API key environment file.
|
|
print(f"\n{BOLD}── API Key ──{RESET}", file=sys.stderr)
|
|
results["api_key"] = setup_api_key(
|
|
service_name=args.service_name,
|
|
api_key=args.api_key,
|
|
dry_run=args.dry_run,
|
|
)
|
|
if results["api_key"].get("env_file") == "failed":
|
|
_fail("Could not store the API key — cannot continue")
|
|
sys.exit(1)
|
|
|
|
# 7. SELinux (non-fatal — only relevant on enforcing systems).
|
|
print(f"\n{BOLD}── SELinux ──{RESET}", file=sys.stderr)
|
|
results["selinux"] = setup_selinux(dry_run=args.dry_run)
|
|
if results["selinux"].get("selinux") == "failed":
|
|
failures.append("SELinux boolean httpd_can_network_connect not set")
|
|
|
|
# 8. nginx.
|
|
print(f"\n{BOLD}── nginx ──{RESET}", file=sys.stderr)
|
|
results["nginx"] = setup_nginx(
|
|
port=args.port,
|
|
upstream_host=upstream_host,
|
|
cert_dir=args.cert_dir,
|
|
dry_run=args.dry_run,
|
|
)
|
|
if results["nginx"].get("nginx_reloaded") is False:
|
|
failures.append("nginx configuration reload failed")
|
|
|
|
# 9. systemd service (model existence probed before writing the unit).
|
|
print(f"\n{BOLD}── systemd Service ──{RESET}", file=sys.stderr)
|
|
_status(f"Verifying {args.model} on Hugging Face")
|
|
hf_status = _hf_model_status(args.model)
|
|
if hf_status == "missing":
|
|
_status_done("not found")
|
|
_fail(f"Model '{args.model}' does not exist on Hugging Face")
|
|
sys.exit(1)
|
|
if hf_status == "gated":
|
|
_status_done("gated")
|
|
warnings.append(
|
|
f"Model '{args.model}' is gated on Hugging Face — "
|
|
"set HF_TOKEN for the vllm user or the first start will fail"
|
|
)
|
|
elif hf_status == "unknown":
|
|
_status_done("could not verify (offline?)")
|
|
else:
|
|
_status_done("found")
|
|
|
|
results["systemd"] = setup_systemd(
|
|
model=args.model,
|
|
port=args.port,
|
|
host=bind_host,
|
|
tensor_parallel=args.tensor_parallel,
|
|
max_model_len=args.max_model_len,
|
|
gpu_memory_utilization=args.gpu_memory_utilization,
|
|
venv_dir=args.venv_dir,
|
|
service_name=args.service_name,
|
|
dry_run=args.dry_run,
|
|
)
|
|
if results["systemd"].get("service_started") is False:
|
|
failures.append(f"{args.service_name} service failed to start")
|
|
|
|
# 10. Firewall.
|
|
print(f"\n{BOLD}── Firewall ──{RESET}", file=sys.stderr)
|
|
results["firewall"] = setup_firewall(dry_run=args.dry_run)
|
|
if results["firewall"].get("firewall_configured") is False:
|
|
failures.append("firewall rule for HTTPS (443) not applied")
|
|
|
|
except FileNotFoundError as exc:
|
|
print(file=sys.stderr)
|
|
_fail(f"Required tool not found: {exc.filename} — install it and re-run")
|
|
sys.exit(1)
|
|
|
|
elapsed = time.monotonic() - start_time
|
|
print_summary(
|
|
model=args.model,
|
|
port=args.port,
|
|
service_name=args.service_name,
|
|
os_display=os_display,
|
|
version_id=version_id,
|
|
results=results,
|
|
warnings=warnings,
|
|
elapsed=elapsed,
|
|
dry_run=args.dry_run,
|
|
uninstall_mode=False,
|
|
bind_host=bind_host,
|
|
generated_api_key=results.get("api_key", {}).get("generated_key"),
|
|
)
|
|
|
|
if failures:
|
|
_fail(f"Completed with {len(failures)} failed step(s):")
|
|
for failure in failures:
|
|
_info(f" - {failure}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|