diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 7a5b6b78328..0cb652949aa 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -22,6 +22,7 @@ ATTR_DEBUG_BLOCK, ATTR_DETECT_BLOCKING_IO, ATTR_DIAGNOSTICS, + ATTR_FEATURE_FLAGS, ATTR_HEALTHY, ATTR_ICON, ATTR_IP_ADDRESS, @@ -41,6 +42,7 @@ ATTR_VERSION, ATTR_VERSION_LATEST, ATTR_WAIT_BOOT, + FeatureFlag, LogLevel, UpdateChannel, ) @@ -70,6 +72,9 @@ vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), vol.Optional(ATTR_DETECT_BLOCKING_IO): vol.Coerce(DetectBlockingIO), vol.Optional(ATTR_COUNTRY): str, + vol.Optional(ATTR_FEATURE_FLAGS): vol.Schema( + {vol.Coerce(FeatureFlag): vol.Boolean()} + ), } ) @@ -104,6 +109,10 @@ async def info(self, request: web.Request) -> dict[str, Any]: ATTR_AUTO_UPDATE: self.sys_updater.auto_update, ATTR_DETECT_BLOCKING_IO: BlockBusterManager.is_enabled(), ATTR_COUNTRY: self.sys_config.country, + ATTR_FEATURE_FLAGS: { + feature.value: self.sys_config.feature_flags.get(feature, False) + for feature in FeatureFlag + }, # Depricated ATTR_WAIT_BOOT: self.sys_config.wait_boot, ATTR_APPS: [ @@ -182,6 +191,10 @@ async def options(self, request: web.Request) -> None: if ATTR_WAIT_BOOT in body: self.sys_config.wait_boot = body[ATTR_WAIT_BOOT] + if ATTR_FEATURE_FLAGS in body: + for feature, enabled in body[ATTR_FEATURE_FLAGS].items(): + self.sys_config.set_feature_flag(feature, enabled) + # Save changes before processing apps in case of errors await self.sys_updater.save_data() await self.sys_config.save_data() diff --git a/supervisor/config.py b/supervisor/config.py index 81929fba773..322a06a0742 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -15,6 +15,7 @@ ATTR_DEBUG_BLOCK, ATTR_DETECT_BLOCKING_IO, ATTR_DIAGNOSTICS, + ATTR_FEATURE_FLAGS, ATTR_IMAGE, ATTR_LAST_BOOT, ATTR_LOGGING, @@ -24,6 +25,7 @@ ENV_SUPERVISOR_SHARE, FILE_HASSIO_CONFIG, SUPERVISOR_DATA, + FeatureFlag, LogLevel, ) from .utils.common import FileConfiguration @@ -195,6 +197,17 @@ def modify_log_level(self) -> None: lvl = getattr(logging, self.logging.value.upper()) logging.getLogger("supervisor").setLevel(lvl) + @property + def feature_flags(self) -> dict[FeatureFlag, bool]: + """Return current state of explicitly configured experimental feature flags.""" + return self._data.get(ATTR_FEATURE_FLAGS, {}) + + def set_feature_flag(self, feature: FeatureFlag, enabled: bool) -> None: + """Enable or disable an experimental feature flag.""" + if ATTR_FEATURE_FLAGS not in self._data: + self._data[ATTR_FEATURE_FLAGS] = {} + self._data[ATTR_FEATURE_FLAGS][feature] = enabled + @property def last_boot(self) -> datetime: """Return last boot datetime.""" diff --git a/supervisor/const.py b/supervisor/const.py index 715af6cd81b..36857f3b42c 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -192,6 +192,7 @@ ATTR_EVENT = "event" ATTR_EXCLUDE_DATABASE = "exclude_database" ATTR_EXTRA = "extra" +ATTR_FEATURE_FLAGS = "feature_flags" ATTR_FEATURES = "features" ATTR_FIELDS = "fields" ATTR_FILENAME = "filename" @@ -548,6 +549,12 @@ class CpuArch(StrEnum): AMD64 = "amd64" +class FeatureFlag(StrEnum): + """Development features that can be toggled.""" + + SUPERVISOR_V2_API = "supervisor_v2_api" + + @dataclass class HomeAssistantUser: """A Home Assistant Core user. diff --git a/supervisor/validate.py b/supervisor/validate.py index da35655ab0a..6b2b06133ae 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -20,6 +20,7 @@ ATTR_DISPLAYNAME, ATTR_DNS, ATTR_ENABLE_IPV6, + ATTR_FEATURE_FLAGS, ATTR_FORCE_SECURITY, ATTR_HASSOS, ATTR_HASSOS_UNRESTRICTED, @@ -47,6 +48,7 @@ ATTR_VERSION, ATTR_WAIT_BOOT, SUPERVISOR_VERSION, + FeatureFlag, LogLevel, UpdateChannel, ) @@ -212,6 +214,9 @@ def validate_repository(repository: str) -> str: vol.Optional(ATTR_DIAGNOSTICS, default=None): vol.Maybe(vol.Boolean()), vol.Optional(ATTR_DETECT_BLOCKING_IO, default=False): vol.Boolean(), vol.Optional(ATTR_COUNTRY): str, + vol.Optional(ATTR_FEATURE_FLAGS, default=dict): vol.Schema( + {vol.Coerce(FeatureFlag): vol.Boolean()} + ), }, extra=vol.REMOVE_EXTRA, ) diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 86fe4e1e608..60a1a8e9806 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -11,7 +11,7 @@ from blockbuster import BlockingError import pytest -from supervisor.const import CoreState +from supervisor.const import CoreState, FeatureFlag from supervisor.core import Core from supervisor.coresys import CoreSys from supervisor.exceptions import HassioError, HostNotSupportedError, StoreGitError @@ -451,3 +451,74 @@ async def test_supervisor_api_stats_failure( "Could not inspect container 'hassio_supervisor': [500] {'message': 'fail'}" in caplog.text ) + + +async def test_api_supervisor_info_feature_flags( + api_client: TestClient, coresys: CoreSys +): + """Test that supervisor info returns all feature flags with default False.""" + resp = await api_client.get("/supervisor/info") + assert resp.status == 200 + result = await resp.json() + + assert "feature_flags" in result["data"] + feature_flags = result["data"]["feature_flags"] + + # All known feature flags should be present and default to False + for feature in FeatureFlag: + assert feature.value in feature_flags + assert feature_flags[feature.value] is False + + +async def test_api_supervisor_options_feature_flags_enable( + api_client: TestClient, coresys: CoreSys +): + """Test enabling a feature flag via supervisor options.""" + assert not coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) + + response = await api_client.post( + "/supervisor/options", + json={"feature_flags": {"supervisor_v2_api": True}}, + ) + assert response.status == 200 + assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True + + +async def test_api_supervisor_options_feature_flags_disable( + api_client: TestClient, coresys: CoreSys +): + """Test disabling a feature flag via supervisor options.""" + coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True) + assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True + + response = await api_client.post( + "/supervisor/options", + json={"feature_flags": {"supervisor_v2_api": False}}, + ) + assert response.status == 200 + assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is False + + +async def test_api_supervisor_options_feature_flags_partial_update( + api_client: TestClient, coresys: CoreSys +): + """Test that omitting a feature flag in options leaves its state unchanged.""" + coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True) + + # Post options without mentioning feature_flags at all + response = await api_client.post("/supervisor/options", json={"debug": False}) + assert response.status == 200 + + # The feature flag should remain True + assert coresys.config.feature_flags.get(FeatureFlag.SUPERVISOR_V2_API) is True + + +async def test_api_supervisor_options_feature_flags_unknown_flag( + api_client: TestClient, +): + """Test that an unknown feature flag name is rejected.""" + response = await api_client.post( + "/supervisor/options", + json={"feature_flags": {"unknown_feature": True}}, + ) + assert response.status == 400