gunicorn/tests/ctl/test_server.py
Benoit Chesneau 0ad47db800
Use user-writable default path for control socket (#3551)
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
2026-03-23 20:08:03 +01:00

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