Compare commits

..

40 Commits

Author SHA1 Message Date
Mike Degatano
1d1a8cdad3 Add API to force repository repair (#6439)
* Add API to force repository repair

* Fix inheritance for error

* Fix absolute import
2026-01-06 16:01:48 +01:00
Mike Degatano
5ebd200b1e Don't remove folder on Home Assistant restore (#6443) 2026-01-05 10:20:46 +01:00
Mike Degatano
1b8f51d5c7 Fix accidental absolute imports (#6446) 2026-01-05 10:19:02 +01:00
dependabot[bot]
6a29f92212 Bump astroid from 4.0.2 to 4.0.3 (#6459)
Bumps [astroid](https://github.com/pylint-dev/astroid) from 4.0.2 to 4.0.3.
- [Release notes](https://github.com/pylint-dev/astroid/releases)
- [Changelog](https://github.com/pylint-dev/astroid/blob/main/ChangeLog)
- [Commits](https://github.com/pylint-dev/astroid/compare/v4.0.2...v4.0.3)

---
updated-dependencies:
- dependency-name: astroid
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 10:18:06 +01:00
dependabot[bot]
61052f78df Bump getsentry/action-release from 3.4.0 to 3.5.0 (#6458)
Bumps [getsentry/action-release](https://github.com/getsentry/action-release) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/getsentry/action-release/releases)
- [Changelog](https://github.com/getsentry/action-release/blob/master/CHANGELOG.md)
- [Commits](128c5058bb...dab6548b3c)

---
updated-dependencies:
- dependency-name: getsentry/action-release
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 10:16:51 +01:00
dependabot[bot]
0c5f48e4af Bump aiohttp from 3.13.2 to 3.13.3 (#6456)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-03 08:26:28 -10:00
dependabot[bot]
4c7a0d5477 Bump gitpython from 3.1.45 to 3.1.46 (#6455) 2026-01-02 10:03:12 +01:00
dependabot[bot]
f384b9ce86 Bump backports-zstd from 1.2.0 to 1.3.0 (#6454) 2025-12-30 08:51:03 +01:00
dependabot[bot]
10327e73c9 Bump coverage from 7.13.0 to 7.13.1 (#6452)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-29 11:44:21 +01:00
dependabot[bot]
d4b1aa82ab Bump time-machine from 3.1.0 to 3.2.0 (#6438)
Bumps [time-machine](https://github.com/adamchainz/time-machine) from 3.1.0 to 3.2.0.
- [Changelog](https://github.com/adamchainz/time-machine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/adamchainz/time-machine/compare/3.1.0...3.2.0)

---
updated-dependencies:
- dependency-name: time-machine
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-20 09:08:06 -05:00
Mike Degatano
7e39226f42 Remove cosign from container (#6442) 2025-12-20 08:56:36 -05:00
dependabot[bot]
1196343620 Bump voluptuous from 0.15.2 to 0.16.0 (#6440)
Bumps [voluptuous](https://github.com/alecthomas/voluptuous) from 0.15.2 to 0.16.0.
- [Release notes](https://github.com/alecthomas/voluptuous/releases)
- [Changelog](https://github.com/alecthomas/voluptuous/blob/master/CHANGELOG.md)
- [Commits](https://github.com/alecthomas/voluptuous/compare/0.15.2...0.16.0)

---
updated-dependencies:
- dependency-name: voluptuous
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 12:15:57 +01:00
dependabot[bot]
8a89beb85c Bump ruff from 0.14.9 to 0.14.10 (#6441)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.9 to 0.14.10.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.9...0.14.10)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.14.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 12:15:47 +01:00
Jan Čermák
75cf60f0d6 Bump uv to v0.9.18 (#6436)
Bump to latest version, full changelog at:
https://github.com/astral-sh/uv/blob/0.9.18/CHANGELOG.md
2025-12-18 16:21:59 +01:00
Jan Čermák
4a70cb0f4e Bump base Docker image to 2025.12.2 (#6437)
This mainly bumps Python to v3.13.11. For all changes, see:
- https://github.com/home-assistant/docker-base/releases/tag/2025.12.2
- https://github.com/home-assistant/docker-base/releases/tag/2025.12.1
- https://github.com/home-assistant/docker-base/releases/tag/2025.12.0
- https://github.com/home-assistant/docker-base/releases/tag/2025.11.3
- https://github.com/home-assistant/docker-base/releases/tag/2025.11.2
2025-12-18 16:21:43 +01:00
Jan Čermák
4b1a82562c Fix missing metadata of stopped add-ons after aiodocker migration (#6435)
After the aiodocker migration in #6415 some add-ons may have been missing IP
addresses because the metadata was retrieved for the container before it was
started and network initialized. This manifested as some containers being
unreachable through ingress (e.g. 'Ingress error: Cannot connect to host
0.0.0.0:8099'), especially if they have been started manually after Supervisor
startup.

To fix it, simply move retrieval of the container metadata (which is then
persisted in DockerInterface._meta) to the end of the run method, where all
attributes should have correct values. This is similar to the flow before the
refactoring, where Container.reload() was called to update the metadata.
2025-12-17 23:01:28 +01:00
dependabot[bot]
7bb361304f Bump sentry-sdk from 2.47.0 to 2.48.0 (#6433)
Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 2.47.0 to 2.48.0.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.47.0...2.48.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-17 19:43:25 +01:00
dependabot[bot]
60e2f00388 Bump pre-commit from 4.5.0 to 4.5.1 (#6434)
Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pre-commit/pre-commit/compare/v4.5.0...v4.5.1)

---
updated-dependencies:
- dependency-name: pre-commit
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-17 19:42:50 +01:00
dependabot[bot]
07c0d538d1 Bump debugpy from 1.8.18 to 1.8.19 (#6431)
Bumps [debugpy](https://github.com/microsoft/debugpy) from 1.8.18 to 1.8.19.
- [Release notes](https://github.com/microsoft/debugpy/releases)
- [Commits](https://github.com/microsoft/debugpy/compare/v1.8.18...v1.8.19)

---
updated-dependencies:
- dependency-name: debugpy
  dependency-version: 1.8.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 16:14:50 +01:00
dependabot[bot]
29fdce5d79 Bump actions/download-artifact from 6.0.0 to 7.0.0 (#6429)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](018cc2cf5b...37930b1c2a)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 10:00:35 +01:00
dependabot[bot]
50542f526c Bump actions/upload-artifact from 5.0.0 to 6.0.0 (#6428)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](330a01c490...b7c566a772)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 10:00:26 +01:00
dependabot[bot]
e08b777814 Bump mypy from 1.19.0 to 1.19.1 (#6430)
Bumps [mypy](https://github.com/python/mypy) from 1.19.0 to 1.19.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.19.0...v1.19.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 10:00:17 +01:00
dependabot[bot]
f48f2fa21b Bump actions/cache from 4.3.0 to 5.0.1 (#6427)
Bumps [actions/cache](https://github.com/actions/cache) from 4.3.0 to 5.0.1.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](0057852bfa...9255dc7a25)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 10:00:03 +01:00
dependabot[bot]
ba8b8a5a26 Bump dessant/lock-threads from 5.0.1 to 6.0.0 (#6426)
Bumps [dessant/lock-threads](https://github.com/dessant/lock-threads) from 5.0.1 to 6.0.0.
- [Release notes](https://github.com/dessant/lock-threads/releases)
- [Changelog](https://github.com/dessant/lock-threads/blob/main/CHANGELOG.md)
- [Commits](1bf7ec2505...7266a7ce5c)

---
updated-dependencies:
- dependency-name: dessant/lock-threads
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 09:59:55 +01:00
dependabot[bot]
a9964e9906 Bump ruff from 0.14.8 to 0.14.9 (#6422)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.8 to 0.14.9.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.8...0.14.9)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.14.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 09:59:50 +01:00
dependabot[bot]
272670878a Bump urllib3 from 2.6.1 to 2.6.2 (#6421)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.1 to 2.6.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.1...2.6.2)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 09:59:21 +01:00
Mike Degatano
d23bc291d5 Migrate create container to aiodocker (#6415)
* Migrate create container to aiodocker

* Fix extra hosts transformation

* Env not Environment

* Fix tests

* Fixes from feedback

---------

Co-authored-by: Jan Čermák <sairon@users.noreply.github.com>
2025-12-15 09:57:30 +01:00
dependabot[bot]
4fc6acfceb Bump debugpy from 1.8.17 to 1.8.18 (#6418)
Bumps [debugpy](https://github.com/microsoft/debugpy) from 1.8.17 to 1.8.18.
- [Release notes](https://github.com/microsoft/debugpy/releases)
- [Commits](https://github.com/microsoft/debugpy/compare/v1.8.17...v1.8.18)

---
updated-dependencies:
- dependency-name: debugpy
  dependency-version: 1.8.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 09:55:28 +01:00
dependabot[bot]
4df0db9df4 Bump aiodns from 3.6.0 to 3.6.1 (#6423)
Bumps [aiodns](https://github.com/saghul/aiodns) from 3.6.0 to 3.6.1.
- [Release notes](https://github.com/saghul/aiodns/releases)
- [Changelog](https://github.com/aio-libs/aiodns/blob/master/ChangeLog)
- [Commits](https://github.com/saghul/aiodns/compare/v3.6.0...v3.6.1)

---
updated-dependencies:
- dependency-name: aiodns
  dependency-version: 3.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 23:42:40 +01:00
dependabot[bot]
27c53048f6 Bump codecov/codecov-action from 5.5.1 to 5.5.2 (#6416) 2025-12-10 09:06:35 +01:00
dependabot[bot]
88ab5e9196 Bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 (#6417) 2025-12-10 07:46:19 +01:00
dependabot[bot]
b7a7475d47 Bump coverage from 7.12.0 to 7.13.0 (#6414) 2025-12-09 07:24:07 +01:00
dependabot[bot]
5fe6b934e2 Bump urllib3 from 2.6.0 to 2.6.1 (#6413) 2025-12-09 07:14:39 +01:00
Hendrik Bergunde
a2d301ed27 Increase timeout waiting for Core API to work around 2025.12.x issues (#6404)
* Fix too short timeouts for Synology NAS 

With Home Assistant Core 2025.12.x updates available the STARTUP_API_RESPONSE_TIMEOUT that HA supervisor is willing to wait (before assuming a startup failure and rolling back the entire core update) seems to be too low on not-so-beefy hosts. The problem has been seen on Synology NAS machines running Home Assistant on the side (like in my case). I have doubled the timeout from 3 to 6 minutes and the upgrade to Core 2025.12.1 works on my Synology DS723+. My update took 4min 56s -- hence the timeout increase was proven necessary.

* Fix tests for increased API Timeout

* Increase the timeout to 10 minutes

* Increase the timeout in tests

---------

Co-authored-by: Jan Čermák <sairon@users.noreply.github.com>
2025-12-08 11:05:57 -05:00
Jan Čermák
cdef1831ba Add option to Core settings to enable duplicated logs (#6400)
Introduce new option `duplicate_log_file` to HA Core configuration that will
set an environment variable `HA_DUPLICATE_LOG_FILE=1` for the Core container if
enabled. This will serve as a flag for Core to enable the legacy log file,
along the standard logging which is handled by Systemd Journal.
2025-12-08 16:35:56 +01:00
dependabot[bot]
b79130816b Bump aiodns from 3.5.0 to 3.6.0 (#6408) 2025-12-08 08:24:12 +01:00
dependabot[bot]
923bc2ba87 Bump backports-zstd from 1.1.0 to 1.2.0 (#6410)
Bumps [backports-zstd](https://github.com/rogdham/backports.zstd) from 1.1.0 to 1.2.0.
- [Changelog](https://github.com/Rogdham/backports.zstd/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rogdham/backports.zstd/compare/v1.1.0...v1.2.0)

---
updated-dependencies:
- dependency-name: backports-zstd
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 08:22:07 +01:00
dependabot[bot]
0f6b211151 Bump pytest from 9.0.1 to 9.0.2 (#6409)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.1 to 9.0.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.1...9.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 08:21:53 +01:00
dependabot[bot]
054c6d0365 Bump peter-evans/create-pull-request from 7.0.9 to 7.0.11 (#6406)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.9 to 7.0.11.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](84ae59a2cd...22a9089034)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: 7.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 08:21:33 +01:00
dependabot[bot]
d920bde7e4 Bump orjson from 3.11.4 to 3.11.5 (#6407) 2025-12-08 07:24:53 +01:00
72 changed files with 1242 additions and 2503 deletions

View File

@@ -152,7 +152,7 @@ jobs:
- name: Upload local wheels artifact
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: wheels-${{ matrix.arch }}
path: wheels
@@ -248,7 +248,7 @@ jobs:
- name: Download local wheels artifact
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: wheels-amd64
path: wheels

View File

@@ -34,7 +34,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -48,7 +48,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -76,7 +76,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -88,7 +88,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -119,7 +119,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -131,7 +131,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -177,7 +177,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -189,7 +189,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -221,7 +221,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -233,7 +233,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -265,7 +265,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -307,7 +307,7 @@ jobs:
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: >-
@@ -318,7 +318,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Restore mypy cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: .mypy_cache
key: >-
@@ -351,7 +351,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -386,7 +386,7 @@ jobs:
-o console_output_style=count \
tests
- name: Upload coverage artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coverage
path: .coverage
@@ -406,7 +406,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: |
@@ -417,7 +417,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Download all coverage artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: coverage
path: coverage/
@@ -428,4 +428,4 @@ jobs:
coverage report
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2

View File

@@ -9,7 +9,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"

View File

@@ -12,7 +12,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Sentry Release
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3.4.0
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3.5.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

View File

@@ -68,7 +68,7 @@ jobs:
run: |
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
- name: Create PR
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
branch: autoupdate-frontend

View File

@@ -7,9 +7,6 @@ ENV \
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1 \
UV_SYSTEM_PYTHON=true
ARG \
COSIGN_VERSION
# Install base
WORKDIR /usr/src
RUN \
@@ -25,9 +22,7 @@ RUN \
openssl \
yaml \
\
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign \
&& pip3 install uv==0.8.9
&& pip3 install uv==0.9.18
# Install requirements
RUN \

View File

@@ -1,12 +1,10 @@
image: ghcr.io/home-assistant/{arch}-hassio-supervisor
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22-2025.11.1
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22-2025.11.1
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22-2025.12.2
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22-2025.12.2
cosign:
base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.*
args:
COSIGN_VERSION: 2.5.3
labels:
io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor

View File

@@ -1,32 +1,32 @@
aiodns==3.5.0
aiodns==3.6.1
aiodocker==0.24.0
aiohttp==3.13.2
aiohttp==3.13.3
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
awesomeversion==25.8.0
backports.zstd==1.1.0
backports.zstd==1.3.0
blockbuster==1.5.26
brotli==1.2.0
ciso8601==2.3.3
colorlog==6.10.1
cpe==1.3.1
cryptography==46.0.3
debugpy==1.8.17
debugpy==1.8.19
deepmerge==2.0
dirhash==0.5.0
docker==7.1.0
faust-cchardet==2.1.19
gitpython==3.1.45
gitpython==3.1.46
jinja2==3.1.6
log-rate-limit==1.4.2
orjson==3.11.4
orjson==3.11.5
pulsectl==24.12.0
pyudev==0.24.4
PyYAML==6.0.3
requests==2.32.5
securetar==2025.12.0
sentry-sdk==2.47.0
sentry-sdk==2.48.0
setuptools==80.9.0
voluptuous==0.15.2
voluptuous==0.16.0
dbus-fast==3.1.2
zlib-fast==0.2.1

View File

@@ -1,16 +1,16 @@
astroid==4.0.2
coverage==7.12.0
mypy==1.19.0
pre-commit==4.5.0
astroid==4.0.3
coverage==7.13.1
mypy==1.19.1
pre-commit==4.5.1
pylint==4.0.4
pytest-aiohttp==1.1.0
pytest-asyncio==1.3.0
pytest-cov==7.0.0
pytest-timeout==2.4.0
pytest==9.0.1
ruff==0.14.8
time-machine==3.1.0
pytest==9.0.2
ruff==0.14.10
time-machine==3.2.0
types-docker==7.1.0.20251202
types-pyyaml==6.0.12.20250915
types-requests==2.32.4.20250913
urllib3==2.6.0
urllib3==2.6.2

View File

@@ -24,8 +24,6 @@ from securetar import AddFileError, atomic_contents_add, secure_path
import voluptuous as vol
from voluptuous.humanize import humanize_error
from supervisor.utils.dt import utc_from_timestamp
from ..bus import EventListener
from ..const import (
ATTR_ACCESS_TOKEN,
@@ -92,6 +90,7 @@ from ..resolution.data import Issue
from ..store.addon import AddonStore
from ..utils import check_port
from ..utils.apparmor import adjust_profile
from ..utils.dt import utc_from_timestamp
from ..utils.json import read_json_file, write_json_file
from ..utils.sentry import async_capture_exception
from .const import (

View File

@@ -11,8 +11,6 @@ from typing import Any
from awesomeversion import AwesomeVersion, AwesomeVersionException
from supervisor.utils.dt import utc_from_timestamp
from ..const import (
ATTR_ADVANCED,
ATTR_APPARMOR,
@@ -100,6 +98,7 @@ from ..exceptions import (
from ..jobs.const import JOB_GROUP_ADDON
from ..jobs.job_group import JobGroup
from ..utils import version_is_new_enough
from ..utils.dt import utc_from_timestamp
from .configuration import FolderMapping
from .const import (
ATTR_BACKUP,

View File

@@ -782,6 +782,10 @@ class RestAPI(CoreSysAttributes):
web.delete(
"/store/repositories/{repository}", api_store.remove_repository
),
web.post(
"/store/repositories/{repository}/repair",
api_store.repositories_repository_repair,
),
]
)

View File

@@ -7,8 +7,6 @@ from aiohttp import web
from awesomeversion import AwesomeVersion
import voluptuous as vol
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from ..const import (
ATTR_ENABLE_IPV6,
ATTR_HOSTNAME,
@@ -23,6 +21,7 @@ from ..const import (
)
from ..coresys import CoreSysAttributes
from ..exceptions import APINotFound
from ..resolution.const import ContextType, IssueType, SuggestionType
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@@ -18,6 +18,7 @@ from ..const import (
ATTR_BLK_WRITE,
ATTR_BOOT,
ATTR_CPU_PERCENT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE,
ATTR_IP_ADDRESS,
ATTR_JOB_ID,
@@ -55,6 +56,7 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE): vol.Boolean(),
vol.Optional(ATTR_DUPLICATE_LOG_FILE): vol.Boolean(),
}
)
@@ -112,6 +114,7 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
ATTR_BACKUPS_EXCLUDE_DATABASE: self.sys_homeassistant.backups_exclude_database,
ATTR_DUPLICATE_LOG_FILE: self.sys_homeassistant.duplicate_log_file,
}
@api_process
@@ -151,6 +154,9 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_BACKUPS_EXCLUDE_DATABASE
]
if ATTR_DUPLICATE_LOG_FILE in body:
self.sys_homeassistant.duplicate_log_file = body[ATTR_DUPLICATE_LOG_FILE]
await self.sys_homeassistant.save_data()
@api_process

View File

@@ -10,8 +10,6 @@ from aiohttp.web import Request, StreamResponse, middleware
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
from awesomeversion import AwesomeVersion
from supervisor.homeassistant.const import LANDINGPAGE
from ...addons.const import RE_SLUG
from ...const import (
REQUEST_FROM,
@@ -23,6 +21,7 @@ from ...const import (
VALID_API_STATES,
)
from ...coresys import CoreSys, CoreSysAttributes
from ...homeassistant.const import LANDINGPAGE
from ...utils import version_is_new_enough
from ..utils import api_return_error, extract_supervisor_token

View File

@@ -13,11 +13,10 @@ from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE
from aiohttp.http_websocket import WSMsgType
from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized
from supervisor.utils.logging import AddonLoggerAdapter
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HomeAssistantAPIError, HomeAssistantAuthError
from ..utils.json import json_dumps
from ..utils.logging import AddonLoggerAdapter
_LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@@ -5,10 +5,9 @@ from typing import Any
from aiohttp import web
import voluptuous as vol
from supervisor.exceptions import APIGone
from ..const import ATTR_FORCE_SECURITY, ATTR_PWNED
from ..coresys import CoreSysAttributes
from ..exceptions import APIGone
from .utils import api_process, api_validate
# pylint: disable=no-value-for-parameter

View File

@@ -54,6 +54,7 @@ from ..const import (
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden, APINotFound, StoreAddonNotFoundError
from ..resolution.const import ContextType, SuggestionType
from ..store.addon import AddonStore
from ..store.repository import Repository
from ..store.validate import validate_repository
@@ -359,3 +360,20 @@ class APIStore(CoreSysAttributes):
"""Remove repository from the store."""
repository: Repository = self._extract_repository(request)
await asyncio.shield(self.sys_store.remove_repository(repository))
@api_process
async def repositories_repository_repair(self, request: web.Request) -> None:
"""Repair repository."""
repository: Repository = self._extract_repository(request)
await asyncio.shield(repository.reset())
# If we have an execute reset suggestion on this repository, dismiss it and the issue
for suggestion in self.sys_resolution.suggestions:
if (
suggestion.type == SuggestionType.EXECUTE_RESET
and suggestion.context == ContextType.STORE
and suggestion.reference == repository.slug
):
for issue in self.sys_resolution.issues_for_suggestion(suggestion):
self.sys_resolution.dismiss_issue(issue)
return

View File

@@ -179,6 +179,7 @@ ATTR_DOCKER = "docker"
ATTR_DOCKER_API = "docker_api"
ATTR_DOCUMENTATION = "documentation"
ATTR_DOMAINS = "domains"
ATTR_DUPLICATE_LOG_FILE = "duplicate_log_file"
ATTR_ENABLE = "enable"
ATTR_ENABLE_IPV6 = "enable_ipv6"
ATTR_ENABLED = "enabled"

View File

@@ -2,8 +2,7 @@
from typing import Any
from supervisor.dbus.utils import dbus_connected
from ...utils import dbus_connected
from .const import BOARD_NAME_SUPERVISED
from .interface import BoardProxy

View File

@@ -7,8 +7,7 @@ from typing import Any
from dbus_fast.aio.message_bus import MessageBus
from supervisor.exceptions import DBusInterfaceError, DBusNotConnectedError
from ..exceptions import DBusInterfaceError, DBusNotConnectedError
from ..utils.dbus import DBus
from .utils import dbus_connected

View File

@@ -2,8 +2,6 @@
from typing import Any
from supervisor.dbus.network.setting import NetworkSetting
from ..const import (
DBUS_ATTR_CONNECTION,
DBUS_ATTR_ID,
@@ -22,6 +20,7 @@ from ..const import (
from ..interface import DBusInterfaceProxy, dbus_property
from ..utils import dbus_connected
from .ip_configuration import IpConfiguration
from .setting import NetworkSetting
class NetworkConnection(DBusInterfaceProxy):

View File

@@ -10,14 +10,13 @@ import os
from pathlib import Path
from socket import SocketIO
import tempfile
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Literal, cast
import aiodocker
from attr import evolve
from awesomeversion import AwesomeVersion
import docker
import docker.errors
from docker.types import Mount
import requests
from ..addons.build import AddonBuild
@@ -68,8 +67,11 @@ from .const import (
PATH_SHARE,
PATH_SSL,
Capabilities,
DockerMount,
MountBindOptions,
MountType,
PropagationMode,
Ulimit,
)
from .interface import DockerInterface
@@ -272,7 +274,7 @@ class DockerAddon(DockerInterface):
}
@property
def network_mode(self) -> str | None:
def network_mode(self) -> Literal["host"] | None:
"""Return network mode for add-on."""
if self.addon.host_network:
return "host"
@@ -311,28 +313,28 @@ class DockerAddon(DockerInterface):
return None
@property
def ulimits(self) -> list[docker.types.Ulimit] | None:
def ulimits(self) -> list[Ulimit] | None:
"""Generate ulimits for add-on."""
limits: list[docker.types.Ulimit] = []
limits: list[Ulimit] = []
# Need schedule functions
if self.addon.with_realtime:
limits.append(docker.types.Ulimit(name="rtprio", soft=90, hard=99))
limits.append(Ulimit(name="rtprio", soft=90, hard=99))
# Set available memory for memlock to 128MB
mem = 128 * 1024 * 1024
limits.append(docker.types.Ulimit(name="memlock", soft=mem, hard=mem))
limits.append(Ulimit(name="memlock", soft=mem, hard=mem))
# Add configurable ulimits from add-on config
for name, config in self.addon.ulimits.items():
if isinstance(config, int):
# Simple format: both soft and hard limits are the same
limits.append(docker.types.Ulimit(name=name, soft=config, hard=config))
limits.append(Ulimit(name=name, soft=config, hard=config))
elif isinstance(config, dict):
# Detailed format: both soft and hard limits are mandatory
soft = config["soft"]
hard = config["hard"]
limits.append(docker.types.Ulimit(name=name, soft=soft, hard=hard))
limits.append(Ulimit(name=name, soft=soft, hard=hard))
# Return None if no ulimits are present
if limits:
@@ -351,7 +353,7 @@ class DockerAddon(DockerInterface):
return None
@property
def mounts(self) -> list[Mount]:
def mounts(self) -> list[DockerMount]:
"""Return mounts for container."""
addon_mapping = self.addon.map_volumes
@@ -361,8 +363,8 @@ class DockerAddon(DockerInterface):
mounts = [
MOUNT_DEV,
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.addon.path_extern_data.as_posix(),
target=target_data_path or PATH_PRIVATE_DATA.as_posix(),
read_only=False,
@@ -372,8 +374,8 @@ class DockerAddon(DockerInterface):
# setup config mappings
if MappingType.CONFIG in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target=addon_mapping[MappingType.CONFIG].path
or PATH_HOMEASSISTANT_CONFIG_LEGACY.as_posix(),
@@ -385,8 +387,8 @@ class DockerAddon(DockerInterface):
# Map addon's public config folder if not using deprecated config option
if self.addon.addon_config_used:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.addon.path_extern_config.as_posix(),
target=addon_mapping[MappingType.ADDON_CONFIG].path
or PATH_PUBLIC_CONFIG.as_posix(),
@@ -397,8 +399,8 @@ class DockerAddon(DockerInterface):
# Map Home Assistant config in new way
if MappingType.HOMEASSISTANT_CONFIG in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target=addon_mapping[MappingType.HOMEASSISTANT_CONFIG].path
or PATH_HOMEASSISTANT_CONFIG.as_posix(),
@@ -410,8 +412,8 @@ class DockerAddon(DockerInterface):
if MappingType.ALL_ADDON_CONFIGS in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_addon_configs.as_posix(),
target=addon_mapping[MappingType.ALL_ADDON_CONFIGS].path
or PATH_ALL_ADDON_CONFIGS.as_posix(),
@@ -421,8 +423,8 @@ class DockerAddon(DockerInterface):
if MappingType.SSL in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_ssl.as_posix(),
target=addon_mapping[MappingType.SSL].path or PATH_SSL.as_posix(),
read_only=addon_mapping[MappingType.SSL].read_only,
@@ -431,8 +433,8 @@ class DockerAddon(DockerInterface):
if MappingType.ADDONS in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_addons_local.as_posix(),
target=addon_mapping[MappingType.ADDONS].path
or PATH_LOCAL_ADDONS.as_posix(),
@@ -442,8 +444,8 @@ class DockerAddon(DockerInterface):
if MappingType.BACKUP in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_backup.as_posix(),
target=addon_mapping[MappingType.BACKUP].path
or PATH_BACKUP.as_posix(),
@@ -453,25 +455,25 @@ class DockerAddon(DockerInterface):
if MappingType.SHARE in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_share.as_posix(),
target=addon_mapping[MappingType.SHARE].path
or PATH_SHARE.as_posix(),
read_only=addon_mapping[MappingType.SHARE].read_only,
propagation=PropagationMode.RSLAVE,
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
)
)
if MappingType.MEDIA in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_media.as_posix(),
target=addon_mapping[MappingType.MEDIA].path
or PATH_MEDIA.as_posix(),
read_only=addon_mapping[MappingType.MEDIA].read_only,
propagation=PropagationMode.RSLAVE,
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
)
)
@@ -483,8 +485,8 @@ class DockerAddon(DockerInterface):
if not Path(gpio_path).exists():
continue
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=gpio_path,
target=gpio_path,
read_only=False,
@@ -494,8 +496,8 @@ class DockerAddon(DockerInterface):
# DeviceTree support
if self.addon.with_devicetree:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source="/sys/firmware/devicetree/base",
target="/device-tree",
read_only=True,
@@ -509,8 +511,8 @@ class DockerAddon(DockerInterface):
# Kernel Modules support
if self.addon.with_kernel_modules:
mounts.append(
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source="/lib/modules",
target="/lib/modules",
read_only=True,
@@ -528,20 +530,20 @@ class DockerAddon(DockerInterface):
# Configuration Audio
if self.addon.with_audio:
mounts += [
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.addon.path_extern_pulse.as_posix(),
target="/etc/pulse/client.conf",
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_plugins.audio.path_extern_pulse.as_posix(),
target="/run/audio",
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_plugins.audio.path_extern_asound.as_posix(),
target="/etc/asound.conf",
read_only=True,
@@ -551,14 +553,14 @@ class DockerAddon(DockerInterface):
# System Journal access
if self.addon.with_journald:
mounts += [
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=SYSTEMD_JOURNAL_PERSISTENT.as_posix(),
target=SYSTEMD_JOURNAL_PERSISTENT.as_posix(),
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=SYSTEMD_JOURNAL_VOLATILE.as_posix(),
target=SYSTEMD_JOURNAL_VOLATILE.as_posix(),
read_only=True,
@@ -706,7 +708,9 @@ class DockerAddon(DockerInterface):
# Remove dangling builder container if it exists by any chance
# E.g. because of an abrupt host shutdown/reboot during a build
with suppress(docker.errors.NotFound):
self.sys_docker.containers.get(builder_name).remove(force=True, v=True)
self.sys_docker.containers_legacy.get(builder_name).remove(
force=True, v=True
)
# Generate Docker config with registry credentials for base image if needed
docker_config_path: Path | None = None
@@ -833,7 +837,7 @@ class DockerAddon(DockerInterface):
"""
try:
# Load needed docker objects
container = self.sys_docker.containers.get(self.name)
container = self.sys_docker.containers_legacy.get(self.name)
# attach_socket returns SocketIO for local Docker connections (Unix socket)
socket = cast(
SocketIO, container.attach_socket(params={"stdin": 1, "stream": 1})
@@ -896,7 +900,7 @@ class DockerAddon(DockerInterface):
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
self.sys_docker.containers_legacy.get, self.name
)
except docker.errors.NotFound:
if self._hw_listener:

View File

@@ -2,9 +2,6 @@
import logging
import docker
from docker.types import Mount
from ..const import DOCKER_CPU_RUNTIME_ALLOCATION
from ..coresys import CoreSysAttributes
from ..exceptions import DockerJobError
@@ -19,7 +16,9 @@ from .const import (
MOUNT_UDEV,
PATH_PRIVATE_DATA,
Capabilities,
DockerMount,
MountType,
Ulimit,
)
from .interface import DockerInterface
@@ -42,12 +41,12 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
return AUDIO_DOCKER_NAME
@property
def mounts(self) -> list[Mount]:
def mounts(self) -> list[DockerMount]:
"""Return mounts for container."""
mounts = [
MOUNT_DEV,
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_audio.as_posix(),
target=PATH_PRIVATE_DATA.as_posix(),
read_only=False,
@@ -75,10 +74,10 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
return [Capabilities.SYS_NICE, Capabilities.SYS_RESOURCE]
@property
def ulimits(self) -> list[docker.types.Ulimit]:
def ulimits(self) -> list[Ulimit]:
"""Generate ulimits for audio."""
# Pulseaudio by default tries to use real-time scheduling with priority of 5.
return [docker.types.Ulimit(name="rtprio", soft=10, hard=10)]
return [Ulimit(name="rtprio", soft=10, hard=10)]
@property
def cpu_rt_runtime(self) -> int | None:

View File

@@ -2,11 +2,13 @@
from __future__ import annotations
from enum import StrEnum
from contextlib import suppress
from dataclasses import dataclass
from enum import Enum, StrEnum
from functools import total_ordering
from pathlib import PurePath
import re
from docker.types import Mount
from typing import Any, cast
from ..const import MACHINE_ID
@@ -79,33 +81,145 @@ class PropagationMode(StrEnum):
RSLAVE = "rslave"
@total_ordering
class PullImageLayerStage(Enum):
"""Job stages for pulling an image layer.
These are a subset of the statuses in a docker pull image log. They
are the standardized ones that are the most useful to us.
"""
PULLING_FS_LAYER = 1, "Pulling fs layer"
RETRYING_DOWNLOAD = 2, "Retrying download"
DOWNLOADING = 2, "Downloading"
VERIFYING_CHECKSUM = 3, "Verifying Checksum"
DOWNLOAD_COMPLETE = 4, "Download complete"
EXTRACTING = 5, "Extracting"
PULL_COMPLETE = 6, "Pull complete"
def __init__(self, order: int, status: str) -> None:
"""Set fields from values."""
self.order = order
self.status = status
def __eq__(self, value: object, /) -> bool:
"""Check equality, allow StrEnum style comparisons on status."""
with suppress(AttributeError):
return self.status == cast(PullImageLayerStage, value).status
return self.status == value
def __lt__(self, other: object) -> bool:
"""Order instances."""
with suppress(AttributeError):
return self.order < cast(PullImageLayerStage, other).order
return False
def __hash__(self) -> int:
"""Hash instance."""
return hash(self.status)
@classmethod
def from_status(cls, status: str) -> PullImageLayerStage | None:
"""Return stage instance from pull log status."""
for i in cls:
if i.status == status:
return i
# This one includes number of seconds until download so its not constant
if RE_RETRYING_DOWNLOAD_STATUS.match(status):
return cls.RETRYING_DOWNLOAD
return None
@dataclass(slots=True, frozen=True)
class MountBindOptions:
"""Bind options for docker mount."""
propagation: PropagationMode | None = None
read_only_non_recursive: bool | None = None
def to_dict(self) -> dict[str, Any]:
"""To dictionary representation."""
out: dict[str, Any] = {}
if self.propagation:
out["Propagation"] = self.propagation.value
if self.read_only_non_recursive is not None:
out["ReadOnlyNonRecursive"] = self.read_only_non_recursive
return out
@dataclass(slots=True, frozen=True)
class DockerMount:
"""A docker mount."""
type: MountType
source: str
target: str
read_only: bool
bind_options: MountBindOptions | None = None
def to_dict(self) -> dict[str, Any]:
"""To dictionary representation."""
out: dict[str, Any] = {
"Type": self.type.value,
"Source": self.source,
"Target": self.target,
"ReadOnly": self.read_only,
}
if self.bind_options:
out["BindOptions"] = self.bind_options.to_dict()
return out
@dataclass(slots=True, frozen=True)
class Ulimit:
"""A linux user limit."""
name: str
soft: int
hard: int
def to_dict(self) -> dict[str, str | int]:
"""To dictionary representation."""
return {
"Name": self.name,
"Soft": self.soft,
"Hard": self.hard,
}
ENV_DUPLICATE_LOG_FILE = "HA_DUPLICATE_LOG_FILE"
ENV_TIME = "TZ"
ENV_TOKEN = "SUPERVISOR_TOKEN"
ENV_TOKEN_OLD = "HASSIO_TOKEN"
LABEL_MANAGED = "supervisor_managed"
MOUNT_DBUS = Mount(
type=MountType.BIND.value, source="/run/dbus", target="/run/dbus", read_only=True
MOUNT_DBUS = DockerMount(
type=MountType.BIND, source="/run/dbus", target="/run/dbus", read_only=True
)
MOUNT_DEV = Mount(
type=MountType.BIND.value, source="/dev", target="/dev", read_only=True
MOUNT_DEV = DockerMount(
type=MountType.BIND,
source="/dev",
target="/dev",
read_only=True,
bind_options=MountBindOptions(read_only_non_recursive=True),
)
MOUNT_DEV.setdefault("BindOptions", {})["ReadOnlyNonRecursive"] = True
MOUNT_DOCKER = Mount(
type=MountType.BIND.value,
MOUNT_DOCKER = DockerMount(
type=MountType.BIND,
source="/run/docker.sock",
target="/run/docker.sock",
read_only=True,
)
MOUNT_MACHINE_ID = Mount(
type=MountType.BIND.value,
MOUNT_MACHINE_ID = DockerMount(
type=MountType.BIND,
source=MACHINE_ID.as_posix(),
target=MACHINE_ID.as_posix(),
read_only=True,
)
MOUNT_UDEV = Mount(
type=MountType.BIND.value, source="/run/udev", target="/run/udev", read_only=True
MOUNT_UDEV = DockerMount(
type=MountType.BIND, source="/run/udev", target="/run/udev", read_only=True
)
PATH_PRIVATE_DATA = PurePath("/data")

View File

@@ -2,13 +2,11 @@
import logging
from docker.types import Mount
from ..coresys import CoreSysAttributes
from ..exceptions import DockerJobError
from ..jobs.const import JobConcurrency
from ..jobs.decorator import Job
from .const import ENV_TIME, MOUNT_DBUS, MountType
from .const import ENV_TIME, MOUNT_DBUS, DockerMount, MountType
from .interface import DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -47,8 +45,8 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
security_opt=self.security_opt,
environment={ENV_TIME: self.sys_timezone},
mounts=[
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_dns.as_posix(),
target="/config",
read_only=False,

View File

@@ -5,7 +5,6 @@ import logging
import re
from awesomeversion import AwesomeVersion
from docker.types import Mount
from ..const import LABEL_MACHINE
from ..exceptions import DockerJobError
@@ -14,6 +13,7 @@ from ..homeassistant.const import LANDINGPAGE
from ..jobs.const import JobConcurrency
from ..jobs.decorator import Job
from .const import (
ENV_DUPLICATE_LOG_FILE,
ENV_TIME,
ENV_TOKEN,
ENV_TOKEN_OLD,
@@ -25,6 +25,8 @@ from .const import (
PATH_PUBLIC_CONFIG,
PATH_SHARE,
PATH_SSL,
DockerMount,
MountBindOptions,
MountType,
PropagationMode,
)
@@ -90,15 +92,15 @@ class DockerHomeAssistant(DockerInterface):
)
@property
def mounts(self) -> list[Mount]:
def mounts(self) -> list[DockerMount]:
"""Return mounts for container."""
mounts = [
MOUNT_DEV,
MOUNT_DBUS,
MOUNT_UDEV,
# HA config folder
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target=PATH_PUBLIC_CONFIG.as_posix(),
read_only=False,
@@ -110,41 +112,45 @@ class DockerHomeAssistant(DockerInterface):
mounts.extend(
[
# All other folders
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_ssl.as_posix(),
target=PATH_SSL.as_posix(),
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_share.as_posix(),
target=PATH_SHARE.as_posix(),
read_only=False,
propagation=PropagationMode.RSLAVE.value,
bind_options=MountBindOptions(
propagation=PropagationMode.RSLAVE
),
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_media.as_posix(),
target=PATH_MEDIA.as_posix(),
read_only=False,
propagation=PropagationMode.RSLAVE.value,
bind_options=MountBindOptions(
propagation=PropagationMode.RSLAVE
),
),
# Configuration audio
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_homeassistant.path_extern_pulse.as_posix(),
target="/etc/pulse/client.conf",
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_plugins.audio.path_extern_pulse.as_posix(),
target="/run/audio",
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_plugins.audio.path_extern_asound.as_posix(),
target="/etc/asound.conf",
read_only=True,
@@ -174,6 +180,8 @@ class DockerHomeAssistant(DockerInterface):
}
if restore_job_id:
environment[ENV_RESTORE_JOB_ID] = restore_job_id
if self.sys_homeassistant.duplicate_log_file:
environment[ENV_DUPLICATE_LOG_FILE] = "1"
await self._run(
tag=(self.sys_homeassistant.version),
name=self.name,
@@ -213,20 +221,20 @@ class DockerHomeAssistant(DockerInterface):
init=True,
entrypoint=[],
mounts=[
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_ssl.as_posix(),
target="/ssl",
read_only=True,
),
Mount(
type=MountType.BIND.value,
DockerMount(
type=MountType.BIND,
source=self.sys_config.path_extern_share.as_posix(),
target="/share",
read_only=False,

View File

@@ -13,13 +13,13 @@ from typing import Any, cast
from uuid import uuid4
import aiodocker
import aiohttp
from awesomeversion import AwesomeVersion
from awesomeversion.strategy import AwesomeVersionStrategy
import docker
from docker.models.containers import Container
import requests
from ..bus import EventListener
from ..const import (
ATTR_PASSWORD,
ATTR_REGISTRY,
@@ -35,18 +35,25 @@ from ..exceptions import (
DockerError,
DockerHubRateLimitExceeded,
DockerJobError,
DockerLogOutOfOrder,
DockerNotFound,
DockerRequestError,
)
from ..jobs import SupervisorJob
from ..jobs.const import JOB_GROUP_DOCKER_INTERFACE, JobConcurrency
from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils.sentry import async_capture_exception
from .const import DOCKER_HUB, DOCKER_HUB_LEGACY, ContainerState, RestartPolicy
from .const import (
DOCKER_HUB,
DOCKER_HUB_LEGACY,
ContainerState,
PullImageLayerStage,
RestartPolicy,
)
from .manager import CommandReturn, PullLogEntry
from .monitor import DockerContainerStateEvent
from .pull_progress import ImagePullProgress
from .stats import DockerStats
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -195,6 +202,159 @@ class DockerInterface(JobGroup, ABC):
return credentials
def _process_pull_image_log( # noqa: C901
self, install_job_id: str, reference: PullLogEntry
) -> None:
"""Process events fired from a docker while pulling an image, filtered to a given job id."""
if (
reference.job_id != install_job_id
or not reference.id
or not reference.status
or not (stage := PullImageLayerStage.from_status(reference.status))
):
return
# Pulling FS Layer is our marker for a layer that needs to be downloaded and extracted. Otherwise it already exists and we can ignore
job: SupervisorJob | None = None
if stage == PullImageLayerStage.PULLING_FS_LAYER:
job = self.sys_jobs.new_job(
name="Pulling container image layer",
initial_stage=stage.status,
reference=reference.id,
parent_id=install_job_id,
internal=True,
)
job.done = False
return
# Find our sub job to update details of
for j in self.sys_jobs.jobs:
if j.parent_id == install_job_id and j.reference == reference.id:
job = j
break
# There should no longer be any real risk of logs out of order anymore.
# However tests with very small images have shown that sometimes Docker
# skips stages in log. So keeping this one as a safety check on null job
if not job:
raise DockerLogOutOfOrder(
f"Received pull image log with status {reference.status} for image id {reference.id} and parent job {install_job_id} but could not find a matching job, skipping",
_LOGGER.debug,
)
# For progress calculation we assume downloading is 70% of time, extracting is 30% and others stages negligible
progress = job.progress
match stage:
case PullImageLayerStage.DOWNLOADING | PullImageLayerStage.EXTRACTING:
if (
reference.progress_detail
and reference.progress_detail.current
and reference.progress_detail.total
):
progress = (
reference.progress_detail.current
/ reference.progress_detail.total
)
if stage == PullImageLayerStage.DOWNLOADING:
progress = 70 * progress
else:
progress = 70 + 30 * progress
case (
PullImageLayerStage.VERIFYING_CHECKSUM
| PullImageLayerStage.DOWNLOAD_COMPLETE
):
progress = 70
case PullImageLayerStage.PULL_COMPLETE:
progress = 100
case PullImageLayerStage.RETRYING_DOWNLOAD:
progress = 0
# No real risk of getting things out of order in current implementation
# but keeping this one in case another change to these trips us up.
if stage != PullImageLayerStage.RETRYING_DOWNLOAD and progress < job.progress:
raise DockerLogOutOfOrder(
f"Received pull image log with status {reference.status} for job {job.uuid} that implied progress was {progress} but current progress is {job.progress}, skipping",
_LOGGER.debug,
)
# Our filters have all passed. Time to update the job
# Only downloading and extracting have progress details. Use that to set extra
# We'll leave it around on later stages as the total bytes may be useful after that stage
# Enforce range to prevent float drift error
progress = max(0, min(progress, 100))
if (
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
and reference.progress_detail
and reference.progress_detail.current is not None
and reference.progress_detail.total is not None
):
job.update(
progress=progress,
stage=stage.status,
extra={
"current": reference.progress_detail.current,
"total": reference.progress_detail.total,
},
)
else:
# If we reach DOWNLOAD_COMPLETE without ever having set extra (small layers that skip
# the downloading phase), set a minimal extra so aggregate progress calculation can proceed
extra = job.extra
if stage == PullImageLayerStage.DOWNLOAD_COMPLETE and not job.extra:
extra = {"current": 1, "total": 1}
job.update(
progress=progress,
stage=stage.status,
done=stage == PullImageLayerStage.PULL_COMPLETE,
extra=None if stage == PullImageLayerStage.RETRYING_DOWNLOAD else extra,
)
# Once we have received a progress update for every child job, start to set status of the main one
install_job = self.sys_jobs.get_job(install_job_id)
layer_jobs = [
job
for job in self.sys_jobs.jobs
if job.parent_id == install_job.uuid
and job.name == "Pulling container image layer"
]
# First set the total bytes to be downloaded/extracted on the main job
if not install_job.extra:
total = 0
for job in layer_jobs:
if not job.extra:
return
total += job.extra["total"]
install_job.extra = {"total": total}
else:
total = install_job.extra["total"]
# Then determine total progress based on progress of each sub-job, factoring in size of each compared to total
progress = 0.0
stage = PullImageLayerStage.PULL_COMPLETE
for job in layer_jobs:
if not job.extra or not job.extra.get("total"):
return
progress += job.progress * (job.extra["total"] / total)
job_stage = PullImageLayerStage.from_status(cast(str, job.stage))
if job_stage < PullImageLayerStage.EXTRACTING:
stage = PullImageLayerStage.DOWNLOADING
elif (
stage == PullImageLayerStage.PULL_COMPLETE
and job_stage < PullImageLayerStage.PULL_COMPLETE
):
stage = PullImageLayerStage.EXTRACTING
# Ensure progress is 100 at this point to prevent float drift
if stage == PullImageLayerStage.PULL_COMPLETE:
progress = 100
# To reduce noise, limit updates to when result has changed by an entire percent or when stage changed
if stage != install_job.stage or progress >= install_job.progress + 1:
install_job.update(stage=stage.status, progress=max(0, min(progress, 100)))
@Job(
name="docker_interface_install",
on_condition=DockerJobError,
@@ -214,55 +374,33 @@ class DockerInterface(JobGroup, ABC):
raise ValueError("Cannot pull without an image!")
image_arch = arch or self.sys_arch.supervisor
platform = MAP_ARCH[image_arch]
pull_progress = ImagePullProgress()
current_job = self.sys_jobs.current
# Try to fetch manifest for accurate size-based progress
# This is optional - if it fails, we fall back to count-based progress
try:
manifest = await self.sys_docker.manifest_fetcher.get_manifest(
image, str(version), platform=platform
)
if manifest:
pull_progress.set_manifest(manifest)
_LOGGER.debug(
"Using manifest for progress: %d layers, %d bytes",
manifest.layer_count,
manifest.total_size,
)
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.warning("Could not fetch manifest for progress: %s", err)
async def process_pull_event(event: PullLogEntry) -> None:
"""Process pull event and update job progress."""
if event.job_id != current_job.uuid:
return
# Process event through progress tracker
pull_progress.process_event(event)
# Update job if progress changed significantly (>= 1%)
should_update, progress = pull_progress.should_update_job()
if should_update:
stage = pull_progress.get_stage()
current_job.update(progress=progress, stage=stage)
listener = self.sys_bus.register_event(
BusEvent.DOCKER_IMAGE_PULL_UPDATE, process_pull_event
)
listener: EventListener | None = None
_LOGGER.info("Downloading docker image %s with tag %s.", image, version)
try:
# Get credentials for private registries to pass to aiodocker
credentials = self._get_credentials(image) or None
curr_job_id = self.sys_jobs.current.uuid
async def process_pull_image_log(reference: PullLogEntry) -> None:
try:
self._process_pull_image_log(curr_job_id, reference)
except DockerLogOutOfOrder as err:
# Send all these to sentry. Missing a few progress updates
# shouldn't matter to users but matters to us
await async_capture_exception(err)
listener = self.sys_bus.register_event(
BusEvent.DOCKER_IMAGE_PULL_UPDATE, process_pull_image_log
)
# Pull new image, passing credentials to aiodocker
docker_image = await self.sys_docker.pull_image(
current_job.uuid,
self.sys_jobs.current.uuid,
image,
str(version),
platform=platform,
platform=MAP_ARCH[image_arch],
auth=credentials,
)
@@ -307,7 +445,8 @@ class DockerInterface(JobGroup, ABC):
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
) from err
finally:
self.sys_bus.remove_listener(listener)
if listener:
self.sys_bus.remove_listener(listener)
self._meta = docker_image
@@ -322,7 +461,7 @@ class DockerInterface(JobGroup, ABC):
"""Get docker container, returns None if not found."""
try:
return await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
self.sys_docker.containers_legacy.get, self.name
)
except docker.errors.NotFound:
return None
@@ -354,7 +493,7 @@ class DockerInterface(JobGroup, ABC):
"""Attach to running Docker container."""
with suppress(docker.errors.DockerException, requests.RequestException):
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
self.sys_docker.containers_legacy.get, self.name
)
self._meta = docker_container.attrs
self.sys_docker.monitor.watch_container(docker_container)
@@ -394,8 +533,11 @@ class DockerInterface(JobGroup, ABC):
"""Run Docker image."""
raise NotImplementedError()
async def _run(self, **kwargs) -> None:
"""Run Docker image with retry inf necessary."""
async def _run(self, *, name: str, **kwargs) -> None:
"""Run Docker image with retry if necessary."""
if not (image := self.image):
raise ValueError(f"Cannot determine image to use to run {self.name}!")
if await self.is_running():
return
@@ -404,16 +546,14 @@ class DockerInterface(JobGroup, ABC):
# Create & Run container
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.run, self.image, **kwargs
)
container_metadata = await self.sys_docker.run(image, name=name, **kwargs)
except DockerNotFound as err:
# If image is missing, capture the exception as this shouldn't happen
await async_capture_exception(err)
raise
# Store metadata
self._meta = docker_container.attrs
self._meta = container_metadata
@Job(
name="docker_interface_stop",

View File

@@ -13,10 +13,12 @@ import logging
import os
from pathlib import Path
import re
from typing import Any, Final, Self, cast
from typing import Any, Final, Literal, Self, cast
import aiodocker
from aiodocker.containers import DockerContainers
from aiodocker.images import DockerImages
from aiodocker.types import JSONObject
from aiohttp import ClientSession, ClientTimeout, UnixConnector
import attr
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
@@ -49,8 +51,16 @@ from ..exceptions import (
)
from ..utils.common import FileConfiguration
from ..validate import SCHEMA_DOCKER_CONFIG
from .const import DOCKER_HUB, DOCKER_HUB_LEGACY, LABEL_MANAGED
from .manifest import RegistryManifestFetcher
from .const import (
DOCKER_HUB,
DOCKER_HUB_LEGACY,
LABEL_MANAGED,
Capabilities,
DockerMount,
MountType,
RestartPolicy,
Ulimit,
)
from .monitor import DockerMonitor
from .network import DockerNetwork
from .utils import get_registry_from_image
@@ -259,9 +269,6 @@ class DockerAPI(CoreSysAttributes):
self._info: DockerInfo | None = None
self.config: DockerConfig = DockerConfig()
self._monitor: DockerMonitor = DockerMonitor(coresys)
self._manifest_fetcher: RegistryManifestFetcher = RegistryManifestFetcher(
coresys
)
async def post_init(self) -> Self:
"""Post init actions that must be done in event loop."""
@@ -301,8 +308,13 @@ class DockerAPI(CoreSysAttributes):
return self.docker.images
@property
def containers(self) -> ContainerCollection:
def containers(self) -> DockerContainers:
"""Return API containers."""
return self.docker.containers
@property
def containers_legacy(self) -> ContainerCollection:
"""Return API containers from Dockerpy."""
return self.dockerpy.containers
@property
@@ -327,11 +339,6 @@ class DockerAPI(CoreSysAttributes):
"""Return docker events monitor."""
return self._monitor
@property
def manifest_fetcher(self) -> RegistryManifestFetcher:
"""Return manifest fetcher for registry access."""
return self._manifest_fetcher
async def load(self) -> None:
"""Start docker events monitor."""
await self.monitor.load()
@@ -340,50 +347,137 @@ class DockerAPI(CoreSysAttributes):
"""Stop docker events monitor."""
await self.monitor.unload()
def run(
def _create_container_config(
self,
image: str,
*,
tag: str = "latest",
dns: bool = True,
ipv4: IPv4Address | None = None,
**kwargs: Any,
) -> Container:
"""Create a Docker container and run it.
init: bool = False,
hostname: str | None = None,
detach: bool = True,
security_opt: list[str] | None = None,
restart_policy: dict[str, RestartPolicy] | None = None,
extra_hosts: dict[str, IPv4Address] | None = None,
environment: dict[str, str | None] | None = None,
mounts: list[DockerMount] | None = None,
ports: dict[str, str | int | None] | None = None,
oom_score_adj: int | None = None,
network_mode: Literal["host"] | None = None,
privileged: bool = False,
device_cgroup_rules: list[str] | None = None,
tmpfs: dict[str, str] | None = None,
entrypoint: list[str] | None = None,
cap_add: list[Capabilities] | None = None,
ulimits: list[Ulimit] | None = None,
cpu_rt_runtime: int | None = None,
stdin_open: bool = False,
pid_mode: str | None = None,
uts_mode: str | None = None,
) -> JSONObject:
"""Map kwargs to create container config.
Need run inside executor.
This only covers the docker options we currently use. It is not intended
to be exhaustive as its dockerpy equivalent was. We'll add to it as we
make use of new feature.
"""
name: str | None = kwargs.get("name")
network_mode: str | None = kwargs.get("network_mode")
hostname: str | None = kwargs.get("hostname")
# Set up host dependent config for container
host_config: dict[str, Any] = {
"NetworkMode": network_mode if network_mode else "default",
"Init": init,
"Privileged": privileged,
}
if security_opt:
host_config["SecurityOpt"] = security_opt
if restart_policy:
host_config["RestartPolicy"] = restart_policy
if extra_hosts:
host_config["ExtraHosts"] = [f"{k}:{v}" for k, v in extra_hosts.items()]
if mounts:
host_config["Mounts"] = [mount.to_dict() for mount in mounts]
if oom_score_adj is not None:
host_config["OomScoreAdj"] = oom_score_adj
if device_cgroup_rules:
host_config["DeviceCgroupRules"] = device_cgroup_rules
if tmpfs:
host_config["Tmpfs"] = tmpfs
if cap_add:
host_config["CapAdd"] = cap_add
if cpu_rt_runtime is not None:
host_config["CPURealtimeRuntime"] = cpu_rt_runtime
if pid_mode:
host_config["PidMode"] = pid_mode
if uts_mode:
host_config["UtsMode"] = uts_mode
if ulimits:
host_config["Ulimits"] = [limit.to_dict() for limit in ulimits]
if "labels" not in kwargs:
kwargs["labels"] = {}
elif isinstance(kwargs["labels"], list):
kwargs["labels"] = dict.fromkeys(kwargs["labels"], "")
# Full container config
config: dict[str, Any] = {
"Image": f"{image}:{tag}",
"Labels": {LABEL_MANAGED: ""},
"OpenStdin": stdin_open,
"StdinOnce": not detach and stdin_open,
"AttachStdin": not detach and stdin_open,
"AttachStdout": not detach,
"AttachStderr": not detach,
"HostConfig": host_config,
}
if hostname:
config["Hostname"] = hostname
if environment:
config["Env"] = [
env if val is None else f"{env}={val}"
for env, val in environment.items()
]
if entrypoint:
config["Entrypoint"] = entrypoint
kwargs["labels"][LABEL_MANAGED] = ""
# Setup DNS
# Set up networking
if dns:
kwargs["dns"] = [str(self.network.dns)]
kwargs["dns_search"] = [DNS_SUFFIX]
host_config["Dns"] = [str(self.network.dns)]
host_config["DnsSearch"] = [DNS_SUFFIX]
# CoreDNS forward plug-in fails in ~6s, then fallback triggers.
# However, the default timeout of glibc and musl is 5s. Increase
# default timeout to make sure CoreDNS fallback is working
# on first query.
kwargs["dns_opt"] = ["timeout:10"]
host_config["DnsOptions"] = ["timeout:10"]
if hostname:
kwargs["domainname"] = DNS_SUFFIX
config["Domainname"] = DNS_SUFFIX
# Setup network
if not network_mode:
kwargs["network"] = None
# Setup ports
if ports:
port_bindings = {
port if "/" in port else f"{port}/tcp": [
{"HostIp": "", "HostPort": str(host_port) if host_port else ""}
]
for port, host_port in ports.items()
}
config["ExposedPorts"] = {port: {} for port in port_bindings}
host_config["PortBindings"] = port_bindings
return config
async def run(
self,
image: str,
*,
name: str,
tag: str = "latest",
hostname: str | None = None,
mounts: list[DockerMount] | None = None,
network_mode: Literal["host"] | None = None,
ipv4: IPv4Address | None = None,
**kwargs,
) -> dict[str, Any]:
"""Create a Docker container and run it."""
if not image or not name:
raise ValueError("image, name and tag cannot be an empty string!")
# Setup cidfile and bind mount it
cidfile_path = None
if name:
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
def create_cidfile() -> None:
# Remove the file/directory if it exists e.g. as a leftover from unclean shutdown
# Note: Can be a directory if Docker auto-started container with restart policy
# before Supervisor could write the CID file
@@ -397,31 +491,37 @@ class DockerAPI(CoreSysAttributes):
# from creating it as a directory if container auto-starts
cidfile_path.touch()
extern_cidfile_path = (
self.coresys.config.path_extern_cid_files / f"{name}.cid"
)
await self.sys_run_in_executor(create_cidfile)
# Bind mount to /run/cid in container
if "volumes" not in kwargs:
kwargs["volumes"] = {}
kwargs["volumes"][str(extern_cidfile_path)] = {
"bind": "/run/cid",
"mode": "ro",
}
# Bind mount to /run/cid in container
extern_cidfile_path = self.coresys.config.path_extern_cid_files / f"{name}.cid"
cid_mount = DockerMount(
type=MountType.BIND,
source=extern_cidfile_path.as_posix(),
target="/run/cid",
read_only=True,
)
if mounts is None:
mounts = [cid_mount]
else:
mounts = [*mounts, cid_mount]
# Create container
config = self._create_container_config(
image,
tag=tag,
hostname=hostname,
mounts=mounts,
network_mode=network_mode,
**kwargs,
)
try:
container = self.containers.create(
f"{image}:{tag}", use_config_proxy=False, **kwargs
)
if cidfile_path:
with cidfile_path.open("w", encoding="ascii") as cidfile:
cidfile.write(str(container.id))
except docker_errors.NotFound as err:
raise DockerNotFound(
f"Image {image}:{tag} does not exist for {name}", _LOGGER.error
) from err
except docker_errors.DockerException as err:
container = await self.containers.create(config, name=name)
except aiodocker.DockerError as err:
if err.status == HTTPStatus.NOT_FOUND:
raise DockerNotFound(
f"Image {image}:{tag} does not exist for {name}", _LOGGER.error
) from err
raise DockerAPIError(
f"Can't create container from {name}: {err}", _LOGGER.error
) from err
@@ -430,43 +530,61 @@ class DockerAPI(CoreSysAttributes):
f"Dockerd connection issue for {name}: {err}", _LOGGER.error
) from err
# Attach network
if not network_mode:
alias = [hostname] if hostname else None
try:
self.network.attach_container(container, alias=alias, ipv4=ipv4)
except DockerError:
_LOGGER.warning("Can't attach %s to hassio-network!", name)
else:
with suppress(DockerError):
self.network.detach_default_bridge(container)
else:
host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST)
# Setup network and store container id in cidfile
def setup_network_and_cidfile() -> None:
# Write cidfile
with cidfile_path.open("w", encoding="ascii") as cidfile:
cidfile.write(str(container.id))
# Check if container is register on host
# https://github.com/moby/moby/issues/23302
if name and name in (
val.get("Name")
for val in host_network.attrs.get("Containers", {}).values()
):
with suppress(docker_errors.NotFound):
host_network.disconnect(name, force=True)
# Attach network
if not network_mode:
alias = [hostname] if hostname else None
try:
self.network.attach_container(
container.id, name, alias=alias, ipv4=ipv4
)
except DockerError:
_LOGGER.warning("Can't attach %s to hassio-network!", name)
else:
with suppress(DockerError):
self.network.detach_default_bridge(container.id, name)
else:
host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST)
# Check if container is register on host
# https://github.com/moby/moby/issues/23302
if name and name in (
val.get("Name")
for val in host_network.attrs.get("Containers", {}).values()
):
with suppress(docker_errors.NotFound):
host_network.disconnect(name, force=True)
await self.sys_run_in_executor(setup_network_and_cidfile)
# Run container
try:
container.start()
except docker_errors.DockerException as err:
await container.start()
except aiodocker.DockerError as err:
raise DockerAPIError(f"Can't start {name}: {err}", _LOGGER.error) from err
except requests.RequestException as err:
raise DockerRequestError(
f"Dockerd connection issue for {name}: {err}", _LOGGER.error
) from err
# Update metadata
with suppress(docker_errors.DockerException, requests.RequestException):
container.reload()
# Get container metadata after the container is started
try:
container_attrs = await container.show()
except aiodocker.DockerError as err:
raise DockerAPIError(
f"Can't inspect started container {name}: {err}", _LOGGER.error
) from err
except requests.RequestException as err:
raise DockerRequestError(
f"Dockerd connection issue for {name}: {err}", _LOGGER.error
) from err
return container
return container_attrs
async def pull_image(
self,
@@ -619,7 +737,9 @@ class DockerAPI(CoreSysAttributes):
) -> bool:
"""Return True if docker container exists in good state and is built from expected image."""
try:
docker_container = await self.sys_run_in_executor(self.containers.get, name)
docker_container = await self.sys_run_in_executor(
self.containers_legacy.get, name
)
docker_image = await self.images.inspect(f"{image}:{version}")
except docker_errors.NotFound:
return False
@@ -648,7 +768,7 @@ class DockerAPI(CoreSysAttributes):
) -> None:
"""Stop/remove Docker container."""
try:
docker_container: Container = self.containers.get(name)
docker_container: Container = self.containers_legacy.get(name)
except docker_errors.NotFound:
# Generally suppressed so we don't log this
raise DockerNotFound() from None
@@ -675,7 +795,7 @@ class DockerAPI(CoreSysAttributes):
def start_container(self, name: str) -> None:
"""Start Docker container."""
try:
docker_container: Container = self.containers.get(name)
docker_container: Container = self.containers_legacy.get(name)
except docker_errors.NotFound:
raise DockerNotFound(
f"{name} not found for starting up", _LOGGER.error
@@ -694,7 +814,7 @@ class DockerAPI(CoreSysAttributes):
def restart_container(self, name: str, timeout: int) -> None:
"""Restart docker container."""
try:
container: Container = self.containers.get(name)
container: Container = self.containers_legacy.get(name)
except docker_errors.NotFound:
raise DockerNotFound(
f"Container {name} not found for restarting", _LOGGER.warning
@@ -713,7 +833,7 @@ class DockerAPI(CoreSysAttributes):
def container_logs(self, name: str, tail: int = 100) -> bytes:
"""Return Docker logs of container."""
try:
docker_container: Container = self.containers.get(name)
docker_container: Container = self.containers_legacy.get(name)
except docker_errors.NotFound:
raise DockerNotFound(
f"Container {name} not found for logs", _LOGGER.warning
@@ -733,7 +853,7 @@ class DockerAPI(CoreSysAttributes):
def container_stats(self, name: str) -> dict[str, Any]:
"""Read and return stats from container."""
try:
docker_container: Container = self.containers.get(name)
docker_container: Container = self.containers_legacy.get(name)
except docker_errors.NotFound:
raise DockerNotFound(
f"Container {name} not found for stats", _LOGGER.warning
@@ -758,7 +878,7 @@ class DockerAPI(CoreSysAttributes):
def container_run_inside(self, name: str, command: str) -> CommandReturn:
"""Execute a command inside Docker container."""
try:
docker_container: Container = self.containers.get(name)
docker_container: Container = self.containers_legacy.get(name)
except docker_errors.NotFound:
raise DockerNotFound(
f"Container {name} not found for running command", _LOGGER.warning

View File

@@ -1,339 +0,0 @@
"""Docker registry manifest fetcher.
Fetches image manifests directly from container registries to get layer sizes
before pulling an image. This enables accurate size-based progress tracking.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import re
from typing import TYPE_CHECKING
import aiohttp
from supervisor.docker.utils import get_registry_from_image
from .const import DOCKER_HUB, DOCKER_HUB_LEGACY
if TYPE_CHECKING:
from ..coresys import CoreSys
_LOGGER = logging.getLogger(__name__)
# Media types for manifest requests
MANIFEST_MEDIA_TYPES = (
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.index.v1+json",
)
@dataclass
class ImageManifest:
"""Container image manifest with layer information."""
digest: str
total_size: int
layers: dict[str, int] # digest -> size in bytes
@property
def layer_count(self) -> int:
"""Return number of layers."""
return len(self.layers)
def parse_image_reference(image: str, tag: str) -> tuple[str, str, str]:
"""Parse image reference into (registry, repository, tag).
Examples:
ghcr.io/home-assistant/home-assistant:2025.1.0
-> (ghcr.io, home-assistant/home-assistant, 2025.1.0)
homeassistant/home-assistant:latest
-> (registry-1.docker.io, homeassistant/home-assistant, latest)
alpine:3.18
-> (registry-1.docker.io, library/alpine, 3.18)
"""
# Check if image has explicit registry host
registry = get_registry_from_image(image)
if registry:
repository = image[len(registry) + 1 :] # Remove "registry/" prefix
else:
registry = DOCKER_HUB
repository = image
# Docker Hub requires "library/" prefix for official images
if "/" not in repository:
repository = f"library/{repository}"
return registry, repository, tag
class RegistryManifestFetcher:
"""Fetches manifests from container registries."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize the fetcher."""
self.coresys = coresys
@property
def _session(self) -> aiohttp.ClientSession:
"""Return the websession for HTTP requests."""
return self.coresys.websession
def _get_credentials(self, registry: str) -> tuple[str, str] | None:
"""Get credentials for registry from Docker config.
Returns (username, password) tuple or None if no credentials.
"""
registries = self.coresys.docker.config.registries
# Map registry hostname to config key
# Docker Hub can be stored as "hub.docker.com" in config
if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY):
if DOCKER_HUB in registries:
creds = registries[DOCKER_HUB]
return creds.get("username"), creds.get("password")
elif registry in registries:
creds = registries[registry]
return creds.get("username"), creds.get("password")
return None
async def _get_auth_token(
self,
registry: str,
repository: str,
) -> str | None:
"""Get authentication token for registry.
Uses the WWW-Authenticate header from a 401 response to discover
the token endpoint, then requests a token with appropriate scope.
"""
# First, make an unauthenticated request to get WWW-Authenticate header
manifest_url = f"https://{registry}/v2/{repository}/manifests/latest"
try:
async with self._session.get(manifest_url) as resp:
if resp.status == 200:
# No auth required
return None
if resp.status != 401:
_LOGGER.warning(
"Unexpected status %d from registry %s", resp.status, registry
)
return None
www_auth = resp.headers.get("WWW-Authenticate", "")
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to connect to registry %s: %s", registry, err)
return None
# Parse WWW-Authenticate: Bearer realm="...",service="...",scope="..."
if not www_auth.startswith("Bearer "):
_LOGGER.warning("Unsupported auth type from %s: %s", registry, www_auth)
return None
params = {}
for match in re.finditer(r'(\w+)="([^"]*)"', www_auth):
params[match.group(1)] = match.group(2)
realm = params.get("realm")
service = params.get("service")
if not realm:
_LOGGER.warning("No realm in WWW-Authenticate from %s", registry)
return None
# Build token request URL
token_url = f"{realm}?scope=repository:{repository}:pull"
if service:
token_url += f"&service={service}"
# Check for credentials
auth = None
credentials = self._get_credentials(registry)
if credentials:
username, password = credentials
if username and password:
auth = aiohttp.BasicAuth(username, password)
_LOGGER.debug("Using credentials for %s", registry)
try:
async with self._session.get(token_url, auth=auth) as resp:
if resp.status != 200:
_LOGGER.warning(
"Failed to get token from %s: %d", realm, resp.status
)
return None
data = await resp.json()
return data.get("token") or data.get("access_token")
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to get auth token: %s", err)
return None
async def _fetch_manifest(
self,
registry: str,
repository: str,
reference: str,
token: str | None,
platform: str,
) -> dict | None:
"""Fetch manifest from registry.
If the manifest is a manifest list (multi-arch), fetches the
platform-specific manifest.
"""
manifest_url = f"https://{registry}/v2/{repository}/manifests/{reference}"
headers = {"Accept": ", ".join(MANIFEST_MEDIA_TYPES)}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
async with self._session.get(manifest_url, headers=headers) as resp:
if resp.status != 200:
_LOGGER.warning(
"Failed to fetch manifest for %s/%s:%s - %d",
registry,
repository,
reference,
resp.status,
)
return None
manifest = await resp.json()
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch manifest: %s", err)
return None
media_type = manifest.get("mediaType", "")
# Check if this is a manifest list (multi-arch image)
if "list" in media_type or "index" in media_type:
manifests = manifest.get("manifests", [])
if not manifests:
_LOGGER.warning("Empty manifest list for %s/%s", registry, repository)
return None
# Platform format is "linux/amd64", "linux/arm64", etc.
parts = platform.split("/")
if len(parts) < 2:
_LOGGER.warning("Invalid platform format: %s", platform)
return None
target_os, target_arch = parts[0], parts[1]
platform_manifest = None
for m in manifests:
plat = m.get("platform", {})
if (
plat.get("os") == target_os
and plat.get("architecture") == target_arch
):
platform_manifest = m
break
if not platform_manifest:
_LOGGER.warning(
"Platform %s/%s not found in manifest list for %s/%s, "
"cannot use manifest for progress tracking",
target_os,
target_arch,
registry,
repository,
)
return None
# Fetch the platform-specific manifest
return await self._fetch_manifest(
registry,
repository,
platform_manifest["digest"],
token,
platform,
)
return manifest
async def get_manifest(
self,
image: str,
tag: str,
platform: str,
) -> ImageManifest | None:
"""Fetch manifest and extract layer sizes.
Args:
image: Image name (e.g., "ghcr.io/home-assistant/home-assistant")
tag: Image tag (e.g., "2025.1.0")
platform: Target platform (e.g., "linux/amd64")
Returns:
ImageManifest with layer sizes, or None if fetch failed.
"""
registry, repository, tag = parse_image_reference(image, tag)
_LOGGER.debug(
"Fetching manifest for %s/%s:%s (platform=%s)",
registry,
repository,
tag,
platform,
)
# Get auth token
token = await self._get_auth_token(registry, repository)
# Fetch manifest
manifest = await self._fetch_manifest(
registry, repository, tag, token, platform
)
if not manifest:
return None
# Extract layer information
layers = manifest.get("layers", [])
if not layers:
_LOGGER.warning(
"No layers in manifest for %s/%s:%s", registry, repository, tag
)
return None
layer_sizes: dict[str, int] = {}
total_size = 0
for layer in layers:
digest = layer.get("digest", "")
size = layer.get("size", 0)
if digest and size:
# Store by short digest (first 12 chars after sha256:)
short_digest = (
digest.split(":")[1][:12] if ":" in digest else digest[:12]
)
layer_sizes[short_digest] = size
total_size += size
digest = manifest.get("config", {}).get("digest", "")
_LOGGER.debug(
"Manifest for %s/%s:%s - %d layers, %d bytes total",
registry,
repository,
tag,
len(layer_sizes),
total_size,
)
return ImageManifest(
digest=digest,
total_size=total_size,
layers=layer_sizes,
)

View File

@@ -7,7 +7,6 @@ import logging
from typing import Self, cast
import docker
from docker.models.containers import Container
from docker.models.networks import Network
import requests
@@ -220,7 +219,8 @@ class DockerNetwork:
def attach_container(
self,
container: Container,
container_id: str,
name: str,
alias: list[str] | None = None,
ipv4: IPv4Address | None = None,
) -> None:
@@ -233,15 +233,15 @@ class DockerNetwork:
self.network.reload()
# Check stale Network
if container.name and container.name in (
if name in (
val.get("Name") for val in self.network.attrs.get("Containers", {}).values()
):
self.stale_cleanup(container.name)
self.stale_cleanup(name)
# Attach Network
try:
self.network.connect(
container, aliases=alias, ipv4_address=str(ipv4) if ipv4 else None
container_id, aliases=alias, ipv4_address=str(ipv4) if ipv4 else None
)
except (
docker.errors.NotFound,
@@ -250,7 +250,7 @@ class DockerNetwork:
requests.RequestException,
) as err:
raise DockerError(
f"Can't connect {container.name} to Supervisor network: {err}",
f"Can't connect {name} to Supervisor network: {err}",
_LOGGER.error,
) from err
@@ -274,17 +274,20 @@ class DockerNetwork:
) as err:
raise DockerError(f"Can't find {name}: {err}", _LOGGER.error) from err
if container.id not in self.containers:
self.attach_container(container, alias, ipv4)
if not (container_id := container.id):
raise DockerError(f"Received invalid metadata from docker for {name}")
def detach_default_bridge(self, container: Container) -> None:
if container_id not in self.containers:
self.attach_container(container_id, name, alias, ipv4)
def detach_default_bridge(self, container_id: str, name: str) -> None:
"""Detach default Docker bridge.
Need run inside executor.
"""
try:
default_network = self.docker.networks.get(DOCKER_NETWORK_DRIVER)
default_network.disconnect(container)
default_network.disconnect(container_id)
except docker.errors.NotFound:
pass
except (
@@ -293,7 +296,7 @@ class DockerNetwork:
requests.RequestException,
) as err:
raise DockerError(
f"Can't disconnect {container.name} from default network: {err}",
f"Can't disconnect {name} from default network: {err}",
_LOGGER.warning,
) from err

View File

@@ -1,368 +0,0 @@
"""Image pull progress tracking."""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
from enum import Enum
import logging
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from .manager import PullLogEntry
from .manifest import ImageManifest
_LOGGER = logging.getLogger(__name__)
# Progress weight distribution: 70% downloading, 30% extraction
DOWNLOAD_WEIGHT = 70.0
EXTRACT_WEIGHT = 30.0
class LayerPullStatus(Enum):
"""Status values for pulling an image layer.
These are a subset of the statuses in a docker pull image log.
The order field allows comparing which stage is further along.
"""
PULLING_FS_LAYER = 1, "Pulling fs layer"
WAITING = 1, "Waiting"
RETRYING = 2, "Retrying" # Matches "Retrying in N seconds"
DOWNLOADING = 3, "Downloading"
VERIFYING_CHECKSUM = 4, "Verifying Checksum"
DOWNLOAD_COMPLETE = 5, "Download complete"
EXTRACTING = 6, "Extracting"
PULL_COMPLETE = 7, "Pull complete"
ALREADY_EXISTS = 7, "Already exists"
def __init__(self, order: int, status: str) -> None:
"""Set fields from values."""
self.order = order
self.status = status
def __eq__(self, value: object, /) -> bool:
"""Check equality, allow string comparisons on status."""
with suppress(AttributeError):
return self.status == cast(LayerPullStatus, value).status
return self.status == value
def __hash__(self) -> int:
"""Return hash based on status string."""
return hash(self.status)
def __lt__(self, other: object) -> bool:
"""Order instances by stage progression."""
with suppress(AttributeError):
return self.order < cast(LayerPullStatus, other).order
return False
@classmethod
def from_status(cls, status: str) -> LayerPullStatus | None:
"""Get enum from status string, or None if not recognized."""
# Handle "Retrying in N seconds" pattern
if status.startswith("Retrying in "):
return cls.RETRYING
for member in cls:
if member.status == status:
return member
return None
@dataclass
class LayerProgress:
"""Track progress of a single layer."""
layer_id: str
total_size: int = 0 # Size in bytes (from downloading, reused for extraction)
download_current: int = 0
extract_current: int = 0 # Extraction progress in bytes (overlay2 only)
download_complete: bool = False
extract_complete: bool = False
already_exists: bool = False # Layer was already locally available
def calculate_progress(self) -> float:
"""Calculate layer progress 0-100.
Progress is weighted: 70% download, 30% extraction.
For overlay2, we have byte-based extraction progress.
For containerd, extraction jumps from 70% to 100% on completion.
"""
if self.already_exists or self.extract_complete:
return 100.0
if self.download_complete:
# Check if we have extraction progress (overlay2)
if self.extract_current > 0 and self.total_size > 0:
extract_pct = min(1.0, self.extract_current / self.total_size)
return DOWNLOAD_WEIGHT + (extract_pct * EXTRACT_WEIGHT)
# No extraction progress yet - return 70%
return DOWNLOAD_WEIGHT
if self.total_size > 0:
download_pct = min(1.0, self.download_current / self.total_size)
return download_pct * DOWNLOAD_WEIGHT
return 0.0
@dataclass
class ImagePullProgress:
"""Track overall progress of pulling an image.
When manifest layer sizes are provided, uses size-weighted progress where
each layer contributes proportionally to its size. This gives accurate
progress based on actual bytes to download.
When manifest is not available, falls back to count-based progress where
each layer contributes equally.
Layers that already exist locally are excluded from the progress calculation.
"""
layers: dict[str, LayerProgress] = field(default_factory=dict)
_last_reported_progress: float = field(default=0.0, repr=False)
_seen_downloading: bool = field(default=False, repr=False)
_manifest_layer_sizes: dict[str, int] = field(default_factory=dict, repr=False)
_total_manifest_size: int = field(default=0, repr=False)
def set_manifest(self, manifest: ImageManifest) -> None:
"""Set manifest layer sizes for accurate size-based progress.
Should be called before processing pull events.
"""
self._manifest_layer_sizes = dict(manifest.layers)
self._total_manifest_size = manifest.total_size
_LOGGER.debug(
"Manifest set: %d layers, %d bytes total",
len(self._manifest_layer_sizes),
self._total_manifest_size,
)
def get_or_create_layer(self, layer_id: str) -> LayerProgress:
"""Get existing layer or create new one."""
if layer_id not in self.layers:
# If we have manifest sizes, pre-populate the layer's total_size
manifest_size = self._manifest_layer_sizes.get(layer_id, 0)
self.layers[layer_id] = LayerProgress(
layer_id=layer_id, total_size=manifest_size
)
return self.layers[layer_id]
def process_event(self, entry: PullLogEntry) -> None:
"""Process a pull log event and update layer state."""
# Skip events without layer ID or status
if not entry.id or not entry.status:
return
# Skip metadata events that aren't layer-specific
# "Pulling from X" has id=tag but isn't a layer
if entry.status.startswith("Pulling from "):
return
# Parse status to enum (returns None for unrecognized statuses)
status = LayerPullStatus.from_status(entry.status)
if status is None:
return
layer = self.get_or_create_layer(entry.id)
# Handle "Already exists" - layer is locally available
if status is LayerPullStatus.ALREADY_EXISTS:
layer.already_exists = True
layer.download_complete = True
layer.extract_complete = True
return
# Handle "Pulling fs layer" / "Waiting" - layer is being tracked
if status in (LayerPullStatus.PULLING_FS_LAYER, LayerPullStatus.WAITING):
return
# Handle "Downloading" - update download progress
if status is LayerPullStatus.DOWNLOADING:
# Mark that we've seen downloading - now we know layer count is complete
self._seen_downloading = True
if entry.progress_detail and entry.progress_detail.current is not None:
layer.download_current = entry.progress_detail.current
if entry.progress_detail and entry.progress_detail.total is not None:
# Only set total_size if not already set or if this is larger
# (handles case where total changes during download)
layer.total_size = max(layer.total_size, entry.progress_detail.total)
return
# Handle "Verifying Checksum" - download is essentially complete
if status is LayerPullStatus.VERIFYING_CHECKSUM:
if layer.total_size > 0:
layer.download_current = layer.total_size
return
# Handle "Download complete" - download phase done
if status is LayerPullStatus.DOWNLOAD_COMPLETE:
layer.download_complete = True
if layer.total_size > 0:
layer.download_current = layer.total_size
elif layer.total_size == 0:
# Small layer that skipped downloading phase
# Set minimal size so it doesn't distort weighted average
layer.total_size = 1
layer.download_current = 1
return
# Handle "Extracting" - extraction in progress
if status is LayerPullStatus.EXTRACTING:
# For overlay2: progressDetail has {current, total} in bytes
# For containerd: progressDetail has {current, units: "s"} (time elapsed)
# We can only use byte-based progress (overlay2)
layer.download_complete = True
if layer.total_size > 0:
layer.download_current = layer.total_size
# Check if this is byte-based extraction progress (overlay2)
# Overlay2 has {current, total} in bytes, no units field
# Containerd has {current, units: "s"} which is useless for progress
if (
entry.progress_detail
and entry.progress_detail.current is not None
and entry.progress_detail.units is None
):
# Use layer's total_size from downloading phase (doesn't change)
layer.extract_current = entry.progress_detail.current
_LOGGER.debug(
"Layer %s extracting: %d/%d (%.1f%%)",
layer.layer_id,
layer.extract_current,
layer.total_size,
(layer.extract_current / layer.total_size * 100)
if layer.total_size > 0
else 0,
)
return
# Handle "Pull complete" - layer is fully done
if status is LayerPullStatus.PULL_COMPLETE:
layer.download_complete = True
layer.extract_complete = True
if layer.total_size > 0:
layer.download_current = layer.total_size
return
# Handle "Retrying in N seconds" - reset download progress
if status is LayerPullStatus.RETRYING:
layer.download_current = 0
layer.download_complete = False
return
def calculate_progress(self) -> float:
"""Calculate overall progress 0-100.
When manifest layer sizes are available, uses size-weighted progress
where each layer contributes proportionally to its size.
When manifest is not available, falls back to count-based progress
where each layer contributes equally.
Layers that already exist locally are excluded from the calculation.
Returns 0 until we've seen the first "Downloading" event, since Docker
reports "Already exists" and "Pulling fs layer" events before we know
the complete layer count.
"""
# Don't report progress until we've seen downloading start
# This ensures we know the full layer count before calculating progress
if not self._seen_downloading or not self.layers:
return 0.0
# Only count layers that need pulling (exclude already_exists)
layers_to_pull = [
layer for layer in self.layers.values() if not layer.already_exists
]
if not layers_to_pull:
# All layers already exist, nothing to download
return 100.0
# Use size-weighted progress if manifest sizes are available
if self._manifest_layer_sizes:
return min(100, self._calculate_size_weighted_progress(layers_to_pull))
# Fall back to count-based progress
total_progress = sum(layer.calculate_progress() for layer in layers_to_pull)
return min(100, total_progress / len(layers_to_pull))
def _calculate_size_weighted_progress(
self, layers_to_pull: list[LayerProgress]
) -> float:
"""Calculate size-weighted progress.
Each layer contributes to progress proportionally to its size.
Progress = sum(layer_progress * layer_size) / total_size
"""
# Calculate total size of layers that need pulling
total_size = sum(layer.total_size for layer in layers_to_pull)
if total_size == 0:
# No size info available, fall back to count-based
total_progress = sum(layer.calculate_progress() for layer in layers_to_pull)
return total_progress / len(layers_to_pull)
# Weight each layer's progress by its size
weighted_progress = 0.0
for layer in layers_to_pull:
if layer.total_size > 0:
layer_weight = layer.total_size / total_size
weighted_progress += layer.calculate_progress() * layer_weight
return weighted_progress
def get_stage(self) -> str | None:
"""Get current stage based on layer states."""
if not self.layers:
return None
# Check if any layer is still downloading
for layer in self.layers.values():
if layer.already_exists:
continue
if not layer.download_complete:
return "Downloading"
# All downloads complete, check if extracting
for layer in self.layers.values():
if layer.already_exists:
continue
if not layer.extract_complete:
return "Extracting"
# All done
return "Pull complete"
def should_update_job(self, threshold: float = 1.0) -> tuple[bool, float]:
"""Check if job should be updated based on progress change.
Returns (should_update, current_progress).
Updates are triggered when progress changes by at least threshold%.
Progress is guaranteed to only increase (monotonic).
"""
current_progress = self.calculate_progress()
# Ensure monotonic progress - never report a decrease
# This can happen when new layers get size info and change the weighted average
if current_progress < self._last_reported_progress:
_LOGGER.debug(
"Progress decreased from %.1f%% to %.1f%%, keeping last reported",
self._last_reported_progress,
current_progress,
)
return False, self._last_reported_progress
if current_progress >= self._last_reported_progress + threshold:
_LOGGER.debug(
"Progress update: %.1f%% -> %.1f%% (delta: %.1f%%)",
self._last_reported_progress,
current_progress,
current_progress - self._last_reported_progress,
)
self._last_reported_progress = current_progress
return True, current_progress
return False, self._last_reported_progress

View File

@@ -54,7 +54,7 @@ class DockerSupervisor(DockerInterface):
"""Attach to running docker container."""
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
self.sys_docker.containers_legacy.get, self.name
)
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError() from err
@@ -74,7 +74,8 @@ class DockerSupervisor(DockerInterface):
_LOGGER.info("Connecting Supervisor to hassio-network")
await self.sys_run_in_executor(
self.sys_docker.network.attach_container,
docker_container,
docker_container.id,
self.name,
alias=["supervisor"],
ipv4=self.sys_docker.network.supervisor,
)
@@ -90,7 +91,7 @@ class DockerSupervisor(DockerInterface):
Need run inside executor.
"""
try:
docker_container = self.sys_docker.containers.get(self.name)
docker_container = self.sys_docker.containers_legacy.get(self.name)
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not get Supervisor container for retag: {err}", _LOGGER.error
@@ -118,7 +119,7 @@ class DockerSupervisor(DockerInterface):
"""Update start tag to new version."""
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
self.sys_docker.containers_legacy.get, self.name
)
docker_image = await self.sys_docker.images.inspect(f"{image}:{version!s}")
except (

View File

@@ -855,6 +855,10 @@ class DockerNotFound(DockerError):
"""Docker object don't Exists."""
class DockerLogOutOfOrder(DockerError):
"""Raise when log from docker action was out of order."""
class DockerNoSpaceOnDevice(DockerError):
"""Raise if a docker pull fails due to available space."""
@@ -965,6 +969,18 @@ class StoreAddonNotFoundError(StoreError, APINotFound):
super().__init__(None, logger)
class StoreRepositoryLocalCannotReset(StoreError, APIError):
"""Raise if user requests a reset on the local addon repository."""
error_key = "store_repository_local_cannot_reset"
message_template = "Can't reset repository {local_repo} as it is not git based!"
extra_fields = {"local_repo": "local"}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Initialize exception."""
super().__init__(None, logger)
class StoreJobError(StoreError, JobException):
"""Raise on job error with git."""
@@ -973,6 +989,18 @@ class StoreInvalidAddonRepo(StoreError):
"""Raise on invalid addon repo."""
class StoreRepositoryUnknownError(StoreError, APIUnknownSupervisorError):
"""Raise when unknown error occurs taking an action for a store repository."""
error_key = "store_repository_unknown_error"
message_template = "An unknown error occurred with addon repository {repo}"
def __init__(self, logger: Callable[..., None] | None = None, *, repo: str) -> None:
"""Initialize exception."""
self.extra_fields = {"repo": repo}
super().__init__(logger)
# Backup

View File

@@ -6,8 +6,6 @@ from pathlib import Path
import shutil
from typing import Any
from supervisor.resolution.const import UnhealthyReason
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import (
DBusError,
@@ -15,6 +13,7 @@ from ..exceptions import (
DBusObjectError,
HardwareNotFound,
)
from ..resolution.const import UnhealthyReason
from .const import UdevSubsystem
from .data import Device

View File

@@ -48,7 +48,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
SECONDS_BETWEEN_API_CHECKS: Final[int] = 5
# Core Stage 1 and some wiggle room
STARTUP_API_RESPONSE_TIMEOUT: Final[timedelta] = timedelta(minutes=3)
STARTUP_API_RESPONSE_TIMEOUT: Final[timedelta] = timedelta(minutes=10)
# All stages plus event start timeout and some wiggle rooom
STARTUP_API_CHECK_RUNNING_TIMEOUT: Final[timedelta] = timedelta(minutes=15)
# While database migration is running, the timeout will be extended

View File

@@ -23,6 +23,7 @@ from ..const import (
ATTR_AUDIO_OUTPUT,
ATTR_BACKUPS_EXCLUDE_DATABASE,
ATTR_BOOT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE,
ATTR_MESSAGE,
ATTR_PORT,
@@ -299,6 +300,16 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
"""Set whether backups should exclude database by default."""
self._data[ATTR_BACKUPS_EXCLUDE_DATABASE] = value
@property
def duplicate_log_file(self) -> bool:
"""Return True if Home Assistant should duplicate logs to file."""
return self._data[ATTR_DUPLICATE_LOG_FILE]
@duplicate_log_file.setter
def duplicate_log_file(self, value: bool) -> None:
"""Set whether Home Assistant should duplicate logs to file."""
self._data[ATTR_DUPLICATE_LOG_FILE] = value
async def load(self) -> None:
"""Prepare Home Assistant object."""
await asyncio.wait(
@@ -497,7 +508,7 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
tmp_dir=self.sys_config.path_tmp,
)
else:
remove_folder(self.sys_config.path_homeassistant)
remove_folder(self.sys_config.path_homeassistant, content_only=True)
try:
shutil.copytree(

View File

@@ -10,6 +10,7 @@ from ..const import (
ATTR_AUDIO_OUTPUT,
ATTR_BACKUPS_EXCLUDE_DATABASE,
ATTR_BOOT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE,
ATTR_PORT,
ATTR_REFRESH_TOKEN,
@@ -36,6 +37,7 @@ SCHEMA_HASS_CONFIG = vol.Schema(
vol.Optional(ATTR_AUDIO_OUTPUT, default=None): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT, default=None): vol.Maybe(str),
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE, default=False): vol.Boolean(),
vol.Optional(ATTR_DUPLICATE_LOG_FILE, default=False): vol.Boolean(),
vol.Optional(ATTR_OVERRIDE_IMAGE, default=False): vol.Boolean(),
},
extra=vol.REMOVE_EXTRA,

View File

@@ -5,8 +5,6 @@ from contextlib import suppress
import logging
from typing import Any
from supervisor.utils.sentry import async_capture_exception
from ..const import ATTR_HOST_INTERNET
from ..coresys import CoreSys, CoreSysAttributes
from ..dbus.const import (
@@ -34,6 +32,7 @@ from ..exceptions import (
from ..jobs.const import JobCondition
from ..jobs.decorator import Job
from ..resolution.checks.network_interface_ipv4 import CheckNetworkInterfaceIPV4
from ..utils.sentry import async_capture_exception
from .configuration import AccessPoint, Interface
from .const import InterfaceMethod, WifiMode

View File

@@ -5,10 +5,9 @@ import logging
from docker.errors import DockerException
from requests import RequestException
from supervisor.docker.const import ADDON_BUILDER_IMAGE
from ...const import CoreState
from ...coresys import CoreSys
from ...docker.const import ADDON_BUILDER_IMAGE
from ..const import (
ContextType,
IssueType,
@@ -74,7 +73,9 @@ class EvaluateContainer(EvaluateBase):
self._images.clear()
try:
containers = await self.sys_run_in_executor(self.sys_docker.containers.list)
containers = await self.sys_run_in_executor(
self.sys_docker.containers_legacy.list
)
except (DockerException, RequestException) as err:
_LOGGER.error("Corrupt docker overlayfs detect: %s", err)
self.sys_resolution.create_issue(

View File

@@ -1,10 +1,9 @@
"""Evaluation class for restart policy."""
from supervisor.docker.const import RestartPolicy
from supervisor.docker.interface import DockerInterface
from ...const import CoreState
from ...coresys import CoreSys
from ...docker.const import RestartPolicy
from ...docker.interface import DockerInterface
from ..const import UnsupportedReason
from .base import EvaluateBase

View File

@@ -2,10 +2,9 @@
import logging
from supervisor.exceptions import BackupFileNotFoundError
from ...backups.const import BackupType
from ...coresys import CoreSys
from ...exceptions import BackupFileNotFoundError
from ..const import MINIMUM_FULL_BACKUPS, ContextType, IssueType, SuggestionType
from .base import FixupBase

View File

@@ -8,8 +8,6 @@ from pathlib import Path
import voluptuous as vol
from supervisor.utils import get_latest_mtime
from ..const import (
ATTR_MAINTAINER,
ATTR_NAME,
@@ -19,7 +17,14 @@ from ..const import (
REPOSITORY_LOCAL,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError, StoreError
from ..exceptions import (
ConfigurationFileError,
StoreError,
StoreGitError,
StoreRepositoryLocalCannotReset,
StoreRepositoryUnknownError,
)
from ..utils import get_latest_mtime
from ..utils.common import read_json_or_yaml_file
from .const import BuiltinRepository
from .git import GitRepo
@@ -198,8 +203,12 @@ class RepositoryGit(Repository, ABC):
async def reset(self) -> None:
"""Reset add-on repository to fix corruption issue with files."""
await self._git.reset()
await self.load()
try:
await self._git.reset()
await self.load()
except StoreGitError as err:
_LOGGER.error("Can't reset repository %s: %s", self.slug, err)
raise StoreRepositoryUnknownError(repo=self.slug) from err
class RepositoryLocal(RepositoryBuiltin):
@@ -238,9 +247,7 @@ class RepositoryLocal(RepositoryBuiltin):
async def reset(self) -> None:
"""Raise. Not supported for local repository."""
raise StoreError(
"Can't reset local repository as it is not git based!", _LOGGER.error
)
raise StoreRepositoryLocalCannotReset(_LOGGER.error)
class RepositoryGitBuiltin(RepositoryBuiltin, RepositoryGit):

View File

@@ -13,8 +13,6 @@ import aiohttp
from aiohttp.client_exceptions import ClientError
from awesomeversion import AwesomeVersion, AwesomeVersionException
from supervisor.jobs import ChildJobSyncFilter
from .const import (
ATTR_SUPERVISOR_INTERNET,
SUPERVISOR_VERSION,
@@ -32,6 +30,7 @@ from .exceptions import (
SupervisorUnknownError,
SupervisorUpdateError,
)
from .jobs import ChildJobSyncFilter
from .jobs.const import JobCondition, JobThrottle
from .jobs.decorator import Job
from .resolution.const import ContextType, IssueType, UnhealthyReason

View File

@@ -8,8 +8,6 @@ import logging
import aiohttp
from awesomeversion import AwesomeVersion
from supervisor.jobs.const import JobConcurrency, JobThrottle
from .bus import EventListener
from .const import (
ATTR_AUDIO,
@@ -32,6 +30,7 @@ from .const import (
)
from .coresys import CoreSys, CoreSysAttributes
from .exceptions import UpdaterError, UpdaterJobError
from .jobs.const import JobConcurrency, JobThrottle
from .jobs.decorator import Job, JobCondition
from .utils.common import FileConfiguration
from .validate import SCHEMA_UPDATER_CONFIG

View File

@@ -9,8 +9,8 @@ import re
from aiohttp import ClientResponse
from supervisor.exceptions import MalformedBinaryEntryError
from supervisor.host.const import LogFormatter
from ..exceptions import MalformedBinaryEntryError
from ..host.const import LogFormatter
_RE_ANSI_CSI_COLORS_PATTERN = re.compile(r"\x1B\[[0-9;]*m")

View File

@@ -227,7 +227,7 @@ async def test_listener_attached_on_install(
container_collection.get.side_effect = DockerException()
with (
patch(
"supervisor.docker.manager.DockerAPI.containers",
"supervisor.docker.manager.DockerAPI.containers_legacy",
new=PropertyMock(return_value=container_collection),
),
patch("pathlib.Path.is_dir", return_value=True),
@@ -527,7 +527,7 @@ async def test_backup_with_pre_command_error(
exc_type_raised: type[HassioError],
) -> None:
"""Test backing up an addon with error running pre command."""
coresys.docker.containers.get.side_effect = container_get_side_effect
coresys.docker.containers_legacy.get.side_effect = container_get_side_effect
container.exec_run.side_effect = exec_run_side_effect
install_addon_ssh.path_data.mkdir()

View File

@@ -679,7 +679,7 @@ async def test_addon_write_stdin_not_supported_error(api_client: TestClient):
async def test_addon_rebuild_fails_error(api_client: TestClient, coresys: CoreSys):
"""Test error when build fails during rebuild for addon."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.docker.containers.run.side_effect = DockerException("fail")
coresys.docker.containers_legacy.run.side_effect = DockerException("fail")
with (
patch.object(

View File

@@ -1201,10 +1201,8 @@ async def test_restore_homeassistant_adds_env(
assert docker.containers.create.call_args.kwargs["name"] == "homeassistant"
assert (
docker.containers.create.call_args.kwargs["environment"][
"SUPERVISOR_RESTORE_JOB_ID"
]
== job.uuid
f"SUPERVISOR_RESTORE_JOB_ID={job.uuid}"
in docker.containers.create.call_args.args[0]["Env"]
)

View File

@@ -35,9 +35,9 @@ async def test_api_core_logs(
async def test_api_stats(api_client: TestClient, coresys: CoreSys):
"""Test stats."""
coresys.docker.containers.get.return_value.status = "running"
coresys.docker.containers.get.return_value.stats.return_value = load_json_fixture(
"container_stats.json"
coresys.docker.containers_legacy.get.return_value.status = "running"
coresys.docker.containers_legacy.get.return_value.stats.return_value = (
load_json_fixture("container_stats.json")
)
resp = await api_client.get("/homeassistant/stats")
@@ -138,14 +138,14 @@ async def test_api_rebuild(
await api_client.post("/homeassistant/rebuild")
assert container.remove.call_count == 2
container.start.assert_called_once()
coresys.docker.containers.create.return_value.start.assert_called_once()
assert not safe_mode_marker.exists()
with patch.object(HomeAssistantCore, "_block_till_run"):
await api_client.post("/homeassistant/rebuild", json={"safe_mode": True})
assert container.remove.call_count == 4
assert container.start.call_count == 2
assert coresys.docker.containers.create.return_value.start.call_count == 2
assert safe_mode_marker.exists()
@@ -305,8 +305,6 @@ async def test_api_progress_updates_home_assistant_update(
and evt.args[0]["data"]["event"] == WSEvent.JOB
and evt.args[0]["data"]["data"]["name"] == "home_assistant_core_update"
]
# Count-based progress: 2 layers need pulling (each worth 50%)
# Layers that already exist are excluded from progress calculation
assert events[:5] == [
{
"stage": None,
@@ -320,36 +318,36 @@ async def test_api_progress_updates_home_assistant_update(
},
{
"stage": None,
"progress": 9.2,
"progress": 0.1,
"done": False,
},
{
"stage": None,
"progress": 25.6,
"progress": 1.7,
"done": False,
},
{
"stage": None,
"progress": 35.4,
"progress": 4.0,
"done": False,
},
]
assert events[-5:] == [
{
"stage": None,
"progress": 95.5,
"done": False,
},
{
"stage": None,
"progress": 96.9,
"done": False,
},
{
"stage": None,
"progress": 98.2,
"done": False,
},
{
"stage": None,
"progress": 98.3,
"done": False,
},
{
"stage": None,
"progress": 99.3,
"done": False,
},
{
"stage": None,
"progress": 100,

View File

@@ -18,8 +18,11 @@ from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState
from supervisor.docker.interface import DockerInterface
from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import StoreGitError
from supervisor.homeassistant.const import WSEvent
from supervisor.homeassistant.module import HomeAssistant
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository
@@ -124,6 +127,81 @@ async def test_api_store_remove_repository(
assert test_repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("repo", ["core", "a474bbd1"])
@pytest.mark.usefixtures("test_repository")
async def test_api_store_repair_repository(api_client: TestClient, repo: str):
"""Test POST /store/repositories/{repository}/repair REST API."""
with patch("supervisor.store.repository.RepositoryGit.reset") as mock_reset:
response = await api_client.post(f"/store/repositories/{repo}/repair")
assert response.status == 200
mock_reset.assert_called_once()
@pytest.mark.parametrize(
"issue_type", [IssueType.CORRUPT_REPOSITORY, IssueType.FATAL_ERROR]
)
@pytest.mark.usefixtures("test_repository")
async def test_api_store_repair_repository_removes_suggestion(
api_client: TestClient,
coresys: CoreSys,
test_repository: Repository,
issue_type: IssueType,
):
"""Test POST /store/repositories/core/repair REST API removes EXECUTE_RESET suggestions."""
issue = Issue(issue_type, ContextType.STORE, reference=test_repository.slug)
suggestion = Suggestion(
SuggestionType.EXECUTE_RESET, ContextType.STORE, reference=test_repository.slug
)
coresys.resolution.add_issue(issue, suggestions=[SuggestionType.EXECUTE_RESET])
with patch("supervisor.store.repository.RepositoryGit.reset") as mock_reset:
response = await api_client.post(
f"/store/repositories/{test_repository.slug}/repair"
)
assert response.status == 200
mock_reset.assert_called_once()
assert issue not in coresys.resolution.issues
assert suggestion not in coresys.resolution.suggestions
@pytest.mark.usefixtures("test_repository")
async def test_api_store_repair_repository_local_fail(api_client: TestClient):
"""Test POST /store/repositories/local/repair REST API fails."""
response = await api_client.post("/store/repositories/local/repair")
assert response.status == 400
result = await response.json()
assert result["error_key"] == "store_repository_local_cannot_reset"
assert result["extra_fields"] == {"local_repo": "local"}
assert result["message"] == "Can't reset repository local as it is not git based!"
async def test_api_store_repair_repository_git_error(
api_client: TestClient, test_repository: Repository
):
"""Test POST /store/repositories/{repository}/repair REST API git error."""
with patch(
"supervisor.store.git.GitRepo.reset",
side_effect=StoreGitError("Git error"),
):
response = await api_client.post(
f"/store/repositories/{test_repository.slug}/repair"
)
assert response.status == 500
result = await response.json()
assert result["error_key"] == "store_repository_unknown_error"
assert result["extra_fields"] == {
"repo": test_repository.slug,
"logs_command": "ha supervisor logs",
}
assert (
result["message"]
== f"An unknown error occurred with addon repository {test_repository.slug}. Check supervisor logs for details (check with 'ha supervisor logs')"
)
async def test_api_store_update_healthcheck(
api_client: TestClient,
coresys: CoreSys,
@@ -761,8 +839,6 @@ async def test_api_progress_updates_addon_install_update(
and evt.args[0]["data"]["data"]["name"] == job_name
and evt.args[0]["data"]["data"]["reference"] == addon_slug
]
# Count-based progress: 2 layers need pulling (each worth 50%)
# Layers that already exist are excluded from progress calculation
assert events[:4] == [
{
"stage": None,
@@ -771,36 +847,36 @@ async def test_api_progress_updates_addon_install_update(
},
{
"stage": None,
"progress": 9.2,
"progress": 0.1,
"done": False,
},
{
"stage": None,
"progress": 25.6,
"progress": 1.7,
"done": False,
},
{
"stage": None,
"progress": 35.4,
"progress": 4.0,
"done": False,
},
]
assert events[-5:] == [
{
"stage": None,
"progress": 95.5,
"done": False,
},
{
"stage": None,
"progress": 96.9,
"done": False,
},
{
"stage": None,
"progress": 98.2,
"done": False,
},
{
"stage": None,
"progress": 98.3,
"done": False,
},
{
"stage": None,
"progress": 99.3,
"done": False,
},
{
"stage": None,
"progress": 100,

View File

@@ -359,8 +359,6 @@ async def test_api_progress_updates_supervisor_update(
and evt.args[0]["data"]["event"] == WSEvent.JOB
and evt.args[0]["data"]["data"]["name"] == "supervisor_update"
]
# Count-based progress: 2 layers need pulling (each worth 50%)
# Layers that already exist are excluded from progress calculation
assert events[:4] == [
{
"stage": None,
@@ -369,36 +367,36 @@ async def test_api_progress_updates_supervisor_update(
},
{
"stage": None,
"progress": 9.2,
"progress": 0.1,
"done": False,
},
{
"stage": None,
"progress": 25.6,
"progress": 1.7,
"done": False,
},
{
"stage": None,
"progress": 35.4,
"progress": 4.0,
"done": False,
},
]
assert events[-5:] == [
{
"stage": None,
"progress": 95.5,
"done": False,
},
{
"stage": None,
"progress": 96.9,
"done": False,
},
{
"stage": None,
"progress": 98.2,
"done": False,
},
{
"stage": None,
"progress": 98.3,
"done": False,
},
{
"stage": None,
"progress": 99.3,
"done": False,
},
{
"stage": None,
"progress": 100,
@@ -414,9 +412,9 @@ async def test_api_progress_updates_supervisor_update(
async def test_api_supervisor_stats(api_client: TestClient, coresys: CoreSys):
"""Test supervisor stats."""
coresys.docker.containers.get.return_value.status = "running"
coresys.docker.containers.get.return_value.stats.return_value = load_json_fixture(
"container_stats.json"
coresys.docker.containers_legacy.get.return_value.status = "running"
coresys.docker.containers_legacy.get.return_value.stats.return_value = (
load_json_fixture("container_stats.json")
)
resp = await api_client.get("/supervisor/stats")
@@ -432,7 +430,7 @@ async def test_supervisor_api_stats_failure(
api_client: TestClient, coresys: CoreSys, caplog: pytest.LogCaptureFixture
):
"""Test supervisor stats failure."""
coresys.docker.containers.get.side_effect = DockerException("fail")
coresys.docker.containers_legacy.get.side_effect = DockerException("fail")
resp = await api_client.get("/supervisor/stats")
assert resp.status == 500

View File

@@ -9,6 +9,7 @@ import subprocess
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
from uuid import uuid4
from aiodocker.containers import DockerContainer, DockerContainers
from aiodocker.docker import DockerImages
from aiohttp import ClientSession, web
from aiohttp.test_utils import TestClient
@@ -120,11 +121,13 @@ async def docker() -> DockerAPI:
"Id": "test123",
"RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"],
}
container_inspect = image_inspect | {"State": {"ExitCode": 0}}
with (
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
patch(
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
"supervisor.docker.manager.DockerAPI.containers_legacy",
return_value=MagicMock(),
),
patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()),
patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()),
@@ -136,6 +139,12 @@ async def docker() -> DockerAPI:
return_value=(docker_images := MagicMock(spec=DockerImages))
),
),
patch(
"supervisor.docker.manager.DockerAPI.containers",
new=PropertyMock(
return_value=(docker_containers := MagicMock(spec=DockerContainers))
),
),
):
docker_obj = await DockerAPI(MagicMock()).post_init()
docker_obj.config._data = {"registries": {}}
@@ -147,16 +156,19 @@ async def docker() -> DockerAPI:
docker_images.import_image = AsyncMock(
return_value=[{"stream": "Loaded image: test:latest\n"}]
)
docker_images.pull.return_value = AsyncIterator([{}])
docker_containers.get.return_value = docker_container = MagicMock(
spec=DockerContainer
)
docker_containers.list.return_value = [docker_container]
docker_containers.create.return_value = docker_container
docker_container.show.return_value = container_inspect
docker_obj.info.logging = "journald"
docker_obj.info.storage = "overlay2"
docker_obj.info.version = AwesomeVersion("1.0.0")
# Mock manifest fetcher to return None (falls back to count-based progress)
docker_obj._manifest_fetcher.get_manifest = AsyncMock(return_value=None)
yield docker_obj
@@ -793,7 +805,7 @@ async def docker_logs(docker: DockerAPI, supervisor_name) -> MagicMock:
"""Mock log output for a container from docker."""
container_mock = MagicMock()
container_mock.logs.return_value = load_binary_fixture("logs_docker_container.txt")
docker.containers.get.return_value = container_mock
docker.containers_legacy.get.return_value = container_mock
yield container_mock.logs
@@ -827,7 +839,7 @@ async def os_available(request: pytest.FixtureRequest) -> None:
@pytest.fixture
async def mount_propagation(docker: DockerAPI, coresys: CoreSys) -> None:
"""Mock supervisor connected to container with propagation set."""
docker.containers.get.return_value = supervisor = MagicMock()
docker.containers_legacy.get.return_value = supervisor = MagicMock()
supervisor.attrs = {
"Mounts": [
{
@@ -847,10 +859,11 @@ async def mount_propagation(docker: DockerAPI, coresys: CoreSys) -> None:
@pytest.fixture
async def container(docker: DockerAPI) -> MagicMock:
"""Mock attrs and status for container on attach."""
docker.containers.get.return_value = addon = MagicMock()
docker.containers.create.return_value = addon
addon.status = "stopped"
addon.attrs = {"State": {"ExitCode": 0}}
attrs = {"State": {"ExitCode": 0}}
docker.containers_legacy.get.return_value = addon = MagicMock(
status="stopped", attrs=attrs
)
docker.containers.create.return_value.show.return_value = attrs
yield addon

View File

@@ -1,7 +1,12 @@
"""Docker tests."""
from docker.types import Mount
from supervisor.docker.const import DockerMount, MountBindOptions, MountType
# dev mount with equivalent of bind-recursive=writable specified via dict value
DEV_MOUNT = Mount(type="bind", source="/dev", target="/dev", read_only=True)
DEV_MOUNT["BindOptions"] = {"ReadOnlyNonRecursive": True}
DEV_MOUNT = DockerMount(
type=MountType.BIND,
source="/dev",
target="/dev",
read_only=True,
bind_options=MountBindOptions(read_only_non_recursive=True),
)

View File

@@ -1,13 +1,13 @@
"""Test docker addon setup."""
import asyncio
from http import HTTPStatus
from ipaddress import IPv4Address
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, Mock, PropertyMock, patch
from docker.errors import NotFound
from docker.types import Mount
import aiodocker
import pytest
from supervisor.addons import validate as vd
@@ -18,6 +18,12 @@ from supervisor.const import BusEvent
from supervisor.coresys import CoreSys
from supervisor.dbus.agent.cgroup import CGroup
from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import (
DockerMount,
MountBindOptions,
MountType,
PropagationMode,
)
from supervisor.docker.manager import DockerAPI
from supervisor.exceptions import CoreDNSError, DockerNotFound
from supervisor.hardware.data import Device
@@ -80,8 +86,8 @@ def test_base_volumes_included(
# Data added as rw
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=docker_addon.addon.path_extern_data.as_posix(),
target="/data",
read_only=False,
@@ -99,8 +105,8 @@ def test_addon_map_folder_defaults(
)
# Config added and is marked rw
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
@@ -110,8 +116,8 @@ def test_addon_map_folder_defaults(
# SSL added and defaults to ro
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_ssl.as_posix(),
target="/ssl",
read_only=True,
@@ -121,30 +127,30 @@ def test_addon_map_folder_defaults(
# Media added and propagation set
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_media.as_posix(),
target="/media",
read_only=True,
propagation="rslave",
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
)
in docker_addon.mounts
)
# Share added and propagation set
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_share.as_posix(),
target="/share",
read_only=True,
propagation="rslave",
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
)
in docker_addon.mounts
)
# Backup not added
assert "/backup" not in [mount["Target"] for mount in docker_addon.mounts]
assert "/backup" not in [mount.target for mount in docker_addon.mounts]
def test_addon_map_homeassistant_folder(
@@ -157,8 +163,8 @@ def test_addon_map_homeassistant_folder(
# Home Assistant config folder mounted to /homeassistant, not /config
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/homeassistant",
read_only=True,
@@ -177,8 +183,8 @@ def test_addon_map_addon_configs_folder(
# Addon configs folder included
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_addon_configs.as_posix(),
target="/addon_configs",
read_only=True,
@@ -197,8 +203,8 @@ def test_addon_map_addon_config_folder(
# Addon config folder included
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=docker_addon.addon.path_extern_config.as_posix(),
target="/config",
read_only=True,
@@ -220,8 +226,8 @@ def test_addon_map_addon_config_folder_with_custom_target(
# Addon config folder included
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=docker_addon.addon.path_extern_config.as_posix(),
target="/custom/target/path",
read_only=False,
@@ -240,8 +246,8 @@ def test_addon_map_data_folder_with_custom_target(
# Addon config folder included
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=docker_addon.addon.path_extern_data.as_posix(),
target="/custom/data/path",
read_only=False,
@@ -260,8 +266,8 @@ def test_addon_ignore_on_config_map(
# Config added and is marked rw
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
@@ -271,11 +277,10 @@ def test_addon_ignore_on_config_map(
# Mount for addon's specific config folder omitted since config in map field
assert (
len([mount for mount in docker_addon.mounts if mount["Target"] == "/config"])
== 1
len([mount for mount in docker_addon.mounts if mount.target == "/config"]) == 1
)
# Home Assistant mount omitted since config in map field
assert "/homeassistant" not in [mount["Target"] for mount in docker_addon.mounts]
assert "/homeassistant" not in [mount.target for mount in docker_addon.mounts]
def test_journald_addon(
@@ -287,8 +292,8 @@ def test_journald_addon(
)
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/var/log/journal",
target="/var/log/journal",
read_only=True,
@@ -296,8 +301,8 @@ def test_journald_addon(
in docker_addon.mounts
)
assert (
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/run/log/journal",
target="/run/log/journal",
read_only=True,
@@ -314,7 +319,7 @@ def test_not_journald_addon(
coresys, addonsdata_system, "basic-addon-config.json"
)
assert "/var/log/journal" not in [mount["Target"] for mount in docker_addon.mounts]
assert "/var/log/journal" not in [mount.target for mount in docker_addon.mounts]
async def test_addon_run_docker_error(
@@ -325,7 +330,9 @@ async def test_addon_run_docker_error(
):
"""Test docker error when addon is run."""
await coresys.dbus.timedate.connect(coresys.dbus.bus)
coresys.docker.containers.create.side_effect = NotFound("Missing")
coresys.docker.containers.create.side_effect = aiodocker.DockerError(
HTTPStatus.NOT_FOUND, {"message": "missing"}
)
docker_addon = get_docker_addon(
coresys, addonsdata_system, "basic-addon-config.json"
)

View File

@@ -2,22 +2,24 @@
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from docker.types import Mount
import pytest
from supervisor.coresys import CoreSys
from supervisor.docker.const import DockerMount, MountType, Ulimit
from supervisor.docker.manager import DockerAPI
from . import DEV_MOUNT
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
@pytest.mark.usefixtures("path_extern")
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, container: MagicMock):
"""Test starting audio plugin."""
config_file = tmp_supervisor_data / "audio" / "pulse_audio.json"
assert not config_file.exists()
with patch.object(DockerAPI, "run") as run:
with patch.object(DockerAPI, "run", return_value=container.attrs) as run:
await coresys.plugins.audio.start()
run.assert_called_once()
@@ -26,21 +28,31 @@ async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
assert run.call_args.kwargs["hostname"] == "hassio-audio"
assert run.call_args.kwargs["cap_add"] == ["SYS_NICE", "SYS_RESOURCE"]
assert run.call_args.kwargs["ulimits"] == [
{"Name": "rtprio", "Soft": 10, "Hard": 10}
Ulimit(name="rtprio", soft=10, hard=10)
]
assert run.call_args.kwargs["mounts"] == [
DEV_MOUNT,
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_audio.as_posix(),
target="/data",
read_only=False,
),
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/run/dbus",
target="/run/dbus",
read_only=True,
),
DockerMount(
type=MountType.BIND,
source="/run/udev",
target="/run/udev",
read_only=True,
),
DockerMount(
type=MountType.BIND,
source="/etc/machine-id",
target="/etc/machine-id",
read_only=True,

View File

@@ -2,20 +2,22 @@
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from docker.types import Mount
import pytest
from supervisor.coresys import CoreSys
from supervisor.docker.const import DockerMount, MountType
from supervisor.docker.manager import DockerAPI
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
@pytest.mark.usefixtures("path_extern")
async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, container: MagicMock):
"""Test starting dns plugin."""
config_file = tmp_supervisor_data / "dns" / "coredns.json"
assert not config_file.exists()
with patch.object(DockerAPI, "run") as run:
with patch.object(DockerAPI, "run", return_value=container.attrs) as run:
await coresys.plugins.dns.start()
run.assert_called_once()
@@ -25,13 +27,18 @@ async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern):
assert run.call_args.kwargs["dns"] is False
assert run.call_args.kwargs["oom_score_adj"] == -300
assert run.call_args.kwargs["mounts"] == [
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_dns.as_posix(),
target="/config",
read_only=False,
),
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
DockerMount(
type=MountType.BIND,
source="/run/dbus",
target="/run/dbus",
read_only=True,
),
]
assert "volumes" not in run.call_args.kwargs

View File

@@ -1,13 +1,18 @@
"""Test Home Assistant container."""
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import ANY, MagicMock, patch
from awesomeversion import AwesomeVersion
from docker.types import Mount
import pytest
from supervisor.coresys import CoreSys
from supervisor.docker.const import (
DockerMount,
MountBindOptions,
MountType,
PropagationMode,
)
from supervisor.docker.homeassistant import DockerHomeAssistant
from supervisor.docker.manager import DockerAPI
from supervisor.homeassistant.const import LANDINGPAGE
@@ -15,14 +20,13 @@ from supervisor.homeassistant.const import LANDINGPAGE
from . import DEV_MOUNT
async def test_homeassistant_start(
coresys: CoreSys, tmp_supervisor_data: Path, path_extern
):
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_homeassistant_start(coresys: CoreSys, container: MagicMock):
"""Test starting homeassistant."""
coresys.homeassistant.version = AwesomeVersion("2023.8.1")
with (
patch.object(DockerAPI, "run") as run,
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
patch.object(
DockerHomeAssistant, "is_running", side_effect=[False, False, True]
),
@@ -46,57 +50,68 @@ async def test_homeassistant_start(
"TZ": ANY,
"SUPERVISOR_TOKEN": ANY,
"HASSIO_TOKEN": ANY,
# no "HA_DUPLICATE_LOG_FILE"
}
assert run.call_args.kwargs["mounts"] == [
DEV_MOUNT,
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/run/dbus",
target="/run/dbus",
read_only=True,
),
DockerMount(
type=MountType.BIND,
source="/run/udev",
target="/run/udev",
read_only=True,
),
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_ssl.as_posix(),
target="/ssl",
read_only=True,
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_share.as_posix(),
target="/share",
read_only=False,
propagation="rslave",
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_media.as_posix(),
target="/media",
read_only=False,
propagation="rslave",
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.homeassistant.path_extern_pulse.as_posix(),
target="/etc/pulse/client.conf",
read_only=True,
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.plugins.audio.path_extern_pulse.as_posix(),
target="/run/audio",
read_only=True,
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source=coresys.plugins.audio.path_extern_asound.as_posix(),
target="/etc/asound.conf",
read_only=True,
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/etc/machine-id",
target="/etc/machine-id",
read_only=True,
@@ -105,14 +120,36 @@ async def test_homeassistant_start(
assert "volumes" not in run.call_args.kwargs
async def test_landingpage_start(
coresys: CoreSys, tmp_supervisor_data: Path, path_extern
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_homeassistant_start_with_duplicate_log_file(
coresys: CoreSys, container: MagicMock
):
"""Test starting homeassistant with duplicate_log_file enabled."""
coresys.homeassistant.version = AwesomeVersion("2025.12.0")
coresys.homeassistant.duplicate_log_file = True
with (
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
patch.object(
DockerHomeAssistant, "is_running", side_effect=[False, False, True]
),
patch("supervisor.homeassistant.core.asyncio.sleep"),
):
await coresys.homeassistant.core.start()
run.assert_called_once()
env = run.call_args.kwargs["environment"]
assert "HA_DUPLICATE_LOG_FILE" in env
assert env["HA_DUPLICATE_LOG_FILE"] == "1"
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_landingpage_start(coresys: CoreSys, container: MagicMock):
"""Test starting landingpage."""
coresys.homeassistant.version = LANDINGPAGE
with (
patch.object(DockerAPI, "run") as run,
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
patch.object(DockerHomeAssistant, "is_running", return_value=False),
):
await coresys.homeassistant.core.start()
@@ -133,19 +170,30 @@ async def test_landingpage_start(
"TZ": ANY,
"SUPERVISOR_TOKEN": ANY,
"HASSIO_TOKEN": ANY,
# no "HA_DUPLICATE_LOG_FILE"
}
assert run.call_args.kwargs["mounts"] == [
DEV_MOUNT,
Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True),
Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/run/dbus",
target="/run/dbus",
read_only=True,
),
DockerMount(
type=MountType.BIND,
source="/run/udev",
target="/run/udev",
read_only=True,
),
DockerMount(
type=MountType.BIND,
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
),
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/etc/machine-id",
target="/etc/machine-id",
read_only=True,

View File

@@ -1,6 +1,7 @@
"""Test Docker interface."""
import asyncio
from http import HTTPStatus
from pathlib import Path
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
@@ -148,7 +149,7 @@ async def test_current_state(
container_collection = MagicMock()
container_collection.get.return_value = Container(attrs)
with patch(
"supervisor.docker.manager.DockerAPI.containers",
"supervisor.docker.manager.DockerAPI.containers_legacy",
new=PropertyMock(return_value=container_collection),
):
assert await coresys.homeassistant.core.instance.current_state() == expected
@@ -158,7 +159,7 @@ async def test_current_state_failures(coresys: CoreSys):
"""Test failure states for current state."""
container_collection = MagicMock()
with patch(
"supervisor.docker.manager.DockerAPI.containers",
"supervisor.docker.manager.DockerAPI.containers_legacy",
new=PropertyMock(return_value=container_collection),
):
container_collection.get.side_effect = NotFound("dne")
@@ -211,7 +212,7 @@ async def test_attach_existing_container(
container_collection.get.return_value = Container(attrs)
with (
patch(
"supervisor.docker.manager.DockerAPI.containers",
"supervisor.docker.manager.DockerAPI.containers_legacy",
new=PropertyMock(return_value=container_collection),
),
patch.object(type(coresys.bus), "fire_event") as fire_event,
@@ -253,7 +254,7 @@ async def test_attach_existing_container(
async def test_attach_container_failure(coresys: CoreSys):
"""Test attach fails to find container but finds image."""
coresys.docker.containers.get.side_effect = DockerException()
coresys.docker.containers_legacy.get.side_effect = DockerException()
coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = (
"sha256:abc123"
)
@@ -271,7 +272,7 @@ async def test_attach_container_failure(coresys: CoreSys):
async def test_attach_total_failure(coresys: CoreSys):
"""Test attach fails to find container or image."""
coresys.docker.containers.get.side_effect = DockerException
coresys.docker.containers_legacy.get.side_effect = DockerException
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
400, {"message": ""}
)
@@ -304,8 +305,10 @@ async def test_run_missing_image(
tmp_supervisor_data: Path,
):
"""Test run captures the exception when image is missing."""
coresys.docker.containers.create.side_effect = [NotFound("missing"), MagicMock()]
container.status = "stopped"
coresys.docker.containers.create.side_effect = [
aiodocker.DockerError(HTTPStatus.NOT_FOUND, {"message": "missing"}),
MagicMock(),
]
install_addon_ssh.data["image"] = "test_image"
with pytest.raises(DockerNotFound):
@@ -724,18 +727,11 @@ async def test_install_progress_handles_layers_skipping_download(
await install_task
await event.wait()
# With the new progress calculation approach:
# - Progress is weighted by layer size
# - Small layers that skip downloading get minimal size (1 byte)
# - Progress should increase monotonically
assert len(install_job_snapshots) > 0
# First update from layer download should have rather low progress ((260937/25445459) / 2 ~ 0.5%)
assert install_job_snapshots[0]["progress"] < 1
# Verify progress is monotonically increasing (or stable)
for i in range(1, len(install_job_snapshots)):
assert (
install_job_snapshots[i]["progress"]
>= install_job_snapshots[i - 1]["progress"]
)
# Total 8 events should lead to a progress update on the install job
assert len(install_job_snapshots) == 8
# Job should complete successfully
assert job.done is True
@@ -871,24 +867,24 @@ async def test_install_progress_containerd_snapshot(
}
assert [c.args[0] for c in ha_ws_client.async_send_command.call_args_list] == [
# Count-based progress: 2 layers, each = 50%. Download = 0-35%, Extract = 35-50%
# During downloading we get continuous progress updates from download status
job_event(0),
job_event(1.7),
job_event(3.4),
job_event(8.4),
job_event(8.5),
job_event(10.2),
job_event(15.2),
job_event(18.7),
job_event(28.8),
job_event(35.7),
job_event(42.4),
job_event(49.3),
job_event(55.8),
job_event(62.7),
# Downloading phase is considered 70% of layer's progress.
# After download complete, extraction takes remaining 30% per layer.
job_event(15.3),
job_event(18.8),
job_event(29.0),
job_event(35.8),
job_event(42.6),
job_event(49.5),
job_event(56.0),
job_event(62.8),
# Downloading phase is considered 70% of total. After we only get one update
# per image downloaded when extraction is finished. It uses the total size
# received during downloading to determine percent complete then.
job_event(70.0),
job_event(85.0),
job_event(84.8),
job_event(100),
job_event(100, True),
]

View File

@@ -4,6 +4,7 @@ import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from aiodocker.containers import DockerContainer
from docker.errors import APIError, DockerException, NotFound
import pytest
from requests import RequestException
@@ -139,40 +140,38 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI):
assert result.output == b"output"
async def test_run_container_with_cidfile(
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
):
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
async def test_run_container_with_cidfile(coresys: CoreSys, docker: DockerAPI):
"""Test container creation with cidfile and bind mount."""
# Mock container
mock_container = MagicMock()
mock_container.id = "test_container_id_12345"
mock_container = MagicMock(spec=DockerContainer, id="test_container_id_12345")
mock_container.show.return_value = mock_metadata = {"Id": mock_container.id}
container_name = "test_container"
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
docker.dockerpy.containers.run.return_value = mock_container
docker.containers.create.return_value = mock_container
# Mock container creation
with patch.object(
docker.containers, "create", return_value=mock_container
) as create_mock:
# Execute run with a container name
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda kwrgs: docker.run(**kwrgs),
{"image": "test_image", "tag": "latest", "name": container_name},
)
result = await docker.run("test_image", tag="latest", name=container_name)
# Check the container creation parameters
create_mock.assert_called_once()
kwargs = create_mock.call_args[1]
create_config = create_mock.call_args.args[0]
assert "volumes" in kwargs
assert str(extern_cidfile_path) in kwargs["volumes"]
assert kwargs["volumes"][str(extern_cidfile_path)]["bind"] == "/run/cid"
assert kwargs["volumes"][str(extern_cidfile_path)]["mode"] == "ro"
assert "HostConfig" in create_config
assert "Mounts" in create_config["HostConfig"]
assert {
"Type": "bind",
"Source": str(extern_cidfile_path),
"Target": "/run/cid",
"ReadOnly": True,
} in create_config["HostConfig"]["Mounts"]
# Verify container start was called
mock_container.start.assert_called_once()
@@ -181,16 +180,15 @@ async def test_run_container_with_cidfile(
assert cidfile_path.exists()
assert cidfile_path.read_text() == mock_container.id
assert result == mock_container
assert result == mock_metadata
async def test_run_container_with_leftover_cidfile(
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
):
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
async def test_run_container_with_leftover_cidfile(coresys: CoreSys, docker: DockerAPI):
"""Test container creation removes leftover cidfile before creating new one."""
# Mock container
mock_container = MagicMock()
mock_container.id = "test_container_id_new"
mock_container = MagicMock(spec=DockerContainer, id="test_container_id_new")
mock_container.show.return_value = mock_metadata = {"Id": mock_container.id}
container_name = "test_container"
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
@@ -203,12 +201,7 @@ async def test_run_container_with_leftover_cidfile(
docker.containers, "create", return_value=mock_container
) as create_mock:
# Execute run with a container name
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda kwrgs: docker.run(**kwrgs),
{"image": "test_image", "tag": "latest", "name": container_name},
)
result = await docker.run("test_image", tag="latest", name=container_name)
# Verify container was created
create_mock.assert_called_once()
@@ -217,7 +210,7 @@ async def test_run_container_with_leftover_cidfile(
assert cidfile_path.exists()
assert cidfile_path.read_text() == mock_container.id
assert result == mock_container
assert result == mock_metadata
async def test_stop_container_with_cidfile_cleanup(
@@ -236,7 +229,7 @@ async def test_stop_container_with_cidfile_cleanup(
# Mock the containers.get method and cidfile cleanup
with (
patch.object(docker.containers, "get", return_value=mock_container),
patch.object(docker.containers_legacy, "get", return_value=mock_container),
):
# Call stop_container with remove_container=True
loop = asyncio.get_event_loop()
@@ -263,7 +256,7 @@ async def test_stop_container_without_removal_no_cidfile_cleanup(docker: DockerA
# Mock the containers.get method and cidfile cleanup
with (
patch.object(docker.containers, "get", return_value=mock_container),
patch.object(docker.containers_legacy, "get", return_value=mock_container),
patch("pathlib.Path.unlink") as mock_unlink,
):
# Call stop_container with remove_container=False
@@ -277,9 +270,8 @@ async def test_stop_container_without_removal_no_cidfile_cleanup(docker: DockerA
mock_unlink.assert_not_called()
async def test_cidfile_cleanup_handles_oserror(
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
):
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
async def test_cidfile_cleanup_handles_oserror(coresys: CoreSys, docker: DockerAPI):
"""Test that cidfile cleanup handles OSError gracefully."""
# Mock container
mock_container = MagicMock()
@@ -293,7 +285,7 @@ async def test_cidfile_cleanup_handles_oserror(
# Mock the containers.get method and cidfile cleanup to raise OSError
with (
patch.object(docker.containers, "get", return_value=mock_container),
patch.object(docker.containers_legacy, "get", return_value=mock_container),
patch("pathlib.Path.is_dir", return_value=False),
patch("pathlib.Path.is_file", return_value=True),
patch(
@@ -311,8 +303,9 @@ async def test_cidfile_cleanup_handles_oserror(
mock_unlink.assert_called_once_with(missing_ok=True)
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
async def test_run_container_with_leftover_cidfile_directory(
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
coresys: CoreSys, docker: DockerAPI
):
"""Test container creation removes leftover cidfile directory before creating new one.
@@ -321,8 +314,8 @@ async def test_run_container_with_leftover_cidfile_directory(
the bind mount source as a directory.
"""
# Mock container
mock_container = MagicMock()
mock_container.id = "test_container_id_new"
mock_container = MagicMock(spec=DockerContainer, id="test_container_id_new")
mock_container.show.return_value = mock_metadata = {"Id": mock_container.id}
container_name = "test_container"
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
@@ -336,12 +329,7 @@ async def test_run_container_with_leftover_cidfile_directory(
docker.containers, "create", return_value=mock_container
) as create_mock:
# Execute run with a container name
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda kwrgs: docker.run(**kwrgs),
{"image": "test_image", "tag": "latest", "name": container_name},
)
result = await docker.run("test_image", tag="latest", name=container_name)
# Verify container was created
create_mock.assert_called_once()
@@ -351,7 +339,7 @@ async def test_run_container_with_leftover_cidfile_directory(
assert cidfile_path.is_file()
assert cidfile_path.read_text() == mock_container.id
assert result == mock_container
assert result == mock_metadata
async def test_repair(coresys: CoreSys, caplog: pytest.LogCaptureFixture):

View File

@@ -1,143 +0,0 @@
"""Tests for registry manifest fetcher."""
from unittest.mock import AsyncMock, MagicMock, patch
from supervisor.coresys import CoreSys
from supervisor.docker.manifest import (
DOCKER_HUB,
ImageManifest,
RegistryManifestFetcher,
parse_image_reference,
)
def test_parse_image_reference_ghcr_io():
"""Test parsing ghcr.io image."""
registry, repo, tag = parse_image_reference(
"ghcr.io/home-assistant/home-assistant", "2025.1.0"
)
assert registry == "ghcr.io"
assert repo == "home-assistant/home-assistant"
assert tag == "2025.1.0"
def test_parse_image_reference_docker_hub_with_org():
"""Test parsing Docker Hub image with organization."""
registry, repo, tag = parse_image_reference(
"homeassistant/home-assistant", "latest"
)
assert registry == DOCKER_HUB
assert repo == "homeassistant/home-assistant"
assert tag == "latest"
def test_parse_image_reference_docker_hub_official_image():
"""Test parsing Docker Hub official image (no org)."""
registry, repo, tag = parse_image_reference("alpine", "3.18")
assert registry == DOCKER_HUB
assert repo == "library/alpine"
assert tag == "3.18"
def test_parse_image_reference_gcr_io():
"""Test parsing gcr.io image."""
registry, repo, tag = parse_image_reference("gcr.io/project/image", "v1")
assert registry == "gcr.io"
assert repo == "project/image"
assert tag == "v1"
def test_image_manifest_layer_count():
"""Test ImageManifest layer_count property."""
manifest = ImageManifest(
digest="sha256:abc",
total_size=1000,
layers={"layer1": 500, "layer2": 500},
)
assert manifest.layer_count == 2
async def test_get_manifest_success(coresys: CoreSys, websession: MagicMock):
"""Test successful manifest fetch by mocking internal methods."""
fetcher = RegistryManifestFetcher(coresys)
manifest_data = {
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": "sha256:abc123"},
"layers": [
{"digest": "sha256:layer1abc123def456789012", "size": 1000},
{"digest": "sha256:layer2def456abc789012345", "size": 2000},
],
}
# Mock the internal methods
with (
patch.object(
fetcher, "_get_auth_token", new=AsyncMock(return_value="test-token")
),
patch.object(
fetcher, "_fetch_manifest", new=AsyncMock(return_value=manifest_data)
),
):
result = await fetcher.get_manifest(
"test.io/org/image", "v1.0", platform="linux/amd64"
)
assert result is not None
assert result.total_size == 3000
assert result.layer_count == 2
# First 12 chars after sha256:
assert "layer1abc123" in result.layers
assert result.layers["layer1abc123"] == 1000
async def test_get_manifest_returns_none_on_failure(
coresys: CoreSys, websession: MagicMock
):
"""Test that get_manifest returns None on failure."""
fetcher = RegistryManifestFetcher(coresys)
with (
patch.object(
fetcher, "_get_auth_token", new=AsyncMock(return_value="test-token")
),
patch.object(fetcher, "_fetch_manifest", new=AsyncMock(return_value=None)),
):
result = await fetcher.get_manifest(
"test.io/org/image", "v1.0", platform="linux/amd64"
)
assert result is None
def test_get_credentials_docker_hub(coresys: CoreSys, websession: MagicMock):
"""Test getting Docker Hub credentials."""
coresys.docker.config._data["registries"] = { # pylint: disable=protected-access
"docker.io": {"username": "user", "password": "pass"}
}
fetcher = RegistryManifestFetcher(coresys)
creds = fetcher._get_credentials(DOCKER_HUB) # pylint: disable=protected-access
assert creds == ("user", "pass")
def test_get_credentials_custom_registry(coresys: CoreSys, websession: MagicMock):
"""Test getting credentials for custom registry."""
coresys.docker.config._data["registries"] = { # pylint: disable=protected-access
"ghcr.io": {"username": "user", "password": "token"}
}
fetcher = RegistryManifestFetcher(coresys)
creds = fetcher._get_credentials("ghcr.io") # pylint: disable=protected-access
assert creds == ("user", "token")
def test_get_credentials_not_found(coresys: CoreSys, websession: MagicMock):
"""Test no credentials found."""
coresys.docker.config._data["registries"] = {} # pylint: disable=protected-access
fetcher = RegistryManifestFetcher(coresys)
creds = fetcher._get_credentials("unknown.io") # pylint: disable=protected-access
assert creds is None

View File

@@ -120,7 +120,7 @@ async def test_unlabeled_container(coresys: CoreSys):
}
)
with patch(
"supervisor.docker.manager.DockerAPI.containers",
"supervisor.docker.manager.DockerAPI.containers_legacy",
new=PropertyMock(return_value=container_collection),
):
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))

View File

@@ -1,17 +1,16 @@
"""Test Observer plugin container."""
from ipaddress import IPv4Address, ip_network
from unittest.mock import patch
from docker.types import Mount
from unittest.mock import MagicMock, patch
from supervisor.coresys import CoreSys
from supervisor.docker.const import DockerMount, MountType
from supervisor.docker.manager import DockerAPI
async def test_start(coresys: CoreSys):
async def test_start(coresys: CoreSys, container: MagicMock):
"""Test starting observer plugin."""
with patch.object(DockerAPI, "run") as run:
with patch.object(DockerAPI, "run", return_value=container.attrs) as run:
await coresys.plugins.observer.start()
run.assert_called_once()
@@ -28,8 +27,8 @@ async def test_start(coresys: CoreSys):
)
assert run.call_args.kwargs["ports"] == {"80/tcp": 4357}
assert run.call_args.kwargs["mounts"] == [
Mount(
type="bind",
DockerMount(
type=MountType.BIND,
source="/run/docker.sock",
target="/run/docker.sock",
read_only=True,

File diff suppressed because it is too large Load Diff

View File

@@ -238,6 +238,7 @@ async def test_install_other_error(
@pytest.mark.usefixtures("path_extern")
async def test_start(
coresys: CoreSys,
container: MagicMock,
container_exc: DockerException | None,
image_exc: aiodocker.DockerError | None,
remove_calls: list[call],
@@ -245,8 +246,8 @@ async def test_start(
"""Test starting Home Assistant."""
coresys.docker.images.inspect.return_value = {"Id": "123"}
coresys.docker.images.inspect.side_effect = image_exc
coresys.docker.containers.get.return_value.id = "123"
coresys.docker.containers.get.side_effect = container_exc
coresys.docker.containers_legacy.get.return_value.id = "123"
coresys.docker.containers_legacy.get.side_effect = container_exc
with (
patch.object(
@@ -254,7 +255,7 @@ async def test_start(
"version",
new=PropertyMock(return_value=AwesomeVersion("2023.7.0")),
),
patch.object(DockerAPI, "run") as run,
patch.object(DockerAPI, "run", return_value=container.attrs) as run,
patch.object(HomeAssistantCore, "_block_till_run") as block_till_run,
):
await coresys.homeassistant.core.start()
@@ -268,17 +269,18 @@ async def test_start(
assert run.call_args.kwargs["name"] == "homeassistant"
assert run.call_args.kwargs["hostname"] == "homeassistant"
coresys.docker.containers.get.return_value.stop.assert_not_called()
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
assert (
coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls
coresys.docker.containers_legacy.get.return_value.remove.call_args_list
== remove_calls
)
async def test_start_existing_container(coresys: CoreSys, path_extern):
"""Test starting Home Assistant when container exists and is viable."""
coresys.docker.images.inspect.return_value = {"Id": "123"}
coresys.docker.containers.get.return_value.image.id = "123"
coresys.docker.containers.get.return_value.status = "exited"
coresys.docker.containers_legacy.get.return_value.image.id = "123"
coresys.docker.containers_legacy.get.return_value.status = "exited"
with (
patch.object(
@@ -291,29 +293,29 @@ async def test_start_existing_container(coresys: CoreSys, path_extern):
await coresys.homeassistant.core.start()
block_till_run.assert_called_once()
coresys.docker.containers.get.return_value.start.assert_called_once()
coresys.docker.containers.get.return_value.stop.assert_not_called()
coresys.docker.containers.get.return_value.remove.assert_not_called()
coresys.docker.containers.get.return_value.run.assert_not_called()
coresys.docker.containers_legacy.get.return_value.start.assert_called_once()
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
coresys.docker.containers_legacy.get.return_value.remove.assert_not_called()
coresys.docker.containers_legacy.get.return_value.run.assert_not_called()
@pytest.mark.parametrize("exists", [True, False])
async def test_stop(coresys: CoreSys, exists: bool):
"""Test stoppping Home Assistant."""
if exists:
coresys.docker.containers.get.return_value.status = "running"
coresys.docker.containers_legacy.get.return_value.status = "running"
else:
coresys.docker.containers.get.side_effect = NotFound("missing")
coresys.docker.containers_legacy.get.side_effect = NotFound("missing")
await coresys.homeassistant.core.stop()
coresys.docker.containers.get.return_value.remove.assert_not_called()
coresys.docker.containers_legacy.get.return_value.remove.assert_not_called()
if exists:
coresys.docker.containers.get.return_value.stop.assert_called_once_with(
coresys.docker.containers_legacy.get.return_value.stop.assert_called_once_with(
timeout=260
)
else:
coresys.docker.containers.get.return_value.stop.assert_not_called()
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
async def test_restart(coresys: CoreSys):
@@ -322,18 +324,20 @@ async def test_restart(coresys: CoreSys):
await coresys.homeassistant.core.restart()
block_till_run.assert_called_once()
coresys.docker.containers.get.return_value.restart.assert_called_once_with(
coresys.docker.containers_legacy.get.return_value.restart.assert_called_once_with(
timeout=260
)
coresys.docker.containers.get.return_value.stop.assert_not_called()
coresys.docker.containers_legacy.get.return_value.stop.assert_not_called()
@pytest.mark.parametrize("get_error", [NotFound("missing"), DockerException(), None])
async def test_restart_failures(coresys: CoreSys, get_error: DockerException | None):
"""Test restart fails when container missing or can't be restarted."""
coresys.docker.containers.get.return_value.restart.side_effect = DockerException()
coresys.docker.containers_legacy.get.return_value.restart.side_effect = (
DockerException()
)
if get_error:
coresys.docker.containers.get.side_effect = get_error
coresys.docker.containers_legacy.get.side_effect = get_error
with pytest.raises(HomeAssistantError):
await coresys.homeassistant.core.restart()
@@ -352,10 +356,12 @@ async def test_stats_failures(
coresys: CoreSys, get_error: DockerException | None, status: str
):
"""Test errors when getting stats."""
coresys.docker.containers.get.return_value.status = status
coresys.docker.containers.get.return_value.stats.side_effect = DockerException()
coresys.docker.containers_legacy.get.return_value.status = status
coresys.docker.containers_legacy.get.return_value.stats.side_effect = (
DockerException()
)
if get_error:
coresys.docker.containers.get.side_effect = get_error
coresys.docker.containers_legacy.get.side_effect = get_error
with pytest.raises(HomeAssistantError):
await coresys.homeassistant.core.stats()
@@ -387,7 +393,7 @@ async def test_api_check_timeout(
):
await coresys.homeassistant.core.start()
assert coresys.homeassistant.api.get_api_state.call_count == 3
assert coresys.homeassistant.api.get_api_state.call_count == 10
assert (
"No Home Assistant Core response, assuming a fatal startup error" in caplog.text
)

View File

@@ -1,7 +1,6 @@
"""Test base plugin functionality."""
import asyncio
from pathlib import Path
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
from awesomeversion import AwesomeVersion
@@ -159,15 +158,13 @@ async def test_plugin_watchdog(coresys: CoreSys, plugin: PluginBase) -> None:
],
indirect=["plugin"],
)
@pytest.mark.usefixtures("coresys", "tmp_supervisor_data", "path_extern")
async def test_plugin_watchdog_max_failed_attempts(
coresys: CoreSys,
capture_exception: Mock,
plugin: PluginBase,
error: PluginError,
container: MagicMock,
caplog: pytest.LogCaptureFixture,
tmp_supervisor_data: Path,
path_extern,
) -> None:
"""Test plugin watchdog gives up after max failed attempts."""
with patch.object(type(plugin.instance), "attach"):

View File

@@ -76,7 +76,7 @@ async def test_check(
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon, folder: str
):
"""Test check reports issue when containers have incorrect config."""
docker.containers.get = _make_mock_container_get(
docker.containers_legacy.get = _make_mock_container_get(
["homeassistant", "hassio_audio", "addon_local_ssh"], folder
)
# Use state used in setup()
@@ -132,7 +132,7 @@ async def test_check(
assert await docker_config.approve_check()
# IF config issue is resolved, all issues are removed except the main one. Which will be removed if check isn't approved
docker.containers.get = _make_mock_container_get([])
docker.containers_legacy.get = _make_mock_container_get([])
with patch.object(DockerInterface, "is_running", return_value=True):
await coresys.plugins.load()
await coresys.homeassistant.load()
@@ -159,7 +159,7 @@ async def test_addon_volume_mount_not_flagged(
] # No media/share
# Mock container that has VOLUME mount to media/share with wrong propagation
docker.containers.get = _make_mock_container_get_with_volume_mount(
docker.containers_legacy.get = _make_mock_container_get_with_volume_mount(
["addon_local_ssh"], folder
)
@@ -221,7 +221,7 @@ async def test_addon_configured_mount_still_flagged(
out.attrs["Mounts"].append(mount)
return out
docker.containers.get = mock_container_get
docker.containers_legacy.get = mock_container_get
await coresys.core.set_state(CoreState.SETUP)
with patch.object(DockerInterface, "is_running", return_value=True):
@@ -275,7 +275,7 @@ async def test_addon_custom_target_path_flagged(
out.attrs["Mounts"].append(mount)
return out
docker.containers.get = mock_container_get
docker.containers_legacy.get = mock_container_get
await coresys.core.set_state(CoreState.SETUP)
with patch.object(DockerInterface, "is_running", return_value=True):

View File

@@ -30,7 +30,7 @@ async def test_evaluation(coresys: CoreSys):
assert container.reason not in coresys.resolution.unsupported
assert UnhealthyReason.DOCKER not in coresys.resolution.unhealthy
coresys.docker.containers.list.return_value = [
coresys.docker.containers_legacy.list.return_value = [
_make_image_attr("armhfbuild/watchtower:latest"),
_make_image_attr("concerco/watchtowerv6:10.0.2"),
_make_image_attr("containrrr/watchtower:1.1"),
@@ -47,7 +47,7 @@ async def test_evaluation(coresys: CoreSys):
"pyouroboros/ouroboros:1.4.3",
}
coresys.docker.containers.list.return_value = []
coresys.docker.containers_legacy.list.return_value = []
await container()
assert container.reason not in coresys.resolution.unsupported
@@ -62,7 +62,7 @@ async def test_corrupt_docker(coresys: CoreSys):
corrupt_docker = Issue(IssueType.CORRUPT_DOCKER, ContextType.SYSTEM)
assert corrupt_docker not in coresys.resolution.issues
coresys.docker.containers.list.side_effect = DockerException
coresys.docker.containers_legacy.list.side_effect = DockerException
await container()
assert corrupt_docker in coresys.resolution.issues

View File

@@ -33,7 +33,7 @@ async def test_evaluation(coresys: CoreSys, install_addon_ssh: Addon):
meta.attrs = observer_attrs if name == "hassio_observer" else addon_attrs
return meta
coresys.docker.containers.get = get_container
coresys.docker.containers_legacy.get = get_container
await coresys.plugins.observer.instance.attach(TEST_VERSION)
await install_addon_ssh.instance.attach(TEST_VERSION)

View File

@@ -31,7 +31,7 @@ async def _mock_wait_for_container() -> None:
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
"""Test fixup rebuilds addon's container."""
docker.containers.get = make_mock_container_get("running")
docker.containers_legacy.get = make_mock_container_get("running")
addon_execute_rebuild = FixupAddonExecuteRebuild(coresys)
@@ -61,7 +61,7 @@ async def test_fixup_stopped_core(
):
"""Test fixup just removes addon's container when it is stopped."""
caplog.clear()
docker.containers.get = make_mock_container_get("stopped")
docker.containers_legacy.get = make_mock_container_get("stopped")
addon_execute_rebuild = FixupAddonExecuteRebuild(coresys)
coresys.resolution.create_issue(
@@ -76,7 +76,7 @@ async def test_fixup_stopped_core(
assert not coresys.resolution.issues
assert not coresys.resolution.suggestions
docker.containers.get("addon_local_ssh").remove.assert_called_once_with(
docker.containers_legacy.get("addon_local_ssh").remove.assert_called_once_with(
force=True, v=True
)
assert "Addon local_ssh is stopped" in caplog.text
@@ -90,7 +90,7 @@ async def test_fixup_unknown_core(
):
"""Test fixup does nothing if addon's container has already been removed."""
caplog.clear()
docker.containers.get.side_effect = NotFound("")
docker.containers_legacy.get.side_effect = NotFound("")
addon_execute_rebuild = FixupAddonExecuteRebuild(coresys)
coresys.resolution.create_issue(

View File

@@ -27,7 +27,7 @@ def make_mock_container_get(status: str):
async def test_fixup(docker: DockerAPI, coresys: CoreSys):
"""Test fixup rebuilds core's container."""
docker.containers.get = make_mock_container_get("running")
docker.containers_legacy.get = make_mock_container_get("running")
core_execute_rebuild = FixupCoreExecuteRebuild(coresys)
@@ -51,7 +51,7 @@ async def test_fixup_stopped_core(
):
"""Test fixup just removes HA's container when it is stopped."""
caplog.clear()
docker.containers.get = make_mock_container_get("stopped")
docker.containers_legacy.get = make_mock_container_get("stopped")
core_execute_rebuild = FixupCoreExecuteRebuild(coresys)
coresys.resolution.create_issue(
@@ -65,7 +65,7 @@ async def test_fixup_stopped_core(
assert not coresys.resolution.issues
assert not coresys.resolution.suggestions
docker.containers.get("homeassistant").remove.assert_called_once_with(
docker.containers_legacy.get("homeassistant").remove.assert_called_once_with(
force=True, v=True
)
assert "Home Assistant is stopped" in caplog.text
@@ -76,7 +76,7 @@ async def test_fixup_unknown_core(
):
"""Test fixup does nothing if core's container has already been removed."""
caplog.clear()
docker.containers.get.side_effect = NotFound("")
docker.containers_legacy.get.side_effect = NotFound("")
core_execute_rebuild = FixupCoreExecuteRebuild(coresys)
coresys.resolution.create_issue(

View File

@@ -28,7 +28,7 @@ def make_mock_container_get(status: str):
@pytest.mark.parametrize("status", ["running", "stopped"])
async def test_fixup(docker: DockerAPI, coresys: CoreSys, status: str):
"""Test fixup rebuilds plugin's container regardless of current state."""
docker.containers.get = make_mock_container_get(status)
docker.containers_legacy.get = make_mock_container_get(status)
plugin_execute_rebuild = FixupPluginExecuteRebuild(coresys)