Commit Graph
886 Commits
Author SHA1 Message Date
Stefan AgnerandClaude Opus 4.8 dd2bd88d1e Queue Home Assistant Core restart/stop behind a running start
The home_assistant_core job group used GROUP_REJECT for start, stop and
restart, so a restart (or stop) requested while Core is still starting was
rejected outright with "Another job is running for job group
home_assistant_core".

This breaks legitimate early restarts. For example, when Core applies a
reverted HTTP config it restarts itself during startup, which Supervisor
refused. Switch stop and restart to GROUP_QUEUE so such requests wait for
the in-progress start to finish and then run, instead of failing. start
keeps GROUP_REJECT; reentrant same-group calls (e.g. rebuild -> start) are
unaffected as the job group already allows them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:41:36 +02:00
f49b81840e Add app-based mapping options for app configs (#6992)
* Rename addon map options to app equivalents

* Add tests for apps/addons default mount targets

* Fixes from feedback

* Fix tests and clean up validation logic a bit

* Update supervisor/apps/validate.py

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Stefan Agner <stefan@agner.ch>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-08 12:54:34 +02:00
3f58d33d9f Check OSError for known filesystem issues on cleanup unlinks (#7007)
Follow-up to review feedback on #7003 and #7004: when removing the
hosts file in CoreDNS.reset() or cleaning up a backup tarfile in
BackupManager.import_backup(), pass any OSError to
sys_resolution.check_oserror() so known filesystem issues (e.g.
EBADMSG) mark the system unhealthy early. For the backup paths the
check only applies to local locations (default and cloud backup),
matching the existing handling in remove() and _copy_to_location().

This also covers the second unlink in import_backup() (cleanup after
a failed load) and stops OSError from bubbling out of both cleanup
paths unhandled to the job: the error is now logged, and the method
still returns None or raises the original BackupInvalidError.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:30:43 +02:00
312e0c0e78 Only report systemd unit failures to Sentry on Home Assistant OS (#7009)
* Only run systemd unit failure check on Home Assistant OS

The systemd unit failure check added in #6976 creates a repair issue and
reports to Sentry for any failed unit on the host. On supervised
installations the first week of the 2026.07.0 rollout surfaced failed
units like gdm.service, tomcat8.service or isc-dhcp-server.service which
are unrelated to Home Assistant and not actionable for us. Skip the
check entirely when not running on Home Assistant OS, like the multiple
data disks check already does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep creating issues on supervised, only skip the Sentry report

Per review, failed host units can still be useful information for the
admin of a supervised system, so keep creating resolution issues there.
Only the Sentry report is limited to Home Assistant OS, there is nothing
we can do about units of a supervised installation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:27:02 +02:00
d08a9ee01d Ignore NetworkManager-wait-online.service unit failures (#7008)
The systemd unit failure check added in #6976 creates a repair issue and
reports to Sentry for any failed unit. NetworkManager-wait-online.service
is a oneshot unit which ends up in failed state whenever the network is
not up in time at boot. Within the first week of the 2026.07.0 rollout
this unit alone is one of the most frequent Sentry reports of this check
(72 users). Home Assistant works offline, so the failure is transient
and not actionable; skip the unit like the firewall service.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:13:11 +02:00
83d15aadcc Track installed OS update pending reboot activation (#7006)
* Track installed OS update pending reboot activation

Since #6982 the OS update no longer reboots the host automatically, so the
system keeps running the old version until the user reboots. In that window
the update kept being offered: need_update compares the running OS version,
which only changes on reboot. Requesting the same update again re-downloaded
and reinstalled the full image. Worse, a Supervisor restart in that window
dropped the in-memory REBOOT_REQUIRED issue and canceled the pending update
altogether, because mark_healthy marks the booted slot active on startup,
reverting the primary boot slot set by the rauc install.

Track the installed version awaiting a reboot as version_pending and expose
it in /os/info. A successful install sets it, updating again to that version
is rejected with a hint to reboot, and need_update no longer reports true for
an update that is already installed. On load, the pending state is recovered
from rauc by comparing the primary boot slot (via a new GetPrimary D-Bus
wrapper) with the booted slot, re-creating the REBOOT_REQUIRED issue as well.
mark_healthy now skips marking the booted slot active while an update is
pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split pending update detection condition

Fix pylint R0916 (too-many-boolean-expressions) by splitting the guard in
_detect_pending_update into a slot data validity check and the actual
pending update check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Report pending OS update as current version in /os/info

The Core update entity derives update availability by comparing version
with version_latest from /os/info, so it keeps offering an update that is
already installed until the system is rebooted. Report an installed update
pending activation as the current version so existing Core releases
reflect update availability correctly. Once Core consumes version_pending,
this can be limited to Core versions predating that support.

The hassos field of the root /info endpoint keeps reporting the running
version, Core only uses it as a HAOS presence check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:45:18 +02:00
5bc795d2f2 Migrate simple attrs classes to stdlib dataclasses (#7005)
* Migrate simple attrs classes to stdlib dataclasses

Several plain data-holder classes still used the attrs library while
newer code in the project uses stdlib dataclasses. Convert
EventListener, Issue, Suggestion, HealthChanged, SupportedChanged,
Device, Message, HostEntry, ServiceInfo, WhoamiData and the scheduler
_Task to @dataclass, and switch the attr.evolve/attr.asdict call sites
to dataclasses.replace/asdict.

Field semantics are preserved: eq=False maps to compare=False,
hash=False and default factories map directly, and the frozen/slots
flags are kept, so equality, hashing and copy behavior are unchanged.
The jobs module keeps using attrs for its validators and setter hooks,
which have no stdlib dataclass equivalent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add slots to remaining dataclasses, freeze HostEntry

Address review feedback: ServiceInfo, Message and HostEntry kept the
flags of their attrs originals, which did not use slots. Enable slots
on all three. HostEntry instances are never mutated after creation, so
it can also be frozen. Message cannot be frozen because Discovery.send
updates the config field of an existing message when an app re-sends
a discovery message with changed configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:38:31 +02:00
François MartinandGitHub 6ffd8d3e93 Fix backup delete error message (#7000) 2026-07-06 14:44:38 +02:00
Mike DegatanoandGitHub 35542d5bcc Replace addon with app in docker container names for apps (#6997)
* Support dual app/addon log identifiers during migration

* Use single journald epoch query for dual app identifiers

* Add API-keyed container epoch error handling

* Template CONTAINER_LOG_EPOCH via extra fields
2026-07-03 15:44:22 +02:00
4452eb9eca Don't roll back Core update when frontend probe can't connect over SSL (#6990)
* Don't roll back Core update when frontend probe can't connect over SSL

The post-update frontend probe added in #6811 connects to Core's
internal address as a plain external HTTP/WebSocket client. When Core is
configured with `ssl_peer_certificate` it requires mutual TLS, so it
resets the unauthenticated probe connection during the handshake. The
probe reported this as a failure and triggered a rollback even though
the update succeeded and the frontend was reachable through the user's
authenticated reverse proxy.

Supervisor only knows whether Core uses SSL, not whether a peer
certificate is required, so it can't tell mutual TLS apart from plain
HTTPS up front. Instead, distinguish how a probe fails: a bad response
(non-200, non-HTML, or a missing WebSocket auth_required frame) means
the endpoint is reachable but genuinely broken and still fails the
update; an inability to connect at all only triggers a rollback when
Core is not using SSL. When the probes can't connect while SSL is on, we
can't rule out mutual TLS rejecting us, so we fall back to a plain TCP
reachability check and otherwise rely on the component check.

This keeps the full HTML and WebSocket verification for plain HTTPS
setups while no longer rolling back updates for mutual-TLS deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Roll back on frontend probe timeout instead of tolerating it under SSL

Mutual TLS (`ssl_peer_certificate`) rejects an unauthenticated connection
within a few handshake packets, so it surfaces as an immediate connection
reset, never as a timeout. Mapping a probe timeout to NO_RESPONSE therefore
granted it the mutual-TLS tolerance (the TCP reachability fallback) it should
not get: a probe that times out reached the listener but never answered,
which points to a genuinely hung frontend.

Map connect and request timeouts to BAD_RESPONSE so they still roll back the
update, and reserve NO_RESPONSE for connection resets and disconnects, the
actual mutual-TLS signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 15:41:42 +02:00
84ad6b9446 Reject apps mapping a dynamic ingress port range port (#6989)
Dynamic ingress port selection (ingress_port: 0) picks a random port
from the 62000-65500 range and hands it to the app to listen on for
ingress. That port is reached over the internal Docker network only.

If an app also maps a container port from that range to the host, the
dynamically chosen ingress port could coincide with it. The ingress
endpoint would then be reachable directly on the host, bypassing ingress
authentication.

Reject such configs during validation instead: an app using dynamic
ingress port selection must not map a port from the dynamic ingress port
range itself. The range bounds are extracted into INGRESS_DYNAMIC_PORT_MIN
and INGRESS_DYNAMIC_PORT_MAX constants shared between the validator and
the allocator.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:42:14 +02:00
Mike DegatanoandGitHub a483d4a503 Make /os/update stop auto-rebooting after OS update (#6982)
* Make OS update require explicit reboot

* Avoid protected OS field access in API update tests
2026-06-30 14:49:24 +02:00
Mike DegatanoandGitHub a2fdcd43a4 Add unhealthy reasons for fsck failures and issues for other systemd failures (#6976)
* Add systemd ListUnitsFiltered support

* Add unhealthy reasons for fsck failures and issues for other systemd failures
2026-06-30 14:49:08 +02:00
Mike DegatanoandGitHub 5c65da8a19 Refine Docker timeout handling and add regression tests (#6970)
* Refine Docker timeout handling and add regression tests

* Add network exception path tests for Docker manager

* Add timeout-path tests for Docker app and network

* Remove unnecessary timeout=None overrides from pull calls and tests
2026-06-26 15:15:10 +02:00
Mike DegatanoandGitHub 00c48a88ea Improve port conflict detection and resolution across Core and apps (#6916)
* Finalize port conflict handling and test coverage

* Fix port conflict regressions and align resolution tests

* Simplify port conflict detection: remove app options validation and manager helper

* Refactor approve_check and process_fixup to accept typed Issue/Suggestion objects

* Remove core port conflict detection; keep app startup handling

* Remove unused code

* Auto-dismiss issue on app start and remove more dead code
2026-06-23 16:26:56 +02:00
Mike DegatanoandGitHub 098867bbe2 Handle Docker status-check failures in Home Assistant API paths (#6955)
* Handle Docker status-check failures in Home Assistant API paths

* Remove empty timeout msg from error
2026-06-23 10:14:35 -04:00
Franck NijhofandGitHub 3c9af57d26 Allow slashes in add-on repository branch names (#6957)
Add-on repositories can pin a branch with the "url#branch" syntax. The
branch part of the repository regex only accepted word characters,
hyphens and dots, so a branch with a slash like "feature/hot-new-stuff"
failed to match and the repository was rejected as invalid.

Git branch names commonly use prefixes such as "feature/" or "fix/", so
allow slashes in the captured branch name.
2026-06-19 09:45:00 +02:00
1857753e22 Redact app options in info for unprivileged apps (#6953)
The `/addons/{slug}/info` endpoint returned the target app's user options,
which can contain secrets such as passwords and API keys. The security
middleware grants every role (including the default role) access to any
`/.+/info` path, so an installed app with `hassio_api: true` and the default
role could read another app's options simply by requesting its info.

Redact the options field in info_data() unless the caller is entitled to see
it: Home Assistant Core (and other non-app internals), the app reading its
own info, or an app with the manager or admin role. Other apps reading a
different app's info now receive an empty options dict while all non-secret
metadata stays available for discovery. This mirrors the existing self-only
restriction on the dedicated /options/config endpoint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:54:57 +02:00
95bb8fe6ab Make Core.shutdown idempotent and safe to call concurrently (#6891)
* Make Core.shutdown idempotent and safe to call concurrently

After #6887, Core.shutdown() now runs in the SIGTERM path during host
shutdown in addition to the existing host.control reboot/shutdown and
backup restore paths. Multiple concurrent callers were possible (e.g.
SIGTERM arriving while a reboot API call is mid-flight), so __main__.py
debounced the signal handler by stashing the in-flight task in a single-
element list and bailing out on the second SIGTERM.

Move the idempotency into Core.shutdown() itself, where it belongs:

- A second call while shutdown is in progress awaits the in-flight
  shutdown via an asyncio.Event rather than re-running the sequence.
- Calls during STOPPING/CLOSE return early (Supervisor is already going
  away; the work is moot).
- Calls during STARTING_STATES (INITIALIZE/STARTUP/SETUP) return early
  too. There is nothing coherent to gracefully stop before startup
  completes, and on the SIGTERM-during-startup path the caller cancels
  startup_task first, so waiting for it to complete would deadlock.
- The sequence is wrapped in try/finally so the completion event is set
  even when an inner step raises.

With that in place the closure workaround in __main__.py collapses to a
plain coresys.create_task(stop_supervisor()): repeat SIGTERMs spawn
extra tasks but each just observes the in-flight shutdown and waits.

Tests cover the four state branches and confirm the event is reset
between repeated shutdown cycles (backup restore re-enters RUNNING).

* Split Core.shutdown() into teardown_services + shutdown

PR feedback (@mdegat01) flagged the "supports repeated use" comment on
_shutdown_event.clear() as describing a use case that does not exist.
Investigating, the real source of confusion is that the old shutdown()
did two different things stitched together:

  - Stop user-facing containers (add-ons + Home Assistant Core), which
    backup restore uses while leaving the host alone.
  - Run the full shutdown ceremony (state transition to SHUTDOWN, stop
    plugins), which only the SIGTERM signal handler and the host
    reboot/power-off API want.

That dispatch was implemented with an asymmetric state transition
("only set SHUTDOWN if state == RUNNING") and a plugin-shutdown gate
("only stop plugins if state in (STOPPING, SHUTDOWN)"). It worked but
made the intent of each branch hard to read, broke the reentrancy
guard on the restore path (state never reaches SHUTDOWN, so concurrent
callers fall through every early return), and forced the misleading
"repeated cycles" framing on the event handling.

Split into two methods with one job each:

  - teardown_services(): stop add-ons + Home Assistant Core. Does not
    change Core state and does not stop plugins. Backup restore calls
    this directly so HA Core's watchdog stays registered (it only
    disables on transitions into CLOSING_STATES) and plugins keep
    running for the restore body to use.
  - shutdown(): real shutdown ceremony. Unconditionally transitions to
    SHUTDOWN, calls teardown_services(), then stops plugins. The
    reentrancy guard (state == SHUTDOWN -> await event) now works
    correctly because every caller transitions state on entry. One-shot
    per process lifetime; no clear() needed.

Update backups/manager.py:867 to call teardown_services() instead.
remove_homeassistant_container moves to teardown_services() since
restore is the only caller that passes it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Release shutdown waiters when set_state() is cancelled

PR feedback from Copilot: set_state() updates Core._state synchronously
(line 84 in core.py) before awaiting _write_run_state(). If the
shutdown task is cancelled while awaiting that write, in-memory state
is already SHUTDOWN but the function exits before entering the
try/finally that sets _shutdown_event. Any concurrent or later
shutdown() caller then sees state == SHUTDOWN and blocks forever on
_shutdown_event.wait().

Move the set_state(SHUTDOWN) call inside the try so finally always
runs and releases waiters. CancelledError still propagates to the
caller after finally as expected; we just no longer leak the lock.

Add a regression test that simulates cancellation inside
_write_run_state() and asserts both that state has moved to SHUTDOWN
and that _shutdown_event is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 17:21:03 +02:00
81e235376e Fix typos repo-wide and add codespell pre-commit hook (#6949)
* Fix typos in comments, docstrings and log messages

Correct 39 spelling mistakes across comments, docstrings and log/error
message strings throughout the package (e.g. "conection" -> "connection",
"Incomming" -> "Incoming", "Rasie" -> "Raise"). All changes are confined
to human-readable text; no identifiers, attributes or D-Bus contracts are
touched, so there is no behavior change. Found with codespell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix typos in tests and CI workflow

Correct spelling mistakes in test comments, docstrings and data, plus one
in the builder workflow, so the whole tree is clean for the codespell hook
added next. The assertion in test_network_manager.py is updated to match
the corrected "Unknown error while processing" log message in the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add codespell pre-commit hook

Wire up codespell so spelling mistakes in comments, docstrings and strings
are caught automatically. The vendored frontend panel is excluded, and
"hass" and "astroid" are added to the ignore list as known false positives
(the Home Assistant abbreviation and the pylint dependency package).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review feedback

Improve grammar in several of the touched comments and docstrings: use the
plural "ignore conditions" for the list-returning property, add the missing
auxiliary verb and fix agreement in the timezone-filter comment, fix
"backups ... use" agreement, and reword "underlay" to "underlying" in the
arch module docstring.

Also drop the "*.json" skip from the codespell hook. It was carried over
from another project but is unnecessary here (all tracked JSON is clean),
and skipping it would needlessly leave translation and data JSON unchecked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reword onboarding comment

"overflight" was a literal calque of the German "überflogen"; use the
idiomatic "skimmed through" instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:09:16 +02:00
dae48c62e4 Treat mount errors as API errors instead of unexpected failures (#6946)
Mount failures generally reflect user configuration or host conditions
(unreachable server, wrong credentials, ...) rather than a Supervisor
bug. As a plain HassioError, MountError reached the generic error branch
of api_process, which logged a full traceback as an "Unexpected error
during API call" and captured the exception to Sentry. The mount reload
path already encoded the opposite intent by explicitly skipping Sentry
for MountError.

Make MountError an APIError so mount failures are surfaced as client-side
errors with their explicit message, without a traceback or Sentry noise,
matching the existing JobException handling. MountNotFound additionally
inherits from APINotFound so it returns a 404 instead of a 400.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:32:22 +02:00
dc6a77507b fix(docker): restore add-on device access after USB re-enumeration (#6877)
* fix(docker): register hw listener and match by-id paths for options-based devices

Two bugs caused a crash loop when a USB device re-enumerates to a different
minor number (e.g. ttyACM0→ttyACM1) after a HAOS reboot:

1. _hw_listener was only registered when addon.static_devices was non-empty.
   Addons that expose a device via the options schema (e.g. Z-Wave JS `device:`
   option) never had the listener registered, so add_devices_allowed was never
   called when the device reappeared at a new minor.

2. _hardware_events matched only device.path and device.sysfs against
   static_devices.  When static_devices (or the new options path) contains a
   by-id symlink, the match always failed because by-id paths live in
   device.links.

Fix: extend the listener registration condition to also cover addon.devices
(options-based), and expand the path-matching set to include device.links so
by-id paths resolve correctly.  For options-based devices, compare the incoming
Device against addon.devices (which re-evaluates options.json against the live
hardware list, picking up the new minor number automatically).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(docker): use option_device_paths for cheap by-id hw event matching

Refactor _hardware_events to avoid per-event full options validation
(including pwnd hashing). Introduce AppOptions.extract_device_paths and
AppModel.option_device_paths to extract raw device paths from options
without resolving against live hardware. Use set-intersection against
{device.path, device.sysfs, *device.links} so by-id symlinks match
correctly after re-enumeration for both static and options-based devices.

Update test to use real schema/options setup and simulate a minor-number
change (ttyACM0→ttyACM1) with a stable by-id symlink.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: improve hw listener test coverage and add policy check

Address PR review feedback:
- Add hardware policy check in _hardware_events to prevent bypassing access
  restrictions on hotplug events (follows same pattern as startup cgroup setup)
- Fix test_app_options_device_hw_listener to properly simulate USB re-enumeration
  with different minor numbers (166:0 → 166:1)
- Add test_app_options_device_policy_check to verify policy enforcement for
  options-based devices
- Update TEST_HW_DEVICE with realistic major/minor attributes (166:0 for tty)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: mock HostFeature.OS_AGENT in hardware event tests

The _hardware_events method has a @Job decorator with conditions=[JobCondition.OS_AGENT],
which checks if HostFeature.OS_AGENT is in sys_host.features. Without mocking this,
the job conditions fail and the hardware event handler is never invoked, causing
add_devices_allowed to not be called and tests to fail.

Add patch.object(type(coresys.host), "features", ...) to all four hardware event tests
to ensure the OS_AGENT job condition is met.

Fixes test failures:
- test_app_new_device (all 6 parametrized cases)
- test_app_new_device_no_haos
- test_app_options_device_hw_listener
- test_app_options_device_policy_check

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: fix TEST_DEV_PATH to match TEST_HW_DEVICE.path

TEST_DEV_PATH was set to /dev/ttyAMA0 but TEST_HW_DEVICE.path is /dev/ttyACM0.
This mismatch would cause the dev_path=TEST_DEV_PATH parametrized test cases
to fail because the hardware event handler checks if the device path intersects
with the app's allowed devices, and "/dev/ttyAMA0" != "/dev/ttyACM0".

Update TEST_DEV_PATH from /dev/ttyAMA0 to /dev/ttyACM0 to match TEST_HW_DEVICE.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: mock option_device_paths in test_app_options_device_hw_listener

The test sets up schema and options but option_device_paths property may not
be working as expected in the test environment. Add an explicit mock for
option_device_paths to ensure it returns the by-id path, guaranteeing that:
1. The hardware listener is registered (checks option_device_paths at registration)
2. The device path matching works correctly in _hardware_events

This ensures the test properly validates that hardware events are processed
for options-based devices after re-enumeration.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: make policy check test actually exercise the policy guard

test_app_options_device_policy_check set the device option via
persist["options"], but option_device_paths reads the merged options and
does not pick that override up during the test, so it returned an empty
set. The hardware event therefore failed the path-match guard and returned
before reaching the allowed_for_access check. The assert_not_called()
assertion then passed regardless of the policy outcome -- it would still
pass if the policy guard were removed entirely.

Mock option_device_paths to return the configured by-id path (mirroring
test_app_options_device_hw_listener) so the event device matches and
execution actually reaches the policy guard the test is meant to verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: add unit test for AppOptions.extract_device_paths

The integration tests exercise extract_device_paths only through a mocked
option_device_paths property, so the schema-walking logic introduced for
the hardware-event matching had no direct coverage.

Add a unit test that drives every schema shape the recursion handles --
flat, optional, filtered, list, nested dict and list of dicts -- and
asserts that non-device options, unset keys and empty values are skipped,
without requiring the devices to exist in hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-06-15 22:49:49 +02:00
Jan ČermákandGitHub ecf890a41c Fix blacklist for v2 endpoints in security middleware, add v2 security tests (#6933)
The blacklist in the security middleware didn't take the v2 prefix into
account, allowing to call routes that are supposed to be blacklisted for
apps with hassio and homeassistant APIs enabled in app config.

Add test checking that these routes are always blacklisted, and
parametrize other tests using v2 endpoints in the test_security module.
2026-06-15 17:55:58 +02:00
153108754c Don't offer a rebuild repair for a detached app (#6932)
This is a corner case: a locally-built app whose container image went missing
for some external reason and which is also detached from its store, e.g. its
local source folder was removed. There is no normal Supervisor command that
produces this combination -- rebuild removes the image before rebuilding, but
it is rejected for detached apps, so it cannot leave a detached app without an
image. The image loss here came from outside Supervisor's normal flow.

Such an app cannot be rebuilt: there is no source. App.load still surfaced a
MISSING_IMAGE repair with an EXECUTE_REPAIR suggestion for it, and the
resolution autofix loop then tried to rebuild it. That dead-ends in
App.path_location, which raises for a detached app, crashing the autofix with
an unhandled exception.

Don't create the rebuild repair when the app is detached. The detached-app
check already surfaces a DETACHED_ADDON_REMOVED issue with an EXECUTE_REMOVE
suggestion for these apps (the built-in local repository is always loaded), so
the user is offered removal instead of a repair that can never succeed.

Also guard the repair fixup itself against a detached build app. With the
App.load change this is only reachable via a race: the repair is created while
the app is attached and the app detaches before the autofix runs (or between
its retries). Skip gracefully there too rather than throwing, mirroring the
is_detached guards already in AppManager.update/rebuild.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:55:40 +02:00
Jan ČermákandGitHub dbb1634bbd Fix ha os import in OS 18.0.rc1 and newer (#6943)
The hassos-config service was renamed to haos-config in
home-assistant/operating-system#4740. This change preserved the old name
as an alias, however, Supervisor uses ListUnits that doesn't report the
aliases and then thinks such unit doesn't exist. To fix that, simply
call the unit by its primary name which now varies by the OS version.

Fixes #6942
2026-06-15 17:34:41 +02:00
Jan ČermákandGitHub 7870cd786f Detect landingpage by io.hass.type label instead of version (#6935)
The landingpage image now stamps a real Core version into its
io.hass.version label rather than the sentinel "landingpage" string.
Supervisor decided whether Core was still just a landingpage by
comparing that version against the LANDINGPAGE constant, so with the
new label it mistook the landingpage for an installed Core. Restarting
the Supervisor mid-install then stranded the system on the landingpage
until the next OS reboot, with no install job scheduled and the
watchdog hot-looping on the missing Core auth API.

Override the version resolution for the Home Assistant container so a
landingpage image (io.hass.type == "landingpage") always reports
LANDINGPAGE, keeping every existing version check working unchanged
while leaving io.hass.version free to carry the real version.

Fixes #6934
2026-06-15 17:22:16 +02:00
7eaf69fcab Derive installed-app source location from the store (#6929)
* Derive installed-app source location from the store

The installed app's source location was persisted as an absolute string in
apps.json, captured at install time. The addons->apps directory migration
renamed the source directories but left that stored string pointing at the old
addons path, so locally-built apps failed to build with a misleading
"dockerfile is missing" error on install/update/rebuild (#6917).

The location is not really app state: it is the source directory discovered
during the store scan and recomputed on every store reload. Derive it from the
store data (App.path_location -> data_store) instead of persisting it, and drop
ATTR_LOCATION from the system schema. REMOVE_EXTRA strips the stale value from
existing apps.json files, so no migration is needed and already-migrated
instances are fixed as well.

location only exists for a store-backed app. The store is loaded before apps
during setup (Core.setup ordering), so an installed, attached app can always
resolve it; reaching path_location while detached is a programming error and
now raises. Detached apps have no source, so their asset accessors
(with_icon/logo/changelog/documentation, long_description) report absence
rather than reading a path, and the path cache is no longer refreshed for them.
Build, apparmor install and backup/restore never touch path_location on a
detached app (build/update are blocked when detached; apparmor and the built
image are captured from the host and restored from the backup), so those paths
are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Guard path_location on app_store instead of is_detached

Address review feedback: path_location guarded on is_detached while the asset
accessors guarded on app_store, two ways of expressing the same condition.
Key path_location off app_store as well, matching install()/update() and the
store API, so the check is consistent and is_detached is no longer needed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:23:03 +02:00
Jan ČermákandGitHub 446a1aacd9 Return 404 for raspberrypi endpoints on boards without firmware (#6926)
* Return 404 for raspberrypi endpoints on boards without firmware

Return "not found" when board doesn't have the firmware update
available. Any other error may indicate it's worth retrying later (as
discussed in [1]), so 404 is more appropriate here.

[1] https://github.com/home-assistant/core/pull/172929#discussion_r3363706261

* Consistently return APINotFound with debug log
2026-06-09 17:15:19 +02:00
3de0de05fb Report progress during initial Core install (#6904)
* Sync docker pull progress to Core install job

The home_assistant_core_install job had no child_job_syncs, so the
download/extract progress tracked on the internal docker_interface_install
job never propagated to the user-visible install job. On a fresh system,
/jobs reporting therefore jumped from 0 to 100 with nothing in between
during the initial Core install.

Mirror what home_assistant_core_update already does and sync the docker
pull progress up to the install job, allocating it the full progress
range. As with the update path, this treats the image pull as 100% of the
task even though image cleanup and Home Assistant start are not accounted
for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Test Core install job is exposed with progress for landing page

The landing page frontend (home-assistant/frontend#52359) polls /jobs/info
during the initial Core install and reads the progress of a root job named
home_assistant_core_install to render a download progress bar. Add a test
that guards this contract: the install must expose a non-internal
home_assistant_core_install job whose progress is driven by the docker image
pull, reporting intermediate values rather than a bare 0 to 100 jump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Exclude Supervisor self-update pull from Core install progress

The home_assistant_core_install (and _update) jobs sync their progress from
the docker image pull via child_job_syncs. The install job may first trigger
a Supervisor self-update, which also pulls an image through a
docker_interface_install job. With an unscoped filter that pull incorrectly
counted towards Core install progress, driving the user-visible job to 100%
before the actual Core image download even started.

Scope the child sync to the Home Assistant container reference so only the
Core image pull contributes. Promote the container name constant to public
(HASS_DOCKER_NAME) so it can be reused for the filter reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reset synced progress when a child job is re-triggered

A parent job syncs its progress from a child via child_job_syncs, capturing
the parent's progress at the time the child starts as the band the child
fills. When the same child ran more than once - e.g. the Core image pull,
which runs in a retry loop and which Docker resumes at layer granularity -
the second child stacked on top of the first attempt's leftover progress,
pushing the user-visible job straight to 100% instead of restarting.

Record a per-sync baseline on the parent: the first child to match a sync
captures the baseline, and children matching the same sync afterwards reuse
it and reset the parent back to it. This implements the behavior the prior
HACK comment anticipated (reset progress instead of skipping the second
sync) and removes the now-unneeded progress >= 100 skip.

Add tests covering an install retry (progress resets rather than overshoots)
and a Supervisor self-update done first (its pull does not count towards the
Core install job).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:33:03 +02:00
1e5d7812cd Bump aiohttp from 3.14.0 to 3.14.1 (#6922)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 11:44:30 -05:00
Stefan AgnerandGitHub d910efa49e Surface Home Assistant update failures as translatable API errors (#6915)
Updating Core to a non-existent version (e.g. a mistyped beta tag) reported
success on the CLI: the image pull failed with a 404, but update() swallowed
the error via "with suppress(HomeAssistantError)" and then ran its post-update
health check against the still-running old Core, which passed.

Split the update routine into image install and start phases. A failed image
install leaves the running Core untouched, so there is nothing to health-check
or roll back; let it bubble out instead of masking it. Only failures after the
image is in place (e.g. the new container starting unhealthy) fall through to
the health check and rollback logic, where the error is now captured at debug
level rather than silently suppressed.

Model the update errors as client-facing APIError subclasses carrying an
error_key so the frontend can translate them and they no longer reach Sentry as
unexpected errors:

- HomeAssistantUpdateError: generic update failure
- HomeAssistantUpdateImageError: image download failed (includes the version)
- HomeAssistantUpdateAlreadyInstalledError: requested version already installed
2026-06-08 15:39:23 +02:00
5ea7908184 Drop obsolete UNIX_SOCKET_CORE_API feature flag (#6908)
Unix socket communication between Supervisor and Home Assistant Core was
initially gated behind the UNIX_SOCKET_CORE_API feature flag, then
enabled by default for Core 2026.5.1+ while older supported versions
still required the flag. Now that the transport has settled and is on by
default, the flag no longer serves a purpose.

Remove the feature flag and the CORE_UNIX_SOCKET_DEFAULT_VERSION split,
collapsing supports_unix_socket back to a single check: the Unix socket
is used for any non-landingpage Core version at or above
CORE_UNIX_SOCKET_MIN_VERSION.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:52:30 +02:00
8ec1c33aa4 Fix WebSocket transport None race condition in proxy (#6241)
Add a transport validity check before the WebSocket upgrade to handle
clients that disconnect during the handshake.

The connection can be lost between the Home Assistant API state check and
the server.prepare() call, leaving request.transport as None. aiohttp's
_pre_start() then raises ConnectionResetError (an AssertionError prior to
aiohttp 3.14.0), which propagates out of the handler as an unhandled
exception. The result is a 500 response and a Sentry report for what is
really just a client disconnect.

The fix detects the closed connection early and raises HTTPBadRequest
with a clear reason, turning the race into a clean 4xx response with a
warning log instead of error noise.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:48:47 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Stefan AgnerClaude Opus 4.8
c2b5482b22 Bump aiohttp from 3.13.5 to 3.14.0 (#6902)
* Bump aiohttp from 3.13.5 to 3.14.0

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Adjust API layer for aiohttp 3.14

aiohttp 3.14 raises NotAppKeyWarning when a plain string is used as a
request storage key. Convert REQUEST_FROM to a web.RequestKey instance so
request[REQUEST_FROM] no longer triggers the warning (which the test suite
escalates to an error, failing every authenticated endpoint). It is typed
as RequestKey[Any] to preserve the current access semantics: handlers store
different origins (App, Home Assistant, host, observer) and narrow the value
to the concrete type they expect.

aiohttp 3.14 also widened the request.post() return type to include
bytearray. Update the _process_dict annotation accordingly to satisfy mypy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Use encode_basic_auth for registry token requests

aiohttp 3.14 deprecates the BasicAuth constructor and the client auth=
request parameter (both removed in aiohttp 4.0), each emitting a
DeprecationWarning at runtime. The registry manifest fetcher hit both when
requesting a token with stored credentials. Switch to aiohttp.encode_basic_auth()
and pass the result via an Authorization header instead.

Add a test covering the credentials path, which the existing tests skipped by
mocking _get_auth_token. It asserts the Authorization header is sent and, since
the suite escalates warnings to errors, guards against the deprecations
returning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:09:57 +02:00
2f331aafa9 Add Raspberry Pi firmware update API (#6886)
* Add Raspberry Pi firmware update API

Expose `io.hass.os.Boards.RaspberryPi.Firmware` via a D-Bus proxy and
`GET/POST /os/boards/raspberrypi/firmware[/update]` REST endpoints for
Raspberry Pi 4 / 5 / CM4 (Yellow). Gated on OS Agent >= 1.9.0. The
update job raises a `REBOOT_REQUIRED` resolution issue on success and
rejects up front when the agent reports `update_blocked`.

The `blocked_reason` field currently returns only
`unsupported_boot_device` regardless of the underlying cause (CM4
without self-update, USB/NVMe boot, etc.), more reasons may be added
later if we need to make distinction.

Refs home-assistant/operating-system#4631

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix pylint issue in tests

* Return blocked_reason=None instead of empty string when update is not blocked

* Fix typo in update_raspberrypi_firmware docstring

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Reject API call for update early if blocked

* Flip API availablility check in _check_rpi_firmware_available

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Remove extra newline in docstring

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 17:37:25 +02:00
9f553c327c Only force a versioned Supervisor update in DEV mode (#6903)
Home Assistant Core now triggers a versionless Supervisor update during
onboarding to ensure Supervisor is current before it continues setup. It
treats a "no update available" response as the signal to proceed.

In DEV mode the update endpoint bypassed the need_update check entirely
and resolved a versionless request to the latest published version. So
Core's onboarding call made a freshly-built DEV Supervisor install the
latest published build and restart, which breaks the run_supervisor CI
job (the API disappears mid-test with "connection refused").

Tie the bypass to an explicit version instead: specifying a version is
still DEV-only, but a versionless request now always respects
need_update. Since need_update is always False in DEV, Core's onboarding
call becomes a no-op there, avoiding the update to the latest published
Supervisor on the dev channel.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:26:22 +02:00
a973d22e35 Derive App state from container state (#6890)
* Derive App state from container state

The App.state setter mixed two responsibilities: it both mutated a
private `_state` field and dispatched side effects (WebSocket events,
issue dismissal, startup_event signaling). On top of that, an installed
but never-started app stayed in AppState.UNKNOWN forever, because the
attach() image-only fallback never fires a container state-change event
and the AppState therefore kept its constructor default. Conceptually,
ContainerState.UNKNOWN ("container does not exist") and AppState.UNKNOWN
("nothing observed yet") happened to share a name but meant different
things, which made the distinction easy to lose.

Make App.state a pure derived property. The source of truth is the last
observed ContainerState (cached on the App), plus a sticky operation-
error flag for start/stop failures that the docker event stream cannot
reflect. When no container has been observed yet, the derivation falls
back to install signals: an attached instance (image present) is
STOPPED, otherwise UNKNOWN. As a side effect, an installed-but-never-
started app now correctly reports STOPPED instead of UNKNOWN.

container_state_changed updates the cached container state and routes
all side effects through a single _emit_state_change helper that diffs
old vs new derived state. The two start/stop failure paths route
through _set_operation_error. Uninstall resets the cached signals so
the derivation naturally returns UNKNOWN.

Tests use a new tests/common.force_app_state helper that pokes the
underlying signals directly; the production class no longer carries
test-only setters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix App state drive to AppState.UNKNOWN

* Unify state mutation through _update_state

Previously, state-driving signal changes were spread across two helpers
(_set_operation_error, _emit_state_change(old_state)) and required each
caller to capture self.state before mutating a private field — leaking
implementation details to call sites and raising the "why am I emitting
the old state?" question pointed out in code review.

Replace both helpers with a single _update_state(*, container_state=,
operation_error=) entry point. Callers describe what changed via
keyword arguments (None leaves a signal untouched); the helper captures
the previous state, applies the updates, recomputes the derived state
and emits side effects if anything changed.

Diff against a tracked _last_state instead of a freshly derived
"current" state, so that an out-of-band mutation between updates does
not silently shift the comparison baseline. The concrete case is
App.uninstall: instance.remove() clears the docker meta mid-flow, which
would otherwise reshape the derivation (RUNNING with no healthcheck
becomes STARTED instead of STARTUP) and suppress the STARTUP transition
that resolves the start-wait task. As a side effect, the initial
UNKNOWN -> STOPPED transition on attach is also now reliably emitted.

Switch the uninstall path to ContainerState.UNKNOWN ("we know there is
no container") rather than the constructor sentinel None.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Cache app state instead of deriving on every read

Building on the previous commit, make App.state a plain read of a
cached _state field rather than re-deriving on every property access.
The derivation moves to _derive_state(), and _update_state() is the
sole place that recomputes and assigns _state, so the value consumers
read always matches what was last emitted to listeners.

This removes the _last_state bookkeeping introduced previously: with a
single cached value there is no longer a separate "derived now" vs
"last emitted" distinction to reconcile, and out-of-band mutations
(e.g. instance.remove() clearing _meta during uninstall) can no longer
silently shift what state returns between updates.

Call _update_state() at the end of load() so the cached state settles
once attach() has run. Image-only attaches do not fire a docker event,
so without this an installed app would stay in the constructor-default
UNKNOWN until first start; this also makes the initial transition on
attach observable to listeners.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pass operation error to _derive_state instead of storing it

The two state-driving signals were not symmetric. _container_state is
genuinely persisted state ("the last thing docker told us") that
re-derivation legitimately reads across calls. _operation_error, on the
other hand, is a momentary "force ERROR for this transition" signal; the
persistence of an error condition already lives in the cached _state.

Storing it as an instance attribute implied a sticky cross-call behavior
that no call path actually exercised: every caller either set it
explicitly right before deriving (start/stop failures, container events)
or ran argless only at load time, where no failure has occurred.

Drop the _operation_error field and pass operation_error as a parameter
to _derive_state(), defaulting to False in _update_state(). A container
observation now supersedes a prior error implicitly via the default,
which lets the container-event and uninstall call sites drop their
explicit operation_error=False.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Settle load state synchronously from current_state

The argless _update_state() settle at the end of load() raced attach()'s
container-state event. attach() fires DOCKER_CONTAINER_STATE_CHANGE via
the bus, which schedules the container_state_changed listener as a task
rather than running it inline. In the deprecated-arch early-return path
there is no await between attach() and the settle, so the listener had
not run yet: _container_state was still None and the settle derived
STOPPED (instance attached) — emitting a transient UNKNOWN->STOPPED even
for a running container before the listener corrected it. The main path
only avoided this incidentally, by having awaits (check_image,
save_persist) in between for the listener to run.

Derive the load-time state synchronously from instance.current_state()
instead of relying on the asynchronously delivered event. current_state()
returns the real container state, or UNKNOWN when only an image is
present (which derives to STOPPED), so both paths settle correctly
without racing the event.

Add a regression test that loading a running container settles to
STARTED, and mock current_state() in the state-listener test which
relies on a clean UNKNOWN baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:50:06 +02:00
Jan ČermákandGitHub b857f81b69 Fix off-by-one in unsupported os_version evaluation (#6888)
* Fix off-by-one in unsupported os_version evaluation

Adjust check for unsupported OS version to match the boundary which is
documented in [1], which says:

> Supervisor considers Home Assistant Operating System older than the
> last 4 major releases as unsupported.

Also adjust the wording in the message to match the statement from the
docs.

This will make another major relase unsupported now, but it was the
original intention anyway.

[1] https://www.home-assistant.io/more-info/unsupported/os_version/

* Reword comments for test_os_version_evaluation test cases
2026-05-28 12:47:27 +02:00
Jan ČermákandGitHub fe4f3b5761 Coordinate graceful shutdown with Home Assistant OS (#6887)
On newer HAOS (home-assistant/operating-system#4736),
hassos-supervisor.service gets a long stop timeout (420 s docker stop +
450 s TimeoutStopSec) so Supervisor can handle the SIGTERM during host
teardown and gracefully stop Core, apps and plugins via its existing
shutdown handler. On older releases the timeout is too short, so
reboot()/shutdown() keep stopping Core in-process before requesting the
reboot/power off.

For host shutdowns not initiated by Supervisor (ACPI, power button,
systemctl reboot), the SIGTERM handler now checks the systemd manager
state. When it is "stopping", Supervisor runs Core.shutdown() to stop
managed services gracefully; on a plain Supervisor restart the state is
"running", so only Supervisor stops as before. The manager state is
exposed by every systemd version, so this works regardless of the OS
release.

Closes home-assistant/operating-system#4642
2026-05-28 12:47:07 +02:00
15fdc6b516 tests: cover OS update success path to catch blocking I/O (#6872)
The existing OS manager update tests only exercise precondition failure
paths (out-of-date supervisor, unhealthy state), which bail out before
the download / RAUC install / cleanup logic ever runs. That left the
finally-clause unlink in OSManager.update() unscanned by blockbuster,
so the blocking Path.unlink() regression fixed in #6863 slipped through
CI and only surfaced on a real device.

Add a happy-path test that stubs _download_raucb to create the bundle
file, drives the RAUC dbus mock to emit a successful Completed signal,
mocks the post-install reboot, and asserts the downloaded bundle is
removed. Reverting the executor wrapping on int_ota.unlink reproduces
the original failure with blockbuster reporting "Blocking call to
os.unlink", confirming the test catches the regression.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:36:30 +02:00
355396aeab Migrate addon/addons config paths and schema names to app/apps (#6865)
* Migrate config file and directory paths from addons to apps

- Rename addons.json -> apps.json (FILE_HASSIO_APPS constant)
- Rename addons/{core,data,local,git} -> apps/{core,data,local,git}
- Rename addon_configs -> app_configs

Backwards compatibility: on startup, Supervisor checks for legacy
paths and renames them if the new paths don't already exist.
- addons.json migration runs in AppManager.load_config (executor)
- Directory migrations run in bootstrap before initialize_system (executor)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Rename SCHEMA_ADDON(S)_* constants to SCHEMA_APP(S)_* in apps/validate.py

Update all references in supervisor and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix remaining test references to legacy addons/* paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Opportunistic remove of addons dir since it should be empty post migration

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 09:47:32 +02:00
f4962208b0 watchdog: include container exit code in restart log message (#6873)
* watchdog: include container exit code in restart log message

Reported in #6868: a user saw a tight loop of "Watchdog found app
phpMyAdmin is failed, restarting..." and assumed the watchdog itself
was the problem, asking whether its window could be widened. The
message gives no hint that the container is exiting on its own, nor
what exit code it returned.

#6848 already plumbed the container exit code through
DockerContainerStateEvent and added a separate log line in
container_state_changed when an app exits non-zero. Build on that by
forwarding event.exit_code into _restart_after_problem for apps, Home
Assistant Core, and plugins, and use it in the watchdog warning when
the state is FAILED. The fallback message is kept for STOPPED and
UNHEALTHY where an exit code is not meaningful.

After this change the example above reads "Watchdog found app
phpMyAdmin exited with code 1, restarting...", making it immediately
clear that the container itself is dying and giving the user a code
to grep for in the add-on logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop unnecessary None check

* tests: set exit_code on FAILED watchdog events

The previous commit dropped the `exit_code is not None` guard from the
watchdog `_restart_after_problem` log statements, which assumed the
production invariant that `ContainerState.FAILED` always carries a
non-None exit code (enforced by `docker/monitor.py` and
`docker/interface.py`). Several tests, however, fired FAILED
`DockerContainerStateEvent`s with no `exit_code`, causing the new
unconditional `%d` formatter to raise `TypeError` at log time.

Align the test fixtures with the production invariant by passing
`exit_code=1` on FAILED events in the apps, Home Assistant Core, plugin
base, and DNS plugin watchdog tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:58:54 +02:00
Stefan AgnerandGitHub 0f881d69fe Model client-state Apps* errors as APIError (#6856)
* apps: model client-state Apps* errors as APIError

Follow-up on #6739: with HassioError now logged and captured by Sentry
in api_process, a handful of Apps* exceptions raised from
AppManager.install/update/rebuild and AppModel._validate_availability
surfaced as "unexpected" 400s with a noisy log entry and a Sentry
event, even though they are all user/client-state errors (clicked
install on an already-installed add-on, "no update available", local
and store versions diverged, system architecture/machine/HA version
incompatible, etc.). The dominant offender is SUPERVISOR-1JVV
("No update available for app core_mosquitto", ~19k events / ~12k
users), but several siblings show the same shape.

Map these through properly so the API returns clean, structured 400s:

- Add modeled APIError subclasses in exceptions.py for the previously
  raw raises in apps/manager.py: AppAlreadyInstalledError,
  AppNotFoundError, AppNotInstalledError, AppNotInStoreError,
  AppNoUpdateAvailableError, AppRebuildVersionChangedError,
  AppRebuildImageBasedError. Each gets a stable error_key, a
  message_template, and an "addon" extra_field.
- Add APIError to AppNotSupportedArchitectureError,
  AppNotSupportedMachineTypeError and
  AppNotSupportedHomeAssistantVersionError so they behave the same as
  the other Apps* APIErrors instead of being treated as unexpected.
- Pass the app's display name (app.name from the add-on config)
  instead of the slug to extra_fields wherever an App or AppModel is
  available at the raise site, so users see "Mosquitto broker" rather
  than "core_mosquitto" in error messages. Slug is only used as a
  fallback when no app object exists (install of an unknown slug,
  update/rebuild of a slug that is not installed).
- Update raise sites in apps/manager.py and apps/model.py to use the
  new typed exceptions and the addon= keyword.

These are all runtime states users hit during normal interaction with
the apps UI, not Supervisor bugs worth paging on.

* apps: include slug alongside name in Apps* APIError extra_fields

Address review feedback from #6856: clients still need the slug to look
up additional add-on information (the name is for display only), and we
should be consistent about it across the Apps* errors touched by this
PR.

Every Apps* APIError raised with an App/AppModel available now carries
both `addon` (display name, used by the message_template) and `slug` in
extra_fields. Raise sites in apps/manager.py and apps/model.py pass
both. The two errors raised before an app object exists keep slug-only
extra_fields and use {slug} in their message:

- AppNotFoundError (install of an unknown slug)
- AppNotInstalledError (update/rebuild of a slug not in self.local)

Pre-existing Apps* APIErrors outside the scope of this PR
(AppUnknownError, AppConfigurationInvalidError, AppBootConfigCannot
ChangeError, AppNotRunningError, AppPortConflict, AppNotSupportedWrite
StdinError, AppBuild*) will be migrated in a follow-up.

* apps: introduce AppAPIError base for uniform addon/slug extra_fields

Address review follow-up on #6856: the addon/slug convention was
enforced only by hand-rolled __init__s, easy to drift on (forget slug,
use a different key, etc.). Promote it to a base class that owns the
shape of extra_fields for all App-related API errors.

- Add AppAPIError(AppsError, APIError). Its __init__ takes
  `app: AppModel | App | AppStore | str` and uniformly populates
  extra_fields with `addon` (display name) and `slug`. Pass a string
  when no app object exists; only `slug` is set in that case. Extra
  per-error fields flow through **extra_fields and merge with the
  defaults.
- Convert the new exceptions added in this PR
  (AppAlreadyInstalledError, AppNotFoundError, AppNotInstalledError,
  AppNotInStoreError, AppNoUpdateAvailableError,
  AppRebuildVersionChangedError) into thin subclasses that only declare
  error_key and message_template -- the __init__ is inherited.
- Migrate the AppNotSupported* errors (architecture, machine type, HA
  version) and AppRebuildImageBasedError to use AppAPIError too;
  their bespoke per-error fields go through **extra_fields. They keep
  inheriting AppNotSupportedError so `except AppNotSupportedError`
  callers (e.g., AppModel._available) still work; MRO routes __init__
  through AppAPIError.
- Update raise sites in apps/manager.py and apps/model.py to pass
  `app=<obj-or-slug>` instead of repeating `addon=...` and `slug=...`.

Pre-existing App* APIErrors outside this PR's scope
(AppUnknownError, AppConfigurationInvalidError,
AppBootConfigCannotChangeError, AppNotRunningError, AppPortConflict,
AppNotSupportedWriteStdinError, AppBuild*) will be migrated to
AppAPIError in a follow-up; the base class is in place for them.

* apps: tighten AppAPIError model per review

Address mdegat01's two follow-ups on #6856 (review approved as-is,
this is the cleanup):

- AppNotFoundError and AppNotInstalledError are raised before any
  App/AppModel object exists (unknown slug; not-installed slug). Pull
  them out of AppAPIError and inherit (AppsError, APIError) directly
  with a slug-only __init__ + {slug} message_template. Removes the
  conceptually-wrong str branch from AppAPIError.__init__: it now
  strictly requires an app-like object with .name and .slug.
- Restore per-class typed __init__ on AppNotSupportedArchitectureError,
  AppNotSupportedMachineTypeError and
  AppNotSupportedHomeAssistantVersionError so callers get an explicit
  signature for the bespoke architectures/machine_types/version params
  instead of dumping them through **extra_fields. Each override just
  delegates to AppAPIError.__init__, which keeps ownership of the
  addon/slug shape. The list-joining for architectures/machine_types
  moves back into the override (raise sites pass the raw list again).

AppRebuildImageBasedError takes no bespoke fields and stays a plain
AppAPIError subclass.
2026-05-22 16:54:59 +02:00
3a010e9c5d apps: avoid blocking is_file() check in restore (#6862)
* apps: avoid blocking is_file() check in restore

Restoring an app off a backup called `Path.is_file()` directly on the
event loop to check whether the bundle contained an `image.tar`, which
triggered a blockbuster BlockingError on `os.stat` in environments where
blocking-call detection is enabled. Wrap the check in
`self.sys_run_in_executor(...)` so the stat happens off the main thread,
matching the pattern already used in `api/backups.py`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests: cover restore path where addon image is missing

The blocking ``is_file()`` call that the previous commit replaced lived
in the ``not instance.exists()`` branch of ``App.restore()`` -- reached
only when the local Docker image for the addon is absent. Existing
app-restore tests start from ``install_app_ssh`` / ``install_app_example``
and rely on the mocked ``docker images.inspect`` which always reports
the image as present, so that branch never executed and blockbuster
never had a chance to flag the blocking stat.

Add a focused test that uninstalls the app after backup, patches
``DockerApp.exists`` to ``False``, and stubs ``install`` / ``import_image``
so the restore drives through the ``image.tar`` check. Verified that
this test raises ``BlockingError: Blocking call to os.stat`` when run
against the unpatched code.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:44:48 +02:00
Stefan AgnerandGitHub d3028d7bfc Enable flake8-pyi, flake8-return, flake8-raise ruff rules (#6861)
Enable the PYI, RET and RSE ruff rule sets and fix the resulting
violations across the codebase. The pylint no-else-* checks
(RET505-508) are now disabled since ruff covers them.

The cleanups are mechanical:

- RSE102: drop empty parentheses from `raise Exception()` when no
  arguments are passed.
- RET505-508: drop `else` branches that follow a `return`, `raise`,
  `continue` or `break`, flattening control flow.
- RET502/504: add explicit return values and remove redundant
  assign-then-return patterns.
- PYI030/032/041: tidy up type annotations (collapse literal unions,
  use `object` for `__eq__`/`__ne__`, drop redundant numeric unions).
2026-05-22 11:04:49 +02:00
Stefan AgnerandGitHub f8e1acf1b0 Enable flake8-pathlib (PTH) ruff rules (#6860)
Turn on the PTH rule set in ruff and convert the remaining call sites
that triggered violations:

- `supervisor/utils/yaml.py` and `tests/homeassistant/test_module.py`
  use `Path.open()` instead of the builtin `open()`.
- `tests/backups/test_backup.py` and
  `tests/resolution/fixup/test_store_execute_reset.py` use
  `Path.iterdir()` instead of `os.listdir()`.
- `tests/mounts/test_mount.py` uses `Path.unlink()` instead of
  `os.remove()`.
- `tests/store/test_translation_load.py` uses `Path.mkdir(parents=True)`
  instead of `os.makedirs()`.
- `tests/hardware/test_disk.py` captures the unpatched `Path.is_dir`,
  `Path.is_symlink` and `Path.stat` before the `patch.object(Path, ...)`
  block so the mocks can delegate to the real implementations without
  reaching for `os.path.isdir`/`os.path.islink`/`os.stat`.
2026-05-20 22:45:59 +02:00
Stefan AgnerandGitHub ed91b18c4b tests: enable flake8-pytest-style (PT) ruff rules (#6857)
* tests: enable flake8-pytest-style (PT) ruff rules

Enable the `PT` ruff rule set and fix the resulting violations across the
test suite:

- PT006: pass parametrize argument names as tuples instead of a single
  comma-separated string.
- PT022: switch fixtures that have no teardown from `yield` to `return`
  so the lack of cleanup is obvious at a glance.
- PT011: add `match=` to broad `pytest.raises(ValueError)` blocks so the
  expected error is anchored to a specific message.
- PT012: hoist setup (patches, branching) out of `pytest.raises()`
  blocks so only the call that is expected to raise remains inside.
- PT013: replace `from pytest import X` with `import pytest` and access
  attributes via the module.
- PT015: replace `try/except` + `assert False` patterns with
  `pytest.raises(...)`.
- PT017: replace `assert` on exceptions inside `except` blocks with
  `pytest.raises(...) as exc_info` and assert on `exc_info.value`.

No behavioral changes to the tests; the full suite still passes.

* tests: address review feedback on PT ruff rule enablement

- Fix fixture return-type annotations after switching `yield` to `return`
  in tests/conftest.py: drop the `Generator[...]`/`AsyncGenerator[...]`
  wrapper for `dns_manager_service`, `supervisor_internet`, `websession`,
  and `mock_update_data` so the annotation matches what the fixture
  actually returns.
- Correct the return-type annotation of `fixture_ip6config_service` from
  `IP4ConfigService` to `IP6ConfigService`.
- Fix recurring "excepiton" typo in tests/utils/test_exception_helper.py.

* tests: verify backup cleanup on permission error

After `test_new_backup_permission_error` raises `BackupPermissionError`,
assert that no tarfile was left behind and `tmp_path` is empty. The
previous version only checked that the exception was raised, which
missed any regression where a partial tarfile would survive the failed
create.

* tests: rename DNS_GOOD_V6 to DNS_V6_UNSUPPORTED

The constant was named "good" but its tests assert that the URLs are
rejected by the DNS validator. The IPv6 URLs are well-formed but
currently rejected because IPv6 doesn't work with the Docker network
(see `dns_url` in supervisor/validate.py). Rename the constant and the
related test to make the intent obvious.
2026-05-20 22:17:54 +02:00
Stefan AgnerandGitHub 0bcedf5b98 Don't fail Supervisor setup when an app image is missing (#6816)
* Don't fail Supervisor setup when an app image is missing

A missing builder image (docker:<version>-cli) during a build-required app
load aborted Supervisor setup entirely, leaving the system stuck in setup
state where every subsequent operation was blocked by the not-healthy
guard. Triggered in practice when the host's Docker patch version had no
matching `-cli` tag published on Docker Hub.

Two issues compounded the failure: `images.pull` in `run_command` leaked a
raw `aiodocker.DockerError` past the `@Job` decorator, which rewrapped it
as `JobException` and bypassed the `suppress(DockerError, ...)` guard in
`addon.load()`; and the load path treated all Docker errors the same
whether the image was simply missing or the daemon itself was misbehaving.

Wrap the pull error in `run_command` so it propagates as Supervisor's
`DockerError` (a `HassioError`) and is preserved by the decorator.
Distinguish 404s in `attach()` and `check_image()` by raising
`DockerNotFound`/`DockerAPIError` instead of generic `DockerError`. In
`addon.load()`, only the `DockerNotFound` path is treated as "image
missing": for build-required apps we skip the inline build and surface a
`MISSING_IMAGE` repair so the resolution autofix loop handles it off the
critical path; for pull-based apps we still attempt install during load
and create the repair on failure. Other `DockerError`s (daemon trouble or
a failed internal install in `check_image`) are logged at CRITICAL — which
the Sentry logging integration captures — and the addon is left detached
rather than masked as a misleading missing-image repair.

In the autofix path, swallow `DockerBuildError`, `DockerNoSpaceOnDevice`,
`DockerRegistryAuthError`, and `DockerRegistryRateLimitExceeded` as
`ResolutionFixupError` so they don't generate Sentry events on every
retry. The repair stays available for manual retry once the underlying
cause (registry tag published, disk freed, credentials fixed, rate limit
expired) is resolved.

* Clarify outer DockerError comment in App.load()

The comment claimed "a future load will reattempt and surface a
MISSING_IMAGE repair if appropriate", but App.load() is only called at
Supervisor startup, on fresh install, and on backup restore — there is no
automatic retry mechanism. Reword to match reality: the CRITICAL log
captures the issue for diagnostics (Sentry), and the user can trigger a
manual repair once the daemon is healthy.

* Clarify comment about user interaction
2026-05-20 17:59:05 +02:00
80b075f32b Update Supervisor before installing Home Assistant Core (#6849)
* Update Supervisor before installing Home Assistant Core

During the install loop, check if Supervisor has a pending update
before attempting to install Home Assistant Core. Supervisor must
always be updated first to avoid incompatibilities.

- If auto-update is enabled, attempt to update Supervisor first.
  On success, the process restarts automatically. On failure, log
  a warning, capture the exception to Sentry, and retry after the
  standard install retry period.
- If auto-update is disabled, log a warning that unknown issues may
  occur and proceed with the Core install anyway to avoid leaving
  users without a UI.

Also extract the 30-second install retry wait into a shared
INSTALL_RETRY_WAIT_SECS constant used across all sleep calls and
log messages in install() and install_landingpage().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix too many nested blocks issue

* Better log messages

Co-authored-by: Stefan Agner <stefan@agner.ch>

* Remove unnecessary exception capture and fix tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-05-20 09:42:12 +02:00
7ecfe42602 apps: log container exit code when app exits non-zero (#6848)
* apps: log container exit code when app exits non-zero

Issue #6840 reports that stopping an app whose process exits 143 (SIGTERM
default disposition) leaves the app in AppState.ERROR. ERROR is the right
state for that — Docker itself treats any non-zero exit as a failure
(e.g. `--restart on-failure`), and 143 specifically means the SIGTERM
grace period was wasted because the app never installed a handler. But
Supervisor previously logged nothing about it, leaving authors with no
hint that their image is misbehaving.

Plumb the exit code through DockerContainerStateEvent and log it from
App.container_state_changed on transitions to FAILED: a warning for 143
nudging the author to trap SIGTERM and exit 0, and an error for any
other non-zero code (crashes, SIGKILL after grace, app's own error
exit).

Refactor _container_state_from_model to return (state, exit_code) so
the docker event monitor and DockerInterface.attach feed the same exit
code through one code path instead of re-reading State.ExitCode in the
caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* apps: address review feedback on exit-code logging

- Replace bare 143 with EXIT_CODE_SIGTERM_DEFAULT (128 + signal.SIGTERM)
  in supervisor/docker/const.py so the reasoning is documented in code,
  not just in the log string.
- Stop populating exit_code on STOPPED transitions. Previously the
  refactor made DockerInterface.attach emit exit_code=0 for cleanly
  stopped containers, while the monitor only emitted an exit code for
  abnormal exits. Align both paths so exit_code is only set on FAILED.
- Add test_app_failed_logs_exit_code covering the three new branches
  (warning on 143, error on other non-zero, silent when None) and
  extend test_attach_existing_container to assert the event's exit_code
  field per state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docker/monitor: flatten exit_code branch to satisfy pylint

The previous if/else inside the `die` branch pushed the function over
pylint's too-many-nested-blocks threshold (6/5). Collapse it back into
a pair of conditional expressions: container_state via ternary on the
exit code, exit_code via `die_exit_code or None` so 0 stays None.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update supervisor/apps/app.py

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
2026-05-20 09:40:06 +02:00