Respite-API / searx_runtime.py
Buffy
Bootstrap searxng venv without pip + get-pip fallback (slim image lacks ensurepip)
363d9ef
Raw History Blame Contribute Delete
6.59 kB
"""User-space SearXNG bootstrap for the Respite search backend.
SearXNG is not on PyPI (the `searxng` package there is the abandoned
pre-NG searx). Instead we fetch the upstream source tarball into a local
cache, install it into a userspace virtualenv on first boot (no root),
and launch the built-in development server bound to 127.0.0.1:8888.
The instance is configured by searxng_settings.yml in the repo root
(SEARXNG_SETTINGS_PATH points there), with the DDG + Google engines
enabled and JSON output on.
Env:
SEARXNG_VERSION source tag/branch to fetch (default: master)
SEARXNG_PORT localhost port (default 8888)
SEARXNG_OFF set to "1" to skip the bootstrap entirely
"""
import os
import shutil
import subprocess
import tarfile
import threading
import time
import urllib.request
SEARXNG_VERSION = os.environ.get("SEARXNG_VERSION", "master")
SEARXNG_PORT = int(os.environ.get("SEARXNG_PORT", "8888"))
SEARXNG_URL = f"https://github.com/searxng/searxng/archive/refs/heads/{SEARXNG_VERSION}.tar.gz"
ROOT = os.path.dirname(os.path.abspath(__file__))
RUNTIME_DIR = os.path.join(ROOT, ".searxng-runtime")
SRC_DIR = os.path.join(RUNTIME_DIR, f"searxng-{SEARXNG_VERSION}")
VENV_DIR = os.path.join(RUNTIME_DIR, "venv")
VENV_PIP = os.path.join(VENV_DIR, "bin", "pip")
SETTINGS_FILE = os.path.join(ROOT, "searxng_settings.yml")
_proc = None
_lock = threading.Lock()
_ready = threading.Event()
_boot_error = ""
def _log(msg):
print(f"[searxng] {msg}", flush=True)
def _fetch_source() -> bool:
if os.path.isdir(SRC_DIR) and os.path.exists(os.path.join(SRC_DIR, "searx", "webapp.py")):
return True
os.makedirs(RUNTIME_DIR, exist_ok=True)
tgz = os.path.join(RUNTIME_DIR, "searxng.tar.gz")
_log(f"downloading {SEARXNG_URL}")
urllib.request.urlretrieve(SEARXNG_URL, tgz)
_log("extracting")
with tarfile.open(tgz, "r:gz") as tf:
tf.extractall(RUNTIME_DIR)
os.remove(tgz)
return os.path.isdir(SRC_DIR)
def _venv_python() -> str | None:
"""Create the venv and install searxng + deps (idempotent)."""
py = os.path.join(VENV_DIR, "bin", "python")
if os.path.exists(py):
# A stale venv from a different interpreter breaks imports; recreate.
r = subprocess.run([py, "-c", "import tomllib"], capture_output=True)
if r.returncode != 0:
_log("stale venv (python<3.11) — recreating")
shutil.rmtree(VENV_DIR, ignore_errors=True)
else:
return py
# Prefer the newest interpreter available (SearXNG master needs 3.11+;
# the container base image ships 3.10).
base = None
for cand in ("python3.13", "python3.12", "python3.11", "python3"):
if shutil.which(cand):
base = cand
break
_log(f"creating venv with {base}")
if not base:
return None
# --without-pip: many slim images ship the interpreter without the
# venv/ensurepip pieces; get-pip.py bootstraps pip inside afterwards.
r = subprocess.run([base, "-m", "venv", "--without-pip", VENV_DIR], capture_output=True)
if r.returncode != 0:
r = subprocess.run([base, "-m", "venv", VENV_DIR], capture_output=True)
if r.returncode != 0:
_log(f"venv creation failed: {r.stderr[-500:]}")
return None
vpy = os.path.join(VENV_DIR, "bin", "python")
if not os.path.exists(os.path.join(VENV_DIR, "bin", "pip")):
_log("bootstrapping pip via get-pip.py")
gp = os.path.join(RUNTIME_DIR, "get-pip.py")
urllib.request.urlretrieve("https://bootstrap.pypa.io/get-pip.py", gp)
if subprocess.run([vpy, gp], capture_output=True).returncode != 0:
_log("get-pip failed")
return None
_log("installing searxng deps (this takes a few minutes on first boot)")
r1 = subprocess.run(
[vpy, "-m", "pip", "install", "--no-input", "-r", os.path.join(SRC_DIR, "requirements.txt")],
capture_output=True, text=True, timeout=900,
)
if r1.returncode != 0:
_log(f"deps install failed: {r1.stderr[-2000:]}")
return None
r2 = subprocess.run(
[vpy, "-m", "pip", "install", "--no-input", "--no-deps", "-e", SRC_DIR],
capture_output=True, text=True, timeout=300,
)
if r2.returncode != 0:
_log(f"searxng install failed: {r2.stderr[-2000:]}")
return None
_log("searxng installed")
return py
def _wait_ready(proc: subprocess.Popen, timeout: float = 120.0) -> bool:
import socket
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
_log(f"searxng exited early with code {proc.returncode}")
return False
try:
with socket.create_connection(("127.0.0.1", SEARXNG_PORT), timeout=1):
return True
except OSError:
time.sleep(0.5)
return False
def start_searxng() -> bool:
"""Launch SearXNG bound to localhost. Safe to call once at startup."""
global _proc
if os.environ.get("SEARXNG_OFF") == "1":
_log("disabled via SEARXNG_OFF")
return False
with _lock:
if _proc and _proc.poll() is None:
return True
global _boot_error
try:
if not _fetch_source():
_boot_error = "source fetch failed"
_log(_boot_error)
return False
py = _venv_python()
if not py:
_boot_error = "venv/pip install failed (see logs)"
return False
except Exception as e:
_boot_error = f"bootstrap failed: {e}"
_log(_boot_error)
return False
env = os.environ.copy()
env["SEARXNG_SETTINGS_PATH"] = SETTINGS_FILE
env["PYTHONPATH"] = SRC_DIR
_log(f"starting searxng on 127.0.0.1:{SEARXNG_PORT}")
_proc = subprocess.Popen(
[py, "-m", "searx.webapp"],
cwd=SRC_DIR,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
threading.Thread(target=_pump_logs, daemon=True).start()
if _wait_ready(_proc):
_log("searxng is READY")
_ready.set()
return True
_log("searxng failed to become ready")
return False
def _pump_logs():
assert _proc and _proc.stdout
for line in _proc.stdout:
_log(line.rstrip())
def searxng_ready() -> bool:
return _ready.is_set()
def boot_error() -> str:
return _boot_error