|
19 | 19 | from typing import TYPE_CHECKING |
20 | 20 | from urllib.parse import urlparse |
21 | 21 |
|
| 22 | +from aiohttp import ClientError |
22 | 23 | from awesomeversion import AwesomeVersion, AwesomeVersionException |
23 | 24 | from homeassistant.components import persistent_notification |
24 | 25 | from homeassistant.core import HomeAssistant |
25 | 26 | from homeassistant.helpers import issue_registry as ir |
| 27 | +from homeassistant.helpers.aiohttp_client import async_get_clientsession |
26 | 28 | from homeassistant.loader import async_get_integration |
27 | 29 |
|
28 | 30 | from .const import ( |
29 | 31 | BIND_HOST_ALL, |
30 | 32 | CHANNEL_DEV, |
| 33 | + COMPONENT_MANIFEST_AT_TAG_URL, |
31 | 34 | DATA_BRINGUP_TASK, |
32 | 35 | DATA_MANAGER, |
33 | 36 | DATA_PENDING_UPDATE_NOTIFY, |
|
43 | 46 | ISSUE_COMPONENT_OUTDATED, |
44 | 47 | ISSUE_PACKAGE_FAILED, |
45 | 48 | ISSUE_START_FAILED, |
| 49 | + ISSUE_UPDATE_HELD, |
46 | 50 | OPT_AUTO_UPDATE, |
47 | 51 | OPT_BIND_HOST, |
48 | 52 | OPT_ENABLE_WEBHOOK, |
|
65 | 69 |
|
66 | 70 | _NOTIFICATION_ID = "ha_mcp_tools_server_connect" |
67 | 71 | _UPDATE_NOTIFICATION_ID = "ha_mcp_tools_server_updated" |
68 | | -_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED) |
| 72 | +# ISSUE_UPDATE_HELD is cleared at bring-up start too: any reload that reaches |
| 73 | +# bring-up either bypassed the hold deliberately (the update entity's Install |
| 74 | +# button) or made it moot; if the hold still applies, the coordinator refresh |
| 75 | +# that follows setup re-files it within moments. |
| 76 | +_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD) |
| 77 | + |
| 78 | +# Per-request timeout for the component-manifest fetch behind the auto-update |
| 79 | +# gate — mirrors the coordinator's PyPI fetch budget; a miss fails open. |
| 80 | +_MANIFEST_FETCH_TIMEOUT_SECONDS = 30 |
69 | 81 |
|
70 | 82 |
|
71 | 83 | async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None: |
@@ -326,6 +338,16 @@ async def async_maybe_auto_update( |
326 | 338 | coordinator's ``data`` type before its first successful refresh), or a |
327 | 339 | bring-up is still in flight (below). |
328 | 340 |
|
| 341 | + A pending update is additionally gated on component compatibility |
| 342 | + (issues #1783/#1785): when the candidate release also shipped a newer |
| 343 | + custom component than the one running, the reload is HELD — loudly (a |
| 344 | + repair issue plus a warning log every check) and escapably (applying the |
| 345 | + HACS component update — which takes an HA restart, as the issue text |
| 346 | + says — unblocks the next check; the update entity's Install button never |
| 347 | + passes through here, so manual installs — like pip-spec overrides above — |
| 348 | + bypass the hold entirely). Every failure inside the gate fails OPEN so a |
| 349 | + GitHub hiccup can never wedge updates. |
| 350 | +
|
329 | 351 | Best-effort: an incomparable version string (AwesomeVersionException) is |
330 | 352 | logged at debug and skipped; the next refresh retries. Genuine bugs |
331 | 353 | propagate per the repo's no-silent-failure convention. |
@@ -363,7 +385,39 @@ async def async_maybe_auto_update( |
363 | 385 | return |
364 | 386 |
|
365 | 387 | if not newer: |
| 388 | + # Up to date: a hold that was pending is resolved (the component |
| 389 | + # update landed and the unblocked reload installed the server). |
| 390 | + ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD) |
| 391 | + return |
| 392 | + |
| 393 | + held = await _async_update_held_by_component(hass, info) |
| 394 | + if held is not None: |
| 395 | + shipped, running = held |
| 396 | + _LOGGER.warning( |
| 397 | + "HA-MCP server %s is available, but that release also updated the " |
| 398 | + "custom component (%s; running %s); holding the automatic server " |
| 399 | + "update until the component is updated via HACS. Press Install on " |
| 400 | + "the HA-MCP server update entity to install anyway.", |
| 401 | + info.latest, |
| 402 | + shipped, |
| 403 | + running, |
| 404 | + ) |
| 405 | + ir.async_create_issue( |
| 406 | + hass, |
| 407 | + DOMAIN, |
| 408 | + ISSUE_UPDATE_HELD, |
| 409 | + is_fixable=False, |
| 410 | + severity=ir.IssueSeverity.WARNING, |
| 411 | + translation_key=ISSUE_UPDATE_HELD, |
| 412 | + translation_placeholders={ |
| 413 | + "latest": str(info.latest), |
| 414 | + "shipped": shipped, |
| 415 | + "running": running, |
| 416 | + }, |
| 417 | + learn_more_url=HACS_COMPONENT_URL, |
| 418 | + ) |
366 | 419 | return |
| 420 | + ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD) |
367 | 421 |
|
368 | 422 | channel = channel_for_dist(info.dist) |
369 | 423 | _LOGGER.info( |
@@ -403,6 +457,79 @@ def _drop_pending_update_notify(hass: HomeAssistant) -> None: |
403 | 457 | hass.data.get(DOMAIN, {}).pop(DATA_PENDING_UPDATE_NOTIFY, None) |
404 | 458 |
|
405 | 459 |
|
| 460 | +async def _async_update_held_by_component( |
| 461 | + hass: HomeAssistant, info: ServerVersionInfo |
| 462 | +) -> tuple[str, str] | None: |
| 463 | + """Return ``(shipped, running)`` when the pending update must be held. |
| 464 | +
|
| 465 | + The #1783/#1785 breakage: a server release whose repo state also bumped the |
| 466 | + custom component auto-installed under the OLD component before HACS had |
| 467 | + even surfaced the component update. The component version in the manifest |
| 468 | + at the candidate release's git tag is what shipped with that server build — |
| 469 | + newer than the running component means the release changed the component |
| 470 | + too, so the automatic server install waits for the component. |
| 471 | +
|
| 472 | + Fails OPEN (returns None → install proceeds, the pre-gate behavior) on |
| 473 | + every expected failure: manifest unreachable, component version unreadable, |
| 474 | + incomparable versions. Blocking updates indefinitely on a transient would |
| 475 | + be worse than the crash this guards against — and the crash itself is now |
| 476 | + also survivable server-side (the tools registry skips a failing module). |
| 477 | + """ |
| 478 | + shipped = await _async_fetch_shipped_component_version(hass, str(info.latest)) |
| 479 | + if shipped is None: |
| 480 | + return None |
| 481 | + |
| 482 | + try: |
| 483 | + integration = await async_get_integration(hass, DOMAIN) |
| 484 | + running = str(integration.version) |
| 485 | + except Exception: |
| 486 | + # Same wide loader surface as _async_check_component_compat: advisory |
| 487 | + # gate, logged visibly rather than swallowed silently. |
| 488 | + _LOGGER.warning( |
| 489 | + "Could not read the HA-MCP component version for the auto-update " |
| 490 | + "gate; proceeding with the update", |
| 491 | + exc_info=True, |
| 492 | + ) |
| 493 | + return None |
| 494 | + |
| 495 | + try: |
| 496 | + if AwesomeVersion(running) < AwesomeVersion(shipped): |
| 497 | + return shipped, running |
| 498 | + except AwesomeVersionException as err: |
| 499 | + # Incomparable version strategies only; real bugs propagate. |
| 500 | + _LOGGER.debug("HA-MCP auto-update gate version compare failed: %s", err) |
| 501 | + return None |
| 502 | + |
| 503 | + |
| 504 | +async def _async_fetch_shipped_component_version( |
| 505 | + hass: HomeAssistant, server_version: str |
| 506 | +) -> str | None: |
| 507 | + """Return the component version shipped at server release ``vX.Y.Z``. |
| 508 | +
|
| 509 | + Reads the component manifest as committed at the release's git tag (raw |
| 510 | + GitHub URL). Stable tags exist before the PyPI publish; a dev tag only |
| 511 | + appears after its binary builds finish, so a fresh dev version can 404 |
| 512 | + here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None |
| 513 | + on any failure; the caller treats that as "nothing to hold on" |
| 514 | + (fail-open). |
| 515 | + """ |
| 516 | + url = COMPONENT_MANIFEST_AT_TAG_URL.format(version=server_version) |
| 517 | + try: |
| 518 | + session = async_get_clientsession(hass) |
| 519 | + async with asyncio.timeout(_MANIFEST_FETCH_TIMEOUT_SECONDS): |
| 520 | + async with session.get(url) as resp: |
| 521 | + resp.raise_for_status() |
| 522 | + # content_type=None: raw.githubusercontent.com serves |
| 523 | + # text/plain, which aiohttp's default json() rejects. |
| 524 | + payload = await resp.json(content_type=None) |
| 525 | + return str(payload["version"]) |
| 526 | + except (ClientError, TimeoutError, KeyError, TypeError, ValueError) as err: |
| 527 | + _LOGGER.debug( |
| 528 | + "HA-MCP shipped-component manifest fetch failed for %s: %s", url, err |
| 529 | + ) |
| 530 | + return None |
| 531 | + |
| 532 | + |
406 | 533 | async def _async_finish_update_cycle(hass: HomeAssistant) -> None: |
407 | 534 | """Refresh the version entity and fire the deferred update notification. |
408 | 535 |
|
|
0 commit comments