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>
* 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>
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>
* 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>
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>
* 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>
* 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>
CoreDNS.reset() called Path.unlink() directly on the hosts file, which
performs blocking file I/O on the event loop. The sibling write_hosts()
method in the same module already offloads its file write via
self.sys_run_in_executor(). Run the unlink in the executor as well for
consistency, keeping the existing OSError suppression.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
In BackupManager.import_backup(), the cleanup after a failed consolidate
called the blocking Path.unlink() directly on the event loop, while the
same tarfile removal elsewhere in the module already runs via
self.sys_run_in_executor(). Move this unlink to the executor as well for
consistency.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The editable install of the Supervisor package resolves its build backend
(setuptools, pinned via build-system.requires in pyproject.toml) in an
isolated build environment. Unlike the runtime requirements install, this
step was not given the locally built wheels, so it resolved build
dependencies solely from the configured package indexes.
When a pinned build dependency is newer than what the Home Assistant
musllinux wheels index currently carries (e.g. a Dependabot setuptools
bump), uv's default first-index strategy never falls through to PyPI and
the editable install fails with "No solution found".
Pass the locally built wheels via --find-links, mirroring the runtime
requirements step, so the isolated build environment can satisfy build
dependencies from them.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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
* 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>
* Use zip() instead of range(len()) for paired iteration
Three list comprehensions paired a list with its asyncio.gather()
results by index using range(len(...)). Iterate the pairs directly with
zip() instead, which is clearer and drops the manual indexing. The
gather results match their input list in order and length, so the
behavior is unchanged.
* Update supervisor/backups/manager.py
Co-authored-by: Stefan Agner <stefan@agner.ch>
* Fix ruff format: wrap long zip() line in backups/manager.py
---------
Co-authored-by: Stefan Agner <stefan@agner.ch>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
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>
mypy is a whole-program checker, but the pre-commit hook passed only the
changed files (with pass_filenames on) and added --ignore-missing-imports,
neither of which matches the CI command (mypy supervisor).
Passing only the changed files gives results that differ from a full run,
and because pre-commit splits a long file list and runs the hook
concurrently, the parallel mypy processes race on the shared SQLite cache
that is enabled by default since mypy 2.0 and crash with intermittent
INTERNAL ERRORs (python/mypy#21525). --ignore-missing-imports is also
redundant: the [tool.mypy] config already disables the import-not-found
and import-untyped error codes.
Set pass_filenames: false so the hook always checks the whole supervisor
package in a single invocation, add require_serial: true as a safeguard,
and drop --ignore-missing-imports so the hook matches mypy supervisor.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BackupManager.reload listed the backup files of each location one after
another, awaiting a location's directory check and glob before starting
the next. With several locations (local, cloud backup, network mounts) a
slow or network-backed location held up listing all the others.
List the backup files of all locations concurrently with asyncio.gather
instead. Each listing already handles its own errors, and the results
are paired back with their location in order, so the behavior is
unchanged.
* 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
remove_delta_apps checked membership against the app_list property for
every installed app. That property rebuilds a list on each access, so
the check rebuilt and linearly scanned it once per installed app.
Resolve the slugs into a set once before the loop for constant-time
membership checks. Behavior is unchanged.
The per-file exclusion filter in folder backups looped over
sys_mounts.bound_mounts for every file. That property rebuilds a list
from a dict on each call, so backing up a folder with many files meant
rebuilding the list and scanning it linearly once per file.
Bind mounts don't change during a backup, so resolve the set of paths to
skip once up front and turn the per-file check into a single set
membership test.
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.
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>
* 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>
* 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>
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>