From 53a8044aff3ad67cf12c92fcd6fa03cd6037cac6 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 8 Oct 2025 12:43:12 +0200 Subject: [PATCH] Add support for ulimit in addon config (#6206) * Add support for ulimit in addon config Similar to docker-compose, this adds support for setting ulimits for addons via the addon config. This is useful e.g. for InfluxDB which on its own does not support setting higher open file descriptor limits, but recommends increasing limits on the host. * Make soft and hard limit mandatory if ulimit is a dict --- supervisor/addons/model.py | 6 +++ supervisor/addons/validate.py | 15 ++++++ supervisor/const.py | 1 + supervisor/docker/addon.py | 13 ++++- tests/addons/test_config.py | 68 ++++++++++++++++++++++++++ tests/docker/test_addon.py | 90 +++++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 1 deletion(-) diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 11b51e7a3..b9ccc427b 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -72,6 +72,7 @@ from ..const import ( ATTR_TYPE, ATTR_UART, ATTR_UDEV, + ATTR_ULIMITS, ATTR_URL, ATTR_USB, ATTR_VERSION, @@ -462,6 +463,11 @@ class AddonModel(JobGroup, ABC): """Return True if the add-on have his own udev.""" return self.data[ATTR_UDEV] + @property + def ulimits(self) -> dict[str, Any]: + """Return ulimits configuration.""" + return self.data[ATTR_ULIMITS] + @property def with_kernel_modules(self) -> bool: """Return True if the add-on access to kernel modules.""" diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index edb510a86..c9703bef5 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -88,6 +88,7 @@ from ..const import ( ATTR_TYPE, ATTR_UART, ATTR_UDEV, + ATTR_ULIMITS, ATTR_URL, ATTR_USB, ATTR_USER, @@ -423,6 +424,20 @@ _SCHEMA_ADDON_CONFIG = vol.Schema( False, ), vol.Optional(ATTR_IMAGE): docker_image, + vol.Optional(ATTR_ULIMITS, default=dict): vol.Any( + {str: vol.Coerce(int)}, # Simple format: {name: limit} + { + str: vol.Any( + vol.Coerce(int), # Simple format for individual entries + vol.Schema( + { # Detailed format for individual entries + vol.Required("soft"): vol.Coerce(int), + vol.Required("hard"): vol.Coerce(int), + } + ), + ) + }, + ), vol.Optional(ATTR_TIMEOUT, default=10): vol.All( vol.Coerce(int), vol.Range(min=10, max=300) ), diff --git a/supervisor/const.py b/supervisor/const.py index b53e928f4..affd05187 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -348,6 +348,7 @@ ATTR_TRANSLATIONS = "translations" ATTR_TYPE = "type" ATTR_UART = "uart" ATTR_UDEV = "udev" +ATTR_ULIMITS = "ulimits" ATTR_UNHEALTHY = "unhealthy" ATTR_UNSAVED = "unsaved" ATTR_UNSUPPORTED = "unsupported" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 200a8618b..014279485 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -318,7 +318,18 @@ class DockerAddon(DockerInterface): mem = 128 * 1024 * 1024 limits.append(docker.types.Ulimit(name="memlock", soft=mem, hard=mem)) - # Return None if no capabilities is present + # Add configurable ulimits from add-on config + for name, config in self.addon.ulimits.items(): + if isinstance(config, int): + # Simple format: both soft and hard limits are the same + limits.append(docker.types.Ulimit(name=name, soft=config, hard=config)) + elif isinstance(config, dict): + # Detailed format: both soft and hard limits are mandatory + soft = config["soft"] + hard = config["hard"] + limits.append(docker.types.Ulimit(name=name, soft=soft, hard=hard)) + + # Return None if no ulimits are present if limits: return limits return None diff --git a/tests/addons/test_config.py b/tests/addons/test_config.py index cc957d761..2fa4645c6 100644 --- a/tests/addons/test_config.py +++ b/tests/addons/test_config.py @@ -419,3 +419,71 @@ def test_valid_schema(): config["schema"] = {"field": "invalid"} with pytest.raises(vol.Invalid): assert vd.SCHEMA_ADDON_CONFIG(config) + + +def test_ulimits_simple_format(): + """Test ulimits simple format validation.""" + config = load_json_fixture("basic-addon-config.json") + + config["ulimits"] = {"nofile": 65535, "nproc": 32768, "memlock": 134217728} + + valid_config = vd.SCHEMA_ADDON_CONFIG(config) + assert valid_config["ulimits"]["nofile"] == 65535 + assert valid_config["ulimits"]["nproc"] == 32768 + assert valid_config["ulimits"]["memlock"] == 134217728 + + +def test_ulimits_detailed_format(): + """Test ulimits detailed format validation.""" + config = load_json_fixture("basic-addon-config.json") + + config["ulimits"] = { + "nofile": {"soft": 20000, "hard": 40000}, + "nproc": 32768, # Mixed format should work + "memlock": {"soft": 67108864, "hard": 134217728}, + } + + valid_config = vd.SCHEMA_ADDON_CONFIG(config) + assert valid_config["ulimits"]["nofile"]["soft"] == 20000 + assert valid_config["ulimits"]["nofile"]["hard"] == 40000 + assert valid_config["ulimits"]["nproc"] == 32768 + assert valid_config["ulimits"]["memlock"]["soft"] == 67108864 + assert valid_config["ulimits"]["memlock"]["hard"] == 134217728 + + +def test_ulimits_empty_dict(): + """Test ulimits with empty dict (default).""" + config = load_json_fixture("basic-addon-config.json") + + valid_config = vd.SCHEMA_ADDON_CONFIG(config) + assert valid_config["ulimits"] == {} + + +def test_ulimits_invalid_values(): + """Test ulimits with invalid values.""" + config = load_json_fixture("basic-addon-config.json") + + # Invalid string values + config["ulimits"] = {"nofile": "invalid"} + with pytest.raises(vol.Invalid): + vd.SCHEMA_ADDON_CONFIG(config) + + # Invalid detailed format + config["ulimits"] = {"nofile": {"invalid_key": 1000}} + with pytest.raises(vol.Invalid): + vd.SCHEMA_ADDON_CONFIG(config) + + # Missing hard value in detailed format + config["ulimits"] = {"nofile": {"soft": 1000}} + with pytest.raises(vol.Invalid): + vd.SCHEMA_ADDON_CONFIG(config) + + # Missing soft value in detailed format + config["ulimits"] = {"nofile": {"hard": 1000}} + with pytest.raises(vol.Invalid): + vd.SCHEMA_ADDON_CONFIG(config) + + # Empty dict in detailed format + config["ulimits"] = {"nofile": {}} + with pytest.raises(vol.Invalid): + vd.SCHEMA_ADDON_CONFIG(config) diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index 706e2fec1..a6fb2fdaa 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -503,3 +503,93 @@ async def test_addon_new_device_no_haos( await install_addon_ssh.stop() assert coresys.resolution.issues == [] assert coresys.resolution.suggestions == [] + + +async def test_ulimits_integration( + coresys: CoreSys, + install_addon_ssh: Addon, +): + """Test ulimits integration with Docker addon.""" + docker_addon = DockerAddon(coresys, install_addon_ssh) + + # Test default case (no ulimits, no realtime) + assert docker_addon.ulimits is None + + # Test with realtime enabled (should have built-in ulimits) + install_addon_ssh.data["realtime"] = True + ulimits = docker_addon.ulimits + assert ulimits is not None + assert len(ulimits) == 2 + # Check for rtprio limit + rtprio_limit = next((u for u in ulimits if u.name == "rtprio"), None) + assert rtprio_limit is not None + assert rtprio_limit.soft == 90 + assert rtprio_limit.hard == 99 + # Check for memlock limit + memlock_limit = next((u for u in ulimits if u.name == "memlock"), None) + assert memlock_limit is not None + assert memlock_limit.soft == 128 * 1024 * 1024 + assert memlock_limit.hard == 128 * 1024 * 1024 + + # Test with configurable ulimits (simple format) + install_addon_ssh.data["realtime"] = False + install_addon_ssh.data["ulimits"] = {"nofile": 65535, "nproc": 32768} + ulimits = docker_addon.ulimits + assert ulimits is not None + assert len(ulimits) == 2 + + nofile_limit = next((u for u in ulimits if u.name == "nofile"), None) + assert nofile_limit is not None + assert nofile_limit.soft == 65535 + assert nofile_limit.hard == 65535 + + nproc_limit = next((u for u in ulimits if u.name == "nproc"), None) + assert nproc_limit is not None + assert nproc_limit.soft == 32768 + assert nproc_limit.hard == 32768 + + # Test with configurable ulimits (detailed format) + install_addon_ssh.data["ulimits"] = { + "nofile": {"soft": 20000, "hard": 40000}, + "memlock": {"soft": 67108864, "hard": 134217728}, + } + ulimits = docker_addon.ulimits + assert ulimits is not None + assert len(ulimits) == 2 + + nofile_limit = next((u for u in ulimits if u.name == "nofile"), None) + assert nofile_limit is not None + assert nofile_limit.soft == 20000 + assert nofile_limit.hard == 40000 + + memlock_limit = next((u for u in ulimits if u.name == "memlock"), None) + assert memlock_limit is not None + assert memlock_limit.soft == 67108864 + assert memlock_limit.hard == 134217728 + + # Test mixed format and realtime (realtime + custom ulimits) + install_addon_ssh.data["realtime"] = True + install_addon_ssh.data["ulimits"] = { + "nofile": 65535, + "core": {"soft": 0, "hard": 0}, # Disable core dumps + } + ulimits = docker_addon.ulimits + assert ulimits is not None + assert ( + len(ulimits) == 4 + ) # rtprio, memlock (from realtime) + nofile, core (from config) + + # Check realtime limits still present + rtprio_limit = next((u for u in ulimits if u.name == "rtprio"), None) + assert rtprio_limit is not None + + # Check custom limits added + nofile_limit = next((u for u in ulimits if u.name == "nofile"), None) + assert nofile_limit is not None + assert nofile_limit.soft == 65535 + assert nofile_limit.hard == 65535 + + core_limit = next((u for u in ulimits if u.name == "core"), None) + assert core_limit is not None + assert core_limit.soft == 0 + assert core_limit.hard == 0