Skip to content

Commit 53a8044

Browse files
authored
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
1 parent c71553f commit 53a8044

File tree

6 files changed

+192
-1
lines changed

6 files changed

+192
-1
lines changed

supervisor/addons/model.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
ATTR_TYPE,
7373
ATTR_UART,
7474
ATTR_UDEV,
75+
ATTR_ULIMITS,
7576
ATTR_URL,
7677
ATTR_USB,
7778
ATTR_VERSION,
@@ -462,6 +463,11 @@ def with_udev(self) -> bool:
462463
"""Return True if the add-on have his own udev."""
463464
return self.data[ATTR_UDEV]
464465

466+
@property
467+
def ulimits(self) -> dict[str, Any]:
468+
"""Return ulimits configuration."""
469+
return self.data[ATTR_ULIMITS]
470+
465471
@property
466472
def with_kernel_modules(self) -> bool:
467473
"""Return True if the add-on access to kernel modules."""

supervisor/addons/validate.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
ATTR_TYPE,
8989
ATTR_UART,
9090
ATTR_UDEV,
91+
ATTR_ULIMITS,
9192
ATTR_URL,
9293
ATTR_USB,
9394
ATTR_USER,
@@ -423,6 +424,20 @@ def _migrate(config: dict[str, Any]):
423424
False,
424425
),
425426
vol.Optional(ATTR_IMAGE): docker_image,
427+
vol.Optional(ATTR_ULIMITS, default=dict): vol.Any(
428+
{str: vol.Coerce(int)}, # Simple format: {name: limit}
429+
{
430+
str: vol.Any(
431+
vol.Coerce(int), # Simple format for individual entries
432+
vol.Schema(
433+
{ # Detailed format for individual entries
434+
vol.Required("soft"): vol.Coerce(int),
435+
vol.Required("hard"): vol.Coerce(int),
436+
}
437+
),
438+
)
439+
},
440+
),
426441
vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
427442
vol.Coerce(int), vol.Range(min=10, max=300)
428443
),

supervisor/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@
348348
ATTR_TYPE = "type"
349349
ATTR_UART = "uart"
350350
ATTR_UDEV = "udev"
351+
ATTR_ULIMITS = "ulimits"
351352
ATTR_UNHEALTHY = "unhealthy"
352353
ATTR_UNSAVED = "unsaved"
353354
ATTR_UNSUPPORTED = "unsupported"

supervisor/docker/addon.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,18 @@ def ulimits(self) -> list[docker.types.Ulimit] | None:
318318
mem = 128 * 1024 * 1024
319319
limits.append(docker.types.Ulimit(name="memlock", soft=mem, hard=mem))
320320

321-
# Return None if no capabilities is present
321+
# Add configurable ulimits from add-on config
322+
for name, config in self.addon.ulimits.items():
323+
if isinstance(config, int):
324+
# Simple format: both soft and hard limits are the same
325+
limits.append(docker.types.Ulimit(name=name, soft=config, hard=config))
326+
elif isinstance(config, dict):
327+
# Detailed format: both soft and hard limits are mandatory
328+
soft = config["soft"]
329+
hard = config["hard"]
330+
limits.append(docker.types.Ulimit(name=name, soft=soft, hard=hard))
331+
332+
# Return None if no ulimits are present
322333
if limits:
323334
return limits
324335
return None

tests/addons/test_config.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,71 @@ def test_valid_schema():
419419
config["schema"] = {"field": "invalid"}
420420
with pytest.raises(vol.Invalid):
421421
assert vd.SCHEMA_ADDON_CONFIG(config)
422+
423+
424+
def test_ulimits_simple_format():
425+
"""Test ulimits simple format validation."""
426+
config = load_json_fixture("basic-addon-config.json")
427+
428+
config["ulimits"] = {"nofile": 65535, "nproc": 32768, "memlock": 134217728}
429+
430+
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
431+
assert valid_config["ulimits"]["nofile"] == 65535
432+
assert valid_config["ulimits"]["nproc"] == 32768
433+
assert valid_config["ulimits"]["memlock"] == 134217728
434+
435+
436+
def test_ulimits_detailed_format():
437+
"""Test ulimits detailed format validation."""
438+
config = load_json_fixture("basic-addon-config.json")
439+
440+
config["ulimits"] = {
441+
"nofile": {"soft": 20000, "hard": 40000},
442+
"nproc": 32768, # Mixed format should work
443+
"memlock": {"soft": 67108864, "hard": 134217728},
444+
}
445+
446+
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
447+
assert valid_config["ulimits"]["nofile"]["soft"] == 20000
448+
assert valid_config["ulimits"]["nofile"]["hard"] == 40000
449+
assert valid_config["ulimits"]["nproc"] == 32768
450+
assert valid_config["ulimits"]["memlock"]["soft"] == 67108864
451+
assert valid_config["ulimits"]["memlock"]["hard"] == 134217728
452+
453+
454+
def test_ulimits_empty_dict():
455+
"""Test ulimits with empty dict (default)."""
456+
config = load_json_fixture("basic-addon-config.json")
457+
458+
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
459+
assert valid_config["ulimits"] == {}
460+
461+
462+
def test_ulimits_invalid_values():
463+
"""Test ulimits with invalid values."""
464+
config = load_json_fixture("basic-addon-config.json")
465+
466+
# Invalid string values
467+
config["ulimits"] = {"nofile": "invalid"}
468+
with pytest.raises(vol.Invalid):
469+
vd.SCHEMA_ADDON_CONFIG(config)
470+
471+
# Invalid detailed format
472+
config["ulimits"] = {"nofile": {"invalid_key": 1000}}
473+
with pytest.raises(vol.Invalid):
474+
vd.SCHEMA_ADDON_CONFIG(config)
475+
476+
# Missing hard value in detailed format
477+
config["ulimits"] = {"nofile": {"soft": 1000}}
478+
with pytest.raises(vol.Invalid):
479+
vd.SCHEMA_ADDON_CONFIG(config)
480+
481+
# Missing soft value in detailed format
482+
config["ulimits"] = {"nofile": {"hard": 1000}}
483+
with pytest.raises(vol.Invalid):
484+
vd.SCHEMA_ADDON_CONFIG(config)
485+
486+
# Empty dict in detailed format
487+
config["ulimits"] = {"nofile": {}}
488+
with pytest.raises(vol.Invalid):
489+
vd.SCHEMA_ADDON_CONFIG(config)

