Compare commits

...

31 Commits

Author SHA1 Message Date
J. Nick Koston
af3256e41e Significantly speed up creating backups with isal via zlib-fast
isal is a drop in replacement for zlib with the
cavet that the compression level mappings are different.
zlib-fast is a tiny piece of middleware to convert
the standard zlib compression levels to isal compression
levels to allow for drop-in replacement

https://github.com/bdraco/zlib-fast/releases/tag/v0.1.0
https://github.com/pycompression/python-isal

Compression for backups is ~5x faster than the baseline

https://github.com/powturbo/TurboBench/issues/43
2024-01-27 13:06:41 -10:00
J. Nick Koston
a163121ad4 Fix dirhash failing to import pkg_resources
dirhash needs pkg_resources which is provided by setuptools

https://github.com/home-assistant/supervisor/actions/runs/7513346221/job/20454994962
2024-01-14 00:02:12 -10:00
J. Nick Koston
eb85be2770 Improve json performance by porting core orjson utils (#4816)
* Improve json performance by porting core orjson utils

* port relevant tests

* pylint

* add test for read_json_file

* add test for read_json_file

* remove workaround for core issue we do not have here

---------

Co-authored-by: Pascal Vizeli <pvizeli@syshack.ch>
2024-01-13 19:19:01 +01:00
Mike Degatano
2da27937a5 Update python to 3.12 (#4815)
* Update python to 3.12

* Fix tests and deprecations

* Fix other references to 3.11

* build.json doesn't exist
2024-01-13 16:35:07 +01:00
dependabot[bot]
2a29b801a4 Bump jinja2 from 3.1.2 to 3.1.3 (#4810)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-12 09:52:44 +01:00
dependabot[bot]
57e65714b0 Bump actions/download-artifact from 4.1.0 to 4.1.1 (#4809)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-12 09:35:09 +01:00
dependabot[bot]
0ae40cb51c Bump gitpython from 3.1.40 to 3.1.41 (#4808)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-12 09:31:31 +01:00
dependabot[bot]
ddd195dfc6 Bump sentry-sdk from 1.39.1 to 1.39.2 (#4811)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-12 09:29:34 +01:00
dependabot[bot]
54b9f23ec5 Bump actions/cache from 3.3.2 to 3.3.3 (#4813)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-12 09:25:51 +01:00
dependabot[bot]
242dd3e626 Bump home-assistant/wheels from 2023.10.5 to 2024.01.0 (#4804) 2024-01-08 08:16:11 +01:00
dependabot[bot]
1b8acb5b60 Bump home-assistant/builder from 2023.12.0 to 2024.01.0 (#4800)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-05 08:19:40 +01:00
dependabot[bot]
a7ab96ab12 Bump flake8 from 6.1.0 to 7.0.0 (#4799)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-05 08:18:05 +01:00
dependabot[bot]
06ab11cf87 Bump attrs from 23.1.0 to 23.2.0 (#4793)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-02 16:57:05 +01:00
dependabot[bot]
1410a1b06e Bump pytest from 7.4.3 to 7.4.4 (#4792) 2024-01-01 14:55:37 +01:00
Mike Degatano
5baf19f7a3 Migrate to pyproject.toml where possible (#4770)
* Migrate to pyproject.toml where possible

* Share requirements and fix version import

* Fix issues with timezone in tests
2023-12-29 11:46:01 +01:00
Mike Degatano
6c66a7ba17 Improve error handling in backup restore (#4791) 2023-12-29 11:45:50 +01:00
dependabot[bot]
37b6e09475 Bump coverage from 7.3.4 to 7.4.0 (#4790)
Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.3.4 to 7.4.0.
- [Release notes](https://github.com/nedbat/coveragepy/releases)
- [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst)
- [Commits](https://github.com/nedbat/coveragepy/compare/7.3.4...7.4.0)

---
updated-dependencies:
- dependency-name: coverage
  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>
2023-12-28 09:22:22 +01:00
Jeff Oakley
e08c8ca26d Add support for setting target path in map config (#4694)
* Added support for setting addon target path in map config

* Updated addon target path mapping to use dataclass

* Added check before adding string folder maps

* Moved enum to addon/const, updated map_volumes logic, fixed test

* Removed log used for debugging

* Use more readable approach to determine addon_config_used

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

* Use cleaner approach for checking volume config

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

* Use dict syntax and ATTR_TYPE

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

* Use coerce for validating mapping type

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

* Default read_only to true in schema

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

* Use ATTR_TYPE and ATTR_READ_ONLY instead of static strings

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

* Use constants instead of in-line strings

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

* Correct type for path

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

* Added read_only and path constants

* Fixed small syntax error and added includes for constants

* Simplify logic for handling string and dict entries in map config

* Use ATTR_PATH instead of inline string

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

* Add missing ATTR_PATH reference

* Moved FolderMapping dataclass to data.py

* Fix edge case where "data" map type is used but optional path is not set

* Move FolderMapping dataclass to configuration.py to prevent circular reference

---------

Co-authored-by: Jeff Oakley <jeff.oakley@LearningCircleSoftware.com>
Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
Co-authored-by: Pascal Vizeli <pvizeli@syshack.ch>
2023-12-27 15:14:23 -05:00
dependabot[bot]
2c09e7929f Bump black from 23.12.0 to 23.12.1 (#4788) 2023-12-26 08:30:35 +01:00
Stefan Agner
3e760f0d85 Always pass explicit architecture of installed add-ons (#4786)
* Pass architecture of installed add-on on update

When using multi-architecture container images, the architecture of the
add-on is not passed to Docker in all cases. This causes the
architecture of the Supervisor container to be used, which potentially
is not supported by the add-on in question.

This commit passes the architecture of the add-on to Docker, so that
the correct image is pulled.

* Call update with architecture

* Also pass architecture on add-on restore

* Fix pytest
2023-12-21 16:52:25 -05:00
Mike Degatano
3cc6bd19ad Mark system as unhealthy on OSError Bad message errors (#4750)
* Bad message error marks system as unhealthy

* Finish adding test cases for changes

* Rename test file for uniqueness

* bad_message to oserror_bad_message

* Omit some checks and check for network mounts
2023-12-21 18:05:29 +01:00
Mike Degatano
b7ddfba71d Set max reanimation attempts on HA watchdog (#4784) 2023-12-21 16:44:39 +01:00
dependabot[bot]
32f21d208f Bump coverage from 7.3.3 to 7.3.4 (#4785)
Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.3.3 to 7.3.4.
- [Release notes](https://github.com/nedbat/coveragepy/releases)
- [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst)
- [Commits](https://github.com/nedbat/coveragepy/compare/7.3.3...7.3.4)

---
updated-dependencies:
- dependency-name: coverage
  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>
2023-12-21 09:01:49 +01:00
Jan Čermák
ed7edd9fe0 Adjust "retry in ..." log messages to avoid confusion (#4783)
As shown in home-assistant/operating-system#3007, error messages printed
to logs when container installation fails can cause some confusion,
because they are sometimes printed to the log on the landing page.
Adjust all wordings of "retry in" to "retrying in" to make it obvious
this happens automatically.
2023-12-20 18:34:42 +01:00
Stefan Agner
fd3c995c7c Fix WiFi WEP configuration (#4781)
It seems that the values for auth-alg and key-mgmt got mixed up.
Trying to safe a WEP configuration currently leads to the error:
23-12-19 10:56:37 ERROR (MainThread) [supervisor.host.network] Can't create config and activate wlan0: 802-11-wireless-security.key-mgmt: 'open' is not a valid value for the property
2023-12-19 13:53:51 +01:00
dependabot[bot]
c0d1a2d53b Bump actions/download-artifact from 4.0.0 to 4.1.0 (#4780)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4.0.0...v4.1.0)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  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>
2023-12-19 08:59:43 +01:00
dependabot[bot]
76bc3015a7 Bump deepmerge from 1.1.0 to 1.1.1 (#4779)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-19 08:45:48 +01:00
dependabot[bot]
ad2896243b Bump sentry-sdk from 1.39.0 to 1.39.1 (#4774)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-18 15:45:05 +01:00
dependabot[bot]
d0dcded42d Bump actions/download-artifact from 3 to 4 (#4777)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2023-12-15 08:27:10 +01:00
dependabot[bot]
a0dfa01287 Bump actions/upload-artifact from 3.1.3 to 4.0.0 (#4776)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-15 08:12:31 +01:00
dependabot[bot]
4ec5c90180 Bump coverage from 7.3.2 to 7.3.3 (#4775) 2023-12-15 07:25:10 +01:00
70 changed files with 1297 additions and 433 deletions

View File

@@ -29,7 +29,7 @@
"files.trimTrailingWhitespace": true,
"python.pythonPath": "/usr/local/bin/python3",
"python.formatting.provider": "black",
"python.formatting.blackArgs": ["--target-version", "py311"],
"python.formatting.blackArgs": ["--target-version", "py312"],
"python.formatting.blackPath": "/usr/local/bin/black"
}
}

View File

@@ -33,7 +33,7 @@ on:
- setup.py
env:
DEFAULT_PYTHON: "3.11"
DEFAULT_PYTHON: "3.12"
BUILD_NAME: supervisor
BUILD_TYPE: supervisor
@@ -75,7 +75,7 @@ jobs:
- name: Check if requirements files changed
id: requirements
run: |
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.json) ]]; then
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.yaml) ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
@@ -106,9 +106,9 @@ jobs:
- name: Build wheels
if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@2023.10.5
uses: home-assistant/wheels@2024.01.0
with:
abi: cp311
abi: cp312
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
@@ -138,7 +138,7 @@ jobs:
- name: Install dirhash and calc hash
if: needs.init.outputs.publish == 'true'
run: |
pip3 install dirhash
pip3 install setuptools dirhash
dir_hash="$(dirhash "${{ github.workspace }}/supervisor" -a sha256 --match "*.py")"
echo "${dir_hash}" > rootfs/supervisor.sha256
@@ -160,7 +160,7 @@ jobs:
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
- name: Build supervisor
uses: home-assistant/builder@2023.12.0
uses: home-assistant/builder@2024.01.0
with:
args: |
$BUILD_ARGS \
@@ -207,7 +207,7 @@ jobs:
- name: Build the Supervisor
if: needs.init.outputs.publish != 'true'
uses: home-assistant/builder@2023.12.0
uses: home-assistant/builder@2024.01.0
with:
args: |
--test \

View File

@@ -8,7 +8,7 @@ on:
pull_request: ~
env:
DEFAULT_PYTHON: "3.11"
DEFAULT_PYTHON: "3.12"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
concurrency:
@@ -33,7 +33,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -47,7 +47,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -75,7 +75,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -88,7 +88,7 @@ jobs:
- name: Run black
run: |
. venv/bin/activate
black --target-version py311 --check supervisor tests setup.py
black --target-version py312 --check supervisor tests setup.py
lint-dockerfile:
name: Check Dockerfile
@@ -119,7 +119,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -131,7 +131,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -163,7 +163,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -195,7 +195,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -207,7 +207,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -236,7 +236,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -248,7 +248,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -280,7 +280,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -312,7 +312,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -324,7 +324,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -357,7 +357,7 @@ jobs:
cosign-release: "v2.0.2"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -392,7 +392,7 @@ jobs:
-o console_output_style=count \
tests
- name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.3
uses: actions/upload-artifact@v4.0.0
with:
name: coverage-${{ matrix.python-version }}
path: .coverage
@@ -411,7 +411,7 @@ jobs:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.2
uses: actions/cache@v3.3.3
with:
path: venv
key: |
@@ -422,7 +422,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Download all coverage artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4.1.1
- name: Combine coverage results
run: |
. venv/bin/activate

View File

@@ -1,16 +1,16 @@
repos:
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 23.12.1
hooks:
- id: black
args:
- --safe
- --quiet
- --target-version
- py311
- py312
files: ^((supervisor|tests)/.+)?[^/]+\.py$
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
rev: 7.0.0
hooks:
- id: flake8
additional_dependencies:
@@ -18,17 +18,17 @@ repos:
- pydocstyle==6.3.0
files: ^(supervisor|script|tests)/.+\.py$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.5.0
hooks:
- id: check-executables-have-shebangs
stages: [manual]
- id: check-json
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.0
hooks:
- id: pyupgrade
args: [--py311-plus]
args: [--py312-plus]

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-hassio-supervisor
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.11-alpine3.18
armhf: ghcr.io/home-assistant/armhf-base-python:3.11-alpine3.18
armv7: ghcr.io/home-assistant/armv7-base-python:3.11-alpine3.18
amd64: ghcr.io/home-assistant/amd64-base-python:3.11-alpine3.18
i386: ghcr.io/home-assistant/i386-base-python:3.11-alpine3.18
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.12-alpine3.18
armhf: ghcr.io/home-assistant/armhf-base-python:3.12-alpine3.18
armv7: ghcr.io/home-assistant/armv7-base-python:3.12-alpine3.18
amd64: ghcr.io/home-assistant/amd64-base-python:3.12-alpine3.18
i386: ghcr.io/home-assistant/i386-base-python:3.12-alpine3.18
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@@ -1,45 +0,0 @@
[MASTER]
reports=no
jobs=2
good-names=id,i,j,k,ex,Run,_,fp,T,os
extension-pkg-whitelist=
ciso8601
# Reasons disabled:
# format - handled by black
# locally-disabled - it spams too much
# duplicate-code - unavoidable
# cyclic-import - doesn't test if both import on load
# abstract-class-not-used - is flaky, should not show up but does
# unused-argument - generic callbacks and setup methods create a lot of warnings
# too-many-* - are not enforced for the sake of readability
# too-few-* - same as too-many-*
# abstract-method - with intro of async there are always methods missing
disable=
format,
abstract-method,
cyclic-import,
duplicate-code,
locally-disabled,
no-else-return,
not-context-manager,
too-few-public-methods,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
unused-argument,
consider-using-with
[EXCEPTIONS]
overgeneral-exceptions=builtins.Exception
[TYPECHECK]
ignored-modules = distutils

112
pyproject.toml Normal file
View File

@@ -0,0 +1,112 @@
[build-system]
requires = ["setuptools~=68.0.0", "wheel~=0.40.0"]
build-backend = "setuptools.build_meta"
[project]
name = "Supervisor"
dynamic = ["version", "dependencies"]
license = { text = "Apache-2.0" }
description = "Open-source private cloud os for Home-Assistant based on HassOS"
readme = "README.md"
authors = [
{ name = "The Home Assistant Authors", email = "hello@home-assistant.io" },
]
keywords = ["docker", "home-assistant", "api"]
requires-python = ">=3.12.0"
[project.urls]
"Homepage" = "https://www.home-assistant.io/"
"Source Code" = "https://github.com/home-assistant/supervisor"
"Bug Reports" = "https://github.com/home-assistant/supervisor/issues"
"Docs: Dev" = "https://developers.home-assistant.io/"
"Discord" = "https://www.home-assistant.io/join-chat/"
"Forum" = "https://community.home-assistant.io/"
[tool.setuptools]
platforms = ["any"]
zip-safe = false
include-package-data = true
[tool.setuptools.packages.find]
include = ["supervisor*"]
[tool.pylint.MAIN]
py-version = "3.11"
# Use a conservative default here; 2 should speed up most setups and not hurt
# any too bad. Override on command line as appropriate.
jobs = 2
persistent = false
extension-pkg-allow-list = ["ciso8601"]
[tool.pylint.BASIC]
class-const-naming-style = "any"
good-names = ["id", "i", "j", "k", "ex", "Run", "_", "fp", "T", "os"]
[tool.pylint."MESSAGES CONTROL"]
# Reasons disabled:
# format - handled by black
# abstract-method - with intro of async there are always methods missing
# cyclic-import - doesn't test if both import on load
# duplicate-code - unavoidable
# locally-disabled - it spams too much
# too-many-* - are not enforced for the sake of readability
# too-few-* - same as too-many-*
# unused-argument - generic callbacks and setup methods create a lot of warnings
disable = [
"format",
"abstract-method",
"cyclic-import",
"duplicate-code",
"locally-disabled",
"no-else-return",
"not-context-manager",
"too-few-public-methods",
"too-many-arguments",
"too-many-branches",
"too-many-instance-attributes",
"too-many-lines",
"too-many-locals",
"too-many-public-methods",
"too-many-return-statements",
"too-many-statements",
"unused-argument",
"consider-using-with",
]
[tool.pylint.REPORTS]
score = false
[tool.pylint.TYPECHECK]
ignored-modules = ["distutils"]
[tool.pylint.FORMAT]
expected-line-ending-format = "LF"
[tool.pylint.EXCEPTIONS]
overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"]
[tool.pytest.ini_options]
testpaths = ["tests"]
norecursedirs = [".git"]
log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
asyncio_mode = "auto"
filterwarnings = [
"error",
"ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash",
"ignore::pytest.PytestUnraisableExceptionWarning",
]
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
line_length = 88
indent = " "
force_sort_within_sections = true
sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
default_section = "THIRDPARTY"
forced_separate = "tests"
combine_as_imports = true
use_parentheses = true
known_first_party = ["supervisor", "tests"]

View File

@@ -1,6 +0,0 @@
[pytest]
asyncio_mode = auto
filterwarnings =
error
ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash
ignore::pytest.PytestUnraisableExceptionWarning

View File

@@ -3,7 +3,7 @@ aiohttp==3.9.1
aiohttp-fast-url-dispatcher==0.3.0
async_timeout==4.0.3
atomicwrites-homeassistant==1.4.1
attrs==23.1.0
attrs==23.2.0
awesomeversion==23.11.0
brotli==1.1.0
ciso8601==2.3.1
@@ -11,17 +11,20 @@ colorlog==6.8.0
cpe==1.2.1
cryptography==41.0.7
debugpy==1.8.0
deepmerge==1.1.0
deepmerge==1.1.1
dirhash==0.2.1
docker==7.0.0
faust-cchardet==2.1.19
gitpython==3.1.40
jinja2==3.1.2
gitpython==3.1.41
jinja2==3.1.3
orjson==3.9.10
pulsectl==23.5.2
pyudev==0.24.1
PyYAML==6.0.1
securetar==2023.12.0
sentry-sdk==1.39.0
sentry-sdk==1.39.2
setuptools==69.0.3
voluptuous==0.14.1
dbus-fast==2.21.0
typing_extensions==4.9.0
zlib-fast==0.1.0

View File

@@ -1,15 +1,15 @@
black==23.12.0
coverage==7.3.2
black==23.12.1
coverage==7.4.0
flake8-docstrings==1.7.0
flake8==6.1.0
flake8==7.0.0
pre-commit==3.6.0
pydocstyle==6.3.0
pylint==3.0.3
pytest-aiohttp==1.0.5
pytest-asyncio==0.18.3
pytest-asyncio==0.23.3
pytest-cov==4.1.0
pytest-timeout==2.2.0
pytest==7.4.3
pytest==7.4.4
pyupgrade==3.15.0
time-machine==2.13.0
typing_extensions==4.9.0

View File

@@ -1,17 +1,3 @@
[isort]
multi_line_output = 3
include_trailing_comma=True
force_grid_wrap=0
line_length=88
indent = " "
force_sort_within_sections = true
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
default_section = THIRDPARTY
forced_separate = tests
combine_as_imports = true
use_parentheses = true
known_first_party = supervisor,tests
[flake8]
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
doctests = True

View File

@@ -1,48 +1,27 @@
"""Home Assistant Supervisor setup."""
from pathlib import Path
import re
from setuptools import setup
from supervisor.const import SUPERVISOR_VERSION
RE_SUPERVISOR_VERSION = re.compile(r"^SUPERVISOR_VERSION =\s*(.+)$")
SUPERVISOR_DIR = Path(__file__).parent
REQUIREMENTS_FILE = SUPERVISOR_DIR / "requirements.txt"
CONST_FILE = SUPERVISOR_DIR / "supervisor/const.py"
REQUIREMENTS = REQUIREMENTS_FILE.read_text(encoding="utf-8")
CONSTANTS = CONST_FILE.read_text(encoding="utf-8")
def _get_supervisor_version():
for line in CONSTANTS.split("/n"):
if match := RE_SUPERVISOR_VERSION.match(line):
return match.group(1)
return "99.9.9dev"
setup(
name="Supervisor",
version=SUPERVISOR_VERSION,
license="BSD License",
author="The Home Assistant Authors",
author_email="hello@home-assistant.io",
url="https://home-assistant.io/",
description=("Open-source private cloud os for Home-Assistant" " based on HassOS"),
long_description=(
"A maintainless private cloud operator system that"
"setup a Home-Assistant instance. Based on HassOS"
),
keywords=["docker", "home-assistant", "api"],
zip_safe=False,
platforms="any",
packages=[
"supervisor.addons",
"supervisor.api",
"supervisor.backups",
"supervisor.dbus.network",
"supervisor.dbus.network.setting",
"supervisor.dbus",
"supervisor.discovery.services",
"supervisor.discovery",
"supervisor.docker",
"supervisor.homeassistant",
"supervisor.host",
"supervisor.jobs",
"supervisor.misc",
"supervisor.plugins",
"supervisor.resolution.checks",
"supervisor.resolution.evaluations",
"supervisor.resolution.fixups",
"supervisor.resolution",
"supervisor.security",
"supervisor.services.modules",
"supervisor.services",
"supervisor.store",
"supervisor.utils",
"supervisor",
],
include_package_data=True,
version=_get_supervisor_version(),
dependencies=REQUIREMENTS.split("/n"),
)

View File

@@ -5,8 +5,13 @@ import logging
from pathlib import Path
import sys
from supervisor import bootstrap
from supervisor.utils.logging import activate_log_queue_handler
import zlib_fast
# Enable fast zlib before importing supervisor
zlib_fast.enable()
from supervisor import bootstrap # noqa: E402
from supervisor.utils.logging import activate_log_queue_handler # noqa: E402
_LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@@ -3,6 +3,7 @@ import asyncio
from collections.abc import Awaitable
from contextlib import suppress
from copy import deepcopy
import errno
from ipaddress import IPv4Address
import logging
from pathlib import Path, PurePath
@@ -47,7 +48,6 @@ from ..const import (
ATTR_VERSION,
ATTR_WATCHDOG,
DNS_SUFFIX,
MAP_ADDON_CONFIG,
AddonBoot,
AddonStartup,
AddonState,
@@ -72,6 +72,7 @@ from ..hardware.data import Device
from ..homeassistant.const import WSEvent, WSType
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from ..store.addon import AddonStore
from ..utils import check_port
from ..utils.apparmor import adjust_profile
@@ -83,6 +84,7 @@ from .const import (
WATCHDOG_THROTTLE_MAX_CALLS,
WATCHDOG_THROTTLE_PERIOD,
AddonBackupMode,
MappingType,
)
from .model import AddonModel, Data
from .options import AddonOptions
@@ -465,7 +467,7 @@ class Addon(AddonModel):
@property
def addon_config_used(self) -> bool:
"""Add-on is using its public config folder."""
return MAP_ADDON_CONFIG in self.map_volumes
return MappingType.ADDON_CONFIG in self.map_volumes
@property
def path_config(self) -> Path:
@@ -716,7 +718,7 @@ class Addon(AddonModel):
store = self.addon_store.clone()
try:
await self.instance.update(store.version, store.image)
await self.instance.update(store.version, store.image, arch=self.arch)
except DockerError as err:
raise AddonsError() from err
@@ -793,6 +795,8 @@ class Addon(AddonModel):
try:
self.path_pulse.write_text(pulse_config, encoding="utf-8")
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err
)
@@ -1151,7 +1155,11 @@ class Addon(AddonModel):
def _extract_tarfile():
"""Extract tar backup."""
with tar_file as backup:
backup.extractall(path=Path(temp), members=secure_path(backup))
backup.extractall(
path=Path(temp),
members=secure_path(backup),
filter="fully_trusted",
)
try:
await self.sys_run_in_executor(_extract_tarfile)
@@ -1205,12 +1213,14 @@ class Addon(AddonModel):
await self.instance.import_image(image_file)
else:
with suppress(DockerError):
await self.instance.install(version, restore_image)
await self.instance.install(
version, restore_image, self.arch
)
await self.instance.cleanup()
elif self.instance.version != version or self.legacy:
_LOGGER.info("Restore/Update of image for addon %s", self.slug)
with suppress(DockerError):
await self.instance.update(version, restore_image)
await self.instance.update(version, restore_image, self.arch)
self._check_ingress_port()
# Restore data and config

View File

@@ -0,0 +1,11 @@
"""Confgiuration Objects for Addon Config."""
from dataclasses import dataclass
@dataclass(slots=True)
class FolderMapping:
"""Represent folder mapping configuration."""
path: str | None
read_only: bool

View File

@@ -12,8 +12,25 @@ class AddonBackupMode(StrEnum):
COLD = "cold"
class MappingType(StrEnum):
"""Mapping type of an Add-on Folder."""
DATA = "data"
CONFIG = "config"
SSL = "ssl"
ADDONS = "addons"
BACKUP = "backup"
SHARE = "share"
MEDIA = "media"
HOMEASSISTANT_CONFIG = "homeassistant_config"
ALL_ADDON_CONFIGS = "all_addon_configs"
ADDON_CONFIG = "addon_config"
ATTR_BACKUP = "backup"
ATTR_CODENOTARY = "codenotary"
ATTR_READ_ONLY = "read_only"
ATTR_PATH = "path"
WATCHDOG_RETRY_SECONDS = 10
WATCHDOG_MAX_ATTEMPTS = 5
WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30)

View File

@@ -65,6 +65,7 @@ from ..const import (
ATTR_TIMEOUT,
ATTR_TMPFS,
ATTR_TRANSLATIONS,
ATTR_TYPE,
ATTR_UART,
ATTR_UDEV,
ATTR_URL,
@@ -86,9 +87,17 @@ from ..exceptions import AddonsNotSupportedError
from ..jobs.const import JOB_GROUP_ADDON
from ..jobs.job_group import JobGroup
from ..utils import version_is_new_enough
from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode
from .configuration import FolderMapping
from .const import (
ATTR_BACKUP,
ATTR_CODENOTARY,
ATTR_PATH,
ATTR_READ_ONLY,
AddonBackupMode,
MappingType,
)
from .options import AddonOptions, UiOptions
from .validate import RE_SERVICE, RE_VOLUME
from .validate import RE_SERVICE
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -538,14 +547,13 @@ class AddonModel(JobGroup, ABC):
return ATTR_IMAGE not in self.data
@property
def map_volumes(self) -> dict[str, bool]:
"""Return a dict of {volume: read-only} from add-on."""
def map_volumes(self) -> dict[MappingType, FolderMapping]:
"""Return a dict of {MappingType: FolderMapping} from add-on."""
volumes = {}
for volume in self.data[ATTR_MAP]:
result = RE_VOLUME.match(volume)
if not result:
continue
volumes[result.group(1)] = result.group(2) != "rw"
volumes[MappingType(volume[ATTR_TYPE])] = FolderMapping(
volume.get(ATTR_PATH), volume[ATTR_READ_ONLY]
)
return volumes

View File

@@ -81,6 +81,7 @@ from ..const import (
ATTR_TIMEOUT,
ATTR_TMPFS,
ATTR_TRANSLATIONS,
ATTR_TYPE,
ATTR_UART,
ATTR_UDEV,
ATTR_URL,
@@ -91,9 +92,6 @@ from ..const import (
ATTR_VIDEO,
ATTR_WATCHDOG,
ATTR_WEBUI,
MAP_ADDON_CONFIG,
MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
ROLE_ALL,
ROLE_DEFAULT,
AddonBoot,
@@ -112,13 +110,21 @@ from ..validate import (
uuid_match,
version_tag,
)
from .const import ATTR_BACKUP, ATTR_CODENOTARY, RE_SLUG, AddonBackupMode
from .const import (
ATTR_BACKUP,
ATTR_CODENOTARY,
ATTR_PATH,
ATTR_READ_ONLY,
RE_SLUG,
AddonBackupMode,
MappingType,
)
from .options import RE_SCHEMA_ELEMENT
_LOGGER: logging.Logger = logging.getLogger(__name__)
RE_VOLUME = re.compile(
r"^(config|ssl|addons|backup|share|media|homeassistant_config|all_addon_configs|addon_config)(?::(rw|ro))?$"
r"^(data|config|ssl|addons|backup|share|media|homeassistant_config|all_addon_configs|addon_config)(?::(rw|ro))?$"
)
RE_SERVICE = re.compile(r"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")
@@ -266,26 +272,45 @@ def _migrate_addon_config(protocol=False):
name,
)
# 2023-11 "map" entries can also be dict to allow path configuration
volumes = []
for entry in config.get(ATTR_MAP, []):
if isinstance(entry, dict):
volumes.append(entry)
if isinstance(entry, str):
result = RE_VOLUME.match(entry)
if not result:
continue
volumes.append(
{
ATTR_TYPE: result.group(1),
ATTR_READ_ONLY: result.group(2) != "rw",
}
)
if volumes:
config[ATTR_MAP] = volumes
# 2023-10 "config" became "homeassistant" so /config can be used for addon's public config
volumes = [RE_VOLUME.match(entry) for entry in config.get(ATTR_MAP, [])]
if any(volume and volume.group(1) == MAP_CONFIG for volume in volumes):
if any(volume[ATTR_TYPE] == MappingType.CONFIG for volume in volumes):
if any(
volume
and volume.group(1) in {MAP_ADDON_CONFIG, MAP_HOMEASSISTANT_CONFIG}
and volume[ATTR_TYPE]
in {MappingType.ADDON_CONFIG, MappingType.HOMEASSISTANT_CONFIG}
for volume in volumes
):
_LOGGER.warning(
"Add-on config using incompatible map options, '%s' and '%s' are ignored if '%s' is included. Please report this to the maintainer of %s",
MAP_ADDON_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
MAP_CONFIG,
MappingType.ADDON_CONFIG,
MappingType.HOMEASSISTANT_CONFIG,
MappingType.CONFIG,
name,
)
else:
_LOGGER.debug(
"Add-on config using deprecated map option '%s' instead of '%s'. Please report this to the maintainer of %s",
MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
MappingType.CONFIG,
MappingType.HOMEASSISTANT_CONFIG,
name,
)
@@ -337,7 +362,15 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_DEVICES): [str],
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
vol.Optional(ATTR_TMPFS, default=False): vol.Boolean(),
vol.Optional(ATTR_MAP, default=list): [vol.Match(RE_VOLUME)],
vol.Optional(ATTR_MAP, default=list): [
vol.Schema(
{
vol.Required(ATTR_TYPE): vol.Coerce(MappingType),
vol.Optional(ATTR_READ_ONLY, default=True): bool,
vol.Optional(ATTR_PATH): str,
}
)
],
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): str},
vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)],
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),

View File

@@ -11,6 +11,7 @@ from ..addons.addon import Addon
from ..const import ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
from ..coresys import CoreSysAttributes
from ..exceptions import APIForbidden
from ..utils.json import json_loads
from .const import CONTENT_TYPE_JSON, CONTENT_TYPE_URL
from .utils import api_process, api_validate
@@ -67,7 +68,7 @@ class APIAuth(CoreSysAttributes):
# Json
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON:
data = await request.json()
data = await request.json(loads=json_loads)
return await self._process_dict(request, addon, data)
# URL encoded

View File

@@ -1,5 +1,6 @@
"""Backups RESTful API."""
import asyncio
import errno
import logging
from pathlib import Path
import re
@@ -36,6 +37,7 @@ from ..const import (
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..mounts.const import MountUsage
from ..resolution.const import UnhealthyReason
from .const import CONTENT_TYPE_TAR
from .utils import api_process, api_validate
@@ -288,6 +290,8 @@ class APIBackups(CoreSysAttributes):
backup.write(chunk)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't write new backup file: %s", err)
return False

View File

@@ -22,7 +22,7 @@ from ..const import (
from ..coresys import CoreSys
from ..exceptions import APIError, APIForbidden, DockerAPIError, HassioError
from ..utils import check_exception_chain, get_message_from_exception_chain
from ..utils.json import JSONEncoder
from ..utils.json import json_dumps, json_loads as json_loads_util
from ..utils.log_format import format_message
from .const import CONTENT_TYPE_BINARY
@@ -48,7 +48,7 @@ def json_loads(data: Any) -> dict[str, Any]:
if not data:
return {}
try:
return json.loads(data)
return json_loads_util(data)
except json.JSONDecodeError as err:
raise APIError("Invalid json") from err
@@ -130,7 +130,7 @@ def api_return_error(
JSON_MESSAGE: message or "Unknown error, see supervisor",
},
status=400,
dumps=lambda x: json.dumps(x, cls=JSONEncoder),
dumps=json_dumps,
)
@@ -138,7 +138,7 @@ def api_return_ok(data: dict[str, Any] | None = None) -> web.Response:
"""Return an API ok answer."""
return web.json_response(
{JSON_RESULT: RESULT_OK, JSON_DATA: data or {}},
dumps=lambda x: json.dumps(x, cls=JSONEncoder),
dumps=json_dumps,
)

View File

@@ -315,7 +315,11 @@ class Backup(CoreSysAttributes):
def _extract_backup():
"""Extract a backup."""
with tarfile.open(self.tarfile, "r:") as tar:
tar.extractall(path=self._tmp.name, members=secure_path(tar))
tar.extractall(
path=self._tmp.name,
members=secure_path(tar),
filter="fully_trusted",
)
await self.sys_run_in_executor(_extract_backup)
@@ -398,10 +402,12 @@ class Backup(CoreSysAttributes):
return start_tasks
async def restore_addons(self, addon_list: list[str]) -> list[asyncio.Task]:
async def restore_addons(
self, addon_list: list[str]
) -> tuple[bool, list[asyncio.Task]]:
"""Restore a list add-on from backup."""
async def _addon_restore(addon_slug: str) -> asyncio.Task | None:
async def _addon_restore(addon_slug: str) -> tuple[bool, asyncio.Task | None]:
"""Task to restore an add-on into backup."""
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile(
@@ -415,25 +421,31 @@ class Backup(CoreSysAttributes):
# If exists inside backup
if not addon_file.path.exists():
_LOGGER.error("Can't find backup %s", addon_slug)
return
return (False, None)
# Perform a restore
try:
return await self.sys_addons.restore(addon_slug, addon_file)
return (True, await self.sys_addons.restore(addon_slug, addon_file))
except AddonsError:
_LOGGER.error("Can't restore backup %s", addon_slug)
return (False, None)
# Save Add-ons sequential
# avoid issue on slow IO
start_tasks: list[asyncio.Task] = []
success = True
for slug in addon_list:
try:
if start_task := await _addon_restore(slug):
start_tasks.append(start_task)
addon_success, start_task = await _addon_restore(slug)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't restore Add-on %s: %s", slug, err)
success = False
else:
success = success and addon_success
if start_task:
start_tasks.append(start_task)
return start_tasks
return (success, start_tasks)
async def store_folders(self, folder_list: list[str]):
"""Backup Supervisor data into backup."""
@@ -483,10 +495,11 @@ class Backup(CoreSysAttributes):
f"Can't backup folder {folder}: {str(err)}", _LOGGER.error
) from err
async def restore_folders(self, folder_list: list[str]):
async def restore_folders(self, folder_list: list[str]) -> bool:
"""Backup Supervisor data into backup."""
success = True
async def _folder_restore(name: str) -> None:
async def _folder_restore(name: str) -> bool:
"""Intenal function to restore a folder."""
slug_name = name.replace("/", "_")
tar_name = Path(
@@ -497,7 +510,7 @@ class Backup(CoreSysAttributes):
# Check if exists inside backup
if not tar_name.exists():
_LOGGER.warning("Can't find restore folder %s", name)
return
return False
# Unmount any mounts within folder
bind_mounts = [
@@ -516,7 +529,7 @@ class Backup(CoreSysAttributes):
await remove_folder(origin_dir, content_only=True)
# Perform a restore
def _restore() -> None:
def _restore() -> bool:
try:
_LOGGER.info("Restore folder %s", name)
with SecureTarFile(
@@ -526,13 +539,17 @@ class Backup(CoreSysAttributes):
gzip=self.compressed,
bufsize=BUF_SIZE,
) as tar_file:
tar_file.extractall(path=origin_dir, members=tar_file)
tar_file.extractall(
path=origin_dir, members=tar_file, filter="fully_trusted"
)
_LOGGER.info("Restore folder %s done", name)
except (tarfile.TarError, OSError) as err:
_LOGGER.warning("Can't restore folder %s: %s", name, err)
return False
return True
try:
await self.sys_run_in_executor(_restore)
return await self.sys_run_in_executor(_restore)
finally:
if bind_mounts:
await asyncio.gather(
@@ -543,9 +560,11 @@ class Backup(CoreSysAttributes):
# avoid issue on slow IO
for folder in folder_list:
try:
await _folder_restore(folder)
success = success and await _folder_restore(folder)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't restore folder %s: %s", folder, err)
success = False
return success
async def store_homeassistant(self, exclude_database: bool = False):
"""Backup Home Assistant Core configuration folder."""
@@ -604,12 +623,12 @@ class Backup(CoreSysAttributes):
"""Store repository list into backup."""
self.repositories = self.sys_store.repository_urls
async def restore_repositories(self, replace: bool = False):
def restore_repositories(self, replace: bool = False) -> Awaitable[None]:
"""Restore repositories from backup.
Return a coroutine.
"""
await self.sys_store.update_repositories(
return self.sys_store.update_repositories(
self.repositories, add_with_errors=True, replace=replace
)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Iterable
import errno
import logging
from pathlib import Path
@@ -14,11 +15,12 @@ from ..const import (
CoreState,
)
from ..dbus.const import UnitActiveState
from ..exceptions import AddonsError, BackupError, BackupJobError
from ..exceptions import AddonsError, BackupError, BackupInvalidError, BackupJobError
from ..jobs.const import JOB_GROUP_BACKUP_MANAGER, JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup
from ..mounts.mount import Mount
from ..resolution.const import UnhealthyReason
from ..utils.common import FileConfiguration
from ..utils.dt import utcnow
from ..utils.sentinel import DEFAULT
@@ -31,18 +33,6 @@ from .validate import ALL_FOLDERS, SCHEMA_BACKUPS_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
def _list_backup_files(path: Path) -> Iterable[Path]:
"""Return iterable of backup files, suppress and log OSError for network mounts."""
try:
# is_dir does a stat syscall which raises if the mount is down
if path.is_dir():
return path.glob("*.tar")
except OSError as err:
_LOGGER.error("Could not list backups from %s: %s", path.as_posix(), err)
return []
class BackupManager(FileConfiguration, JobGroup):
"""Manage backups."""
@@ -119,6 +109,19 @@ class BackupManager(FileConfiguration, JobGroup):
)
self.sys_jobs.current.stage = stage
def _list_backup_files(self, path: Path) -> Iterable[Path]:
"""Return iterable of backup files, suppress and log OSError for network mounts."""
try:
# is_dir does a stat syscall which raises if the mount is down
if path.is_dir():
return path.glob("*.tar")
except OSError as err:
if err.errno == errno.EBADMSG and path == self.sys_config.path_backup:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Could not list backups from %s: %s", path.as_posix(), err)
return []
def _create_backup(
self,
name: str,
@@ -169,7 +172,7 @@ class BackupManager(FileConfiguration, JobGroup):
tasks = [
self.sys_create_task(_load_backup(tar_file))
for path in self.backup_locations
for tar_file in _list_backup_files(path)
for tar_file in self._list_backup_files(path)
]
_LOGGER.info("Found %d backup files", len(tasks))
@@ -184,6 +187,11 @@ class BackupManager(FileConfiguration, JobGroup):
_LOGGER.info("Removed backup file %s", backup.slug)
except OSError as err:
if (
err.errno == errno.EBADMSG
and backup.tarfile.parent == self.sys_config.path_backup
):
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't remove backup %s: %s", backup.slug, err)
return False
@@ -208,6 +216,8 @@ class BackupManager(FileConfiguration, JobGroup):
backup.tarfile.rename(tar_origin)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't move backup file to storage: %s", err)
return None
@@ -378,6 +388,7 @@ class BackupManager(FileConfiguration, JobGroup):
Must be called from an existing restore job.
"""
addon_start_tasks: list[Awaitable[None]] | None = None
success = True
try:
task_hass: asyncio.Task | None = None
@@ -389,7 +400,7 @@ class BackupManager(FileConfiguration, JobGroup):
# Process folders
if folder_list:
self._change_stage(RestoreJobStage.FOLDERS, backup)
await backup.restore_folders(folder_list)
success = await backup.restore_folders(folder_list)
# Process Home-Assistant
if homeassistant:
@@ -409,13 +420,17 @@ class BackupManager(FileConfiguration, JobGroup):
await self.sys_addons.uninstall(addon.slug)
except AddonsError:
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
success = False
if addon_list:
self._change_stage(RestoreJobStage.ADDON_REPOSITORIES, backup)
await backup.restore_repositories(replace)
self._change_stage(RestoreJobStage.ADDONS, backup)
addon_start_tasks = await backup.restore_addons(addon_list)
restore_success, addon_start_tasks = await backup.restore_addons(
addon_list
)
success = success and restore_success
# Wait for Home Assistant Core update/downgrade
if task_hass:
@@ -423,18 +438,24 @@ class BackupManager(FileConfiguration, JobGroup):
RestoreJobStage.AWAIT_HOME_ASSISTANT_RESTART, backup
)
await task_hass
except BackupError:
raise
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Restore %s error", backup.slug)
capture_exception(err)
return False
raise BackupError(
f"Restore {backup.slug} error, check logs for details"
) from err
else:
if addon_start_tasks:
self._change_stage(RestoreJobStage.AWAIT_ADDON_RESTARTS, backup)
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
# Failure to resume addons post restore is still a restore failure
if any(
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
):
return False
return True
return success
finally:
# Leave Home Assistant alone if it wasn't part of the restore
if homeassistant:
@@ -469,32 +490,34 @@ class BackupManager(FileConfiguration, JobGroup):
self.sys_jobs.current.reference = backup.slug
if backup.sys_type != BackupType.FULL:
_LOGGER.error("%s is only a partial backup!", backup.slug)
return False
raise BackupInvalidError(
f"{backup.slug} is only a partial backup!", _LOGGER.error
)
if backup.protected and not backup.set_password(password):
_LOGGER.error("Invalid password for backup %s", backup.slug)
return False
raise BackupInvalidError(
f"Invalid password for backup {backup.slug}", _LOGGER.error
)
if backup.supervisor_version > self.sys_supervisor.version:
_LOGGER.error(
"Backup was made on supervisor version %s, can't restore on %s. Must update supervisor first.",
backup.supervisor_version,
self.sys_supervisor.version,
raise BackupInvalidError(
f"Backup was made on supervisor version {backup.supervisor_version}, "
f"can't restore on {self.sys_supervisor.version}. Must update supervisor first.",
_LOGGER.error,
)
return False
_LOGGER.info("Full-Restore %s start", backup.slug)
self.sys_core.state = CoreState.FREEZE
# Stop Home-Assistant / Add-ons
await self.sys_core.shutdown()
try:
# Stop Home-Assistant / Add-ons
await self.sys_core.shutdown()
success = await self._do_restore(
backup, backup.addon_list, backup.folders, True, True
)
self.sys_core.state = CoreState.RUNNING
success = await self._do_restore(
backup, backup.addon_list, backup.folders, True, True
)
finally:
self.sys_core.state = CoreState.RUNNING
if success:
_LOGGER.info("Full-Restore %s done", backup.slug)
@@ -533,29 +556,31 @@ class BackupManager(FileConfiguration, JobGroup):
homeassistant = True
if backup.protected and not backup.set_password(password):
_LOGGER.error("Invalid password for backup %s", backup.slug)
return False
raise BackupInvalidError(
f"Invalid password for backup {backup.slug}", _LOGGER.error
)
if backup.homeassistant is None and homeassistant:
_LOGGER.error("No Home Assistant Core data inside the backup")
return False
raise BackupInvalidError(
"No Home Assistant Core data inside the backup", _LOGGER.error
)
if backup.supervisor_version > self.sys_supervisor.version:
_LOGGER.error(
"Backup was made on supervisor version %s, can't restore on %s. Must update supervisor first.",
backup.supervisor_version,
self.sys_supervisor.version,
raise BackupInvalidError(
f"Backup was made on supervisor version {backup.supervisor_version}, "
f"can't restore on {self.sys_supervisor.version}. Must update supervisor first.",
_LOGGER.error,
)
return False
_LOGGER.info("Partial-Restore %s start", backup.slug)
self.sys_core.state = CoreState.FREEZE
success = await self._do_restore(
backup, addon_list, folder_list, homeassistant, False
)
self.sys_core.state = CoreState.RUNNING
try:
success = await self._do_restore(
backup, addon_list, folder_list, homeassistant, False
)
finally:
self.sys_core.state = CoreState.RUNNING
if success:
_LOGGER.info("Partial-Restore %s done", backup.slug)

View File

@@ -1,5 +1,5 @@
"""Bootstrap Supervisor."""
from datetime import datetime
from datetime import UTC, datetime
import logging
import os
from pathlib import Path, PurePath
@@ -50,7 +50,7 @@ MOUNTS_CREDENTIALS = PurePath(".mounts_credentials")
EMERGENCY_DATA = PurePath("emergency")
ADDON_CONFIGS = PurePath("addon_configs")
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
DEFAULT_BOOT_TIME = datetime.fromtimestamp(0, UTC).isoformat()
# We filter out UTC because it's the system default fallback
# Core also not respect the cotnainer timezone and reset timezones
@@ -164,7 +164,7 @@ class CoreConfig(FileConfiguration):
boot_time = parse_datetime(boot_str)
if not boot_time:
return datetime.utcfromtimestamp(1)
return datetime.fromtimestamp(1, UTC)
return boot_time
@last_boot.setter

View File

@@ -345,17 +345,6 @@ PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need"
WANT_SERVICE = "want"
MAP_CONFIG = "config"
MAP_SSL = "ssl"
MAP_ADDONS = "addons"
MAP_BACKUP = "backup"
MAP_SHARE = "share"
MAP_MEDIA = "media"
MAP_HOMEASSISTANT_CONFIG = "homeassistant_config"
MAP_ALL_ADDON_CONFIGS = "all_addon_configs"
MAP_ADDON_CONFIG = "addon_config"
ARCH_ARMHF = "armhf"
ARCH_ARMV7 = "armv7"
ARCH_AARCH64 = "aarch64"

View File

@@ -148,8 +148,8 @@ def get_connection_from_interface(
wireless["security"] = Variant("s", CONF_ATTR_802_WIRELESS_SECURITY)
wireless_security = {}
if interface.wifi.auth == "wep":
wireless_security["auth-alg"] = Variant("s", "none")
wireless_security["key-mgmt"] = Variant("s", "open")
wireless_security["auth-alg"] = Variant("s", "open")
wireless_security["key-mgmt"] = Variant("s", "none")
elif interface.wifi.auth == "wpa-psk":
wireless_security["auth-alg"] = Variant("s", "open")
wireless_security["key-mgmt"] = Variant("s", "wpa-psk")

View File

@@ -15,18 +15,10 @@ from docker.types import Mount
import requests
from ..addons.build import AddonBuild
from ..addons.const import MappingType
from ..bus import EventListener
from ..const import (
DOCKER_CPU_RUNTIME_ALLOCATION,
MAP_ADDON_CONFIG,
MAP_ADDONS,
MAP_ALL_ADDON_CONFIGS,
MAP_BACKUP,
MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
MAP_MEDIA,
MAP_SHARE,
MAP_SSL,
SECURITY_DISABLE,
SECURITY_PROFILE,
SYSTEMD_JOURNAL_PERSISTENT,
@@ -332,24 +324,28 @@ class DockerAddon(DockerInterface):
"""Return mounts for container."""
addon_mapping = self.addon.map_volumes
target_data_path = ""
if MappingType.DATA in addon_mapping:
target_data_path = addon_mapping[MappingType.DATA].path
mounts = [
MOUNT_DEV,
Mount(
type=MountType.BIND,
source=self.addon.path_extern_data.as_posix(),
target="/data",
target=target_data_path or "/data",
read_only=False,
),
]
# setup config mappings
if MAP_CONFIG in addon_mapping:
if MappingType.CONFIG in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=addon_mapping[MAP_CONFIG],
target=addon_mapping[MappingType.CONFIG].path or "/config",
read_only=addon_mapping[MappingType.CONFIG].read_only,
)
)
@@ -360,80 +356,85 @@ class DockerAddon(DockerInterface):
Mount(
type=MountType.BIND,
source=self.addon.path_extern_config.as_posix(),
target="/config",
read_only=addon_mapping[MAP_ADDON_CONFIG],
target=addon_mapping[MappingType.ADDON_CONFIG].path
or "/config",
read_only=addon_mapping[MappingType.ADDON_CONFIG].read_only,
)
)
# Map Home Assistant config in new way
if MAP_HOMEASSISTANT_CONFIG in addon_mapping:
if MappingType.HOMEASSISTANT_CONFIG in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target="/homeassistant",
read_only=addon_mapping[MAP_HOMEASSISTANT_CONFIG],
target=addon_mapping[MappingType.HOMEASSISTANT_CONFIG].path
or "/homeassistant",
read_only=addon_mapping[
MappingType.HOMEASSISTANT_CONFIG
].read_only,
)
)
if MAP_ALL_ADDON_CONFIGS in addon_mapping:
if MappingType.ALL_ADDON_CONFIGS in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_addon_configs.as_posix(),
target="/addon_configs",
read_only=addon_mapping[MAP_ALL_ADDON_CONFIGS],
target=addon_mapping[MappingType.ALL_ADDON_CONFIGS].path
or "/addon_configs",
read_only=addon_mapping[MappingType.ALL_ADDON_CONFIGS].read_only,
)
)
if MAP_SSL in addon_mapping:
if MappingType.SSL in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_ssl.as_posix(),
target="/ssl",
read_only=addon_mapping[MAP_SSL],
target=addon_mapping[MappingType.SSL].path or "/ssl",
read_only=addon_mapping[MappingType.SSL].read_only,
)
)
if MAP_ADDONS in addon_mapping:
if MappingType.ADDONS in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_addons_local.as_posix(),
target="/addons",
read_only=addon_mapping[MAP_ADDONS],
target=addon_mapping[MappingType.ADDONS].path or "/addons",
read_only=addon_mapping[MappingType.ADDONS].read_only,
)
)
if MAP_BACKUP in addon_mapping:
if MappingType.BACKUP in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_backup.as_posix(),
target="/backup",
read_only=addon_mapping[MAP_BACKUP],
target=addon_mapping[MappingType.BACKUP].path or "/backup",
read_only=addon_mapping[MappingType.BACKUP].read_only,
)
)
if MAP_SHARE in addon_mapping:
if MappingType.SHARE in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_share.as_posix(),
target="/share",
read_only=addon_mapping[MAP_SHARE],
target=addon_mapping[MappingType.SHARE].path or "/share",
read_only=addon_mapping[MappingType.SHARE].read_only,
propagation=PropagationMode.RSLAVE,
)
)
if MAP_MEDIA in addon_mapping:
if MappingType.MEDIA in addon_mapping:
mounts.append(
Mount(
type=MountType.BIND,
source=self.sys_config.path_extern_media.as_posix(),
target="/media",
read_only=addon_mapping[MAP_MEDIA],
target=addon_mapping[MappingType.MEDIA].path or "/media",
read_only=addon_mapping[MappingType.MEDIA].read_only,
propagation=PropagationMode.RSLAVE,
)
)
@@ -602,7 +603,11 @@ class DockerAddon(DockerInterface):
on_condition=DockerJobError,
)
async def update(
self, version: AwesomeVersion, image: str | None = None, latest: bool = False
self,
version: AwesomeVersion,
image: str | None = None,
latest: bool = False,
arch: CpuArch | None = None,
) -> None:
"""Update a docker image."""
image = image or self.image
@@ -613,7 +618,11 @@ class DockerAddon(DockerInterface):
# Update docker image
await self.install(
version, image=image, latest=latest, need_build=self.addon.latest_need_build
version,
image=image,
latest=latest,
arch=arch,
need_build=self.addon.latest_need_build,
)
@Job(

View File

@@ -593,6 +593,10 @@ class HomeAssistantBackupError(BackupError, HomeAssistantError):
"""Raise if an error during Home Assistant Core backup is happening."""
class BackupInvalidError(BackupError):
"""Raise if backup or password provided is invalid."""
class BackupJobError(BackupError, JobException):
"""Raise on Backup job error."""

View File

@@ -1,5 +1,5 @@
"""Read hardware info from system."""
from datetime import datetime
from datetime import UTC, datetime
import logging
from pathlib import Path
import re
@@ -55,7 +55,7 @@ class HwHelper(CoreSysAttributes):
_LOGGER.error("Can't found last boot time!")
return None
return datetime.utcfromtimestamp(int(found.group(1)))
return datetime.fromtimestamp(int(found.group(1)), UTC)
def hide_virtual_device(self, udev_device: pyudev.Device) -> bool:
"""Small helper to hide not needed Devices."""

View File

@@ -129,7 +129,7 @@ class HomeAssistantCore(JobGroup):
while True:
if not self.sys_updater.image_homeassistant:
_LOGGER.warning(
"Found no information about Home Assistant. Retry in 30sec"
"Found no information about Home Assistant. Retrying in 30sec"
)
await asyncio.sleep(30)
await self.sys_updater.reload()
@@ -145,7 +145,7 @@ class HomeAssistantCore(JobGroup):
except Exception as err: # pylint: disable=broad-except
capture_exception(err)
_LOGGER.warning("Fails install landingpage, retry after 30sec")
_LOGGER.warning("Failed to install landingpage, retrying after 30sec")
await asyncio.sleep(30)
self.sys_homeassistant.version = LANDINGPAGE
@@ -177,7 +177,7 @@ class HomeAssistantCore(JobGroup):
except Exception as err: # pylint: disable=broad-except
capture_exception(err)
_LOGGER.warning("Error on Home Assistant installation. Retry in 30sec")
_LOGGER.warning("Error on Home Assistant installation. Retrying in 30sec")
await asyncio.sleep(30)
_LOGGER.info("Home Assistant docker now installed")

View File

@@ -1,6 +1,7 @@
"""Home Assistant control object."""
import asyncio
from datetime import timedelta
import errno
from ipaddress import IPv4Address
import logging
from pathlib import Path, PurePath
@@ -42,6 +43,7 @@ from ..exceptions import (
from ..hardware.const import PolicyGroup
from ..hardware.data import Device
from ..jobs.decorator import Job, JobExecutionLimit
from ..resolution.const import UnhealthyReason
from ..utils import remove_folder
from ..utils.common import FileConfiguration
from ..utils.json import read_json_file, write_json_file
@@ -300,6 +302,8 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
try:
self.path_pulse.write_text(pulse_config, encoding="utf-8")
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Home Assistant can't write pulse/client.config: %s", err)
else:
_LOGGER.info("Update pulse/client.config: %s", self.path_pulse)
@@ -407,7 +411,11 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
def _extract_tarfile():
"""Extract tar backup."""
with tar_file as backup:
backup.extractall(path=temp_path, members=secure_path(backup))
backup.extractall(
path=temp_path,
members=secure_path(backup),
filter="fully_trusted",
)
try:
await self.sys_run_in_executor(_extract_tarfile)
@@ -477,7 +485,8 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
ATTR_REFRESH_TOKEN,
ATTR_WATCHDOG,
):
self._data[attr] = data[attr]
if attr in data:
self._data[attr] = data[attr]
@Job(
name="home_assistant_get_users",

View File

@@ -1,6 +1,7 @@
"""AppArmor control for host."""
from __future__ import annotations
import errno
import logging
from pathlib import Path
import shutil
@@ -9,7 +10,7 @@ from awesomeversion import AwesomeVersion
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import DBusError, HostAppArmorError
from ..resolution.const import UnsupportedReason
from ..resolution.const import UnhealthyReason, UnsupportedReason
from ..utils.apparmor import validate_profile
from .const import HostFeature
@@ -80,6 +81,8 @@ class AppArmorControl(CoreSysAttributes):
try:
await self.sys_run_in_executor(shutil.copyfile, profile_file, dest_profile)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HostAppArmorError(
f"Can't copy {profile_file}: {err}", _LOGGER.error
) from err
@@ -103,6 +106,8 @@ class AppArmorControl(CoreSysAttributes):
try:
await self.sys_run_in_executor(profile_file.unlink)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HostAppArmorError(
f"Can't remove profile: {err}", _LOGGER.error
) from err
@@ -117,6 +122,8 @@ class AppArmorControl(CoreSysAttributes):
try:
await self.sys_run_in_executor(shutil.copy, profile_file, backup_file)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HostAppArmorError(
f"Can't backup profile {profile_name}: {err}", _LOGGER.error
) from err

View File

@@ -15,6 +15,8 @@ from ..utils.sentry import capture_exception
_LOGGER: logging.Logger = logging.getLogger(__name__)
HASS_WATCHDOG_API = "HASS_WATCHDOG_API"
HASS_WATCHDOG_REANIMATE_FAILURES = "HASS_WATCHDOG_REANIMATE_FAILURES"
HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS = 5
RUN_UPDATE_SUPERVISOR = 29100
RUN_UPDATE_ADDONS = 57600
@@ -154,6 +156,18 @@ class Tasks(CoreSysAttributes):
return
if await self.sys_homeassistant.api.check_api_state():
# Home Assistant is running properly
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = 0
return
# Give up after 5 reanimation failures in a row. Supervisor cannot fix this issue.
reanimate_fails = self._cache.get(HASS_WATCHDOG_REANIMATE_FAILURES, 0)
if reanimate_fails >= HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS:
if reanimate_fails == HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS:
_LOGGER.critical(
"Watchdog cannot reanimate Home Assistant, failed all %s attempts.",
reanimate_fails,
)
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] += 1
return
# Init cache data
@@ -171,7 +185,11 @@ class Tasks(CoreSysAttributes):
await self.sys_homeassistant.core.restart()
except HomeAssistantError as err:
_LOGGER.error("Home Assistant watchdog reanimation failed!")
capture_exception(err)
if reanimate_fails == 0:
capture_exception(err)
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = reanimate_fails + 1
else:
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = 0
finally:
self._cache[HASS_WATCHDOG_API] = 0

View File

@@ -1,5 +1,6 @@
"""OS support on supervisor."""
from collections.abc import Awaitable
import errno
import logging
from pathlib import Path
@@ -13,6 +14,7 @@ from ..dbus.rauc import RaucState
from ..exceptions import DBusError, HassOSJobError, HassOSUpdateError
from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from .data_disk import DataDisk
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -120,6 +122,8 @@ class OSManager(CoreSysAttributes):
) from err
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise HassOSUpdateError(
f"Can't write OTA file: {err!s}", _LOGGER.error
) from err

View File

@@ -4,6 +4,7 @@ Code: https://github.com/home-assistant/plugin-audio
"""
import asyncio
from contextlib import suppress
import errno
import logging
from pathlib import Path, PurePath
import shutil
@@ -25,6 +26,7 @@ from ..exceptions import (
)
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import UnhealthyReason
from ..utils.json import write_json_file
from ..utils.sentry import capture_exception
from .base import PluginBase
@@ -83,6 +85,9 @@ class PluginAudio(PluginBase):
PULSE_CLIENT_TMPL.read_text(encoding="utf-8")
)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read pulse-client.tmpl: %s", err)
await super().load()
@@ -93,6 +98,8 @@ class PluginAudio(PluginBase):
try:
shutil.copy(ASOUND_TMPL, asound)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't create default asound: %s", err)
async def install(self) -> None:

View File

@@ -66,7 +66,7 @@ class PluginCli(PluginBase):
image=self.sys_updater.image_cli,
)
break
_LOGGER.warning("Error on install cli plugin. Retry in 30sec")
_LOGGER.warning("Error on install cli plugin. Retrying in 30sec")
await asyncio.sleep(30)
_LOGGER.info("CLI plugin is now installed")

View File

@@ -4,6 +4,7 @@ Code: https://github.com/home-assistant/plugin-dns
"""
import asyncio
from contextlib import suppress
import errno
from ipaddress import IPv4Address
import logging
from pathlib import Path
@@ -29,7 +30,7 @@ from ..exceptions import (
)
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.json import write_json_file
from ..utils.sentry import capture_exception
from ..validate import dns_url
@@ -146,12 +147,16 @@ class PluginDns(PluginBase):
RESOLV_TMPL.read_text(encoding="utf-8")
)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read resolve.tmpl: %s", err)
try:
self.hosts_template = jinja2.Template(
HOSTS_TMPL.read_text(encoding="utf-8")
)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't read hosts.tmpl: %s", err)
await self._init_hosts()
@@ -175,7 +180,7 @@ class PluginDns(PluginBase):
self.latest_version, image=self.sys_updater.image_dns
)
break
_LOGGER.warning("Error on install CoreDNS plugin. Retry in 30sec")
_LOGGER.warning("Error on install CoreDNS plugin. Retrying in 30sec")
await asyncio.sleep(30)
_LOGGER.info("CoreDNS plugin now installed")
@@ -364,6 +369,8 @@ class PluginDns(PluginBase):
self.hosts.write_text, data, encoding="utf-8"
)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise CoreDNSError(f"Can't update hosts: {err}", _LOGGER.error) from err
async def add_host(
@@ -436,6 +443,12 @@ class PluginDns(PluginBase):
def _write_resolv(self, resolv_conf: Path) -> None:
"""Update/Write resolv.conf file."""
if not self.resolv_template:
_LOGGER.warning(
"Resolv template is missing, cannot write/update %s", resolv_conf
)
return
nameservers = [str(self.sys_docker.network.dns), "127.0.0.11"]
# Read resolv config
@@ -445,6 +458,8 @@ class PluginDns(PluginBase):
try:
resolv_conf.write_text(data)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.warning("Can't write/update %s: %s", resolv_conf, err)
return

View File

@@ -62,7 +62,7 @@ class PluginMulticast(PluginBase):
self.latest_version, image=self.sys_updater.image_multicast
)
break
_LOGGER.warning("Error on install Multicast plugin. Retry in 30sec")
_LOGGER.warning("Error on install Multicast plugin. Retrying in 30sec")
await asyncio.sleep(30)
_LOGGER.info("Multicast plugin is now installed")

View File

@@ -70,7 +70,7 @@ class PluginObserver(PluginBase):
self.latest_version, image=self.sys_updater.image_observer
)
break
_LOGGER.warning("Error on install observer plugin. Retry in 30sec")
_LOGGER.warning("Error on install observer plugin. Retrying in 30sec")
await asyncio.sleep(30)
_LOGGER.info("observer plugin now installed")

View File

@@ -59,9 +59,10 @@ class UnhealthyReason(StrEnum):
"""Reasons for unsupported status."""
DOCKER = "docker"
OSERROR_BAD_MESSAGE = "oserror_bad_message"
PRIVILEGED = "privileged"
SUPERVISOR = "supervisor"
SETUP = "setup"
PRIVILEGED = "privileged"
UNTRUSTED = "untrusted"

View File

@@ -1,4 +1,5 @@
"""Evaluation class for Content Trust."""
import errno
import logging
from pathlib import Path
@@ -6,7 +7,7 @@ from ...const import CoreState
from ...coresys import CoreSys
from ...exceptions import CodeNotaryError, CodeNotaryUntrusted
from ...utils.codenotary import calc_checksum_path_sourcecode
from ..const import ContextType, IssueType, UnsupportedReason
from ..const import ContextType, IssueType, UnhealthyReason, UnsupportedReason
from .base import EvaluateBase
_SUPERVISOR_SOURCE = Path("/usr/src/supervisor/supervisor")
@@ -48,6 +49,9 @@ class EvaluateSourceMods(EvaluateBase):
calc_checksum_path_sourcecode, _SUPERVISOR_SOURCE
)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
self.sys_resolution.create_issue(
IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM
)

View File

@@ -1,5 +1,6 @@
"""Init file for Supervisor add-on data."""
from dataclasses import dataclass
import errno
import logging
from pathlib import Path
from typing import Any
@@ -19,7 +20,7 @@ from ..const import (
)
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.common import find_one_filetype, read_json_or_yaml_file
from ..utils.json import read_json_file
from .const import StoreType
@@ -157,7 +158,9 @@ class StoreData(CoreSysAttributes):
addon_list = await self.sys_run_in_executor(_get_addons_list)
except OSError as err:
suggestion = None
if path.stem != StoreType.LOCAL:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
elif path.stem != StoreType.LOCAL:
suggestion = [SuggestionType.EXECUTE_RESET]
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,

View File

@@ -2,6 +2,7 @@
from collections.abc import Awaitable
from contextlib import suppress
from datetime import timedelta
import errno
from ipaddress import IPv4Address
import logging
from pathlib import Path
@@ -27,7 +28,7 @@ from .exceptions import (
)
from .jobs.const import JobCondition, JobExecutionLimit
from .jobs.decorator import Job
from .resolution.const import ContextType, IssueType
from .resolution.const import ContextType, IssueType, UnhealthyReason
from .utils.codenotary import calc_checksum
from .utils.sentry import capture_exception
@@ -155,6 +156,8 @@ class Supervisor(CoreSysAttributes):
try:
profile_file.write_text(data, encoding="utf-8")
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
raise SupervisorAppArmorError(
f"Can't write temporary profile: {err!s}", _LOGGER.error
) from err

View File

@@ -1,15 +1,12 @@
"""Tools file for Supervisor."""
from contextlib import suppress
from datetime import datetime, timedelta, timezone, tzinfo
from datetime import UTC, datetime, timedelta, timezone, tzinfo
import re
from typing import Any
import zoneinfo
import ciso8601
UTC = timezone.utc
# Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved.
# https://github.com/django/django/blob/master/LICENSE
@@ -67,7 +64,7 @@ def utcnow() -> datetime:
def utc_from_timestamp(timestamp: float) -> datetime:
"""Return a UTC time from a timestamp."""
return datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC)
return datetime.fromtimestamp(timestamp, UTC).replace(tzinfo=UTC)
def get_time_zone(time_zone_str: str) -> tzinfo | None:

View File

@@ -1,40 +1,63 @@
"""Tools file for Supervisor."""
from datetime import datetime
import json
from functools import partial
import logging
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from atomicwrites import atomic_write
import orjson
from ..exceptions import JsonFileError
_LOGGER: logging.Logger = logging.getLogger(__name__)
class JSONEncoder(json.JSONEncoder):
"""JSONEncoder that supports Supervisor objects."""
def json_dumps(data: Any) -> str:
"""Dump json string."""
return json_bytes(data).decode("utf-8")
def default(self, o: Any) -> Any:
"""Convert Supervisor special objects.
Hand other objects to the original method.
"""
if isinstance(o, datetime):
return o.isoformat()
if isinstance(o, set):
return list(o)
if isinstance(o, Path):
return o.as_posix()
def json_encoder_default(obj: Any) -> Any:
"""Convert Supervisor special objects."""
if isinstance(obj, (set, tuple)):
return list(obj)
if isinstance(obj, float):
return float(obj)
if isinstance(obj, Path):
return obj.as_posix()
raise TypeError
return super().default(o)
if TYPE_CHECKING:
def json_bytes(obj: Any) -> bytes:
"""Dump json bytes."""
else:
json_bytes = partial(
orjson.dumps, # pylint: disable=no-member
option=orjson.OPT_NON_STR_KEYS, # pylint: disable=no-member
default=json_encoder_default,
)
"""Dump json bytes."""
# pylint - https://github.com/ijl/orjson/issues/248
json_loads = orjson.loads # pylint: disable=no-member
def write_json_file(jsonfile: Path, data: Any) -> None:
"""Write a JSON file."""
try:
with atomic_write(jsonfile, overwrite=True) as fp:
fp.write(json.dumps(data, indent=2, cls=JSONEncoder))
fp.write(
orjson.dumps( # pylint: disable=no-member
data,
option=orjson.OPT_INDENT_2 # pylint: disable=no-member
| orjson.OPT_NON_STR_KEYS, # pylint: disable=no-member
default=json_encoder_default,
).decode("utf-8")
)
jsonfile.chmod(0o600)
except (OSError, ValueError, TypeError) as err:
raise JsonFileError(
@@ -45,7 +68,7 @@ def write_json_file(jsonfile: Path, data: Any) -> None:
def read_json_file(jsonfile: Path) -> Any:
"""Read a JSON file and return a dict."""
try:
return json.loads(jsonfile.read_text())
return json_loads(jsonfile.read_bytes())
except (OSError, ValueError, TypeError, UnicodeDecodeError) as err:
raise JsonFileError(
f"Can't read json from {jsonfile!s}: {err!s}", _LOGGER.error

View File

@@ -2,6 +2,7 @@
import asyncio
from datetime import timedelta
import errno
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
@@ -696,3 +697,27 @@ async def test_local_example_ingress_port_set(
await install_addon_example.load()
assert install_addon_example.ingress_port != 0
def test_addon_pulse_error(
coresys: CoreSys,
install_addon_example: Addon,
caplog: pytest.LogCaptureFixture,
tmp_supervisor_data,
):
"""Test error writing pulse config for addon."""
with patch(
"supervisor.addons.addon.Path.write_text", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
install_addon_example.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
install_addon_example.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False

View File

@@ -68,7 +68,7 @@ async def test_image_added_removed_on_update(
await coresys.addons.update(TEST_ADDON_SLUG)
build.assert_not_called()
install.assert_called_once_with(
AwesomeVersion("10.0.0"), "test/amd64-my-ssh-addon", False, None
AwesomeVersion("10.0.0"), "test/amd64-my-ssh-addon", False, "amd64"
)
assert install_addon_ssh.need_update is False

View File

@@ -110,7 +110,6 @@ async def test_bad_requests(
fail_on_query_string,
api_system,
caplog: pytest.LogCaptureFixture,
event_loop: asyncio.BaseEventLoop,
) -> None:
"""Test request paths that should be filtered."""
@@ -122,7 +121,7 @@ async def test_bad_requests(
man_params = ""
http = urllib3.PoolManager()
resp = await event_loop.run_in_executor(
resp = await asyncio.get_running_loop().run_in_executor(
None,
http.request,
"GET",

View File

@@ -21,9 +21,9 @@ def fixture_backup_mock():
backup_instance.store_folders = AsyncMock(return_value=None)
backup_instance.store_homeassistant = AsyncMock(return_value=None)
backup_instance.store_addons = AsyncMock(return_value=None)
backup_instance.restore_folders = AsyncMock(return_value=None)
backup_instance.restore_folders = AsyncMock(return_value=True)
backup_instance.restore_homeassistant = AsyncMock(return_value=None)
backup_instance.restore_addons = AsyncMock(return_value=None)
backup_instance.restore_addons = AsyncMock(return_value=(True, []))
backup_instance.restore_repositories = AsyncMock(return_value=None)
yield backup_mock

View File

@@ -1,6 +1,8 @@
"""Test BackupManager class."""
import asyncio
import errno
from pathlib import Path
from shutil import rmtree
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, patch
@@ -20,7 +22,13 @@ from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState
from supervisor.docker.homeassistant import DockerHomeAssistant
from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import AddonsError, BackupError, BackupJobError, DockerError
from supervisor.exceptions import (
AddonsError,
BackupError,
BackupInvalidError,
BackupJobError,
DockerError,
)
from supervisor.homeassistant.api import HomeAssistantAPI
from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant
@@ -198,7 +206,7 @@ async def test_do_restore_full(coresys: CoreSys, full_backup_mock, install_addon
manager = BackupManager(coresys)
backup_instance = full_backup_mock.return_value
await manager.do_restore_full(backup_instance)
assert await manager.do_restore_full(backup_instance)
backup_instance.restore_homeassistant.assert_called_once()
backup_instance.restore_repositories.assert_called_once()
@@ -227,7 +235,7 @@ async def test_do_restore_full_different_addon(
backup_instance = full_backup_mock.return_value
backup_instance.addon_list = ["differentslug"]
await manager.do_restore_full(backup_instance)
assert await manager.do_restore_full(backup_instance)
backup_instance.restore_homeassistant.assert_called_once()
backup_instance.restore_repositories.assert_called_once()
@@ -254,7 +262,7 @@ async def test_do_restore_partial_minimal(
manager = BackupManager(coresys)
backup_instance = partial_backup_mock.return_value
await manager.do_restore_partial(backup_instance, homeassistant=False)
assert await manager.do_restore_partial(backup_instance, homeassistant=False)
backup_instance.restore_homeassistant.assert_not_called()
backup_instance.restore_repositories.assert_not_called()
@@ -278,7 +286,7 @@ async def test_do_restore_partial_maximal(coresys: CoreSys, partial_backup_mock)
manager = BackupManager(coresys)
backup_instance = partial_backup_mock.return_value
await manager.do_restore_partial(
assert await manager.do_restore_partial(
backup_instance,
addons=[TEST_ADDON_SLUG],
folders=[FOLDER_SHARE, FOLDER_HOMEASSISTANT],
@@ -297,25 +305,31 @@ async def test_do_restore_partial_maximal(coresys: CoreSys, partial_backup_mock)
assert coresys.core.state == CoreState.RUNNING
async def test_fail_invalid_full_backup(coresys: CoreSys, full_backup_mock: MagicMock):
async def test_fail_invalid_full_backup(
coresys: CoreSys, full_backup_mock: MagicMock, partial_backup_mock: MagicMock
):
"""Test restore fails with invalid backup."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
manager = BackupManager(coresys)
with pytest.raises(BackupInvalidError):
await manager.do_restore_full(partial_backup_mock.return_value)
backup_instance = full_backup_mock.return_value
backup_instance.protected = True
backup_instance.set_password.return_value = False
assert await manager.do_restore_full(backup_instance) is False
with pytest.raises(BackupInvalidError):
await manager.do_restore_full(backup_instance)
backup_instance.protected = False
backup_instance.supervisor_version = "2022.08.4"
with patch.object(
type(coresys.supervisor), "version", new=PropertyMock(return_value="2022.08.3")
):
assert await manager.do_restore_full(backup_instance) is False
), pytest.raises(BackupInvalidError):
await manager.do_restore_full(backup_instance)
async def test_fail_invalid_partial_backup(
@@ -331,20 +345,20 @@ async def test_fail_invalid_partial_backup(
backup_instance.protected = True
backup_instance.set_password.return_value = False
assert await manager.do_restore_partial(backup_instance) is False
with pytest.raises(BackupInvalidError):
await manager.do_restore_partial(backup_instance)
backup_instance.protected = False
backup_instance.homeassistant = None
assert (
await manager.do_restore_partial(backup_instance, homeassistant=True) is False
)
with pytest.raises(BackupInvalidError):
await manager.do_restore_partial(backup_instance, homeassistant=True)
backup_instance.supervisor_version = "2022.08.4"
with patch.object(
type(coresys.supervisor), "version", new=PropertyMock(return_value="2022.08.3")
):
assert await manager.do_restore_partial(backup_instance) is False
), pytest.raises(BackupInvalidError):
await manager.do_restore_partial(backup_instance)
async def test_backup_error(
@@ -366,15 +380,20 @@ async def test_backup_error(
async def test_restore_error(
coresys: CoreSys, full_backup_mock: MagicMock, capture_exception: Mock
):
"""Test restoring full Backup."""
"""Test restoring full Backup with errors."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.homeassistant.core.start = AsyncMock(return_value=None)
backup_instance = full_backup_mock.return_value
backup_instance.restore_dockerconfig.side_effect = (err := DockerError())
await coresys.backups.do_restore_full(backup_instance)
backup_instance.restore_dockerconfig.side_effect = BackupError()
with pytest.raises(BackupError):
await coresys.backups.do_restore_full(backup_instance)
capture_exception.assert_not_called()
backup_instance.restore_dockerconfig.side_effect = (err := DockerError())
with pytest.raises(BackupError):
await coresys.backups.do_restore_full(backup_instance)
capture_exception.assert_called_once_with(err)
@@ -639,17 +658,17 @@ async def test_partial_backup_to_mount(
"test", homeassistant=True, location=mount
)
assert (mount_dir / f"{backup.slug}.tar").exists()
assert (mount_dir / f"{backup.slug}.tar").exists()
# Reload and check that backups in mounts are listed
await coresys.backups.reload()
assert coresys.backups.get(backup.slug)
# Reload and check that backups in mounts are listed
await coresys.backups.reload()
assert coresys.backups.get(backup.slug)
# Remove marker file and restore. Confirm it comes back
marker.unlink()
# Remove marker file and restore. Confirm it comes back
marker.unlink()
with patch.object(DockerHomeAssistant, "is_running", return_value=True):
await coresys.backups.do_restore_partial(backup, homeassistant=True)
with patch.object(DockerHomeAssistant, "is_running", return_value=True):
await coresys.backups.do_restore_partial(backup, homeassistant=True)
assert marker.exists()
@@ -1555,3 +1574,82 @@ async def test_skip_homeassistant_database(
assert read_json_file(test_db) == {"hello": "world"}
assert read_json_file(test_db_wal) == {"hello": "world"}
assert not test_db_shm.exists()
@pytest.mark.parametrize(
"tar_parent,healthy_expected",
[
(Path("/data/mounts/test"), True),
(Path("/data/backup"), False),
],
)
def test_backup_remove_error(
coresys: CoreSys,
full_backup_mock: Backup,
tar_parent: Path,
healthy_expected: bool,
):
"""Test removing a backup error."""
full_backup_mock.tarfile.unlink.side_effect = (err := OSError())
full_backup_mock.tarfile.parent = tar_parent
err.errno = errno.EBUSY
assert coresys.backups.remove(full_backup_mock) is False
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
assert coresys.backups.remove(full_backup_mock) is False
assert coresys.core.healthy is healthy_expected
@pytest.mark.parametrize(
"error_path,healthy_expected",
[(Path("/data/backup"), False), (Path("/data/mounts/backup_test"), True)],
)
async def test_reload_error(
coresys: CoreSys,
caplog: pytest.LogCaptureFixture,
error_path: Path,
healthy_expected: bool,
path_extern,
mount_propagation,
):
"""Test error during reload."""
err = OSError()
def mock_is_dir(path: Path) -> bool:
"""Mock of is_dir."""
if path == error_path:
raise err
return True
# Add a backup mount
await coresys.mounts.load()
await coresys.mounts.create_mount(
Mount.from_dict(
coresys,
{
"name": "backup_test",
"usage": "backup",
"type": "cifs",
"server": "test.local",
"share": "test",
},
)
)
with patch("supervisor.backups.manager.Path.is_dir", new=mock_is_dir), patch(
"supervisor.backups.manager.Path.glob", return_value=[]
):
err.errno = errno.EBUSY
await coresys.backups.reload()
assert "Could not list backups" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.backups.reload()
assert "Could not list backups" in caplog.text
assert coresys.core.healthy is healthy_expected

View File

@@ -293,7 +293,6 @@ async def fixture_all_dbus_services(
@pytest.fixture
async def coresys(
event_loop,
docker,
dbus_session_bus,
all_dbus_services,
@@ -590,7 +589,7 @@ async def backups(
) -> list[Backup]:
"""Create and return mock backups."""
for i in range(request.param if hasattr(request, "param") else 5):
slug = f"sn{i+1}"
slug = f"sn{i + 1}"
temp_tar = Path(tmp_path, f"{slug}.tar")
with SecureTarFile(temp_tar, "w"):
pass

View File

@@ -201,6 +201,49 @@ def test_addon_map_addon_config_folder(
)
def test_addon_map_addon_config_folder_with_custom_target(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):
"""Test mounts for addon which maps its own config folder and sets target path."""
config = load_json_fixture("addon-config-map-addon_config.json")
config["map"].remove("addon_config")
config["map"].append(
{"type": "addon_config", "read_only": False, "path": "/custom/target/path"}
)
docker_addon = get_docker_addon(coresys, addonsdata_system, config)
# Addon config folder included
assert (
Mount(
type="bind",
source=docker_addon.addon.path_extern_config.as_posix(),
target="/custom/target/path",
read_only=False,
)
in docker_addon.mounts
)
def test_addon_map_data_folder_with_custom_target(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):
"""Test mounts for addon which sets target path for data folder."""
config = load_json_fixture("addon-config-map-addon_config.json")
config["map"].append({"type": "data", "path": "/custom/data/path"})
docker_addon = get_docker_addon(coresys, addonsdata_system, config)
# Addon config folder included
assert (
Mount(
type="bind",
source=docker_addon.addon.path_extern_data.as_posix(),
target="/custom/data/path",
read_only=False,
)
in docker_addon.mounts
)
def test_addon_ignore_on_config_map(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):

View File

@@ -1,12 +1,15 @@
"""Test hardware utils."""
# pylint: disable=protected-access
import errno
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from pytest import LogCaptureFixture
from supervisor.coresys import CoreSys
from supervisor.hardware.data import Device
def test_have_audio(coresys):
def test_have_audio(coresys: CoreSys):
"""Test usb device filter."""
assert not coresys.hardware.helper.support_audio
@@ -26,7 +29,7 @@ def test_have_audio(coresys):
assert coresys.hardware.helper.support_audio
def test_have_usb(coresys):
def test_have_usb(coresys: CoreSys):
"""Test usb device filter."""
assert not coresys.hardware.helper.support_usb
@@ -46,7 +49,7 @@ def test_have_usb(coresys):
assert coresys.hardware.helper.support_usb
def test_have_gpio(coresys):
def test_have_gpio(coresys: CoreSys):
"""Test usb device filter."""
assert not coresys.hardware.helper.support_gpio
@@ -66,7 +69,7 @@ def test_have_gpio(coresys):
assert coresys.hardware.helper.support_gpio
def test_hide_virtual_device(coresys):
def test_hide_virtual_device(coresys: CoreSys):
"""Test hidding virtual devices."""
udev_device = MagicMock()
@@ -81,3 +84,15 @@ def test_hide_virtual_device(coresys):
udev_device.sys_path = "/sys/devices/virtual/vc/vcs1"
assert coresys.hardware.helper.hide_virtual_device(udev_device)
def test_last_boot_error(coresys: CoreSys, caplog: LogCaptureFixture):
"""Test error reading last boot."""
with patch(
"supervisor.hardware.helper.Path.read_text", side_effect=(err := OSError())
):
err.errno = errno.EBADMSG
assert coresys.hardware.helper.last_boot is None
assert coresys.core.healthy is True
assert "Can't read stat data" in caplog.text

View File

@@ -65,7 +65,7 @@ async def test_install_landingpage_docker_error(
await coresys.homeassistant.core.install_landingpage()
sleep.assert_awaited_once_with(30)
assert "Fails install landingpage, retry after 30sec" in caplog.text
assert "Failed to install landingpage, retrying after 30sec" in caplog.text
capture_exception.assert_not_called()
@@ -87,7 +87,7 @@ async def test_install_landingpage_other_error(
await coresys.homeassistant.core.install_landingpage()
sleep.assert_awaited_once_with(30)
assert "Fails install landingpage, retry after 30sec" in caplog.text
assert "Failed to install landingpage, retrying after 30sec" in caplog.text
capture_exception.assert_called_once_with(err)
@@ -113,7 +113,7 @@ async def test_install_docker_error(
await coresys.homeassistant.core.install()
sleep.assert_awaited_once_with(30)
assert "Error on Home Assistant installation. Retry in 30sec" in caplog.text
assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text
capture_exception.assert_not_called()
@@ -137,7 +137,7 @@ async def test_install_other_error(
await coresys.homeassistant.core.install()
sleep.assert_awaited_once_with(30)
assert "Error on Home Assistant installation. Retry in 30sec" in caplog.text
assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text
capture_exception.assert_called_once_with(err)

View File

@@ -1,9 +1,12 @@
"""Test Homeassistant module."""
import asyncio
import errno
from pathlib import Path
from unittest.mock import AsyncMock, patch
from pytest import LogCaptureFixture
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.docker.interface import DockerInterface
@@ -44,3 +47,23 @@ async def test_get_users_none(coresys: CoreSys, ha_ws_client: AsyncMock):
assert [] == await coresys.homeassistant.get_users.__wrapped__(
coresys.homeassistant
)
def test_write_pulse_error(coresys: CoreSys, caplog: LogCaptureFixture):
"""Test errors writing pulse config."""
with patch(
"supervisor.homeassistant.module.Path.write_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBUSY
coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False

View File

@@ -0,0 +1,60 @@
"""Test host apparmor control."""
import errno
from pathlib import Path
from unittest.mock import patch
from pytest import raises
from supervisor.coresys import CoreSys
from supervisor.exceptions import HostAppArmorError
async def test_load_profile_error(coresys: CoreSys):
"""Test error loading apparmor profile."""
test_path = Path("test")
with patch("supervisor.host.apparmor.validate_profile"), patch(
"supervisor.host.apparmor.shutil.copyfile", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
with raises(HostAppArmorError):
await coresys.host.apparmor.load_profile("test", test_path)
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with raises(HostAppArmorError):
await coresys.host.apparmor.load_profile("test", test_path)
assert coresys.core.healthy is False
async def test_remove_profile_error(coresys: CoreSys, path_extern):
"""Test error removing apparmor profile."""
coresys.host.apparmor._profiles.add("test") # pylint: disable=protected-access
with patch("supervisor.host.apparmor.Path.unlink", side_effect=(err := OSError())):
err.errno = errno.EBUSY
with raises(HostAppArmorError):
await coresys.host.apparmor.remove_profile("test")
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with raises(HostAppArmorError):
await coresys.host.apparmor.remove_profile("test")
assert coresys.core.healthy is False
async def test_backup_profile_error(coresys: CoreSys, path_extern):
"""Test error while backing up apparmor profile."""
test_path = Path("test")
coresys.host.apparmor._profiles.add("test") # pylint: disable=protected-access
with patch(
"supervisor.host.apparmor.shutil.copyfile", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
with raises(HostAppArmorError):
await coresys.host.apparmor.backup_profile("test", test_path)
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with raises(HostAppArmorError):
await coresys.host.apparmor.backup_profile("test", test_path)
assert coresys.core.healthy is False

View File

@@ -274,9 +274,7 @@ async def test_exception_conditions(coresys: CoreSys):
await test.execute()
async def test_execution_limit_single_wait(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop
):
async def test_execution_limit_single_wait(coresys: CoreSys):
"""Test the single wait job execution limit."""
class TestClass:
@@ -302,9 +300,7 @@ async def test_execution_limit_single_wait(
await asyncio.gather(*[test.execute(0.1), test.execute(0.1), test.execute(0.1)])
async def test_execution_limit_throttle_wait(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop
):
async def test_execution_limit_throttle_wait(coresys: CoreSys):
"""Test the throttle wait job execution limit."""
class TestClass:
@@ -339,7 +335,7 @@ async def test_execution_limit_throttle_wait(
@pytest.mark.parametrize("error", [None, PluginJobError])
async def test_execution_limit_throttle_rate_limit(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop, error: JobException | None
coresys: CoreSys, error: JobException | None
):
"""Test the throttle wait job execution limit."""
@@ -379,9 +375,7 @@ async def test_execution_limit_throttle_rate_limit(
assert test.call == 3
async def test_execution_limit_throttle(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop
):
async def test_execution_limit_throttle(coresys: CoreSys):
"""Test the ignore conditions decorator."""
class TestClass:
@@ -414,9 +408,7 @@ async def test_execution_limit_throttle(
assert test.call == 1
async def test_execution_limit_once(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop
):
async def test_execution_limit_once(coresys: CoreSys):
"""Test the ignore conditions decorator."""
class TestClass:
@@ -439,7 +431,7 @@ async def test_execution_limit_once(
await asyncio.sleep(sleep)
test = TestClass(coresys)
run_task = event_loop.create_task(test.execute(0.3))
run_task = asyncio.get_running_loop().create_task(test.execute(0.3))
await asyncio.sleep(0.1)
with pytest.raises(JobException):
@@ -595,7 +587,7 @@ async def test_host_network(coresys: CoreSys):
assert await test.execute()
async def test_job_group_once(coresys: CoreSys, event_loop: asyncio.BaseEventLoop):
async def test_job_group_once(coresys: CoreSys):
"""Test job group once execution limitation."""
class TestClass(JobGroup):
@@ -644,7 +636,7 @@ async def test_job_group_once(coresys: CoreSys, event_loop: asyncio.BaseEventLoo
return True
test = TestClass(coresys)
run_task = event_loop.create_task(test.execute())
run_task = asyncio.get_running_loop().create_task(test.execute())
await asyncio.sleep(0)
# All methods with group limits should be locked
@@ -664,7 +656,7 @@ async def test_job_group_once(coresys: CoreSys, event_loop: asyncio.BaseEventLoo
assert await run_task
async def test_job_group_wait(coresys: CoreSys, event_loop: asyncio.BaseEventLoop):
async def test_job_group_wait(coresys: CoreSys):
"""Test job group wait execution limitation."""
class TestClass(JobGroup):
@@ -706,6 +698,7 @@ async def test_job_group_wait(coresys: CoreSys, event_loop: asyncio.BaseEventLoo
self.other_count += 1
test = TestClass(coresys)
event_loop = asyncio.get_running_loop()
run_task = event_loop.create_task(test.execute())
await asyncio.sleep(0)
@@ -725,7 +718,7 @@ async def test_job_group_wait(coresys: CoreSys, event_loop: asyncio.BaseEventLoo
assert test.other_count == 1
async def test_job_cleanup(coresys: CoreSys, event_loop: asyncio.BaseEventLoop):
async def test_job_cleanup(coresys: CoreSys):
"""Test job is cleaned up."""
class TestClass:
@@ -745,7 +738,7 @@ async def test_job_cleanup(coresys: CoreSys, event_loop: asyncio.BaseEventLoop):
return True
test = TestClass(coresys)
run_task = event_loop.create_task(test.execute())
run_task = asyncio.get_running_loop().create_task(test.execute())
await asyncio.sleep(0)
assert coresys.jobs.jobs == [test.job]
@@ -758,7 +751,7 @@ async def test_job_cleanup(coresys: CoreSys, event_loop: asyncio.BaseEventLoop):
assert test.job.done
async def test_job_skip_cleanup(coresys: CoreSys, event_loop: asyncio.BaseEventLoop):
async def test_job_skip_cleanup(coresys: CoreSys):
"""Test job is left in job manager when cleanup is false."""
class TestClass:
@@ -782,7 +775,7 @@ async def test_job_skip_cleanup(coresys: CoreSys, event_loop: asyncio.BaseEventL
return True
test = TestClass(coresys)
run_task = event_loop.create_task(test.execute())
run_task = asyncio.get_running_loop().create_task(test.execute())
await asyncio.sleep(0)
assert coresys.jobs.jobs == [test.job]
@@ -795,9 +788,7 @@ async def test_job_skip_cleanup(coresys: CoreSys, event_loop: asyncio.BaseEventL
assert test.job.done
async def test_execution_limit_group_throttle(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop
):
async def test_execution_limit_group_throttle(coresys: CoreSys):
"""Test the group throttle execution limit."""
class TestClass(JobGroup):
@@ -844,9 +835,7 @@ async def test_execution_limit_group_throttle(
assert test2.call == 2
async def test_execution_limit_group_throttle_wait(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop
):
async def test_execution_limit_group_throttle_wait(coresys: CoreSys):
"""Test the group throttle wait job execution limit."""
class TestClass(JobGroup):
@@ -897,7 +886,7 @@ async def test_execution_limit_group_throttle_wait(
@pytest.mark.parametrize("error", [None, PluginJobError])
async def test_execution_limit_group_throttle_rate_limit(
coresys: CoreSys, event_loop: asyncio.BaseEventLoop, error: JobException | None
coresys: CoreSys, error: JobException | None
):
"""Test the group throttle rate limit job execution limit."""

133
tests/misc/test_tasks.py Normal file
View File

@@ -0,0 +1,133 @@
"""Test scheduled tasks."""
from unittest.mock import MagicMock, Mock, patch
from awesomeversion import AwesomeVersion
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import HomeAssistantError
from supervisor.homeassistant.api import HomeAssistantAPI
from supervisor.homeassistant.const import LANDINGPAGE
from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.misc.tasks import Tasks
# pylint: disable=protected-access
@pytest.fixture(name="tasks")
async def fixture_tasks(coresys: CoreSys, container: MagicMock) -> Tasks:
"""Return task manager."""
coresys.homeassistant.watchdog = True
coresys.homeassistant.version = AwesomeVersion("2023.12.0")
container.status = "running"
yield Tasks(coresys)
async def test_watchdog_homeassistant_api(
tasks: Tasks, caplog: pytest.LogCaptureFixture
):
"""Test watchdog of homeassistant api."""
with patch.object(
HomeAssistantAPI, "check_api_state", return_value=False
), patch.object(HomeAssistantCore, "restart") as restart:
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
assert "Watchdog miss API response from Home Assistant" in caplog.text
assert "Watchdog found a problem with Home Assistant API!" not in caplog.text
caplog.clear()
await tasks._watchdog_homeassistant_api()
restart.assert_called_once()
assert "Watchdog miss API response from Home Assistant" not in caplog.text
assert "Watchdog found a problem with Home Assistant API!" in caplog.text
async def test_watchdog_homeassistant_api_off(tasks: Tasks, coresys: CoreSys):
"""Test watchdog of homeassistant api does not run when disabled."""
coresys.homeassistant.watchdog = False
with patch.object(
HomeAssistantAPI, "check_api_state", return_value=False
), patch.object(HomeAssistantCore, "restart") as restart:
await tasks._watchdog_homeassistant_api()
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
async def test_watchdog_homeassistant_api_error_state(tasks: Tasks, coresys: CoreSys):
"""Test watchdog of homeassistant api does not restart when in error state."""
coresys.homeassistant.core._error_state = True
with patch.object(
HomeAssistantAPI, "check_api_state", return_value=False
), patch.object(HomeAssistantCore, "restart") as restart:
await tasks._watchdog_homeassistant_api()
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
async def test_watchdog_homeassistant_api_landing_page(tasks: Tasks, coresys: CoreSys):
"""Test watchdog of homeassistant api does not monitor landing page."""
coresys.homeassistant.version = LANDINGPAGE
with patch.object(
HomeAssistantAPI, "check_api_state", return_value=False
), patch.object(HomeAssistantCore, "restart") as restart:
await tasks._watchdog_homeassistant_api()
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
async def test_watchdog_homeassistant_api_not_running(
tasks: Tasks, container: MagicMock
):
"""Test watchdog of homeassistant api does not monitor when home assistant not running."""
container.status = "stopped"
with patch.object(
HomeAssistantAPI, "check_api_state", return_value=False
), patch.object(HomeAssistantCore, "restart") as restart:
await tasks._watchdog_homeassistant_api()
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
async def test_watchdog_homeassistant_api_reanimation_limit(
tasks: Tasks, caplog: pytest.LogCaptureFixture, capture_exception: Mock
):
"""Test watchdog of homeassistant api stops after max reanimation failures."""
with patch.object(
HomeAssistantAPI, "check_api_state", return_value=False
), patch.object(
HomeAssistantCore, "restart", side_effect=(err := HomeAssistantError())
) as restart:
for _ in range(5):
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
await tasks._watchdog_homeassistant_api()
restart.assert_called_once()
assert "Home Assistant watchdog reanimation failed!" in caplog.text
restart.reset_mock()
capture_exception.assert_called_once_with(err)
caplog.clear()
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
assert "Watchdog miss API response from Home Assistant" not in caplog.text
assert "Watchdog found a problem with Home Assistant API!" not in caplog.text
assert (
"Watchdog cannot reanimate Home Assistant, failed all 5 attempts."
in caplog.text
)
caplog.clear()
await tasks._watchdog_homeassistant_api()
restart.assert_not_called()
assert not caplog.text

View File

@@ -1,4 +1,5 @@
"""Test audio plugin."""
import errno
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
@@ -56,3 +57,26 @@ async def test_config_write(
"debug": True,
},
)
async def test_load_error(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error reading config file during load."""
with patch(
"supervisor.plugins.audio.Path.read_text", side_effect=(err := OSError())
), patch("supervisor.plugins.audio.shutil.copy", side_effect=err):
err.errno = errno.EBUSY
await coresys.plugins.audio.load()
assert "Can't read pulse-client.tmpl" in caplog.text
assert "Can't create default asound" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.audio.load()
assert "Can't read pulse-client.tmpl" in caplog.text
assert "Can't create default asound" in caplog.text
assert coresys.core.healthy is False

View File

@@ -1,5 +1,6 @@
"""Test DNS plugin."""
import asyncio
import errno
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
@@ -183,3 +184,49 @@ async def test_loop_detection_on_failure(coresys: CoreSys):
Suggestion(SuggestionType.EXECUTE_RESET, ContextType.PLUGIN, "dns")
]
rebuild.assert_called_once()
async def test_load_error(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error reading config files during load."""
with patch(
"supervisor.plugins.dns.Path.read_text", side_effect=(err := OSError())
), patch("supervisor.plugins.dns.Path.write_text", side_effect=err):
err.errno = errno.EBUSY
await coresys.plugins.dns.load()
assert "Can't read resolve.tmpl" in caplog.text
assert "Can't read hosts.tmpl" in caplog.text
assert "Resolv template is missing" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.dns.load()
assert "Can't read resolve.tmpl" in caplog.text
assert "Can't read hosts.tmpl" in caplog.text
assert "Resolv template is missing" in caplog.text
assert coresys.core.healthy is False
async def test_load_error_writing_resolv(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error writing resolv during load."""
with patch(
"supervisor.plugins.dns.Path.write_text", side_effect=(err := OSError())
):
err.errno = errno.EBUSY
await coresys.plugins.dns.load()
assert "Can't write/update /etc/resolv.conf" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.dns.load()
assert "Can't write/update /etc/resolv.conf" in caplog.text
assert coresys.core.healthy is False

View File

@@ -1,5 +1,6 @@
"""Test evaluation base."""
# pylint: disable=import-error,protected-access
import errno
from unittest.mock import patch
from supervisor.const import CoreState
@@ -50,3 +51,20 @@ async def test_did_run(coresys: CoreSys):
await apparmor()
evaluate.assert_not_called()
evaluate.reset_mock()
async def test_evaluation_error(coresys: CoreSys):
"""Test error reading file during evaluation."""
apparmor = EvaluateAppArmor(coresys)
coresys.core.state = CoreState.INITIALIZE
assert apparmor.reason not in coresys.resolution.unsupported
with patch(
"supervisor.resolution.evaluations.apparmor.Path.read_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBADMSG
await apparmor()
assert apparmor.reason in coresys.resolution.unsupported
assert coresys.core.healthy is True

View File

@@ -17,7 +17,7 @@ async def test_evaluation(coresys: CoreSys):
assert operating_system.reason not in coresys.resolution.unsupported
coresys.host._info = MagicMock(operating_system="unsupported")
coresys.host._info = MagicMock(operating_system="unsupported", timezone=None)
await operating_system()
assert operating_system.reason in coresys.resolution.unsupported
@@ -26,7 +26,7 @@ async def test_evaluation(coresys: CoreSys):
assert operating_system.reason not in coresys.resolution.unsupported
coresys.os._available = False
coresys.host._info = MagicMock(operating_system=SUPPORTED_OS[0])
coresys.host._info = MagicMock(operating_system=SUPPORTED_OS[0], timezone=None)
await operating_system()
assert operating_system.reason not in coresys.resolution.unsupported

View File

@@ -15,7 +15,7 @@ async def test_evaluation(coresys: CoreSys):
assert agent.reason not in coresys.resolution.unsupported
coresys._host = MagicMock()
coresys._host = MagicMock(info=MagicMock(timezone=None))
coresys.host.features = [HostFeature.HOSTNAME]
await agent()

View File

@@ -1,5 +1,6 @@
"""Test evaluation base."""
# pylint: disable=import-error,protected-access
import errno
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
@@ -7,6 +8,8 @@ from unittest.mock import AsyncMock, patch
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.exceptions import CodeNotaryError, CodeNotaryUntrusted
from supervisor.resolution.const import ContextType, IssueType
from supervisor.resolution.data import Issue
from supervisor.resolution.evaluations.source_mods import EvaluateSourceMods
@@ -56,3 +59,30 @@ async def test_did_run(coresys: CoreSys):
await sourcemods()
evaluate.assert_not_called()
evaluate.reset_mock()
async def test_evaluation_error(coresys: CoreSys):
"""Test error reading file during evaluation."""
sourcemods = EvaluateSourceMods(coresys)
coresys.core.state = CoreState.RUNNING
corrupt_fs = Issue(IssueType.CORRUPT_FILESYSTEM, ContextType.SYSTEM)
assert sourcemods.reason not in coresys.resolution.unsupported
assert corrupt_fs not in coresys.resolution.issues
with patch(
"supervisor.utils.codenotary.dirhash",
side_effect=(err := OSError()),
):
err.errno = errno.EBUSY
await sourcemods()
assert sourcemods.reason not in coresys.resolution.unsupported
assert corrupt_fs in coresys.resolution.issues
assert coresys.core.healthy is True
coresys.resolution.dismiss_issue(corrupt_fs)
err.errno = errno.EBADMSG
await sourcemods()
assert sourcemods.reason not in coresys.resolution.unsupported
assert corrupt_fs in coresys.resolution.issues
assert coresys.core.healthy is False

View File

@@ -15,7 +15,7 @@ async def test_evaluation(coresys: CoreSys):
assert systemd.reason not in coresys.resolution.unsupported
coresys._host = MagicMock()
coresys._host = MagicMock(info=MagicMock(timezone=None))
coresys.host.features = [HostFeature.HOSTNAME]
await systemd()

View File

@@ -1,8 +1,13 @@
"""Test that we are reading add-on files correctly."""
import errno
from pathlib import Path
from unittest.mock import patch
from supervisor.coresys import CoreSys
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
# pylint: disable=protected-access
async def test_read_addon_files(coresys: CoreSys):
@@ -23,3 +28,23 @@ async def test_read_addon_files(coresys: CoreSys):
assert len(addon_list) == 1
assert str(addon_list[0]) == "addon/config.yml"
async def test_reading_addon_files_error(coresys: CoreSys):
"""Test error trying to read addon files."""
corrupt_repo = Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test")
reset_repo = Suggestion(SuggestionType.EXECUTE_RESET, ContextType.STORE, "test")
with patch("pathlib.Path.glob", side_effect=(err := OSError())):
err.errno = errno.EBUSY
assert (await coresys.store.data._find_addons(Path("test"), {})) is None
assert corrupt_repo in coresys.resolution.issues
assert reset_repo in coresys.resolution.suggestions
assert coresys.core.healthy is True
coresys.resolution.dismiss_issue(corrupt_repo)
err.errno = errno.EBADMSG
assert (await coresys.store.data._find_addons(Path("test"), {})) is None
assert corrupt_repo in coresys.resolution.issues
assert reset_repo not in coresys.resolution.suggestions
assert coresys.core.healthy is False

View File

@@ -1,8 +1,11 @@
"""Testing handling with CoreState."""
# pylint: disable=W0212
import datetime
import errno
from unittest.mock import AsyncMock, PropertyMock, patch
from pytest import LogCaptureFixture
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.exceptions import WhoamiSSLError
@@ -14,7 +17,6 @@ from supervisor.utils.whoami import WhoamiData
def test_write_state(run_dir, coresys: CoreSys):
"""Test write corestate to /run/supervisor."""
coresys.core.state = CoreState.RUNNING
assert run_dir.read_text() == CoreState.RUNNING
@@ -77,3 +79,16 @@ async def test_adjust_system_datetime_if_time_behind(coresys: CoreSys):
mock_retrieve_whoami.assert_called_once()
mock_set_datetime.assert_called_once()
mock_check_connectivity.assert_called_once()
def test_write_state_failure(run_dir, coresys: CoreSys, caplog: LogCaptureFixture):
"""Test failure to write corestate to /run/supervisor."""
with patch(
"supervisor.core.RUN_SUPERVISOR_STATE.write_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBADMSG
coresys.core.state = CoreState.RUNNING
assert "Can't update the Supervisor state" in caplog.text
assert coresys.core.healthy is True

View File

@@ -1,6 +1,7 @@
"""Test supervisor object."""
from datetime import timedelta
import errno
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
from aiohttp import ClientTimeout
@@ -11,7 +12,11 @@ import pytest
from supervisor.const import UpdateChannel
from supervisor.coresys import CoreSys
from supervisor.docker.supervisor import DockerSupervisor
from supervisor.exceptions import DockerError, SupervisorUpdateError
from supervisor.exceptions import (
DockerError,
SupervisorAppArmorError,
SupervisorUpdateError,
)
from supervisor.host.apparmor import AppArmorControl
from supervisor.resolution.const import ContextType, IssueType
from supervisor.resolution.data import Issue
@@ -108,3 +113,22 @@ async def test_update_apparmor(
timeout=ClientTimeout(total=10),
)
load_profile.assert_called_once()
async def test_update_apparmor_error(coresys: CoreSys, tmp_supervisor_data):
"""Test error updating apparmor profile."""
with patch("supervisor.coresys.aiohttp.ClientSession.get") as get, patch.object(
AppArmorControl, "load_profile"
), patch("supervisor.supervisor.Path.write_text", side_effect=(err := OSError())):
get.return_value.__aenter__.return_value.status = 200
get.return_value.__aenter__.return_value.text = AsyncMock(return_value="")
err.errno = errno.EBUSY
with pytest.raises(SupervisorAppArmorError):
await coresys.supervisor.update_apparmor()
assert coresys.core.healthy is True
err.errno = errno.EBADMSG
with pytest.raises(SupervisorAppArmorError):
await coresys.supervisor.update_apparmor()
assert coresys.core.healthy is False

View File

@@ -1,5 +1,8 @@
"""test json."""
from supervisor.utils.json import write_json_file
import time
from typing import NamedTuple
from supervisor.utils.json import json_dumps, read_json_file, write_json_file
def test_file_permissions(tmp_path):
@@ -18,3 +21,42 @@ def test_new_file_permissions(tmp_path):
write_json_file(tempfile, {"test": "data"})
assert oct(tempfile.stat().st_mode)[-3:] == "600"
def test_file_round_trip(tmp_path):
"""Test file permissions."""
tempfile = tmp_path / "test.json"
write_json_file(tempfile, {"test": "data"})
assert tempfile.is_file()
assert oct(tempfile.stat().st_mode)[-3:] == "600"
assert read_json_file(tempfile) == {"test": "data"}
def test_json_dumps_float_subclass() -> None:
"""Test the json dumps a float subclass."""
class FloatSubclass(float):
"""A float subclass."""
assert json_dumps({"c": FloatSubclass(1.2)}) == '{"c":1.2}'
def test_json_dumps_tuple_subclass() -> None:
"""Test the json dumps a tuple subclass."""
tt = time.struct_time((1999, 3, 17, 32, 44, 55, 2, 76, 0))
assert json_dumps(tt) == "[1999,3,17,32,44,55,2,76,0]"
def test_json_dumps_named_tuple_subclass() -> None:
"""Test the json dumps a tuple subclass."""
class NamedTupleSubclass(NamedTuple):
"""A NamedTuple subclass."""
name: str
nts = NamedTupleSubclass("a")
assert json_dumps(nts) == '["a"]'

View File

@@ -21,4 +21,4 @@ commands =
[testenv:black]
basepython = python3
commands =
black --target-version py311 --check supervisor tests setup.py
black --target-version py312 --check supervisor tests setup.py