mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
The previous default /run/gunicorn.ctl requires root permissions. Now uses $XDG_RUNTIME_DIR/gunicorn.ctl if available, otherwise $HOME/.gunicorn/gunicorn.ctl. This works on Linux, FreeBSD, OpenBSD, and macOS without requiring elevated privileges. - Add _get_default_control_socket() helper in config.py - Create parent directory automatically with 0o700 permissions - Update gunicornc CLI to use the same default path - Add unit tests for path selection and directory creation
420 lines
12 KiB
Python
420 lines
12 KiB
Python
#
|
|
# This file is part of gunicorn released under the MIT license.
|
|
# See the NOTICE for more information.
|
|
|
|
"""Tests for control socket server."""
|
|
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from gunicorn.ctl.server import ControlSocketServer
|
|
from gunicorn.ctl.client import ControlClient
|
|
|
|
|
|
class MockWorker:
|
|
"""Mock worker for testing."""
|
|
|
|
def __init__(self, pid, age, booted=True, aborted=False):
|
|
self.pid = pid
|
|
self.age = age
|
|
self.booted = booted
|
|
self.aborted = aborted
|
|
self.tmp = MagicMock()
|
|
self.tmp.last_update.return_value = time.monotonic()
|
|
|
|
|
|
class MockConfig:
|
|
"""Mock config for testing."""
|
|
|
|
def __init__(self):
|
|
self.bind = ['127.0.0.1:8000']
|
|
self.workers = 4
|
|
self.worker_class = 'sync'
|
|
self.threads = 1
|
|
self.timeout = 30
|
|
self.graceful_timeout = 30
|
|
self.keepalive = 2
|
|
self.max_requests = 0
|
|
self.max_requests_jitter = 0
|
|
self.worker_connections = 1000
|
|
self.preload_app = False
|
|
self.daemon = False
|
|
self.pidfile = None
|
|
self.proc_name = 'test_app'
|
|
self.reload = False
|
|
self.dirty_workers = 0
|
|
self.dirty_apps = []
|
|
self.dirty_timeout = 30
|
|
self.control_socket = 'gunicorn.ctl'
|
|
self.control_socket_disable = False
|
|
|
|
|
|
class MockLog:
|
|
"""Mock logger for testing."""
|
|
|
|
def debug(self, msg, *args):
|
|
pass
|
|
|
|
def info(self, msg, *args):
|
|
pass
|
|
|
|
def warning(self, msg, *args):
|
|
pass
|
|
|
|
def error(self, msg, *args):
|
|
pass
|
|
|
|
def exception(self, msg, *args):
|
|
pass
|
|
|
|
|
|
class MockArbiter:
|
|
"""Mock arbiter for testing."""
|
|
|
|
def __init__(self):
|
|
self.cfg = MockConfig()
|
|
self.log = MockLog()
|
|
self.pid = 12345
|
|
self.WORKERS = {}
|
|
self.LISTENERS = []
|
|
self.dirty_arbiter_pid = 0
|
|
self.dirty_arbiter = None
|
|
self.num_workers = 4
|
|
self._stats = {
|
|
'start_time': time.time() - 3600,
|
|
'workers_spawned': 10,
|
|
'workers_killed': 5,
|
|
'reloads': 2,
|
|
}
|
|
|
|
def wakeup(self):
|
|
pass
|
|
|
|
|
|
class TestControlSocketServerInit:
|
|
"""Tests for server initialization."""
|
|
|
|
def test_init(self):
|
|
"""Test server initialization."""
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, "/tmp/test.sock", 0o600)
|
|
|
|
assert server.arbiter is arbiter
|
|
assert server.socket_path == "/tmp/test.sock"
|
|
assert server.socket_mode == 0o600
|
|
assert server._running is False
|
|
|
|
|
|
class TestControlSocketServerLifecycle:
|
|
"""Tests for server start/stop."""
|
|
|
|
def test_start_stop(self):
|
|
"""Test starting and stopping the server."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
# Wait for server to start
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
assert os.path.exists(socket_path)
|
|
|
|
server.stop()
|
|
|
|
# Wait for cleanup
|
|
time.sleep(0.2)
|
|
|
|
# Socket should be cleaned up
|
|
assert not os.path.exists(socket_path)
|
|
|
|
def test_start_already_running(self):
|
|
"""Test that start is idempotent."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
first_thread = server._thread
|
|
server.start()
|
|
|
|
assert server._thread is first_thread
|
|
|
|
server.stop()
|
|
|
|
def test_stop_not_running(self):
|
|
"""Test stopping a non-running server."""
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, "/tmp/test.sock")
|
|
|
|
# Should not raise
|
|
server.stop()
|
|
|
|
|
|
class TestControlSocketServerIntegration:
|
|
"""Integration tests for server with client."""
|
|
|
|
def test_show_workers(self):
|
|
"""Test show workers command."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
arbiter.WORKERS = {
|
|
1001: MockWorker(1001, 1),
|
|
1002: MockWorker(1002, 2),
|
|
}
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
# Wait for server to start
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
try:
|
|
with ControlClient(socket_path, timeout=5.0) as client:
|
|
result = client.send_command("show workers")
|
|
|
|
assert result["count"] == 2
|
|
assert len(result["workers"]) == 2
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_show_stats(self):
|
|
"""Test show stats command."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
try:
|
|
with ControlClient(socket_path, timeout=5.0) as client:
|
|
result = client.send_command("show stats")
|
|
|
|
assert result["pid"] == 12345
|
|
assert result["workers_spawned"] == 10
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_help_command(self):
|
|
"""Test help command."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
try:
|
|
with ControlClient(socket_path, timeout=5.0) as client:
|
|
result = client.send_command("help")
|
|
|
|
assert "commands" in result
|
|
assert "show workers" in result["commands"]
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_worker_add(self):
|
|
"""Test worker add command."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
arbiter.wakeup = MagicMock()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
try:
|
|
with ControlClient(socket_path, timeout=5.0) as client:
|
|
result = client.send_command("worker add 2")
|
|
|
|
assert result["added"] == 2
|
|
assert result["total"] == 6
|
|
assert arbiter.num_workers == 6
|
|
arbiter.wakeup.assert_called()
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_invalid_command(self):
|
|
"""Test handling invalid command."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
try:
|
|
with ControlClient(socket_path, timeout=5.0) as client:
|
|
with pytest.raises(Exception) as exc_info:
|
|
client.send_command("invalid_command")
|
|
|
|
assert "Unknown command" in str(exc_info.value)
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_multiple_commands(self):
|
|
"""Test sending multiple commands on same connection."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
arbiter.WORKERS = {1001: MockWorker(1001, 1)}
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
time.sleep(0.2) # Extra wait for server to be fully ready
|
|
|
|
try:
|
|
with ControlClient(socket_path, timeout=5.0) as client:
|
|
result1 = client.send_command("show workers")
|
|
result2 = client.send_command("show stats")
|
|
result3 = client.send_command("help")
|
|
|
|
assert result1["count"] == 1
|
|
assert result2["pid"] == 12345
|
|
assert "commands" in result3
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
class TestControlSocketServerPermissions:
|
|
"""Tests for socket permissions."""
|
|
|
|
@pytest.mark.skipif(
|
|
os.uname().sysname == "FreeBSD",
|
|
reason="FreeBSD socket permissions behavior differs"
|
|
)
|
|
def test_socket_permissions(self):
|
|
"""Test that socket is created with correct permissions."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, "test.sock")
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path, 0o660)
|
|
|
|
server.start()
|
|
|
|
# Wait for socket to exist
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
# Extra wait for chmod to complete
|
|
time.sleep(0.2)
|
|
|
|
try:
|
|
mode = os.stat(socket_path).st_mode & 0o777
|
|
assert mode == 0o660
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
class TestControlSocketServerDirectoryCreation:
|
|
"""Tests for automatic directory creation."""
|
|
|
|
def test_creates_parent_directory(self):
|
|
"""Test that server creates parent directory if it doesn't exist."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create a path with a non-existent subdirectory
|
|
subdir = os.path.join(tmpdir, '.gunicorn')
|
|
socket_path = os.path.join(subdir, 'gunicorn.ctl')
|
|
|
|
assert not os.path.exists(subdir)
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
# Wait for socket to exist
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
try:
|
|
# Directory should have been created
|
|
assert os.path.isdir(subdir)
|
|
# Directory should have restricted permissions (0o700)
|
|
mode = os.stat(subdir).st_mode & 0o777
|
|
assert mode == 0o700
|
|
# Socket should exist
|
|
assert os.path.exists(socket_path)
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_works_with_existing_directory(self):
|
|
"""Test that server works when parent directory already exists."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
socket_path = os.path.join(tmpdir, 'gunicorn.ctl')
|
|
|
|
arbiter = MockArbiter()
|
|
server = ControlSocketServer(arbiter, socket_path)
|
|
|
|
server.start()
|
|
|
|
# Wait for socket to exist
|
|
for _ in range(50):
|
|
if os.path.exists(socket_path):
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
try:
|
|
assert os.path.exists(socket_path)
|
|
finally:
|
|
server.stop()
|