mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-28 18:39:22 +00:00
Compare commits
31 Commits
2023.12.0
...
faster_bac
Author | SHA1 | Date | |
---|---|---|---|
![]() |
af3256e41e | ||
![]() |
a163121ad4 | ||
![]() |
eb85be2770 | ||
![]() |
2da27937a5 | ||
![]() |
2a29b801a4 | ||
![]() |
57e65714b0 | ||
![]() |
0ae40cb51c | ||
![]() |
ddd195dfc6 | ||
![]() |
54b9f23ec5 | ||
![]() |
242dd3e626 | ||
![]() |
1b8acb5b60 | ||
![]() |
a7ab96ab12 | ||
![]() |
06ab11cf87 | ||
![]() |
1410a1b06e | ||
![]() |
5baf19f7a3 | ||
![]() |
6c66a7ba17 | ||
![]() |
37b6e09475 | ||
![]() |
e08c8ca26d | ||
![]() |
2c09e7929f | ||
![]() |
3e760f0d85 | ||
![]() |
3cc6bd19ad | ||
![]() |
b7ddfba71d | ||
![]() |
32f21d208f | ||
![]() |
ed7edd9fe0 | ||
![]() |
fd3c995c7c | ||
![]() |
c0d1a2d53b | ||
![]() |
76bc3015a7 | ||
![]() |
ad2896243b | ||
![]() |
d0dcded42d | ||
![]() |
a0dfa01287 | ||
![]() |
4ec5c90180 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -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 \
|
||||
|
38
.github/workflows/ci.yaml
vendored
38
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
@@ -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]
|
||||
|
10
build.yaml
10
build.yaml
@@ -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
|
||||
|
45
pylintrc
45
pylintrc
@@ -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
112
pyproject.toml
Normal 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"]
|
@@ -1,6 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
filterwarnings =
|
||||
error
|
||||
ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash
|
||||
ignore::pytest.PytestUnraisableExceptionWarning
|
@@ -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
|
||||
|
@@ -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
|
||||
|
14
setup.cfg
14
setup.cfg
@@ -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
|
||||
|
63
setup.py
63
setup.py
@@ -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"),
|
||||
)
|
||||
|
@@ -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__)
|
||||
|
||||
|
@@ -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
|
||||
|
11
supervisor/addons/configuration.py
Normal file
11
supervisor/addons/configuration.py
Normal 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
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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(),
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -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
|
||||
)
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
@@ -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")
|
||||
|
@@ -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(
|
||||
|
@@ -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."""
|
||||
|
||||
|
@@ -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."""
|
||||
|
@@ -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")
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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")
|
||||
|
@@ -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")
|
||||
|
@@ -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"
|
||||
|
||||
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
):
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
60
tests/host/test_apparmor_control.py
Normal file
60
tests/host/test_apparmor_control.py
Normal 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
|
@@ -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
133
tests/misc/test_tasks.py
Normal 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
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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"]'
|
||||
|
Reference in New Issue
Block a user