From 831a598b6ff2389a5fd7c9ca382e8e892c9fb5c1 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 24 Sep 2025 11:12:05 +0200 Subject: [PATCH 1/2] 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. --- supervisor/addons/model.py | 6 +++ supervisor/addons/validate.py | 15 ++++++ supervisor/const.py | 1 + supervisor/docker/addon.py | 15 +++++- tests/addons/test_config.py | 53 +++++++++++++++++++++ tests/docker/test_addon.py | 90 +++++++++++++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 1 deletion(-) diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 11b51e7a39d..b9ccc427b2c 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -72,6 +72,7 @@ ATTR_TYPE, ATTR_UART, ATTR_UDEV, + ATTR_ULIMITS, ATTR_URL, ATTR_USB, ATTR_VERSION, @@ -462,6 +463,11 @@ def with_udev(self) -> bool: """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 edb510a861d..4d9f3d8079d 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -88,6 +88,7 @@ ATTR_TYPE, ATTR_UART, ATTR_UDEV, + ATTR_ULIMITS, ATTR_URL, ATTR_USB, ATTR_USER, @@ -423,6 +424,20 @@ def _migrate(config: dict[str, Any]): 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.Optional("soft"): vol.Coerce(int), + vol.Optional("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 b53e928f4a5..affd0518711 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -348,6 +348,7 @@ 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 200a8618b50..2c65f39c192 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -318,7 +318,20 @@ def ulimits(self) -> list[docker.types.Ulimit] | None: 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: separate soft and hard limits + soft = config.get("soft", config.get("hard", 0)) + hard = config.get("hard", config.get("soft", 0)) + # Always add the ulimit if it's a valid dict format (even if values are 0) + if "soft" in config or "hard" in config: + 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 cc957d76130..c8cf8b48227 100644 --- a/tests/addons/test_config.py +++ b/tests/addons/test_config.py @@ -419,3 +419,56 @@ 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) diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index 37700bf9348..2f46f1cb439 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -500,3 +500,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 From 1710f5bb5371ee7c800d86d851d704518da33c23 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Oct 2025 11:56:23 +0200 Subject: [PATCH 2/2] Make soft and hard limit mandatory if ulimit is a dict --- supervisor/addons/validate.py | 4 ++-- supervisor/docker/addon.py | 10 ++++------ tests/addons/test_config.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index 4d9f3d8079d..c9703bef5d0 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -431,8 +431,8 @@ def _migrate(config: dict[str, Any]): vol.Coerce(int), # Simple format for individual entries vol.Schema( { # Detailed format for individual entries - vol.Optional("soft"): vol.Coerce(int), - vol.Optional("hard"): vol.Coerce(int), + vol.Required("soft"): vol.Coerce(int), + vol.Required("hard"): vol.Coerce(int), } ), ) diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 2c65f39c192..01427948509 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -324,12 +324,10 @@ def ulimits(self) -> list[docker.types.Ulimit] | None: # 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: separate soft and hard limits - soft = config.get("soft", config.get("hard", 0)) - hard = config.get("hard", config.get("soft", 0)) - # Always add the ulimit if it's a valid dict format (even if values are 0) - if "soft" in config or "hard" in config: - limits.append(docker.types.Ulimit(name=name, soft=soft, hard=hard)) + # 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: diff --git a/tests/addons/test_config.py b/tests/addons/test_config.py index c8cf8b48227..2fa4645c6fe 100644 --- a/tests/addons/test_config.py +++ b/tests/addons/test_config.py @@ -472,3 +472,18 @@ def test_ulimits_invalid_values(): 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)