Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions supervisor/api/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ATTR_DEBUG_BLOCK,
ATTR_DETECT_BLOCKING_IO,
ATTR_DIAGNOSTICS,
ATTR_FEATURE_FLAGS,
ATTR_HEALTHY,
ATTR_ICON,
ATTR_IP_ADDRESS,
Expand All @@ -41,6 +42,7 @@
ATTR_VERSION,
ATTR_VERSION_LATEST,
ATTR_WAIT_BOOT,
FeatureFlag,
LogLevel,
UpdateChannel,
)
Expand Down Expand Up @@ -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()}
),
}
)

Expand Down Expand Up @@ -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: self.sys_config.feature_flags.get(feature, False)
for feature in FeatureFlag
},
# Depricated
ATTR_WAIT_BOOT: self.sys_config.wait_boot,
ATTR_APPS: [
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions supervisor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ATTR_DEBUG_BLOCK,
ATTR_DETECT_BLOCKING_IO,
ATTR_DIAGNOSTICS,
ATTR_FEATURE_FLAGS,
ATTR_IMAGE,
ATTR_LAST_BOOT,
ATTR_LOGGING,
Expand All @@ -24,6 +25,7 @@
ENV_SUPERVISOR_SHARE,
FILE_HASSIO_CONFIG,
SUPERVISOR_DATA,
FeatureFlag,
LogLevel,
)
from .utils.common import FileConfiguration
Expand Down Expand Up @@ -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 all 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."""
Expand Down
7 changes: 7 additions & 0 deletions supervisor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions supervisor/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ATTR_DISPLAYNAME,
ATTR_DNS,
ATTR_ENABLE_IPV6,
ATTR_FEATURE_FLAGS,
ATTR_FORCE_SECURITY,
ATTR_HASSOS,
ATTR_HASSOS_UNRESTRICTED,
Expand Down Expand Up @@ -47,6 +48,7 @@
ATTR_VERSION,
ATTR_WAIT_BOOT,
SUPERVISOR_VERSION,
FeatureFlag,
LogLevel,
UpdateChannel,
)
Expand Down Expand Up @@ -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,
)
Expand Down
73 changes: 72 additions & 1 deletion tests/api/test_supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 in feature_flags
assert feature_flags[feature] 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