tests/docker/test_addon.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,93 @@ async def test_addon_new_device_no_haos(
503503
await install_addon_ssh.stop()
504504
assert coresys.resolution.issues == []
505505
assert coresys.resolution.suggestions == []
506+
507+
508+
async def test_ulimits_integration(
509+
coresys: CoreSys,
510+
install_addon_ssh: Addon,
511+
):
512+
"""Test ulimits integration with Docker addon."""
513+
docker_addon = DockerAddon(coresys, install_addon_ssh)
514+
515+
# Test default case (no ulimits, no realtime)
516+
assert docker_addon.ulimits is None
517+
518+
# Test with realtime enabled (should have built-in ulimits)
519+
install_addon_ssh.data["realtime"] = True
520+
ulimits = docker_addon.ulimits
521+
assert ulimits is not None
522+
assert len(ulimits) == 2
523+
# Check for rtprio limit
524+
rtprio_limit = next((u for u in ulimits if u.name == "rtprio"), None)
525+
assert rtprio_limit is not None
526+
assert rtprio_limit.soft == 90
527+
assert rtprio_limit.hard == 99
528+
# Check for memlock limit
529+
memlock_limit = next((u for u in ulimits if u.name == "memlock"), None)
530+
assert memlock_limit is not None
531+
assert memlock_limit.soft == 128 * 1024 * 1024
532+
assert memlock_limit.hard == 128 * 1024 * 1024
533+
534+
# Test with configurable ulimits (simple format)
535+
install_addon_ssh.data["realtime"] = False
536+
install_addon_ssh.data["ulimits"] = {"nofile": 65535, "nproc": 32768}
537+
ulimits = docker_addon.ulimits
538+
assert ulimits is not None
539+
assert len(ulimits) == 2
540+
541+
nofile_limit = next((u for u in ulimits if u.name == "nofile"), None)
542+
assert nofile_limit is not None
543+
assert nofile_limit.soft == 65535
544+
assert nofile_limit.hard == 65535
545+
546+
nproc_limit = next((u for u in ulimits if u.name == "nproc"), None)
547+
assert nproc_limit is not None
548+
assert nproc_limit.soft == 32768
549+
assert nproc_limit.hard == 32768
550+
551+
# Test with configurable ulimits (detailed format)
552+
install_addon_ssh.data["ulimits"] = {
553+
"nofile": {"soft": 20000, "hard": 40000},
554+
"memlock": {"soft": 67108864, "hard": 134217728},
555+
}
556+
ulimits = docker_addon.ulimits
557+
assert ulimits is not None
558+
assert len(ulimits) == 2
559+
560+
nofile_limit = next((u for u in ulimits if u.name == "nofile"), None)
561+
assert nofile_limit is not None
562+
assert nofile_limit.soft == 20000
563+
assert nofile_limit.hard == 40000
564+
565+
memlock_limit = next((u for u in ulimits if u.name == "memlock"), None)
566+
assert memlock_limit is not None
567+
assert memlock_limit.soft == 67108864
568+
assert memlock_limit.hard == 134217728
569+
570+
# Test mixed format and realtime (realtime + custom ulimits)
571+
install_addon_ssh.data["realtime"] = True
572+
install_addon_ssh.data["ulimits"] = {
573+
"nofile": 65535,
574+
"core": {"soft": 0, "hard": 0}, # Disable core dumps
575+
}
576+
ulimits = docker_addon.ulimits
577+
assert ulimits is not None
578+
assert (
579+
len(ulimits) == 4
580+
) # rtprio, memlock (from realtime) + nofile, core (from config)
581+
582+
# Check realtime limits still present
583+
rtprio_limit = next((u for u in ulimits if u.name == "rtprio"), None)
584+
assert rtprio_limit is not None
585+
586+
# Check custom limits added
587+
nofile_limit = next((u for u in ulimits if u.name == "nofile"), None)
588+
assert nofile_limit is not None
589+
assert nofile_limit.soft == 65535
590+
assert nofile_limit.hard == 65535
591+
592+
core_limit = next((u for u in ulimits if u.name == "core"), None)
593+
assert core_limit is not None
594+
assert core_limit.soft == 0
595+
assert core_limit.hard == 0

0 commit comments

Comments
 (0)