diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index a639833c3811ae..1653c33e5ecb26 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -112,11 +112,14 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" +PLACEHOLDER_KEY_FREE_SPACE = "free_space" ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" +ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned" +ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space" CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" @@ -137,6 +140,24 @@ REQUEST_REFRESH_DELAY = 10 +HELP_URLS = { + "help_url": "https://www.home-assistant.io/help/", + "community_url": "https://community.home-assistant.io/", +} + +EXTRA_PLACEHOLDERS = { + "issue_mount_mount_failed": { + "storage_url": "/config/storage", + }, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS, + ISSUE_KEY_SYSTEM_FREE_SPACE: { + "more_info_free_space": "https://www.home-assistant.io/more-info/free-space", + }, + ISSUE_KEY_ADDON_PWNED: { + "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", + }, +} + class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 0486dc1f85f9fd..df1ca87fe0b6d9 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -41,17 +41,21 @@ EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + EXTRA_PLACEHOLDERS, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + ISSUE_KEY_SYSTEM_FREE_SPACE, PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_ADDON_URL, + PLACEHOLDER_KEY_FREE_SPACE, PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, UPDATE_KEY_SUPERVISOR, ) -from .coordinator import get_addons_info +from .coordinator import get_addons_info, get_host_info from .handler import HassIO, get_supervisor_client ISSUE_KEY_UNHEALTHY = "unhealthy" @@ -78,6 +82,8 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, "issue_system_disk_lifetime", + ISSUE_KEY_SYSTEM_FREE_SPACE, + ISSUE_KEY_ADDON_PWNED, } _LOGGER = logging.getLogger(__name__) @@ -241,11 +247,17 @@ def issues(self) -> set[Issue]: def add_issue(self, issue: Issue) -> None: """Add or update an issue in the list. Create or update a repair if necessary.""" if issue.key in ISSUE_KEYS_FOR_REPAIRS: - placeholders: dict[str, str] | None = None + placeholders: dict[str, str] = {} + if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: + placeholders |= EXTRA_PLACEHOLDERS[issue.key] + if issue.reference: - placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference} + placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference - if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING: + if issue.key in { + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_PWNED, + }: placeholders[PLACEHOLDER_KEY_ADDON_URL] = ( f"/hassio/addon/{issue.reference}" ) @@ -257,6 +269,19 @@ def add_issue(self, issue: Issue) -> None: else: placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: + host_info = get_host_info(self._hass) + if ( + host_info + and "data" in host_info + and "disk_free" in host_info["data"] + ): + placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str( + host_info["data"]["disk_free"] + ) + else: + placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2" + async_create_issue( self._hass, DOMAIN, @@ -264,7 +289,7 @@ def add_issue(self, issue: Issue) -> None: is_fixable=bool(issue.suggestions), severity=IssueSeverity.WARNING, translation_key=issue.key, - translation_placeholders=placeholders, + translation_placeholders=placeholders or None, ) self._issues[issue.uuid] = issue diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 0e8122c08b995d..ff32e2cbab9768 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -16,8 +16,10 @@ from . import get_addons_info, get_issues_info from .const import ( + EXTRA_PLACEHOLDERS, ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + ISSUE_KEY_ADDON_PWNED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_COMPONENTS, @@ -26,11 +28,6 @@ from .handler import get_supervisor_client from .issues import Issue, Suggestion -HELP_URLS = { - "help_url": "https://www.home-assistant.io/help/", - "community_url": "https://community.home-assistant.io/", -} - SUGGESTION_CONFIRMATION_REQUIRED = { "addon_execute_remove", "system_adopt_data_disk", @@ -38,14 +35,6 @@ } -EXTRA_PLACEHOLDERS = { - "issue_mount_mount_failed": { - "storage_url": "/config/storage", - }, - ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS, -} - - class SupervisorIssueRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -219,6 +208,7 @@ async def async_create_fix_flow( if issue and issue.key in { ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_KEY_ADDON_PWNED, }: return AddonIssueRepairFlow(hass, issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 393fe480057385..af11a663aaa69b 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -52,6 +52,10 @@ } } }, + "issue_addon_pwned": { + "title": "Insecure secrets detected in add-on configuration", + "description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue." + }, "issue_mount_mount_failed": { "title": "Network storage device failed", "fix_flow": { @@ -119,6 +123,10 @@ "title": "Disk lifetime exceeding 90%", "description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data." }, + "issue_system_free_space": { + "title": "Data disk is running low on free space", + "description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. See [clear up storage]({more_info_free_space}) for tips on how to free up space." + }, "unhealthy": { "title": "Unhealthy system - {reason}", "description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more." diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a71ee370b32314..476062ab6afd93 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -108,6 +108,7 @@ def all_setup_requests( "chassis": "vm", "operating_system": "Debian GNU/Linux 10 (buster)", "kernel": "4.19.0-6-amd64", + "disk_free": 1.6, }, }, }, diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index ddcbe5708c66fb..20473ff4041639 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -950,3 +950,157 @@ async def test_supervisor_issues_disk_lifetime( fixable=False, placeholders=None, ) + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_free_space( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for too little free space remaining.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "free_space", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="system", + type_="free_space", + fixable=False, + placeholders={ + "more_info_free_space": "https://www.home-assistant.io/more-info/free-space", + "free_space": "1.6", + }, + ) + + +async def test_supervisor_issues_free_space_host_info_fail( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for too little free space remaining without host info.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "free_space", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="system", + type_="free_space", + fixable=False, + placeholders={ + "more_info_free_space": "https://www.home-assistant.io/more-info/free-space", + "free_space": "<2", + }, + ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_addon_pwned( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for pwned secret in an addon.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "pwned", + "context": "addon", + "reference": "test", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="addon", + type_="pwned", + fixable=False, + placeholders={ + "reference": "test", + "addon": "test", + "addon_url": "/hassio/addon/test", + "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", + }, + )