Files
supervisor/tests/addons/test_config.py
Stefan Agner 53a8044aff 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
2025-10-08 12:43:12 +02:00

490 lines
14 KiB
Python

"""Validate Add-on configs."""
import pytest
import voluptuous as vol
from supervisor.addons import validate as vd
from supervisor.addons.const import AddonBackupMode
from ..common import load_json_fixture
def test_basic_config():
"""Validate basic config and check the default values."""
config = load_json_fixture("basic-addon-config.json")
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["name"] == "Test Add-on"
assert valid_config["image"] == "test/{arch}-my-custom-addon"
# Check defaults
assert not valid_config["host_network"]
assert not valid_config["host_ipc"]
assert not valid_config["host_dbus"]
assert not valid_config["host_pid"]
assert not valid_config["host_uts"]
assert not valid_config["hassio_api"]
assert not valid_config["homeassistant_api"]
assert not valid_config["docker_api"]
def test_migration_startup():
"""Migrate Startup Type."""
config = load_json_fixture("basic-addon-config.json")
config["startup"] = "before"
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["startup"] == "services"
config["startup"] = "after"
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["startup"] == "application"
def test_migration_auto_uart():
"""Migrate auto uart Type."""
config = load_json_fixture("basic-addon-config.json")
config["auto_uart"] = True
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["uart"]
assert "auto_uart" not in valid_config
def test_migration_devices():
"""Migrate devices Type."""
config = load_json_fixture("basic-addon-config.json")
config["devices"] = ["test:test:rw", "bla"]
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["devices"] == ["test", "bla"]
def test_migration_tmpfs():
"""Migrate tmpfs Type."""
config = load_json_fixture("basic-addon-config.json")
config["tmpfs"] = "test:test:rw"
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["tmpfs"]
def test_migration_backup():
"""Migrate snapshot to backup."""
config = load_json_fixture("basic-addon-config.json")
config["snapshot"] = AddonBackupMode.HOT
config["snapshot_pre"] = "pre_command"
config["snapshot_post"] = "post_command"
config["snapshot_exclude"] = ["excludeed"]
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config.get("snapshot") is None
assert valid_config.get("snapshot_pre") is None
assert valid_config.get("snapshot_post") is None
assert valid_config.get("snapshot_exclude") is None
assert valid_config["backup"] == AddonBackupMode.HOT
assert valid_config["backup_pre"] == "pre_command"
assert valid_config["backup_post"] == "post_command"
assert valid_config["backup_exclude"] == ["excludeed"]
def test_invalid_repository():
"""Validate basic config with invalid repositories."""
config = load_json_fixture("basic-addon-config.json")
config["image"] = "-invalid-something"
with pytest.raises(vol.Invalid):
vd.SCHEMA_ADDON_CONFIG(config)
config["image"] = "ghcr.io/home-assistant/no-valid-repo:no-tag-allow"
with pytest.raises(vol.Invalid):
vd.SCHEMA_ADDON_CONFIG(config)
config["image"] = (
"registry.gitlab.com/company/add-ons/test-example/text-example:no-tag-allow"
)
with pytest.raises(vol.Invalid):
vd.SCHEMA_ADDON_CONFIG(config)
def test_valid_repository():
"""Validate basic config with different valid repositories."""
config = load_json_fixture("basic-addon-config.json")
custom_registry = "registry.gitlab.com/company/add-ons/core/test-example"
config["image"] = custom_registry
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["image"] == custom_registry
def test_valid_map():
"""Validate basic config with different valid maps."""
config = load_json_fixture("basic-addon-config.json")
config["map"] = ["backup:rw", "ssl:ro", "config"]
vd.SCHEMA_ADDON_CONFIG(config)
def test_malformed_map_entries():
"""Test that malformed map entries are handled gracefully (issue #6124)."""
config = load_json_fixture("basic-addon-config.json")
# Test case 1: Empty dict in map (should be skipped with warning)
config["map"] = [{}]
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["map"] == []
# Test case 2: Dict missing required 'type' field (should be skipped with warning)
config["map"] = [{"read_only": False, "path": "/custom"}]
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["map"] == []
# Test case 3: Invalid string format that doesn't match regex
config["map"] = ["invalid_format", "not:a:valid:mapping", "share:invalid_mode"]
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["map"] == []
# Test case 4: Mix of valid and invalid entries (invalid should be filtered out)
config["map"] = [
"share:rw", # Valid string format
"invalid_string", # Invalid string format
{}, # Invalid empty dict
{"type": "config", "read_only": True}, # Valid dict format
{"read_only": False}, # Invalid - missing type
]
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
# Should only keep the valid entries
assert len(valid_config["map"]) == 2
assert any(entry["type"] == "share" for entry in valid_config["map"])
assert any(entry["type"] == "config" for entry in valid_config["map"])
# Test case 5: The specific case from the UplandJacob repo (malformed YAML format)
# This simulates what YAML "- addon_config: rw" creates
config["map"] = [{"addon_config": "rw"}] # Wrong structure, missing 'type' key
valid_config = vd.SCHEMA_ADDON_CONFIG(config)
assert valid_config["map"] == []
def test_valid_basic_build():
"""Validate basic build config."""
config = load_json_fixture("basic-build-config.json")
vd.SCHEMA_BUILD_CONFIG(config)
async def test_valid_manifest_build():
"""Validate build config with manifest build from."""
config = load_json_fixture("build-config-manifest.json")
vd.SCHEMA_BUILD_CONFIG(config)
def test_valid_machine():
"""Validate valid machine config."""
config = load_json_fixture("basic-addon-config.json")
config["machine"] = [
"intel-nuc",
"odroid-c2",
"odroid-n2",
"odroid-xu",
"qemuarm-64",
"qemuarm",
"qemux86-64",
"qemux86",
"raspberrypi",
"raspberrypi2",
"raspberrypi3-64",
"raspberrypi3",
"raspberrypi4-64",
"raspberrypi4",
"raspberrypi5-64",
"tinker",
]
assert vd.SCHEMA_ADDON_CONFIG(config)
config["machine"] = [
"!intel-nuc",
"!odroid-c2",
"!odroid-n2",
"!odroid-xu",
"!qemuarm-64",
"!qemuarm",
"!qemux86-64",
"!qemux86",
"!raspberrypi",
"!raspberrypi2",
"!raspberrypi3-64",
"!raspberrypi3",
"!raspberrypi4-64",
"!raspberrypi4",
"!raspberrypi5-64",
"!tinker",
]
assert vd.SCHEMA_ADDON_CONFIG(config)
config["machine"] = [
"odroid-n2",
"!odroid-xu",
"qemuarm-64",
"!qemuarm",
"qemux86-64",
"qemux86",
"raspberrypi",
"raspberrypi4-64",
"raspberrypi4",
"raspberrypi5-64",
"!tinker",
]
assert vd.SCHEMA_ADDON_CONFIG(config)
def test_invalid_machine():
"""Validate invalid machine config."""
config = load_json_fixture("basic-addon-config.json")
config["machine"] = [
"intel-nuc",
"raspberrypi3",
"raspberrypi4-64",
"raspberrypi4",
"tinkerxy",
]
with pytest.raises(vol.Invalid):
assert vd.SCHEMA_ADDON_CONFIG(config)
config["machine"] = [
"intel-nuc",
"intel-nuc",
]
with pytest.raises(vol.Invalid):
assert vd.SCHEMA_ADDON_CONFIG(config)
def test_watchdog_url():
"""Test Valid watchdog options."""
config = load_json_fixture("basic-addon-config.json")
for test_options in (
"tcp://[HOST]:[PORT:8123]",
"http://[HOST]:[PORT:8080]/health",
"https://[HOST]:[PORT:80]/",
):
config["watchdog"] = test_options
assert vd.SCHEMA_ADDON_CONFIG(config)
def test_valid_slug():
"""Test valid and invalid addon slugs."""
config = load_json_fixture("basic-addon-config.json")
# All examples pulled from https://analytics.home-assistant.io/addons.json
config["slug"] = "uptime-kuma"
assert vd.SCHEMA_ADDON_CONFIG(config)
config["slug"] = "hassio_google_drive_backup"
assert vd.SCHEMA_ADDON_CONFIG(config)
config["slug"] = "paradox_alarm_interface_3.x"
assert vd.SCHEMA_ADDON_CONFIG(config)
config["slug"] = "Lupusec2Mqtt"
assert vd.SCHEMA_ADDON_CONFIG(config)
# No whitespace
config["slug"] = "my addon"
with pytest.raises(vol.Invalid):
assert vd.SCHEMA_ADDON_CONFIG(config)
# No url control chars (or other non-word ascii characters)
config["slug"] = "a/b_&_c\\d_@ddon$:_test=#2?"
with pytest.raises(vol.Invalid):
assert vd.SCHEMA_ADDON_CONFIG(config)
# No unicode
config["slug"] = "complemento telefónico"
with pytest.raises(vol.Invalid):
assert vd.SCHEMA_ADDON_CONFIG(config)
def test_valid_schema():
"""Test valid and invalid addon slugs."""
config = load_json_fixture("basic-addon-config.json")
# Basic types
config["schema"] = {
"bool_basic": "bool",
"mail_basic": "email",
"url_basic": "url",
"port_basic": "port",
"match_basic": "match(.*@.*)",
"list_basic": "list(option1|option2|option3)",
# device
"device_basic": "device",
"device_filter": "device(subsystem=tty)",
# str
"str_basic": "str",
"str_basic2": "str(,)",
"str_min": "str(5,)",
"str_max": "str(,10)",
"str_minmax": "str(5,10)",
# password
"password_basic": "password",
"password_basic2": "password(,)",
"password_min": "password(5,)",
"password_max": "password(,10)",
"password_minmax": "password(5,10)",
# int
"int_basic": "int",
"int_basic2": "int(,)",
"int_min": "int(5,)",
"int_max": "int(,10)",
"int_minmax": "int(5,10)",
# float
"float_basic": "float",
"float_basic2": "float(,)",
"float_min": "float(5,)",
"float_max": "float(,10)",
"float_minmax": "float(5,10)",
}
assert vd.SCHEMA_ADDON_CONFIG(config)
# Different valid ways of nesting dicts and lists
config["schema"] = {
"str_list": ["str"],
"dict_in_list": [
{
"required": "str",
"optional": "str?",
}
],
"dict": {
"required": "str",
"optional": "str?",
"str_list_in_dict": ["str"],
"dict_in_list_in_dict": [
{
"required": "str",
"optional": "str?",
"str_list_in_dict_in_list_in_dict": ["str"],
}
],
"dict_in_dict": {
"str_list_in_dict_in_dict": ["str"],
"dict_in_list_in_dict_in_dict": [
{
"required": "str",
"optional": "str?",
}
],
"dict_in_dict_in_dict": {
"required": "str",
"optional": "str",
},
},
},
}
assert vd.SCHEMA_ADDON_CONFIG(config)
# List nested within dict within list
config["schema"] = {"field": [{"subfield": ["str"]}]}
assert vd.SCHEMA_ADDON_CONFIG(config)
# No lists directly nested within each other
config["schema"] = {"field": [["str"]]}
with pytest.raises(vol.Invalid):
assert vd.SCHEMA_ADDON_CONFIG(config)
# Field types must be valid
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)