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..c9703bef5d0 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.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 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..01427948509 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -318,7 +318,18 @@ 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: 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 cc957d76130..2fa4645c6fe 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 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