Add view to download support package to Cloud component (#135856)

This commit is contained in:
Abílio Costa 2025-02-04 16:52:40 +00:00 committed by GitHub
parent 2f5816c5b6
commit 9a9374bf45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 258 additions and 13 deletions

View File

@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.system_health import get_info as get_system_health_info
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
hass.http.register_view(CloudRegisterView)
hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView)
hass.http.register_view(DownloadSupportPackageView)
_CLOUD_ERRORS.update(
{
@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
return self.json_message("ok")
class DownloadSupportPackageView(HomeAssistantView):
"""Download support package view."""
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
def _generate_markdown(
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
return "No information available\n"
markdown = ""
first = True
for key, value in domain_info.items():
markdown += f"{key} | {value}\n"
if first:
markdown += "--- | ---\n"
first = False
return markdown + "\n"
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
f"<details><summary>{domain}</summary>\n\n"
f"{domain_info_md}"
"</details>\n\n"
)
return markdown
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""
hass = request.app[KEY_HASS]
domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {})
markdown = self._generate_markdown(hass_info, domain_health)
return web.Response(
body=markdown,
content_type="text/markdown",
headers={
"Content-Disposition": 'attachment; filename="support_package.md"'
},
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
@websocket_api.async_response

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from collections.abc import AsyncGenerator, Awaitable, Callable
import dataclasses
from datetime import datetime
import logging
@ -101,6 +101,57 @@ async def get_integration_info(
return result
async def _registered_domain_data(
hass: HomeAssistant,
) -> AsyncGenerator[tuple[str, dict[str, Any]]]:
registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN]
for domain, domain_data in zip(
registrations,
await asyncio.gather(
*(
get_integration_info(hass, registration)
for registration in registrations.values()
)
),
strict=False,
):
yield domain, domain_data
async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]:
"""Get the full set of system health information."""
domains: dict[str, dict[str, Any]] = {}
async def _get_info_value(value: Any) -> Any:
if not asyncio.iscoroutine(value):
return value
try:
return await value
except Exception as exception:
_LOGGER.exception("Error fetching system info for %s - %s", domain, key)
return f"Exception: {exception}"
async for domain, domain_data in _registered_domain_data(hass):
domain_info: dict[str, Any] = {}
for key, value in domain_data["info"].items():
info_value = await _get_info_value(value)
if isinstance(info_value, datetime):
domain_info[key] = info_value.isoformat()
elif (
isinstance(info_value, dict)
and "type" in info_value
and info_value["type"] == "failed"
):
domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}"
else:
domain_info[key] = info_value
domains[domain] = domain_info
return domains
@callback
def _format_value(val: Any) -> Any:
"""Format a system health value."""
@ -115,20 +166,10 @@ async def handle_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle an info request via a subscription."""
registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN]
data = {}
pending_info: dict[tuple[str, str], asyncio.Task] = {}
for domain, domain_data in zip(
registrations,
await asyncio.gather(
*(
get_integration_info(hass, registration)
for registration in registrations.values()
)
),
strict=False,
):
async for domain, domain_data in _registered_domain_data(hass):
for key, value in domain_data["info"].items():
if asyncio.iscoroutine(value):
value = asyncio.create_task(value)

View File

@ -0,0 +1,49 @@
# serializer version: 1
# name: test_download_support_package
'''
## System Information
version | core-2025.2.0
--- | ---
installation_type | Home Assistant Core
dev | False
hassio | False
docker | False
user | hass
virtualenv | False
python_version | 3.13.1
os_name | Linux
os_version | 6.12.9
arch | x86_64
timezone | US/Pacific
config_dir | config
<details><summary>mock_no_info_integration</summary>
No information available
</details>
<details><summary>cloud</summary>
logged_in | True
--- | ---
subscription_expiration | 2025-01-17T11:19:31+00:00
relayer_connected | True
relayer_region | xx-earth-616
remote_enabled | True
remote_connected | False
alexa_enabled | True
google_enabled | False
cloud_ice_servers_enabled | True
remote_server | us-west-1
certificate_status | CertificateStatus.READY
instance_id | 12345678901234567890
can_reach_cert_server | Exception: Unexpected exception
can_reach_cloud_auth | Failed: unreachable
can_reach_cloud | ok
</details>
'''
# ---

View File

@ -1,10 +1,11 @@
"""Tests for the HTTP API for the cloud component."""
from collections.abc import Callable, Coroutine
from copy import deepcopy
from http import HTTPStatus
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
import aiohttp
from hass_nabucasa import thingtalk
@ -15,9 +16,12 @@ from hass_nabucasa.auth import (
UnknownError,
)
from hass_nabucasa.const import STATE_CONNECTED
from hass_nabucasa.remote import CertificateStatus
from hass_nabucasa.voice import TTS_VOICES
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import system_health
from homeassistant.components.alexa import errors as alexa_errors
# pylint: disable-next=hass-component-root-import
@ -30,8 +34,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.location import LocationInfo
from tests.common import mock_platform
from tests.components.google_assistant import MockConfig
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@ -113,6 +119,7 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None:
"user_pool_id": "user_pool_id",
"region": "region",
"relayer_server": "relayer",
"acme_server": "cert-server",
"accounts_server": "api-test.hass.io",
"google_actions": {"filter": {"include_domains": "light"}},
"alexa": {
@ -1860,3 +1867,96 @@ async def test_logout_view_dispatch_event(
assert async_dispatcher_send_mock.call_count == 1
assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event"
assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"}
async def test_download_support_package(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
snapshot: SnapshotAssertion,
) -> None:
"""Test downloading a support package file."""
aioclient_mock.get("https://cloud.bla.com/status", text="")
aioclient_mock.get(
"https://cert-server/directory", exc=Exception("Unexpected exception")
)
aioclient_mock.get(
"https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
exc=aiohttp.ClientError,
)
def async_register_mock_platform(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
return {}
register.async_register_info(mock_empty_info, "/config/mock_integration")
mock_platform(
hass,
"mock_no_info_integration.system_health",
MagicMock(async_register=async_register_mock_platform),
)
hass.config.components.add("mock_no_info_integration")
assert await async_setup_component(hass, "system_health", {})
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
hexmock.return_value = "12345678901234567890"
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
"user_pool_id": "AAAA",
"region": "us-east-1",
"acme_server": "cert-server",
"relayer_server": "cloud.bla.com",
},
},
)
await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
cloud.remote.snitun_server = "us-west-1"
cloud.remote.certificate_status = CertificateStatus.READY
cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
await cloud.client.async_system_message({"region": "xx-earth-616"})
await set_cloud_prefs(
{
"alexa_enabled": True,
"google_enabled": False,
"remote_enabled": True,
"cloud_ice_servers_enabled": True,
}
)
cloud_client = await hass_client()
with (
patch.object(hass.config, "config_dir", new="config"),
patch(
"homeassistant.components.homeassistant.system_health.system_info.async_get_system_info",
return_value={
"installation_type": "Home Assistant Core",
"version": "2025.2.0",
"dev": False,
"hassio": False,
"virtualenv": False,
"python_version": "3.13.1",
"docker": False,
"arch": "x86_64",
"timezone": "US/Pacific",
"os_name": "Linux",
"os_version": "6.12.9",
"user": "hass",
},
),
):
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
assert await req.text() == snapshot