gunicorn/tests/test_config.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

592 lines
17 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
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
from gunicorn.util import load_class
from gunicorn.workers.sync import SyncWorker
from gunicorn import glogging
from gunicorn.instrument import statsd
dirname = os.path.dirname(__file__)
def cfg_module():
return 'config.test_cfg'
def alt_cfg_module():
return 'config.test_cfg_alt'
def cfg_file():
return os.path.join(dirname, "config", "test_cfg.py")
def alt_cfg_file():
return os.path.join(dirname, "config", "test_cfg_alt.py")
def cfg_file_with_wsgi_app():
return os.path.join(dirname, "config", "test_cfg_with_wsgi_app.py")
def paster_ini():
return os.path.join(dirname, "..", "examples", "frameworks", "pylonstest", "nose.ini")
class AltArgs:
def __init__(self, args=None):
self.args = args or []
self.orig = sys.argv
def __enter__(self):
sys.argv = self.args
def __exit__(self, exc_type, exc_inst, traceback):
sys.argv = self.orig
class NoConfigApp(Application):
def __init__(self):
super().__init__("no_usage", prog="gunicorn_test")
def init(self, parser, opts, args):
pass
def load(self):
pass
class CustomWorker(SyncWorker):
pass
class WSGIApp(WSGIApplication):
def __init__(self):
super().__init__("no_usage", prog="gunicorn_test")
def load(self):
pass
def test_worker_class():
c = config.Config()
c.set("worker_class", CustomWorker)
assert c.worker_class == CustomWorker
try:
assert isinstance(load_class(c.worker_class), object)
except AttributeError:
pytest.fail("'load_class doesn't support type class argument'")
def test_defaults():
c = config.Config()
for s in config.KNOWN_SETTINGS:
assert c.settings[s.name].validator(s.default) == c.settings[s.name].get()
def test_property_access():
c = config.Config()
for s in config.KNOWN_SETTINGS:
getattr(c, s.name)
# Class was loaded
assert c.worker_class == SyncWorker
# logger class was loaded
assert c.logger_class == glogging.Logger
# Workers defaults to 1
assert c.workers == 1
c.set("workers", 3)
assert c.workers == 3
# Address is parsed
assert c.address == [("127.0.0.1", 8000)]
# User and group defaults
assert os.geteuid() == c.uid
assert os.getegid() == c.gid
# Proc name
assert "gunicorn" == c.proc_name
# Not a config property
pytest.raises(AttributeError, getattr, c, "foo")
# Force to be not an error
class Baz:
def get(self):
return 3.14
c.settings["foo"] = Baz()
assert c.foo == 3.14
# Attempt to set a cfg not via c.set
pytest.raises(AttributeError, setattr, c, "proc_name", "baz")
# No setting for name
pytest.raises(AttributeError, c.set, "baz", "bar")
def test_bool_validation():
c = config.Config()
assert c.preload_app is False
c.set("preload_app", True)
assert c.preload_app is True
c.set("preload_app", "true")
assert c.preload_app is True
c.set("preload_app", "false")
assert c.preload_app is False
pytest.raises(ValueError, c.set, "preload_app", "zilch")
pytest.raises(TypeError, c.set, "preload_app", 4)
def test_pos_int_validation():
c = config.Config()
assert c.workers == 1
c.set("workers", 4)
assert c.workers == 4
c.set("workers", "5")
assert c.workers == 5
c.set("workers", "0xFF")
assert c.workers == 255
c.set("workers", True)
assert c.workers == 1 # Yes. That's right...
pytest.raises(ValueError, c.set, "workers", -21)
pytest.raises(TypeError, c.set, "workers", c)
def test_str_validation():
c = config.Config()
assert c.proc_name == "gunicorn"
c.set("proc_name", " foo ")
assert c.proc_name == "foo"
pytest.raises(TypeError, c.set, "proc_name", 2)
def test_str_to_addr_list_validation():
c = config.Config()
# Values remain as strings for backward compatibility
assert c.proxy_allow_ips == ["127.0.0.1", "::1"]
assert c.forwarded_allow_ips == ["127.0.0.1", "::1"]
# Single IPs are validated but kept as strings
c.set("forwarded_allow_ips", "127.0.0.1,192.0.2.1")
assert c.forwarded_allow_ips == ["127.0.0.1", "192.0.2.1"]
# CIDR networks are supported and kept as strings
c.set("forwarded_allow_ips", "127.0.0.0/8,192.168.0.0/16")
assert c.forwarded_allow_ips == ["127.0.0.0/8", "192.168.0.0/16"]
# Wildcard is preserved as string
c.set("forwarded_allow_ips", "*")
assert c.forwarded_allow_ips == ["*"]
c.set("forwarded_allow_ips", "")
assert c.forwarded_allow_ips == []
c.set("forwarded_allow_ips", None)
assert c.forwarded_allow_ips == []
# demand addresses are specified unambiguously
pytest.raises(TypeError, c.set, "forwarded_allow_ips", 1)
# demand networks are specified unambiguously
pytest.raises(ValueError, c.set, "forwarded_allow_ips", "127.0.0")
# detect typos
pytest.raises(ValueError, c.set, "forwarded_allow_ips", "::f:")
# dangerous typos such as accidentally permitting half the internet
# clearly recognizable - masked bits are not zero
pytest.raises(ValueError, c.set, "forwarded_allow_ips", "100.64.0.0/1")
def test_str_to_list():
c = config.Config()
assert c.forwarder_headers == ["SCRIPT_NAME", "PATH_INFO"]
c.set("forwarder_headers", "SCRIPT_NAME,REMOTE_USER")
assert c.forwarder_headers == ["SCRIPT_NAME", "REMOTE_USER"]
c.set("forwarder_headers", "")
assert c.forwarder_headers == []
c.set("forwarder_headers", None)
assert c.forwarder_headers == []
def test_callable_validation():
c = config.Config()
def func(a, b):
pass
c.set("pre_fork", func)
assert c.pre_fork == func
pytest.raises(TypeError, c.set, "pre_fork", 1)
pytest.raises(TypeError, c.set, "pre_fork", lambda x: True)
def test_reload_engine_validation():
c = config.Config()
assert c.reload_engine == "auto"
c.set('reload_engine', 'poll')
assert c.reload_engine == 'poll'
pytest.raises(ConfigError, c.set, "reload_engine", "invalid")
def test_callable_validation_for_string():
from os.path import isdir as testfunc
assert config.validate_callable(-1)("os.path.isdir") == testfunc
# invalid values tests
pytest.raises(
TypeError,
config.validate_callable(-1), ""
)
pytest.raises(
TypeError,
config.validate_callable(-1), "os.path.not_found_func"
)
pytest.raises(
TypeError,
config.validate_callable(-1), "notfoundmodule.func"
)
def test_cmd_line():
with AltArgs(["prog_name", "-b", "blargh"]):
app = NoConfigApp()
assert app.cfg.bind == ["blargh"]
with AltArgs(["prog_name", "-w", "3"]):
app = NoConfigApp()
assert app.cfg.workers == 3
with AltArgs(["prog_name", "--preload"]):
app = NoConfigApp()
assert app.cfg.preload_app
def test_cmd_line_invalid_setting(capsys):
with AltArgs(["prog_name", "-q", "bar"]):
with pytest.raises(SystemExit):
NoConfigApp()
_, err = capsys.readouterr()
assert "error: unrecognized arguments: -q" in err
def test_app_config():
with AltArgs():
app = NoConfigApp()
for s in config.KNOWN_SETTINGS:
assert app.cfg.settings[s.name].validator(s.default) == app.cfg.settings[s.name].get()
def test_load_config():
with AltArgs(["prog_name", "-c", cfg_file()]):
app = NoConfigApp()
assert app.cfg.bind == ["unix:/tmp/bar/baz"]
assert app.cfg.workers == 3
assert app.cfg.proc_name == "fooey"
def test_load_config_explicit_file():
with AltArgs(["prog_name", "-c", "file:%s" % cfg_file()]):
app = NoConfigApp()
assert app.cfg.bind == ["unix:/tmp/bar/baz"]
assert app.cfg.workers == 3
assert app.cfg.proc_name == "fooey"
def test_load_config_module():
with AltArgs(["prog_name", "-c", "python:%s" % cfg_module()]):
app = NoConfigApp()
assert app.cfg.bind == ["unix:/tmp/bar/baz"]
assert app.cfg.workers == 3
assert app.cfg.proc_name == "fooey"
def test_cli_overrides_config():
with AltArgs(["prog_name", "-c", cfg_file(), "-b", "blarney"]):
app = NoConfigApp()
assert app.cfg.bind == ["blarney"]
assert app.cfg.proc_name == "fooey"
def test_cli_overrides_config_module():
with AltArgs(["prog_name", "-c", "python:%s" % cfg_module(), "-b", "blarney"]):
app = NoConfigApp()
assert app.cfg.bind == ["blarney"]
assert app.cfg.proc_name == "fooey"
@pytest.fixture
def create_config_file(request):
default_config = os.path.join(os.path.abspath(os.getcwd()),
'gunicorn.conf.py')
with open(default_config, 'w+') as default:
default.write("bind='0.0.0.0:9090'")
def fin():
os.unlink(default_config)
request.addfinalizer(fin)
return default
def test_default_config_file(create_config_file):
assert config.get_default_config_file() == create_config_file.name
with AltArgs(["prog_name"]):
app = NoConfigApp()
assert app.cfg.bind == ["0.0.0.0:9090"]
def test_post_request():
c = config.Config()
def post_request_4(worker, req, environ, resp):
return 4
def post_request_3(worker, req, environ):
return 3
def post_request_2(worker, req):
return 2
c.set("post_request", post_request_4)
assert c.post_request(1, 2, 3, 4) == 4
c.set("post_request", post_request_3)
assert c.post_request(1, 2, 3, 4) == 3
c.set("post_request", post_request_2)
assert c.post_request(1, 2, 3, 4) == 2
def test_nworkers_changed():
c = config.Config()
def nworkers_changed_3(server, new_value, old_value):
return 3
c.set("nworkers_changed", nworkers_changed_3)
assert c.nworkers_changed(1, 2, 3) == 3
def test_statsd_host():
c = config.Config()
assert c.statsd_host is None
c.set("statsd_host", "localhost")
assert c.statsd_host == ("localhost", 8125)
c.set("statsd_host", "statsd:7777")
assert c.statsd_host == ("statsd", 7777)
c.set("statsd_host", "unix:///path/to.sock")
assert c.statsd_host == "/path/to.sock"
pytest.raises(TypeError, c.set, "statsd_host", 666)
pytest.raises(TypeError, c.set, "statsd_host", "host:string")
def test_statsd_host_with_unix_as_hostname():
# This is a regression test for major release 20. After this release
# we should consider modifying the behavior of util.parse_address to
# simplify gunicorn's code
c = config.Config()
c.set("statsd_host", "unix:7777")
assert c.statsd_host == ("unix", 7777)
c.set("statsd_host", "unix://some.socket")
assert c.statsd_host == "some.socket"
def test_statsd_changes_logger():
c = config.Config()
assert c.logger_class == glogging.Logger
c.set('statsd_host', 'localhost:12345')
assert c.logger_class == statsd.Statsd
class MyLogger(glogging.Logger):
# dummy custom logger class for testing
pass
def test_always_use_configured_logger():
c = config.Config()
c.set('logger_class', __name__ + '.MyLogger')
assert c.logger_class == MyLogger
c.set('statsd_host', 'localhost:12345')
# still uses custom logger over statsd
assert c.logger_class == MyLogger
def test_load_enviroment_variables_config(monkeypatch):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--workers=4")
with AltArgs():
app = NoConfigApp()
assert app.cfg.workers == 4
def test_config_file_environment_variable(monkeypatch):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--config=" + alt_cfg_file())
with AltArgs():
app = NoConfigApp()
assert app.cfg.proc_name == "not-fooey"
assert app.cfg.config == alt_cfg_file()
with AltArgs(["prog_name", "--config", cfg_file()]):
app = NoConfigApp()
assert app.cfg.proc_name == "fooey"
assert app.cfg.config == cfg_file()
def test_invalid_enviroment_variables_config(monkeypatch, capsys):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--foo=bar")
with AltArgs():
with pytest.raises(SystemExit):
NoConfigApp()
_, err = capsys.readouterr()
assert "error: unrecognized arguments: --foo" in err
def test_cli_overrides_enviroment_variables_module(monkeypatch):
monkeypatch.setenv("GUNICORN_CMD_ARGS", "--workers=4")
with AltArgs(["prog_name", "-c", cfg_file(), "--workers", "3"]):
app = NoConfigApp()
assert app.cfg.workers == 3
@pytest.mark.parametrize("options, expected", [
(["app:app"], 'app:app'),
(["-c", cfg_file(), "app:app"], 'app:app'),
(["-c", cfg_file_with_wsgi_app(), "app:app"], 'app:app'),
(["-c", cfg_file_with_wsgi_app()], 'app1:app1'),
])
def test_wsgi_app_config(options, expected):
cmdline = ["prog_name"]
cmdline.extend(options)
with AltArgs(cmdline):
app = WSGIApp()
assert app.app_uri == expected
@pytest.mark.parametrize("options", [
([]),
(["-c", cfg_file()]),
])
def test_non_wsgi_app(options, capsys):
cmdline = ["prog_name"]
cmdline.extend(options)
with AltArgs(cmdline):
with pytest.raises(SystemExit):
WSGIApp()
_, err = capsys.readouterr()
assert "Error: No application module specified." in err
@pytest.mark.parametrize("options, expected", [
(["myapp:app"], False),
(["--reload", "myapp:app"], True),
(["--reload", "--", "myapp:app"], True),
(["--reload", "-w 2", "myapp:app"], True),
])
def test_reload(options, expected):
cmdline = ["prog_name"]
cmdline.extend(options)
with AltArgs(cmdline):
app = NoConfigApp()
assert app.cfg.reload == expected
@pytest.mark.parametrize("options, expected", [
(["--umask", "0", "myapp:app"], 0),
(["--umask", "0o0", "myapp:app"], 0),
(["--umask", "0x0", "myapp:app"], 0),
(["--umask", "0xFF", "myapp:app"], 255),
(["--umask", "0022", "myapp:app"], 18),
])
def test_umask_config(options, expected):
cmdline = ["prog_name"]
cmdline.extend(options)
with AltArgs(cmdline):
app = NoConfigApp()
assert app.cfg.umask == expected
def _test_ssl_version(options, expected):
cmdline = ["prog_name"]
cmdline.extend(options)
with AltArgs(cmdline):
app = NoConfigApp()
assert app.cfg.ssl_version == expected
def test_bind_fd():
with AltArgs(["prog_name", "-b", "fd://42"]):
app = NoConfigApp()
assert app.cfg.bind == ["fd://42"]
def test_repr():
c = config.Config()
c.set("workers", 5)
assert "with value 5" in repr(c.settings['workers'])
def test_str():
c = config.Config()
o = str(c)
# match the first few lines, some different types, but don't go OTT
# to avoid needless test fails with changes
OUTPUT_MATCH = {
'access_log_format': '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"',
'accesslog': 'None',
'backlog': '2048',
'bind': "['127.0.0.1:8000']",
'capture_output': 'False',
'child_exit': '<ChildExit.child_exit()>',
}
for i, line in enumerate(o.splitlines()):
m = re.match(r'^(\w+)\s+= ', line)
assert m, "Line {} didn't match expected format: {!r}".format(i, line)
key = m.group(1)
try:
s = OUTPUT_MATCH.pop(key)
except KeyError:
continue
line_re = r'^{}\s+= {}$'.format(key, re.escape(s))
assert re.match(line_re, line), '{!r} != {!r}'.format(line_re, line)
if not OUTPUT_MATCH:
break
else:
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')