mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
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
This commit is contained in:
parent
3667a10478
commit
0ad47db800
@ -54,7 +54,9 @@ A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``.
|
||||
|
||||
**Command line:** `--control-socket PATH`
|
||||
|
||||
**Default:** `'/run/gunicorn.ctl'`
|
||||
**Default:**
|
||||
|
||||
$XDG_RUNTIME_DIR/gunicorn.ctl or $HOME/.gunicorn/gunicorn.ctl
|
||||
|
||||
Unix socket path for control interface.
|
||||
|
||||
@ -62,9 +64,9 @@ The control socket allows runtime management of Gunicorn via the
|
||||
``gunicornc`` command-line tool. Commands include viewing worker
|
||||
status, adjusting worker count, and graceful reload/shutdown.
|
||||
|
||||
By default, creates ``/run/gunicorn.ctl`` (requires write access to
|
||||
``/run``). For user-level deployments, specify a different path such
|
||||
as ``/tmp/gunicorn.ctl`` or ``~/.gunicorn.ctl``.
|
||||
Default: ``$XDG_RUNTIME_DIR/gunicorn.ctl`` if XDG_RUNTIME_DIR is set,
|
||||
otherwise ``$HOME/.gunicorn/gunicorn.ctl``. The parent directory is
|
||||
created automatically if needed.
|
||||
|
||||
Use ``--no-control-socket`` to disable.
|
||||
|
||||
|
||||
@ -3128,13 +3128,32 @@ class DirtyWorkerExit(Setting):
|
||||
|
||||
# Control Socket Settings
|
||||
|
||||
|
||||
def _get_default_control_socket():
|
||||
"""Get default control socket path based on available directories.
|
||||
|
||||
Prefers XDG_RUNTIME_DIR if available (standard on Linux, sometimes BSD),
|
||||
falls back to $HOME/.gunicorn/ directory.
|
||||
"""
|
||||
# Prefer XDG_RUNTIME_DIR if available
|
||||
xdg_runtime = os.environ.get('XDG_RUNTIME_DIR')
|
||||
if xdg_runtime and os.path.isdir(xdg_runtime):
|
||||
return os.path.join(xdg_runtime, 'gunicorn.ctl')
|
||||
|
||||
# Fall back to $HOME/.gunicorn/
|
||||
home = os.path.expanduser('~')
|
||||
gunicorn_dir = os.path.join(home, '.gunicorn')
|
||||
return os.path.join(gunicorn_dir, 'gunicorn.ctl')
|
||||
|
||||
|
||||
class ControlSocket(Setting):
|
||||
name = "control_socket"
|
||||
section = "Control"
|
||||
cli = ["--control-socket"]
|
||||
meta = "PATH"
|
||||
validator = validate_string
|
||||
default = "/run/gunicorn.ctl"
|
||||
default = _get_default_control_socket()
|
||||
default_doc = "$XDG_RUNTIME_DIR/gunicorn.ctl or $HOME/.gunicorn/gunicorn.ctl"
|
||||
desc = """\
|
||||
Unix socket path for control interface.
|
||||
|
||||
@ -3142,9 +3161,9 @@ class ControlSocket(Setting):
|
||||
``gunicornc`` command-line tool. Commands include viewing worker
|
||||
status, adjusting worker count, and graceful reload/shutdown.
|
||||
|
||||
By default, creates ``/run/gunicorn.ctl`` (requires write access to
|
||||
``/run``). For user-level deployments, specify a different path such
|
||||
as ``/tmp/gunicorn.ctl`` or ``~/.gunicorn.ctl``.
|
||||
Default: ``$XDG_RUNTIME_DIR/gunicorn.ctl`` if XDG_RUNTIME_DIR is set,
|
||||
otherwise ``$HOME/.gunicorn/gunicorn.ctl``. The parent directory is
|
||||
created automatically if needed.
|
||||
|
||||
Use ``--no-control-socket`` to disable.
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gunicorn.config import _get_default_control_socket
|
||||
from gunicorn.ctl.client import ControlClient, ControlClientError, parse_command
|
||||
|
||||
|
||||
@ -405,8 +406,8 @@ Examples:
|
||||
|
||||
parser.add_argument(
|
||||
'-s', '--socket',
|
||||
default='gunicorn.ctl',
|
||||
help='Control socket path (default: gunicorn.ctl in current directory)'
|
||||
default=_get_default_control_socket(),
|
||||
help='Control socket path (default: auto-detected based on XDG_RUNTIME_DIR or ~/.gunicorn/)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
|
||||
@ -187,6 +187,11 @@ class ControlSocketServer:
|
||||
"""Main async server loop."""
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
# Create parent directory if needed (for ~/.gunicorn/)
|
||||
socket_dir = os.path.dirname(self.socket_path)
|
||||
if socket_dir and not os.path.exists(socket_dir):
|
||||
os.makedirs(socket_dir, mode=0o700)
|
||||
|
||||
# Remove socket if it exists
|
||||
if os.path.exists(self.socket_path):
|
||||
os.unlink(self.socket_path)
|
||||
|
||||
@ -361,3 +361,59 @@ class TestControlSocketServerPermissions:
|
||||
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()
|
||||
|
||||
@ -5,10 +5,12 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from gunicorn import config
|
||||
from gunicorn.config import _get_default_control_socket
|
||||
from gunicorn.app.base import Application
|
||||
from gunicorn.app.wsgiapp import WSGIApplication
|
||||
from gunicorn.errors import ConfigError
|
||||
@ -551,3 +553,39 @@ def test_str():
|
||||
assert False, 'missing expected setting lines? {}'.format(
|
||||
OUTPUT_MATCH.keys()
|
||||
)
|
||||
|
||||
|
||||
# Tests for _get_default_control_socket
|
||||
|
||||
class TestGetDefaultControlSocket:
|
||||
"""Tests for the _get_default_control_socket function."""
|
||||
|
||||
def test_uses_xdg_runtime_dir_when_set_and_exists(self, monkeypatch):
|
||||
"""When XDG_RUNTIME_DIR is set and exists, use it."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.setenv('XDG_RUNTIME_DIR', tmpdir)
|
||||
result = _get_default_control_socket()
|
||||
assert result == os.path.join(tmpdir, 'gunicorn.ctl')
|
||||
|
||||
def test_falls_back_when_xdg_runtime_dir_not_exists(self, monkeypatch):
|
||||
"""When XDG_RUNTIME_DIR is set but doesn't exist, fall back to home."""
|
||||
monkeypatch.setenv('XDG_RUNTIME_DIR', '/nonexistent/path/that/does/not/exist')
|
||||
monkeypatch.setenv('HOME', '/home/testuser')
|
||||
result = _get_default_control_socket()
|
||||
assert result == '/home/testuser/.gunicorn/gunicorn.ctl'
|
||||
|
||||
def test_falls_back_when_xdg_runtime_dir_not_set(self, monkeypatch):
|
||||
"""When XDG_RUNTIME_DIR is not set, use home directory."""
|
||||
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
|
||||
monkeypatch.setenv('HOME', '/home/testuser')
|
||||
result = _get_default_control_socket()
|
||||
assert result == '/home/testuser/.gunicorn/gunicorn.ctl'
|
||||
|
||||
def test_uses_home_directory_structure(self, monkeypatch):
|
||||
"""Verify the path structure uses .gunicorn subdirectory."""
|
||||
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.setenv('HOME', tmpdir)
|
||||
result = _get_default_control_socket()
|
||||
assert result == os.path.join(tmpdir, '.gunicorn', 'gunicorn.ctl')
|
||||
assert result.endswith('.gunicorn/gunicorn.ctl')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user