mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-10-01 01:38:07 +08:00
489 lines
17 KiB
Python
489 lines
17 KiB
Python
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
|
|
# SPDX-License-Identifier: AGPL-3.0
|
|
"""Bootstrap script for OpenViking HTTP Server."""
|
|
|
|
import asyncio
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import uvicorn
|
|
|
|
from openviking.server.app import (
|
|
WORKER_BOT_API_URL_ENV,
|
|
WORKER_WITH_BOT_ENV,
|
|
create_app,
|
|
)
|
|
from openviking.server.config import get_server_url_from_server_data, load_server_config
|
|
from openviking_cli.utils.config import OPENVIKING_CONFIG_ENV
|
|
from openviking_cli.utils.config.config_loader import resolve_config_path
|
|
from openviking_cli.utils.config.consts import (
|
|
DEFAULT_CONFIG_DIR,
|
|
DEFAULT_OV_CONF,
|
|
DEFAULT_OVCLI_CONF,
|
|
OPENVIKING_CLI_CONFIG_ENV,
|
|
)
|
|
from openviking_cli.utils.logger import configure_uvicorn_logging
|
|
|
|
|
|
@dataclass
|
|
class BotProcess:
|
|
process: subprocess.Popen
|
|
log_file: Optional[object] = None
|
|
|
|
|
|
def _get_version() -> str:
|
|
try:
|
|
from openviking import __version__
|
|
|
|
return __version__
|
|
except ImportError:
|
|
return "unknown"
|
|
|
|
|
|
VIKINGBOT_DEFAULT_HOST = "127.0.0.1"
|
|
VIKINGBOT_DEFAULT_PORT = 18790
|
|
|
|
|
|
def _abort_if_port_in_use(port: int, label: str) -> None:
|
|
"""Exit with a clear message if anything is already listening on ``port``.
|
|
|
|
Without this, ``--with-bot`` would spawn a vikingbot subprocess that
|
|
silently fails to bind while a stale process keeps serving traffic —
|
|
the operator believes they upgraded but the old binary still answers.
|
|
"""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(0.5)
|
|
try:
|
|
s.connect(("127.0.0.1", port))
|
|
in_use = True
|
|
except (ConnectionRefusedError, socket.timeout, OSError):
|
|
in_use = False
|
|
if in_use:
|
|
print(
|
|
f"Error: {label} port {port} is already in use.\n"
|
|
f" A previous process is still bound — refusing to start a duplicate.\n"
|
|
f" Identify it: lsof -nP -iTCP:{port} -sTCP:LISTEN\n"
|
|
f" Kill it, then retry.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def _normalize_host_arg(host: Optional[str]) -> Optional[str]:
|
|
"""Normalize special CLI host values."""
|
|
if host is None:
|
|
return None
|
|
if host.strip().lower() == "all":
|
|
return None
|
|
return host
|
|
|
|
|
|
def _resolve_default_bot_log_dir(config_path: Optional[str]) -> str:
|
|
"""Resolve default bot log directory from current ov.conf storage.workspace."""
|
|
default_storage = DEFAULT_CONFIG_DIR / "data"
|
|
default_log_dir = default_storage / "bot" / "logs"
|
|
|
|
resolved_path = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
|
|
if resolved_path is None:
|
|
return str(default_log_dir)
|
|
|
|
try:
|
|
with open(resolved_path, "r", encoding="utf-8-sig") as f:
|
|
raw = os.path.expandvars(f.read())
|
|
data = json.loads(raw)
|
|
storage = data.get("storage", {})
|
|
workspace = storage.get("workspace") if isinstance(storage, dict) else None
|
|
if not workspace:
|
|
return str(default_log_dir)
|
|
return str(Path(workspace).expanduser().resolve() / "bot" / "logs")
|
|
except Exception:
|
|
return str(default_log_dir)
|
|
|
|
|
|
def _resolve_cli_config_for_bot(config_path: Optional[str]) -> Optional[str]:
|
|
"""Resolve which ovcli.conf the vikingbot child process should use."""
|
|
explicit_cli_config = os.environ.get(OPENVIKING_CLI_CONFIG_ENV)
|
|
if explicit_cli_config:
|
|
return explicit_cli_config
|
|
|
|
resolved_ov_conf = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
|
|
if resolved_ov_conf is not None:
|
|
colocated_cli_config = Path(resolved_ov_conf).resolve().parent / DEFAULT_OVCLI_CONF
|
|
if colocated_cli_config.exists():
|
|
return str(colocated_cli_config)
|
|
|
|
default_cli_config = DEFAULT_CONFIG_DIR / DEFAULT_OVCLI_CONF
|
|
if default_cli_config.exists():
|
|
return str(default_cli_config)
|
|
|
|
return None
|
|
|
|
|
|
def main():
|
|
"""Main entry point for openviking-server command."""
|
|
parser = argparse.ArgumentParser(
|
|
description="OpenViking HTTP Server",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version=f"openviking-server {_get_version()}",
|
|
)
|
|
parser.add_argument(
|
|
"--host",
|
|
type=str,
|
|
default=None,
|
|
help="Host to bind to",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=None,
|
|
help="Port to bind to",
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
type=str,
|
|
default=None,
|
|
help="Path to ov.conf config file",
|
|
)
|
|
parser.add_argument(
|
|
"--workers",
|
|
type=int,
|
|
default=None,
|
|
help="Number of uvicorn worker processes (default: 1, or server.workers in ov.conf)",
|
|
)
|
|
parser.add_argument(
|
|
"--bot",
|
|
"--with-bot",
|
|
action="store_true",
|
|
dest="with_bot",
|
|
help="Enable Bot API proxy to Vikingbot (requires Vikingbot running)",
|
|
)
|
|
parser.add_argument(
|
|
"--bot-port",
|
|
type=int,
|
|
default=VIKINGBOT_DEFAULT_PORT,
|
|
dest="bot_port",
|
|
help=f"Vikingbot gateway port (default: {VIKINGBOT_DEFAULT_PORT})",
|
|
)
|
|
parser.add_argument(
|
|
"--enable-bot-logging",
|
|
action="store_true",
|
|
dest="enable_bot_logging",
|
|
default=None,
|
|
help="Enable logging vikingbot output to files (default: True when --with-bot is used)",
|
|
)
|
|
parser.add_argument(
|
|
"--disable-bot-logging",
|
|
action="store_false",
|
|
dest="enable_bot_logging",
|
|
help="Disable logging vikingbot output to files",
|
|
)
|
|
parser.add_argument(
|
|
"--bot-log-dir",
|
|
type=str,
|
|
default=None,
|
|
help="Directory to store vikingbot log files (default: {storage.workspace or ~/.openviking/data}/bot/logs)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Set OPENVIKING_CONFIG_FILE environment variable if --config is provided
|
|
# This allows OpenVikingConfigSingleton to load from the specified config file
|
|
if args.config is not None:
|
|
os.environ[OPENVIKING_CONFIG_ENV] = args.config
|
|
|
|
from openviking_cli.utils.config.open_viking_config import OpenVikingConfigSingleton
|
|
|
|
# Load server config from ov.conf
|
|
try:
|
|
resolved_config_path = resolve_config_path(
|
|
args.config,
|
|
OPENVIKING_CONFIG_ENV,
|
|
DEFAULT_OV_CONF,
|
|
)
|
|
config = load_server_config(args.config)
|
|
OpenVikingConfigSingleton.initialize(config_path=args.config)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
print(e, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Configure logging early so that all subsequent steps have proper logging
|
|
configure_uvicorn_logging()
|
|
|
|
# 🔍 Authentication health check - CRITICAL: will exit if check fails
|
|
try:
|
|
from openviking.server.auth.health_check import run_startup_health_check_or_exit
|
|
asyncio.run(run_startup_health_check_or_exit(config))
|
|
except Exception as e:
|
|
# Don't fail startup if health check itself has issues
|
|
print(f"Warning: Authentication health check failed to run: {e}", file=sys.stderr)
|
|
print("Continuing startup anyway...", file=sys.stderr)
|
|
|
|
# Ensure Ollama is running if configured
|
|
try:
|
|
from openviking_cli.utils.ollama import detect_ollama_in_config, ensure_ollama_for_server
|
|
|
|
ov_config = OpenVikingConfigSingleton.get_instance()
|
|
uses_ollama, ollama_host, ollama_port = detect_ollama_in_config(ov_config)
|
|
if uses_ollama:
|
|
result = ensure_ollama_for_server(ollama_host, ollama_port)
|
|
if result.success:
|
|
print(f"Ollama is running at {ollama_host}:{ollama_port}")
|
|
else:
|
|
print(
|
|
f"Warning: Ollama not available at {ollama_host}:{ollama_port}. "
|
|
f"Embedding/VLM may fail. ({result.message})",
|
|
file=sys.stderr,
|
|
)
|
|
if result.stderr_output:
|
|
print(f" Ollama stderr: {result.stderr_output}", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"Warning: Ollama pre-flight check failed: {e}", file=sys.stderr)
|
|
|
|
# Override with command line arguments
|
|
if args.host is not None:
|
|
config.host = _normalize_host_arg(args.host)
|
|
if args.port is not None:
|
|
config.port = args.port
|
|
if args.workers is not None:
|
|
config.workers = args.workers
|
|
if args.with_bot:
|
|
config.with_bot = True
|
|
|
|
bot_process: Optional[BotProcess] = None
|
|
if config.with_bot:
|
|
bot_port = args.bot_port
|
|
config.bot_api_url = f"http://{VIKINGBOT_DEFAULT_HOST}:{bot_port}"
|
|
_abort_if_port_in_use(bot_port, "vikingbot gateway")
|
|
print(f"Bot API proxy enabled, forwarding to {config.bot_api_url}")
|
|
# Determine if bot logging should be enabled
|
|
enable_bot_logging = args.enable_bot_logging
|
|
if enable_bot_logging is None:
|
|
# Reaching this block means bot integration is enabled, either by
|
|
# ``--with-bot`` or by ``server.with_bot`` in ov.conf. Default
|
|
# logging must not depend only on which activation surface was
|
|
# used, otherwise config-enabled gateways silently lose their
|
|
# child-process diagnostics.
|
|
enable_bot_logging = True
|
|
bot_log_dir = args.bot_log_dir or _resolve_default_bot_log_dir(args.config)
|
|
# Start vikingbot gateway if --with-bot is set
|
|
bot_process = _start_vikingbot_gateway(
|
|
enable_bot_logging,
|
|
bot_log_dir,
|
|
bot_port,
|
|
config_path=args.config,
|
|
managed_server_url=get_server_url_from_server_data(config),
|
|
)
|
|
if bot_process is None:
|
|
print(
|
|
"Error: --with-bot was requested, but VikingBot could not be started.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
# Create and run server app
|
|
app = create_app(
|
|
config,
|
|
config_path=(
|
|
str(resolved_config_path) if resolved_config_path is not None else args.config
|
|
),
|
|
)
|
|
workers_info = f" (workers: {config.workers})" if config.workers > 1 else ""
|
|
print(f"OpenViking HTTP Server is running on {config.host}:{config.port}{workers_info}")
|
|
|
|
try:
|
|
workers = config.workers
|
|
if workers > 1:
|
|
# Multi-worker mode requires an import string so each worker
|
|
# can independently import the application. We stash the
|
|
# resolved config path in an env-var so that the factory can
|
|
# pick it up (ServerConfig already reads OPENVIKING_CONFIG_FILE).
|
|
os.environ[WORKER_WITH_BOT_ENV] = "1" if config.with_bot else "0"
|
|
os.environ[WORKER_BOT_API_URL_ENV] = config.bot_api_url
|
|
uvicorn.run(
|
|
"openviking.server.app:create_worker_app",
|
|
factory=True,
|
|
host=config.host,
|
|
port=config.port,
|
|
workers=workers,
|
|
timeout_keep_alive=config.timeout_keep_alive,
|
|
log_config=None,
|
|
)
|
|
else:
|
|
uvicorn.run(
|
|
app,
|
|
host=config.host,
|
|
port=config.port,
|
|
timeout_keep_alive=config.timeout_keep_alive,
|
|
log_config=None,
|
|
)
|
|
finally:
|
|
# Cleanup vikingbot process on shutdown
|
|
if bot_process is not None:
|
|
_stop_vikingbot_gateway(bot_process)
|
|
|
|
|
|
def _handle_vikingbot_failure(output: str, returncode: int) -> None:
|
|
"""Handle vikingbot startup failure and provide helpful error messages."""
|
|
print(f"\nError: vikingbot gateway exited early (code {returncode})", file=sys.stderr)
|
|
|
|
# Check for common dependency errors
|
|
if "ModuleNotFoundError" in output:
|
|
print("\nMissing dependencies detected!", file=sys.stderr)
|
|
print(
|
|
"\nTo use --with-bot, you need to install openviking with bot dependencies:",
|
|
file=sys.stderr,
|
|
)
|
|
print(' pip install "openviking[bot]"', file=sys.stderr)
|
|
print(" # Or for development:", file=sys.stderr)
|
|
print(' uv pip install -e ".[bot,dev]"', file=sys.stderr)
|
|
|
|
if output:
|
|
print(f"\nDetailed error:\n{output}", file=sys.stderr)
|
|
|
|
|
|
def _start_vikingbot_gateway(
|
|
enable_logging: bool,
|
|
log_dir: str,
|
|
port: int = VIKINGBOT_DEFAULT_PORT,
|
|
config_path: Optional[str] = None,
|
|
managed_server_url: Optional[str] = None,
|
|
) -> Optional[BotProcess]:
|
|
"""Start vikingbot gateway as a subprocess."""
|
|
print("Starting vikingbot gateway...")
|
|
|
|
# Check if vikingbot is available
|
|
vikingbot_cmd = None
|
|
if shutil.which("vikingbot"):
|
|
vikingbot_cmd = ["vikingbot", "gateway"]
|
|
else:
|
|
# Try python -m vikingbot
|
|
python_cmd = sys.executable
|
|
try:
|
|
result = subprocess.run(
|
|
[python_cmd, "-m", "vikingbot", "--help"], capture_output=True, timeout=15
|
|
)
|
|
if result.returncode == 0:
|
|
vikingbot_cmd = [python_cmd, "-m", "vikingbot", "gateway"]
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
|
|
if vikingbot_cmd is None:
|
|
print("Warning: vikingbot not found. Please install vikingbot first.")
|
|
print(" uv pip install -e '.[bot,dev]'")
|
|
return None
|
|
|
|
vikingbot_cmd.extend(["--host", VIKINGBOT_DEFAULT_HOST, "--port", str(port)])
|
|
|
|
# Prepare logging
|
|
log_file = None
|
|
stdout_handler = subprocess.PIPE
|
|
stderr_handler = subprocess.PIPE
|
|
log_file_path = None
|
|
|
|
if enable_logging:
|
|
try:
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
log_filename = "vikingbot.log"
|
|
log_file_path = os.path.join(log_dir, log_filename)
|
|
log_file = open(log_file_path, "a")
|
|
stdout_handler = log_file
|
|
stderr_handler = log_file
|
|
print(f"Vikingbot logs will be written to: {log_file_path}")
|
|
except Exception as e:
|
|
print(f"Warning: Failed to setup bot logging: {e}")
|
|
if log_file:
|
|
log_file.close()
|
|
log_file = None
|
|
stdout_handler = subprocess.PIPE
|
|
stderr_handler = subprocess.PIPE
|
|
|
|
# Start vikingbot gateway process
|
|
try:
|
|
# Set environment to ensure it uses the same Python environment
|
|
env = os.environ.copy()
|
|
cli_config_path = _resolve_cli_config_for_bot(config_path)
|
|
if cli_config_path is not None:
|
|
env[OPENVIKING_CLI_CONFIG_ENV] = cli_config_path
|
|
env["VIKINGBOT_WITH_OPENVIKING_SERVER"] = "1"
|
|
if managed_server_url:
|
|
env["VIKINGBOT_MANAGED_OV_SERVER_URL"] = managed_server_url
|
|
|
|
process = subprocess.Popen(
|
|
vikingbot_cmd,
|
|
stdout=stdout_handler,
|
|
stderr=stderr_handler,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
|
|
# Wait a moment to check if it started successfully
|
|
time.sleep(2)
|
|
if process.poll() is not None:
|
|
# Process exited early
|
|
if log_file:
|
|
log_file.close()
|
|
if log_file_path:
|
|
with open(log_file_path, "r") as f:
|
|
output = f.read()
|
|
_handle_vikingbot_failure(output, process.returncode)
|
|
else:
|
|
stdout, stderr = process.communicate(timeout=1)
|
|
_handle_vikingbot_failure(stderr, process.returncode)
|
|
sys.exit(1)
|
|
|
|
print(f"Vikingbot gateway started (PID: {process.pid})")
|
|
|
|
return BotProcess(process=process, log_file=log_file)
|
|
|
|
except Exception as e:
|
|
if log_file:
|
|
log_file.close()
|
|
print(f"Warning: Failed to start vikingbot gateway: {e}")
|
|
return None
|
|
|
|
|
|
def _stop_vikingbot_gateway(bot_process: BotProcess) -> None:
|
|
"""Stop the vikingbot gateway subprocess."""
|
|
if bot_process is None:
|
|
return
|
|
|
|
print(f"\nStopping vikingbot gateway (PID: {bot_process.process.pid})...")
|
|
|
|
try:
|
|
# Try graceful termination first
|
|
bot_process.process.terminate()
|
|
try:
|
|
bot_process.process.wait(timeout=5)
|
|
print("Vikingbot gateway stopped gracefully.")
|
|
except subprocess.TimeoutExpired:
|
|
# Force kill if it doesn't stop in time
|
|
bot_process.process.kill()
|
|
bot_process.process.wait()
|
|
print("Vikingbot gateway force killed.")
|
|
except Exception as e:
|
|
print(f"Error stopping vikingbot gateway: {e}")
|
|
finally:
|
|
# Close the log file if it exists
|
|
if bot_process.log_file is not None:
|
|
try:
|
|
bot_process.log_file.close()
|
|
except Exception as e:
|
|
print(f"Error closing bot log file: {e}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|