#!/usr/bin/env python3 """ Tests for archivebox server command. Verify server can start (basic smoke tests only, no full server testing). """ import os import asyncio import json import signal import shutil import socket import subprocess import sys import time from datetime import datetime from pathlib import Path from types import SimpleNamespace import pytest from archivebox.tests.conftest import ( assert_no_processes_for_data_dir, get_free_port, kill_processes_for_data_dir, cli_env, start_archivebox_server, stop_archivebox_process, wait_for_pid_to_disappear, wait_for_port_open, wait_for_process, run_archivebox_cmd, ) def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path, monkeypatch): """Admin sessions must survive `archivebox server` restarts for a collection.""" from archivebox.config.collection import write_config_file (tmp_path / ".archivebox_id").write_text("testcoll") monkeypatch.setenv("BASE_URL", "http://archivebox.localhost:9292") first = subprocess.run( [ sys.executable, "-c", ( "import os;" "os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.core.settings');" "import django;" "django.setup();" "from django.conf import settings;" "print(settings.SECRET_KEY);" "print(settings.SESSION_ENGINE);" "print(settings.SESSION_COOKIE_NAME);" "print(settings.SESSION_COOKIE_DOMAIN);" "print(settings.SESSION_COOKIE_SECURE);" "print(settings.SESSION_EXPIRE_AT_BROWSER_CLOSE)" ), ], capture_output=True, text=True, check=True, ) first_lines = first.stdout.strip().splitlines() assert first_lines[0], first.stderr # Simulate the next `archivebox server` process, reading only persisted # collection config. If SECRET_KEY falls back to the random default_factory # here, Django will reject existing signed session cookies after restart. monkeypatch.delenv("BASE_URL", raising=False) write_config_file({"BASE_URL": "http://archivebox.localhost:9292"}) second = subprocess.run( [ sys.executable, "-c", ( "import os;" "os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.core.settings');" "import django;" "django.setup();" "from django.conf import settings;" "print(settings.SECRET_KEY);" "print(settings.SESSION_ENGINE);" "print(settings.SESSION_COOKIE_NAME);" "print(settings.SESSION_COOKIE_DOMAIN);" "print(settings.SESSION_COOKIE_SECURE);" "print(settings.SESSION_EXPIRE_AT_BROWSER_CLOSE)" ), ], capture_output=True, text=True, check=True, ) assert second.stdout.strip().splitlines() == first_lines assert first_lines[1] == "django.contrib.sessions.backends.db" assert first_lines[2].startswith("archivebox_sessionid_") assert first_lines[3:] == ["None", "False", "False"] def test_https_base_url_enables_proxy_ssl_header_and_secure_cookies(tmp_path): (tmp_path / ".archivebox_id").write_text("testcoll") env = os.environ.copy() env["BASE_URL"] = "https://archive.example.com" env["DJANGO_SETTINGS_MODULE"] = "archivebox.core.settings" repo_root = Path(__file__).resolve().parents[2] env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{env.get('PYTHONPATH', '')}" result = subprocess.run( [ sys.executable, "-c", ( "import django, json;" "django.setup();" "from django.conf import settings;" "print(json.dumps({" "'csrf_secure': settings.CSRF_COOKIE_SECURE," "'session_secure': settings.SESSION_COOKIE_SECURE," "'proxy_ssl_header': settings.SECURE_PROXY_SSL_HEADER," "}))" ), ], capture_output=True, text=True, check=True, env=env, cwd=tmp_path, ) assert json.loads(result.stdout) == { "csrf_secure": True, "session_secure": True, "proxy_ssl_header": ["HTTP_X_FORWARDED_PROTO", "https"], } def test_sqlite_connections_use_explicit_busy_timeout(): from archivebox.core.settings import SQLITE_CONNECTION_OPTIONS assert SQLITE_CONNECTION_OPTIONS["OPTIONS"]["timeout"] == 30.0 assert "PRAGMA busy_timeout = 30000;" in SQLITE_CONNECTION_OPTIONS["OPTIONS"]["init_command"] assert "PRAGMA journal_mode = WAL;" in SQLITE_CONNECTION_OPTIONS["OPTIONS"]["init_command"] def test_server_shows_usage_info(initialized_archive): """Test that server command shows usage or starts.""" # Just check that the command is recognized # We won't actually start a full server in tests result = run_archivebox_cmd( ["server", "--help"], timeout=10, ) assert result.returncode == 0 assert "server" in result.stdout.lower() or "http" in result.stdout.lower() def test_server_help_lists_runtime_options(initialized_archive): """Test that server help exposes the current runtime options.""" # Check init flag is recognized result = run_archivebox_cmd( ["server", "--help"], timeout=10, ) assert result.returncode == 0 assert "--daemonize" in result.stdout assert "--reload" in result.stdout def test_runner_worker_uses_current_interpreter(): """The supervised runner should use the active Python environment, not PATH.""" from archivebox.workers.supervisord_util import RUNNER_WORKER assert RUNNER_WORKER["command"] == f"{sys.executable} -m archivebox run --daemon" assert RUNNER_WORKER["autorestart"] == "true" assert 'ARCHIVEBOX_RUNNER_DAEMON="1"' in RUNNER_WORKER["environment"] def test_daphne_worker_uses_default_application_close_timeout(): from archivebox.workers.supervisord_util import SERVER_WORKER command = SERVER_WORKER("127.0.0.1", "8000")["command"] assert "daphne" in command assert "--application-close-timeout=0" not in command def test_reload_workers_use_current_interpreter_and_supervisord_managed_runner(): from archivebox.workers.supervisord_util import RUNNER_WATCH_WORKER, RUNSERVER_WORKER runserver = RUNSERVER_WORKER("127.0.0.1", "8000", reload=True) watcher = RUNNER_WATCH_WORKER("http://127.0.0.1:8000") assert runserver["name"] == "worker_runserver" assert runserver["command"] == f"{sys.executable} -m archivebox manage runserver 127.0.0.1:8000" assert 'ARCHIVEBOX_RUNSERVER="1"' in runserver["environment"] assert 'ARCHIVEBOX_AUTORELOAD="1"' in runserver["environment"] assert 'ARCHIVEBOX_RUNSERVER_BIND_URL="http://127.0.0.1:8000"' in runserver["environment"] assert watcher["name"] == "worker_runner_watch" assert watcher["command"] == f"{sys.executable} -m archivebox manage runner_watch --bind-url=http://127.0.0.1:8000" def test_server_daemon_starts_real_plugin_owned_sonic_worker(archivebox_daemon_server): if shutil.which("sonic") is None: pytest.skip("sonic server binary is required for Sonic worker integration tests") server = archivebox_daemon_server( SEARCH_BACKEND_ENGINE="sonic", ) state = server.wait_for_workers(("worker_daphne", "worker_sonic", "worker_runner")) assert state["worker_daphne"]["statename"] == "RUNNING", state assert state["worker_runner"]["statename"] == "RUNNING", state assert state["worker_sonic"]["statename"] == "RUNNING", state assert "sonic" in state["worker_sonic"]["name"] def test_server_daemon_restarts_runner_killed_by_signal(archivebox_daemon_server): server = archivebox_daemon_server( SEARCH_BACKEND_ENGINE="sqlite", ) state = server.wait_for_workers(("worker_daphne", "worker_runner")) old_runner_pid = state["worker_runner"]["pid"] os.kill(old_runner_pid, signal.SIGTERM) deadline = time.time() + 30 while time.time() < deadline: state = server.worker_state() runner = state.get("worker_runner", {}) if runner.get("statename") == "RUNNING" and runner.get("pid") and runner.get("pid") != old_runner_pid: break time.sleep(0.5) else: raise AssertionError(f"worker_runner did not restart after SIGTERM: {state}") assert state["worker_daphne"]["statename"] == "RUNNING", state def test_live_server_machine_search_engine_update_reaches_subsequent_snapshot_runtime(archivebox_daemon_server): server = archivebox_daemon_server(SEARCH_BACKEND_ENGINE="ripgrep") server.wait_for_workers(("worker_daphne", "worker_runner")) setup_result = subprocess.run( [ sys.executable, "-c", ( "import django;" "django.setup();" "from archivebox.base_models.models import get_or_create_system_user_pk;" "from archivebox.crawls.models import Crawl;" "from archivebox.core.models import Snapshot;" "from archivebox.machine.models import Machine;" "machine = Machine.current(refresh=True);" "machine.config = {**dict(machine.config or {}), 'SEARCH_BACKEND_ENGINE': 'sqlite'};" "machine.save(update_fields=['config', 'modified_at']);" "crawl = Crawl.objects.create(" "urls='https://example.com/live-machine-search-config'," "created_by_id=get_or_create_system_user_pk()," "config={}," ");" "snapshot = Snapshot.objects.create(" "url='https://example.com/live-machine-search-config'," "crawl=crawl," ");" "print(snapshot.id)" ), ], cwd=server.data_dir, env=server.env, capture_output=True, text=True, timeout=30, ) assert setup_result.returncode == 0, setup_result.stderr or setup_result.stdout snapshot_id = setup_result.stdout.strip().splitlines()[-1] result = subprocess.run( [ sys.executable, "-c", ( "import django,json;" "django.setup();" "from archivebox.core.models import Snapshot;" "from archivebox.config.common import get_config;" f"snapshot = Snapshot.objects.select_related('crawl').get(id='{snapshot_id}');" "runtime = get_config(snapshot=snapshot).for_crawl_runtime(" "crawl=snapshot.crawl," "snapshot=snapshot," "extra_context={'snapshot_id': str(snapshot.id)}," ");" "print(json.dumps({" "'sqlite_enabled': runtime.get('SEARCH_BACKEND_SQLITE_ENABLED')," "'engine_in_runtime': 'SEARCH_BACKEND_ENGINE' in runtime," "}))" ), ], cwd=server.data_dir, env=server.env, capture_output=True, text=True, timeout=30, ) assert result.returncode == 0, result.stderr or result.stdout resolved = json.loads(result.stdout.strip().splitlines()[-1]) assert resolved == {"sqlite_enabled": True, "engine_in_runtime": False} def test_sonic_worker_is_disabled_when_sonic_disabled(tmp_path): from archivebox.workers.supervisord_util import get_sonic_supervisord_worker_from_plugin worker = get_sonic_supervisord_worker_from_plugin( SimpleNamespace( DATA_DIR=str(tmp_path), SEARCH_BACKEND_SONIC_ENABLED=False, SEARCH_BACKEND_SONIC_HOST_NAME="127.0.0.1", SEARCH_BACKEND_SONIC_PORT=get_free_port(), SEARCH_BACKEND_SONIC_PASSWORD="SecretPassword", SONIC_BINARY="sonic", ), ) assert worker is None def test_sonic_daemon_event_handler_accepts_real_running_worker(archivebox_daemon_server): if shutil.which("sonic") is None: pytest.skip("sonic server binary is required for Sonic worker integration tests") from abx_dl.events import ProcessStdoutEvent from abx_dl.orchestrator import create_bus from archivebox.search.sonic_daemon import register_sonic_daemon_event_handler from abx_plugins.plugins.search_backend_sonic.daemon import prepare_sonic_daemon sonic_port = get_free_port() server = archivebox_daemon_server( SEARCH_BACKEND_ENGINE="sonic", SEARCH_BACKEND_SONIC_PORT=str(sonic_port), ) state = server.wait_for_workers(("worker_sonic",)) assert state["worker_sonic"]["statename"] == "RUNNING", state daemon_event = prepare_sonic_daemon( SimpleNamespace( DATA_DIR=str(server.data_dir), SEARCH_BACKEND_SONIC_ENABLED=True, SEARCH_BACKEND_SONIC_HOST_NAME="127.0.0.1", SEARCH_BACKEND_SONIC_PORT=sonic_port, SEARCH_BACKEND_SONIC_PASSWORD="SecretPassword", SONIC_BINARY="sonic", ), ) async def run_test(): bus = create_bus(name="test_sonic_daemon_event_handler_accepts_real_running_worker") try: register_sonic_daemon_event_handler(bus) event = await bus.emit( ProcessStdoutEvent( line=json.dumps(daemon_event.to_record()), ), ).now() await event.event_results_list() finally: await bus.destroy() asyncio.run(run_test()) def test_supervisord_sync_does_not_start_duplicate_sonic_listener(initialized_archive, db): from abx_plugins.plugins.search_backend_sonic.daemon import get_sonic_supervisord_worker from archivebox.tests.test_orm_helpers import use_archivebox_db from archivebox.workers.supervisord_util import ( get_or_create_supervisord_process, get_worker, stop_existing_supervisord_process, sync_supervisord_workers, ) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", 0)) listener.listen() sonic_port = listener.getsockname()[1] worker = get_sonic_supervisord_worker( SimpleNamespace( DATA_DIR=str(initialized_archive), SEARCH_BACKEND_ENGINE="sonic", SEARCH_BACKEND_SONIC_HOST_NAME="127.0.0.1", SEARCH_BACKEND_SONIC_PORT=sonic_port, SEARCH_BACKEND_SONIC_PASSWORD="SecretPassword", SONIC_BINARY="sonic", ), ) assert worker is not None try: with use_archivebox_db(initialized_archive): supervisor = get_or_create_supervisord_process(daemonize=False) state = sync_supervisord_workers(supervisor, [(worker, False)], prune=True) sonic_state = state["worker_sonic"] assert sonic_state["statename"] != "RUNNING", sonic_state assert get_worker(supervisor, "worker_sonic")["statename"] != "RUNNING" finally: listener.close() with use_archivebox_db(initialized_archive): stop_existing_supervisord_process() def test_supervisord_takeover_stops_all_live_process_rows(initialized_archive, db): import psutil from django.utils import timezone from archivebox.config import CONSTANTS from archivebox.machine.models import Machine, Process from archivebox.tests.test_orm_helpers import use_archivebox_db env = cli_env() procs = [] try: for _index in range(2): proc = run_archivebox_cmd( ["run", "--daemon"], cwd=initialized_archive, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, wait=False, ) procs.append(proc) started_at = datetime.fromtimestamp(psutil.Process(proc.pid).create_time(), tz=timezone.get_current_timezone()) with use_archivebox_db(initialized_archive): Process.objects.create( machine=Machine.current(), process_type=Process.TypeChoices.SUPERVISORD, worker_type="supervisord", pwd=str(CONSTANTS.DATA_DIR), cmd=[], pid=proc.pid, started_at=started_at, status=Process.StatusChoices.RUNNING, ) with use_archivebox_db(initialized_archive): from archivebox.workers.supervisord_util import stop_existing_supervisord_process stop_existing_supervisord_process() for proc in procs: proc.wait(timeout=10) with use_archivebox_db(initialized_archive): assert not Process.objects.filter( process_type=Process.TypeChoices.SUPERVISORD, status=Process.StatusChoices.RUNNING, pwd=str(CONSTANTS.DATA_DIR), ).exists() finally: for proc in procs: if proc.poll() is None: os.killpg(proc.pid, signal.SIGKILL) @pytest.mark.timeout(300) @pytest.mark.parametrize( ("stop_signal", "expected_notice"), [ (signal.SIGHUP, "Got SIGHUP"), (signal.SIGINT, "Got SIGINT"), (signal.SIGTERM, "Got SIGTERM"), (signal.SIGKILL, None), ], ) def test_live_server_signal_exit_and_resume_uses_existing_supervisor_state(initialized_archive, stop_signal, expected_notice): env = cli_env(live=True) port = get_free_port() server = None resumed = None try: server = start_archivebox_server(initialized_archive, port=port, log_name=f"server-{stop_signal.name}.log", env=env) server_log = server.log_path os.kill(server.pid, stop_signal) try: server.wait(timeout=20 if stop_signal != signal.SIGKILL else 5) except subprocess.TimeoutExpired: os.kill(server.pid, signal.SIGKILL) server.wait(timeout=5) if expected_notice: log_text = server_log.read_text(encoding="utf-8", errors="replace") assert expected_notice in log_text assert "ArchiveBox server shut down gracefully" in log_text assert_no_processes_for_data_dir(initialized_archive, timeout=12) resumed = start_archivebox_server(initialized_archive, port=port, log_name=f"server-{stop_signal.name}-resumed.log", env=env) resumed_log = resumed.log_path _cmd_result = run_archivebox_cmd(["status"], cwd=initialized_archive, env=env, timeout=60) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr or stdout os.kill(resumed.pid, signal.SIGTERM) resumed.wait(timeout=20) resumed_text = resumed_log.read_text(encoding="utf-8", errors="replace") assert "Got SIGTERM" in resumed_text assert "ArchiveBox server shut down gracefully" in resumed_text assert_no_processes_for_data_dir(initialized_archive, timeout=12) finally: for proc in (server, resumed): if proc is not None and proc.poll() is None: stop_archivebox_process(proc, signal.SIGKILL) kill_processes_for_data_dir(initialized_archive) @pytest.mark.timeout(180) def test_live_daemonized_server_keeps_supervisord_owned_by_archivebox_parent(initialized_archive): env = cli_env(live=True) port = get_free_port() bind_url = f"http://127.0.0.1:{port}" try: _cmd_result = run_archivebox_cmd( ["server", "--daemonize", f"127.0.0.1:{port}"], cwd=initialized_archive, env=env, timeout=90, ) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr or stdout wait_for_port_open("127.0.0.1", port, timeout=30) server_process = wait_for_process( lambda _proc, command: "archivebox" in command and " server " in f" {command} " and bind_url.replace("http://", "") in command, ) supervisord = wait_for_process( lambda proc, command: proc.ppid() == server_process.pid and "supervisord" in command, ) wait_for_process( lambda proc, command: proc.ppid() == supervisord.pid and "supervisord_watchdog" in command, ) os.kill(server_process.pid, signal.SIGKILL) wait_for_pid_to_disappear(server_process.pid, timeout=10) wait_for_pid_to_disappear(supervisord.pid, timeout=20) assert_no_processes_for_data_dir(initialized_archive, timeout=12) finally: kill_processes_for_data_dir(initialized_archive) assert_no_processes_for_data_dir(initialized_archive, timeout=12) @pytest.mark.timeout(240) def test_live_servers_in_different_data_dirs_do_not_interfere(initialized_archive): first_data_dir = initialized_archive second_data_dir = initialized_archive.parent / f"{initialized_archive.name}-second" second_data_dir.mkdir() second_env = cli_env(live=True) _cmd_result = run_archivebox_cmd(["init"], cwd=second_data_dir, env=second_env, timeout=90) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr or stdout first_port = get_free_port() second_port = get_free_port() first = None second = None first_resumed = None try: first = start_archivebox_server( first_data_dir, port=first_port, log_name="server-first-data-dir.log", env=cli_env(live=True), ) second = start_archivebox_server(second_data_dir, port=second_port, log_name="server-second-data-dir.log", env=second_env) _cmd_result = run_archivebox_cmd( ["status"], cwd=first_data_dir, env=cli_env(live=True), timeout=60, ) first_stdout, first_stderr, first_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode _cmd_result = run_archivebox_cmd( ["status"], cwd=second_data_dir, env=second_env, timeout=60, ) second_stdout, second_stderr, second_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert first_returncode == 0, first_stderr or first_stdout assert second_returncode == 0, second_stderr or second_stdout stop_archivebox_process(first, signal.SIGTERM) first = None assert second.poll() is None, "stopping one DATA_DIR server must not stop another DATA_DIR server" first_resumed = start_archivebox_server( first_data_dir, port=first_port, log_name="server-first-data-dir-resumed.log", env=cli_env(live=True), ) assert second.poll() is None, "restarting one DATA_DIR server must not take over another DATA_DIR supervisor" finally: for proc in (first, first_resumed, second): if proc is not None and proc.poll() is None: stop_archivebox_process(proc, signal.SIGTERM) kill_processes_for_data_dir(first_data_dir) kill_processes_for_data_dir(second_data_dir) assert_no_processes_for_data_dir(first_data_dir, timeout=12) assert_no_processes_for_data_dir(second_data_dir, timeout=12)