Files
OpenViking/openviking/server/bootstrap.py
T

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()