|
5 | 5 | import asyncio |
6 | 6 | import json |
7 | 7 | import logging |
| 8 | +import os |
8 | 9 | import ssl |
9 | 10 | from typing import Any |
10 | 11 |
|
11 | 12 | import httpx |
12 | 13 |
|
| 14 | +from .._version import is_running_in_addon |
13 | 15 | from ..config import get_global_settings |
14 | 16 |
|
15 | 17 |
|
@@ -434,28 +436,184 @@ async def get_error_log(self) -> str: |
434 | 436 | return response if isinstance(response, str) else str(response) |
435 | 437 |
|
436 | 438 | async def get_addon_logs(self, slug: str) -> str: |
437 | | - """Fetch an add-on's container logs via HA Core's Supervisor REST proxy. |
| 439 | + """Fetch an add-on's container logs. |
438 | 440 |
|
439 | | - Uses `/api/hassio/addons/{slug}/logs`, which HA Core proxies to |
440 | | - Supervisor and returns as `text/plain`. This avoids the |
441 | | - `supervisor/api` websocket path that tries to JSON-decode the text |
442 | | - body and always fails (see #950). |
| 441 | + Branch on ``is_running_in_addon()`` (which keys off ``SUPERVISOR_TOKEN`` |
| 442 | + in env): inside the add-on container goes directly to the Supervisor |
| 443 | + REST API at ``http://supervisor/addons/{slug}/logs`` with the |
| 444 | + Supervisor token. The HA Core proxy at |
| 445 | + ``/api/hassio/addons/{slug}/logs`` rejects this token+path combination |
| 446 | + on current HA Core releases (see #1116) — the direct path bypasses |
| 447 | + HA Core entirely and is the documented Supervisor contract. |
| 448 | +
|
| 449 | + On non-addon installs (Docker, pyinstaller, pip pointing at a normal |
| 450 | + HA URL), falls back to the HA Core proxy path. That path requires an |
| 451 | + admin LLA but works fine when not invoked from the add-on container. |
| 452 | +
|
| 453 | + Both branches return ``text/plain`` log content. |
443 | 454 |
|
444 | 455 | Raises: |
445 | | - HomeAssistantAuthError: 401 from HA Core. |
446 | | - HomeAssistantAPIError: Non-2xx response (e.g. 404 unknown slug, |
447 | | - 400 addon not installed). `status_code` is set so callers |
448 | | - can map to specific suggestions. |
| 456 | + HomeAssistantAuthError: 401 response, or ``SUPERVISOR_TOKEN`` empty |
| 457 | + at call time on the addon branch. |
| 458 | + HomeAssistantAPIError: 403 (role too low — addon needs hassio_role |
| 459 | + ``manager``), 404 (unknown slug), or other non-2xx. The |
| 460 | + ``status_code`` attribute lets callers map to specific |
| 461 | + suggestions. |
449 | 462 | HomeAssistantConnectionError: Network, timeout, or transport error. |
450 | 463 | """ |
451 | | - logger.debug(f"Fetching addon logs for slug={slug}") |
| 464 | + if is_running_in_addon(): |
| 465 | + return await self._get_addon_logs_via_supervisor(slug) |
| 466 | + |
| 467 | + logger.debug(f"Fetching addon logs for slug={slug} via HA Core proxy") |
452 | 468 | response = await self._raw_request( |
453 | 469 | "GET", |
454 | 470 | f"/hassio/addons/{slug}/logs", |
455 | 471 | headers={"Accept": "text/plain"}, |
456 | 472 | ) |
457 | 473 | return response.text |
458 | 474 |
|
| 475 | + async def _supervisor_logs_get(self, path: str) -> str: |
| 476 | + """Fetch ``text/plain`` logs from a Supervisor REST endpoint. |
| 477 | +
|
| 478 | + ``path`` is everything between ``http://supervisor/`` and ``/logs``: |
| 479 | +
|
| 480 | + - ``"addons/<slug>"`` for add-on container logs |
| 481 | + - ``"<service>"`` (where service ∈ {supervisor, host, core, dns, audio, |
| 482 | + multicast, observer}) for system-service logs |
| 483 | +
|
| 484 | + Bypasses ``HomeAssistantClient.httpx_client`` because the Supervisor |
| 485 | + endpoint uses a different base URL (``http://supervisor``) and a |
| 486 | + different token (``SUPERVISOR_TOKEN``) than HA Core REST. Both |
| 487 | + endpoints require the addon's ``hassio_role`` to be ``manager`` (not |
| 488 | + ``default``); a ``default`` role gets a 403 here — see #1116. |
| 489 | +
|
| 490 | + Raises: |
| 491 | + HomeAssistantAuthError: ``SUPERVISOR_TOKEN`` absent at call time, |
| 492 | + or 401 from Supervisor. |
| 493 | + HomeAssistantAPIError: 403 (role too low — distinct branch with |
| 494 | + role hint), 404, other 4xx/5xx. Tries to parse Supervisor's |
| 495 | + ``{"result":"error","message":"..."}`` JSON envelope before |
| 496 | + falling back to text body / reason phrase / placeholder. |
| 497 | + HomeAssistantConnectionError: Timeout or transport error, with |
| 498 | + distinct messages so callers can tell them apart. |
| 499 | + """ |
| 500 | + token = os.environ.get("SUPERVISOR_TOKEN", "") |
| 501 | + if not token: |
| 502 | + # The is_running_in_addon() gate already keys off SUPERVISOR_TOKEN |
| 503 | + # being truthy, so a direct caller landing here without one is a |
| 504 | + # detection/config mismatch — fail-fast with a distinct message |
| 505 | + # so operators don't read it as "token rejected". |
| 506 | + raise HomeAssistantAuthError( |
| 507 | + f"Supervisor token absent at call time for /{path}/logs " |
| 508 | + "(addon-mode gate fired but SUPERVISOR_TOKEN env var not set)" |
| 509 | + ) |
| 510 | + |
| 511 | + url = f"http://supervisor/{path}/logs" |
| 512 | + logger.debug("Fetching %s via Supervisor direct", url) |
| 513 | + |
| 514 | + try: |
| 515 | + async with httpx.AsyncClient( |
| 516 | + timeout=httpx.Timeout(self.timeout), |
| 517 | + # `verify` is a no-op for plain http://supervisor, but kept |
| 518 | + # for symmetry with the other two direct-Supervisor httpx |
| 519 | + # clients (#1128 establishes the 3-site convention). |
| 520 | + verify=self.verify_ssl, |
| 521 | + ) as client: |
| 522 | + response = await client.get( |
| 523 | + url, |
| 524 | + headers={ |
| 525 | + "Authorization": f"Bearer {token}", |
| 526 | + "Accept": "text/plain", |
| 527 | + }, |
| 528 | + ) |
| 529 | + except httpx.TimeoutException as e: |
| 530 | + raise HomeAssistantConnectionError( |
| 531 | + f"Timeout fetching /{path}/logs from Supervisor: {e}" |
| 532 | + ) from e |
| 533 | + except httpx.HTTPError as e: |
| 534 | + raise HomeAssistantConnectionError( |
| 535 | + f"Transport error fetching /{path}/logs from Supervisor: {e}" |
| 536 | + ) from e |
| 537 | + |
| 538 | + if response.status_code == 401: |
| 539 | + raise HomeAssistantAuthError( |
| 540 | + f"Invalid Supervisor token for /{path}/logs" |
| 541 | + ) |
| 542 | + if response.status_code == 403: |
| 543 | + # Distinct from 401: token is valid but addon's hassio_role isn't |
| 544 | + # high enough. Most-likely cause for this exact endpoint at the |
| 545 | + # time #1116 surfaced (default → manager bump in addon config.yaml |
| 546 | + # is the same-PR companion fix). |
| 547 | + logger.warning( |
| 548 | + "Supervisor returned 403 for /%s/logs — addon hassio_role may " |
| 549 | + "be too low (need 'manager')", |
| 550 | + path, |
| 551 | + ) |
| 552 | + raise HomeAssistantAPIError( |
| 553 | + f"Supervisor forbids /{path}/logs (403) — addon's hassio_role " |
| 554 | + "may be 'default'; need 'manager' or higher", |
| 555 | + status_code=403, |
| 556 | + response_data={"path": path}, |
| 557 | + ) |
| 558 | + if response.status_code >= 400: |
| 559 | + text_body = response.text |
| 560 | + # Supervisor returns {"result":"error","message":"..."} JSON on |
| 561 | + # some 4xx paths. Try parsing that first so the user sees the |
| 562 | + # human message instead of a JSON blob; then fall back to the |
| 563 | + # text body, then reason_phrase, then a placeholder. |
| 564 | + message = "" |
| 565 | + try: |
| 566 | + envelope = json.loads(text_body) if text_body else None |
| 567 | + if isinstance(envelope, dict): |
| 568 | + msg = envelope.get("message") |
| 569 | + if isinstance(msg, str) and msg: |
| 570 | + message = msg |
| 571 | + except json.JSONDecodeError: |
| 572 | + pass |
| 573 | + if not message: |
| 574 | + message = ( |
| 575 | + text_body.strip() or response.reason_phrase or "<empty body>" |
| 576 | + ) |
| 577 | + logger.warning( |
| 578 | + "Supervisor returned %s for /%s/logs: %s", |
| 579 | + response.status_code, path, message, |
| 580 | + ) |
| 581 | + raise HomeAssistantAPIError( |
| 582 | + f"API error: {response.status_code} - {message}", |
| 583 | + status_code=response.status_code, |
| 584 | + response_data={"message": text_body, "path": path}, |
| 585 | + ) |
| 586 | + return response.text |
| 587 | + |
| 588 | + async def _get_addon_logs_via_supervisor(self, slug: str) -> str: |
| 589 | + """Fetch add-on container logs directly from Supervisor's REST API. |
| 590 | +
|
| 591 | + Distinct from ``tools_bug_report._fetch_addon_logs``: that helper is |
| 592 | + hardcoded to ``/addons/self/logs`` and silently swallows failures |
| 593 | + (it's an aux-data fetch for bug reports, fine to skip on error). This |
| 594 | + helper takes arbitrary slugs and surfaces failures as exceptions |
| 595 | + because callers (``ha_get_logs(source="supervisor", slug=...)``) need |
| 596 | + them. Both endpoints require ``hassio_role: manager``. |
| 597 | +
|
| 598 | + Delegates to ``_supervisor_logs_get`` so error handling stays in |
| 599 | + lockstep with ``_get_system_service_logs``. |
| 600 | + """ |
| 601 | + return await self._supervisor_logs_get(f"addons/{slug}") |
| 602 | + |
| 603 | + async def _get_system_service_logs(self, service: str) -> str: |
| 604 | + """Fetch HA system-service logs directly from Supervisor's REST API. |
| 605 | +
|
| 606 | + Hits ``http://supervisor/{service}/logs``. ``service`` must be one of |
| 607 | + the seven Supervisor-managed services: ``supervisor``, ``host``, |
| 608 | + ``core``, ``dns``, ``audio``, ``multicast``, ``observer``. Caller is |
| 609 | + responsible for validating ``service`` against the allowed set; this |
| 610 | + helper does no validation and will raise ``HomeAssistantAPIError`` on |
| 611 | + any unknown path (404 from Supervisor). |
| 612 | +
|
| 613 | + Requires ``hassio_role: manager`` like the addon-logs path. |
| 614 | + """ |
| 615 | + return await self._supervisor_logs_get(service) |
| 616 | + |
459 | 617 | async def test_connection(self) -> tuple[bool, str | None]: |
460 | 618 | """ |
461 | 619 | Test connection to Home Assistant. |
|
0 commit comments