mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-29 02:49:22 +00:00
Compare commits
81 Commits
2023.11.2
...
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 | ||
![]() |
a0c813bfc1 | ||
![]() |
5f7b3a7087 | ||
![]() |
6426f02a2c | ||
![]() |
7fef92c480 | ||
![]() |
c64744dedf | ||
![]() |
72a2088931 | ||
![]() |
db54556b0f | ||
![]() |
a2653d8462 | ||
![]() |
ef778238f6 | ||
![]() |
4cc0ddc35d | ||
![]() |
a0429179a0 | ||
![]() |
5cfb45c668 | ||
![]() |
a53b7041f5 | ||
![]() |
f534fae293 | ||
![]() |
f7cbd968d2 | ||
![]() |
844d76290c | ||
![]() |
8c8122eee0 | ||
![]() |
d63f0d5e0b | ||
![]() |
96f4ba5d25 | ||
![]() |
72e64676da | ||
![]() |
883e54f989 | ||
![]() |
c2d4be3304 | ||
![]() |
de737ddb91 | ||
![]() |
11ec6dd9ac | ||
![]() |
df7541e397 | ||
![]() |
95ac53d780 | ||
![]() |
e8c4b32a65 | ||
![]() |
eca535c978 | ||
![]() |
9088810b49 | ||
![]() |
172a7053ed | ||
![]() |
3d5bd2adef | ||
![]() |
cb03d039f4 | ||
![]() |
bb31b1bc6e | ||
![]() |
727532858e | ||
![]() |
c0868d9dac | ||
![]() |
ce26e1dac6 | ||
![]() |
c74f87ca12 | ||
![]() |
043111b91c | ||
![]() |
5c579e557c | ||
![]() |
f8f51740c1 | ||
![]() |
176b63df52 | ||
![]() |
e1979357a5 | ||
![]() |
030527a4f2 | ||
![]() |
cca74da1f3 | ||
![]() |
928aff342f | ||
![]() |
60a97235df | ||
![]() |
c77779cf9d | ||
![]() |
9351796ba8 | ||
![]() |
bef0f023d4 | ||
![]() |
3116f183f5 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
24
.github/workflows/builder.yml
vendored
24
.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
|
||||
|
||||
@@ -70,13 +70,13 @@ jobs:
|
||||
- name: Get changed files
|
||||
id: changed_files
|
||||
if: steps.version.outputs.publish == 'false'
|
||||
uses: jitterbit/get-changed-files@v1
|
||||
uses: masesgroup/retrieve-changed-files@v3.0.0
|
||||
|
||||
- name: Check if requirements files changed
|
||||
id: requirements
|
||||
run: |
|
||||
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.json) ]]; then
|
||||
echo "::set-output name=changed::true"
|
||||
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.yaml) ]]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
@@ -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 }}
|
||||
@@ -125,20 +125,20 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Install Cosign
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: sigstore/cosign-installer@v3.2.0
|
||||
uses: sigstore/cosign-installer@v3.3.0
|
||||
with:
|
||||
cosign-release: "v2.0.2"
|
||||
|
||||
- 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.09.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.09.0
|
||||
uses: home-assistant/builder@2024.01.0
|
||||
with:
|
||||
args: |
|
||||
--test \
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
if [ "$(echo $test | jq -r '.result')" != "ok" ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "::set-output name=slug::$(echo $test | jq -r '.data.slug')"
|
||||
echo "slug=$(echo $test | jq -r '.data.slug')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Uninstall SSH add-on
|
||||
run: |
|
||||
|
73
.github/workflows/ci.yaml
vendored
73
.github/workflows/ci.yaml
vendored
@@ -8,8 +8,8 @@ on:
|
||||
pull_request: ~
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.11"
|
||||
PRE_COMMIT_HOME: ~/.cache/pre-commit
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
PRE_COMMIT_CACHE: ~/.cache/pre-commit
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
@@ -28,12 +28,12 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
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,9 +47,10 @@ 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_HOME }}
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
lookup-only: true
|
||||
key: |
|
||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
restore-keys: |
|
||||
@@ -68,13 +69,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -87,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
|
||||
@@ -112,13 +113,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -130,9 +131,9 @@ 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_HOME }}
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Fail job if cache restore failed
|
||||
@@ -156,13 +157,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -188,13 +189,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -206,9 +207,9 @@ 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_HOME }}
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Fail job if cache restore failed
|
||||
@@ -229,13 +230,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -247,9 +248,9 @@ 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_HOME }}
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Fail job if cache restore failed
|
||||
@@ -273,13 +274,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -305,13 +306,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -323,9 +324,9 @@ 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_HOME }}
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Fail job if cache restore failed
|
||||
@@ -346,17 +347,17 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.2.0
|
||||
uses: sigstore/cosign-installer@v3.3.0
|
||||
with:
|
||||
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: |
|
||||
@@ -391,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
|
||||
@@ -404,13 +405,13 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
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: |
|
||||
@@ -421,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
|
||||
|
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v4.0.1
|
||||
- uses: dessant/lock-threads@v5.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
echo Current version: $latest
|
||||
echo New target version: $datepre.$newpost
|
||||
echo "::set-output name=version::$datepre.$newpost"
|
||||
echo "version=$datepre.$newpost" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run Release Drafter
|
||||
uses: release-drafter/release-drafter@v5.25.0
|
||||
|
2
.github/workflows/sentry.yaml
vendored
2
.github/workflows/sentry.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.4.1
|
||||
uses: getsentry/action-release@v1.6.0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v8.0.0
|
||||
- uses: actions/stale@v9.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
@@ -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]
|
||||
|
@@ -15,7 +15,7 @@ WORKDIR /usr/src
|
||||
RUN \
|
||||
set -x \
|
||||
&& apk add --no-cache \
|
||||
coreutils \
|
||||
findutils \
|
||||
eudev \
|
||||
eudev-libs \
|
||||
git \
|
||||
|
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,2 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
@@ -1,26 +1,30 @@
|
||||
aiodns==3.1.1
|
||||
aiohttp==3.8.6
|
||||
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
|
||||
colorlog==6.7.0
|
||||
colorlog==6.8.0
|
||||
cpe==1.2.1
|
||||
cryptography==41.0.5
|
||||
cryptography==41.0.7
|
||||
debugpy==1.8.0
|
||||
deepmerge==1.1.0
|
||||
deepmerge==1.1.1
|
||||
dirhash==0.2.1
|
||||
docker==6.1.3
|
||||
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.3.0
|
||||
sentry-sdk==1.34.0
|
||||
voluptuous==0.13.1
|
||||
dbus-fast==2.14.0
|
||||
typing_extensions==4.8.0
|
||||
securetar==2023.12.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,16 +1,16 @@
|
||||
black==23.11.0
|
||||
coverage==7.3.2
|
||||
black==23.12.1
|
||||
coverage==7.4.0
|
||||
flake8-docstrings==1.7.0
|
||||
flake8==6.1.0
|
||||
pre-commit==3.5.0
|
||||
flake8==7.0.0
|
||||
pre-commit==3.6.0
|
||||
pydocstyle==6.3.0
|
||||
pylint==3.0.2
|
||||
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.8.0
|
||||
urllib3==2.0.7
|
||||
typing_extensions==4.9.0
|
||||
urllib3==2.1.0
|
||||
|
@@ -15,7 +15,7 @@ do
|
||||
if [[ "${supervisor_state}" = "running" ]]; then
|
||||
|
||||
# Check API
|
||||
if bashio::supervisor.ping; then
|
||||
if bashio::supervisor.ping > /dev/null; then
|
||||
failed_count=0
|
||||
else
|
||||
bashio::log.warning "Maybe found an issue on API healthy"
|
||||
|
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,7 +5,13 @@ import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from supervisor import bootstrap
|
||||
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__)
|
||||
|
||||
@@ -38,6 +44,8 @@ if __name__ == "__main__":
|
||||
executor = ThreadPoolExecutor(thread_name_prefix="SyncWorker")
|
||||
loop.set_default_executor(executor)
|
||||
|
||||
activate_log_queue_handler()
|
||||
|
||||
_LOGGER.info("Initializing Supervisor setup")
|
||||
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
|
||||
loop.set_debug(coresys.config.debug)
|
||||
|
@@ -1,374 +1 @@
|
||||
"""Init file for Supervisor add-ons."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import tarfile
|
||||
from typing import Union
|
||||
|
||||
from ..const import AddonBoot, AddonStartup, AddonState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import (
|
||||
AddonConfigurationError,
|
||||
AddonsError,
|
||||
AddonsJobError,
|
||||
AddonsNotSupportedError,
|
||||
CoreDNSError,
|
||||
DockerAPIError,
|
||||
DockerError,
|
||||
DockerNotFound,
|
||||
HassioError,
|
||||
HomeAssistantAPIError,
|
||||
)
|
||||
from ..jobs.decorator import Job, JobCondition
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
from ..store.addon import AddonStore
|
||||
from ..utils import check_exception_chain
|
||||
from ..utils.sentry import capture_exception
|
||||
from .addon import Addon
|
||||
from .const import ADDON_UPDATE_CONDITIONS
|
||||
from .data import AddonsData
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
AnyAddon = Union[Addon, AddonStore]
|
||||
|
||||
|
||||
class AddonManager(CoreSysAttributes):
|
||||
"""Manage add-ons inside Supervisor."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize Docker base wrapper."""
|
||||
self.coresys: CoreSys = coresys
|
||||
self.data: AddonsData = AddonsData(coresys)
|
||||
self.local: dict[str, Addon] = {}
|
||||
self.store: dict[str, AddonStore] = {}
|
||||
|
||||
@property
|
||||
def all(self) -> list[AnyAddon]:
|
||||
"""Return a list of all add-ons."""
|
||||
addons: dict[str, AnyAddon] = {**self.store, **self.local}
|
||||
return list(addons.values())
|
||||
|
||||
@property
|
||||
def installed(self) -> list[Addon]:
|
||||
"""Return a list of all installed add-ons."""
|
||||
return list(self.local.values())
|
||||
|
||||
def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None:
|
||||
"""Return an add-on from slug.
|
||||
|
||||
Prio:
|
||||
1 - Local
|
||||
2 - Store
|
||||
"""
|
||||
if addon_slug in self.local:
|
||||
return self.local[addon_slug]
|
||||
if not local_only:
|
||||
return self.store.get(addon_slug)
|
||||
return None
|
||||
|
||||
def from_token(self, token: str) -> Addon | None:
|
||||
"""Return an add-on from Supervisor token."""
|
||||
for addon in self.installed:
|
||||
if token == addon.supervisor_token:
|
||||
return addon
|
||||
return None
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Start up add-on management."""
|
||||
tasks = []
|
||||
for slug in self.data.system:
|
||||
addon = self.local[slug] = Addon(self.coresys, slug)
|
||||
tasks.append(self.sys_create_task(addon.load()))
|
||||
|
||||
# Run initial tasks
|
||||
_LOGGER.info("Found %d installed add-ons", len(tasks))
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
# Sync DNS
|
||||
await self.sync_dns()
|
||||
|
||||
async def boot(self, stage: AddonStartup) -> None:
|
||||
"""Boot add-ons with mode auto."""
|
||||
tasks: list[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
|
||||
continue
|
||||
tasks.append(addon)
|
||||
|
||||
# Evaluate add-ons which need to be started
|
||||
_LOGGER.info("Phase '%s' starting %d add-ons", stage, len(tasks))
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Start Add-ons sequential
|
||||
# avoid issue on slow IO
|
||||
# Config.wait_boot is deprecated. Until addons update with healthchecks,
|
||||
# add a sleep task for it to keep the same minimum amount of wait time
|
||||
wait_boot: list[Awaitable[None]] = [asyncio.sleep(self.sys_config.wait_boot)]
|
||||
for addon in tasks:
|
||||
try:
|
||||
if start_task := await addon.start():
|
||||
wait_boot.append(start_task)
|
||||
except AddonsError as err:
|
||||
# Check if there is an system/user issue
|
||||
if check_exception_chain(
|
||||
err, (DockerAPIError, DockerNotFound, AddonConfigurationError)
|
||||
):
|
||||
addon.boot = AddonBoot.MANUAL
|
||||
addon.save_persist()
|
||||
except HassioError:
|
||||
pass # These are already handled
|
||||
else:
|
||||
continue
|
||||
|
||||
_LOGGER.warning("Can't start Add-on %s", addon.slug)
|
||||
|
||||
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
||||
await asyncio.gather(*wait_boot, return_exceptions=True)
|
||||
|
||||
async def shutdown(self, stage: AddonStartup) -> None:
|
||||
"""Shutdown addons."""
|
||||
tasks: list[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.state != AddonState.STARTED or addon.startup != stage:
|
||||
continue
|
||||
tasks.append(addon)
|
||||
|
||||
# Evaluate add-ons which need to be stopped
|
||||
_LOGGER.info("Phase '%s' stopping %d add-ons", stage, len(tasks))
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Stop Add-ons sequential
|
||||
# avoid issue on slow IO
|
||||
for addon in tasks:
|
||||
try:
|
||||
await addon.stop()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_install",
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def install(self, slug: str) -> None:
|
||||
"""Install an add-on."""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
|
||||
store = self.store.get(slug)
|
||||
|
||||
if not store:
|
||||
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error)
|
||||
|
||||
store.validate_availability()
|
||||
|
||||
await Addon(self.coresys, slug).install()
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully installed", slug)
|
||||
|
||||
async def uninstall(self, slug: str) -> None:
|
||||
"""Remove an add-on."""
|
||||
if slug not in self.local:
|
||||
_LOGGER.warning("Add-on %s is not installed", slug)
|
||||
return
|
||||
|
||||
await self.local[slug].uninstall()
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_update",
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def update(
|
||||
self, slug: str, backup: bool | None = False
|
||||
) -> asyncio.Task | None:
|
||||
"""Update add-on.
|
||||
|
||||
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after update. Else nothing is returned.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
raise AddonsError(
|
||||
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||
)
|
||||
store = self.store[slug]
|
||||
|
||||
if addon.version == store.version:
|
||||
raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning)
|
||||
|
||||
# Check if available, Maybe something have changed
|
||||
store.validate_availability()
|
||||
|
||||
if backup:
|
||||
await self.sys_backups.do_backup_partial(
|
||||
name=f"addon_{addon.slug}_{addon.version}",
|
||||
homeassistant=False,
|
||||
addons=[addon.slug],
|
||||
)
|
||||
|
||||
return await addon.update()
|
||||
|
||||
@Job(
|
||||
name="addon_manager_rebuild",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def rebuild(self, slug: str) -> asyncio.Task | None:
|
||||
"""Perform a rebuild of local build add-on.
|
||||
|
||||
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after rebuild. Else nothing is returned.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
raise AddonsError(
|
||||
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||
)
|
||||
store = self.store[slug]
|
||||
|
||||
# Check if a rebuild is possible now
|
||||
if addon.version != store.version:
|
||||
raise AddonsError(
|
||||
"Version changed, use Update instead Rebuild", _LOGGER.error
|
||||
)
|
||||
if not addon.need_build:
|
||||
raise AddonsNotSupportedError(
|
||||
"Can't rebuild a image based add-on", _LOGGER.error
|
||||
)
|
||||
|
||||
return await addon.rebuild()
|
||||
|
||||
@Job(
|
||||
name="addon_manager_restore",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def restore(
|
||||
self, slug: str, tar_file: tarfile.TarFile
|
||||
) -> asyncio.Task | None:
|
||||
"""Restore state of an add-on.
|
||||
|
||||
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after restore. Else nothing is returned.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
||||
addon = Addon(self.coresys, slug)
|
||||
had_ingress = False
|
||||
else:
|
||||
_LOGGER.debug("Add-on %s is local available for restore", slug)
|
||||
addon = self.local[slug]
|
||||
had_ingress = addon.ingress_panel
|
||||
|
||||
wait_for_start = await addon.restore(tar_file)
|
||||
|
||||
# Check if new
|
||||
if slug not in self.local:
|
||||
_LOGGER.info("Detect new Add-on after restore %s", slug)
|
||||
self.local[slug] = addon
|
||||
|
||||
# Update ingress
|
||||
if had_ingress != addon.ingress_panel:
|
||||
await self.sys_ingress.reload()
|
||||
with suppress(HomeAssistantAPIError):
|
||||
await self.sys_ingress.update_hass_panel(addon)
|
||||
|
||||
return wait_for_start
|
||||
|
||||
@Job(
|
||||
name="addon_manager_repair",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST],
|
||||
)
|
||||
async def repair(self) -> None:
|
||||
"""Repair local add-ons."""
|
||||
needs_repair: list[Addon] = []
|
||||
|
||||
# Evaluate Add-ons to repair
|
||||
for addon in self.installed:
|
||||
if await addon.instance.exists():
|
||||
continue
|
||||
needs_repair.append(addon)
|
||||
|
||||
_LOGGER.info("Found %d add-ons to repair", len(needs_repair))
|
||||
if not needs_repair:
|
||||
return
|
||||
|
||||
for addon in needs_repair:
|
||||
_LOGGER.info("Repairing for add-on: %s", addon.slug)
|
||||
with suppress(DockerError, KeyError):
|
||||
# Need pull a image again
|
||||
if not addon.need_build:
|
||||
await addon.instance.install(addon.version, addon.image)
|
||||
continue
|
||||
|
||||
# Need local lookup
|
||||
if addon.need_build and not addon.is_detached:
|
||||
store = self.store[addon.slug]
|
||||
# If this add-on is available for rebuild
|
||||
if addon.version == store.version:
|
||||
await addon.instance.install(addon.version, addon.image)
|
||||
continue
|
||||
|
||||
_LOGGER.error("Can't repair %s", addon.slug)
|
||||
with suppress(AddonsError):
|
||||
await self.uninstall(addon.slug)
|
||||
|
||||
async def sync_dns(self) -> None:
|
||||
"""Sync add-ons DNS names."""
|
||||
# Update hosts
|
||||
add_host_coros: list[Awaitable[None]] = []
|
||||
for addon in self.installed:
|
||||
try:
|
||||
if not await addon.instance.is_running():
|
||||
continue
|
||||
except DockerError as err:
|
||||
_LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err)
|
||||
self.sys_resolution.create_issue(
|
||||
IssueType.CORRUPT_DOCKER,
|
||||
ContextType.ADDON,
|
||||
reference=addon.slug,
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
capture_exception(err)
|
||||
else:
|
||||
add_host_coros.append(
|
||||
self.sys_plugins.dns.add_host(
|
||||
ipv4=addon.ip_address, names=[addon.hostname], write=False
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*add_host_coros)
|
||||
|
||||
# Write hosts files
|
||||
with suppress(CoreDNSError):
|
||||
await self.sys_plugins.dns.write_hosts()
|
||||
|
@@ -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
|
||||
@@ -184,6 +186,7 @@ class Addon(AddonModel):
|
||||
)
|
||||
)
|
||||
|
||||
await self._check_ingress_port()
|
||||
with suppress(DockerError):
|
||||
await self.instance.attach(version=self.version)
|
||||
|
||||
@@ -395,7 +398,7 @@ class Addon(AddonModel):
|
||||
|
||||
port = self.data[ATTR_INGRESS_PORT]
|
||||
if port == 0:
|
||||
return self.sys_ingress.get_dynamic_port(self.slug)
|
||||
raise RuntimeError(f"No port set for add-on {self.slug}")
|
||||
return port
|
||||
|
||||
@property
|
||||
@@ -464,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:
|
||||
@@ -539,7 +542,7 @@ class Addon(AddonModel):
|
||||
|
||||
# TCP monitoring
|
||||
if s_prefix == "tcp":
|
||||
return await self.sys_run_in_executor(check_port, self.ip_address, port)
|
||||
return await check_port(self.ip_address, port)
|
||||
|
||||
# lookup the correct protocol from config
|
||||
if t_proto:
|
||||
@@ -602,6 +605,16 @@ class Addon(AddonModel):
|
||||
_LOGGER.info("Removing add-on data folder %s", self.path_data)
|
||||
await remove_data(self.path_data)
|
||||
|
||||
async def _check_ingress_port(self):
|
||||
"""Assign a ingress port if dynamic port selection is used."""
|
||||
if not self.with_ingress:
|
||||
return
|
||||
|
||||
if self.data[ATTR_INGRESS_PORT] == 0:
|
||||
self.data[ATTR_INGRESS_PORT] = await self.sys_ingress.get_dynamic_port(
|
||||
self.slug
|
||||
)
|
||||
|
||||
@Job(
|
||||
name="addon_install",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
@@ -705,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
|
||||
|
||||
@@ -716,6 +729,7 @@ class Addon(AddonModel):
|
||||
try:
|
||||
_LOGGER.info("Add-on '%s' successfully updated", self.slug)
|
||||
self.sys_addons.data.update(store)
|
||||
await self._check_ingress_port()
|
||||
|
||||
# Cleanup
|
||||
with suppress(DockerError):
|
||||
@@ -781,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
|
||||
)
|
||||
@@ -1139,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)
|
||||
@@ -1193,12 +1213,15 @@ 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
|
||||
def _restore_data():
|
||||
|
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)
|
||||
|
374
supervisor/addons/manager.py
Normal file
374
supervisor/addons/manager.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Supervisor add-on manager."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import tarfile
|
||||
from typing import Union
|
||||
|
||||
from ..const import AddonBoot, AddonStartup, AddonState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import (
|
||||
AddonConfigurationError,
|
||||
AddonsError,
|
||||
AddonsJobError,
|
||||
AddonsNotSupportedError,
|
||||
CoreDNSError,
|
||||
DockerAPIError,
|
||||
DockerError,
|
||||
DockerNotFound,
|
||||
HassioError,
|
||||
HomeAssistantAPIError,
|
||||
)
|
||||
from ..jobs.decorator import Job, JobCondition
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
from ..store.addon import AddonStore
|
||||
from ..utils import check_exception_chain
|
||||
from ..utils.sentry import capture_exception
|
||||
from .addon import Addon
|
||||
from .const import ADDON_UPDATE_CONDITIONS
|
||||
from .data import AddonsData
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
AnyAddon = Union[Addon, AddonStore]
|
||||
|
||||
|
||||
class AddonManager(CoreSysAttributes):
|
||||
"""Manage add-ons inside Supervisor."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize Docker base wrapper."""
|
||||
self.coresys: CoreSys = coresys
|
||||
self.data: AddonsData = AddonsData(coresys)
|
||||
self.local: dict[str, Addon] = {}
|
||||
self.store: dict[str, AddonStore] = {}
|
||||
|
||||
@property
|
||||
def all(self) -> list[AnyAddon]:
|
||||
"""Return a list of all add-ons."""
|
||||
addons: dict[str, AnyAddon] = {**self.store, **self.local}
|
||||
return list(addons.values())
|
||||
|
||||
@property
|
||||
def installed(self) -> list[Addon]:
|
||||
"""Return a list of all installed add-ons."""
|
||||
return list(self.local.values())
|
||||
|
||||
def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None:
|
||||
"""Return an add-on from slug.
|
||||
|
||||
Prio:
|
||||
1 - Local
|
||||
2 - Store
|
||||
"""
|
||||
if addon_slug in self.local:
|
||||
return self.local[addon_slug]
|
||||
if not local_only:
|
||||
return self.store.get(addon_slug)
|
||||
return None
|
||||
|
||||
def from_token(self, token: str) -> Addon | None:
|
||||
"""Return an add-on from Supervisor token."""
|
||||
for addon in self.installed:
|
||||
if token == addon.supervisor_token:
|
||||
return addon
|
||||
return None
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Start up add-on management."""
|
||||
tasks = []
|
||||
for slug in self.data.system:
|
||||
addon = self.local[slug] = Addon(self.coresys, slug)
|
||||
tasks.append(self.sys_create_task(addon.load()))
|
||||
|
||||
# Run initial tasks
|
||||
_LOGGER.info("Found %d installed add-ons", len(tasks))
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
# Sync DNS
|
||||
await self.sync_dns()
|
||||
|
||||
async def boot(self, stage: AddonStartup) -> None:
|
||||
"""Boot add-ons with mode auto."""
|
||||
tasks: list[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
|
||||
continue
|
||||
tasks.append(addon)
|
||||
|
||||
# Evaluate add-ons which need to be started
|
||||
_LOGGER.info("Phase '%s' starting %d add-ons", stage, len(tasks))
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Start Add-ons sequential
|
||||
# avoid issue on slow IO
|
||||
# Config.wait_boot is deprecated. Until addons update with healthchecks,
|
||||
# add a sleep task for it to keep the same minimum amount of wait time
|
||||
wait_boot: list[Awaitable[None]] = [asyncio.sleep(self.sys_config.wait_boot)]
|
||||
for addon in tasks:
|
||||
try:
|
||||
if start_task := await addon.start():
|
||||
wait_boot.append(start_task)
|
||||
except AddonsError as err:
|
||||
# Check if there is an system/user issue
|
||||
if check_exception_chain(
|
||||
err, (DockerAPIError, DockerNotFound, AddonConfigurationError)
|
||||
):
|
||||
addon.boot = AddonBoot.MANUAL
|
||||
addon.save_persist()
|
||||
except HassioError:
|
||||
pass # These are already handled
|
||||
else:
|
||||
continue
|
||||
|
||||
_LOGGER.warning("Can't start Add-on %s", addon.slug)
|
||||
|
||||
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
||||
await asyncio.gather(*wait_boot, return_exceptions=True)
|
||||
|
||||
async def shutdown(self, stage: AddonStartup) -> None:
|
||||
"""Shutdown addons."""
|
||||
tasks: list[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.state != AddonState.STARTED or addon.startup != stage:
|
||||
continue
|
||||
tasks.append(addon)
|
||||
|
||||
# Evaluate add-ons which need to be stopped
|
||||
_LOGGER.info("Phase '%s' stopping %d add-ons", stage, len(tasks))
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Stop Add-ons sequential
|
||||
# avoid issue on slow IO
|
||||
for addon in tasks:
|
||||
try:
|
||||
await addon.stop()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_install",
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def install(self, slug: str) -> None:
|
||||
"""Install an add-on."""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
|
||||
store = self.store.get(slug)
|
||||
|
||||
if not store:
|
||||
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error)
|
||||
|
||||
store.validate_availability()
|
||||
|
||||
await Addon(self.coresys, slug).install()
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully installed", slug)
|
||||
|
||||
async def uninstall(self, slug: str) -> None:
|
||||
"""Remove an add-on."""
|
||||
if slug not in self.local:
|
||||
_LOGGER.warning("Add-on %s is not installed", slug)
|
||||
return
|
||||
|
||||
await self.local[slug].uninstall()
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||
|
||||
@Job(
|
||||
name="addon_manager_update",
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def update(
|
||||
self, slug: str, backup: bool | None = False
|
||||
) -> asyncio.Task | None:
|
||||
"""Update add-on.
|
||||
|
||||
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after update. Else nothing is returned.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
raise AddonsError(
|
||||
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||
)
|
||||
store = self.store[slug]
|
||||
|
||||
if addon.version == store.version:
|
||||
raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning)
|
||||
|
||||
# Check if available, Maybe something have changed
|
||||
store.validate_availability()
|
||||
|
||||
if backup:
|
||||
await self.sys_backups.do_backup_partial(
|
||||
name=f"addon_{addon.slug}_{addon.version}",
|
||||
homeassistant=False,
|
||||
addons=[addon.slug],
|
||||
)
|
||||
|
||||
return await addon.update()
|
||||
|
||||
@Job(
|
||||
name="addon_manager_rebuild",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def rebuild(self, slug: str) -> asyncio.Task | None:
|
||||
"""Perform a rebuild of local build add-on.
|
||||
|
||||
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after rebuild. Else nothing is returned.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
raise AddonsError(
|
||||
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||
)
|
||||
store = self.store[slug]
|
||||
|
||||
# Check if a rebuild is possible now
|
||||
if addon.version != store.version:
|
||||
raise AddonsError(
|
||||
"Version changed, use Update instead Rebuild", _LOGGER.error
|
||||
)
|
||||
if not addon.need_build:
|
||||
raise AddonsNotSupportedError(
|
||||
"Can't rebuild a image based add-on", _LOGGER.error
|
||||
)
|
||||
|
||||
return await addon.rebuild()
|
||||
|
||||
@Job(
|
||||
name="addon_manager_restore",
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def restore(
|
||||
self, slug: str, tar_file: tarfile.TarFile
|
||||
) -> asyncio.Task | None:
|
||||
"""Restore state of an add-on.
|
||||
|
||||
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||
if addon is started after restore. Else nothing is returned.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
||||
if slug not in self.local:
|
||||
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
||||
addon = Addon(self.coresys, slug)
|
||||
had_ingress = False
|
||||
else:
|
||||
_LOGGER.debug("Add-on %s is local available for restore", slug)
|
||||
addon = self.local[slug]
|
||||
had_ingress = addon.ingress_panel
|
||||
|
||||
wait_for_start = await addon.restore(tar_file)
|
||||
|
||||
# Check if new
|
||||
if slug not in self.local:
|
||||
_LOGGER.info("Detect new Add-on after restore %s", slug)
|
||||
self.local[slug] = addon
|
||||
|
||||
# Update ingress
|
||||
if had_ingress != addon.ingress_panel:
|
||||
await self.sys_ingress.reload()
|
||||
with suppress(HomeAssistantAPIError):
|
||||
await self.sys_ingress.update_hass_panel(addon)
|
||||
|
||||
return wait_for_start
|
||||
|
||||
@Job(
|
||||
name="addon_manager_repair",
|
||||
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST],
|
||||
)
|
||||
async def repair(self) -> None:
|
||||
"""Repair local add-ons."""
|
||||
needs_repair: list[Addon] = []
|
||||
|
||||
# Evaluate Add-ons to repair
|
||||
for addon in self.installed:
|
||||
if await addon.instance.exists():
|
||||
continue
|
||||
needs_repair.append(addon)
|
||||
|
||||
_LOGGER.info("Found %d add-ons to repair", len(needs_repair))
|
||||
if not needs_repair:
|
||||
return
|
||||
|
||||
for addon in needs_repair:
|
||||
_LOGGER.info("Repairing for add-on: %s", addon.slug)
|
||||
with suppress(DockerError, KeyError):
|
||||
# Need pull a image again
|
||||
if not addon.need_build:
|
||||
await addon.instance.install(addon.version, addon.image)
|
||||
continue
|
||||
|
||||
# Need local lookup
|
||||
if addon.need_build and not addon.is_detached:
|
||||
store = self.store[addon.slug]
|
||||
# If this add-on is available for rebuild
|
||||
if addon.version == store.version:
|
||||
await addon.instance.install(addon.version, addon.image)
|
||||
continue
|
||||
|
||||
_LOGGER.error("Can't repair %s", addon.slug)
|
||||
with suppress(AddonsError):
|
||||
await self.uninstall(addon.slug)
|
||||
|
||||
async def sync_dns(self) -> None:
|
||||
"""Sync add-ons DNS names."""
|
||||
# Update hosts
|
||||
add_host_coros: list[Awaitable[None]] = []
|
||||
for addon in self.installed:
|
||||
try:
|
||||
if not await addon.instance.is_running():
|
||||
continue
|
||||
except DockerError as err:
|
||||
_LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err)
|
||||
self.sys_resolution.create_issue(
|
||||
IssueType.CORRUPT_DOCKER,
|
||||
ContextType.ADDON,
|
||||
reference=addon.slug,
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
capture_exception(err)
|
||||
else:
|
||||
add_host_coros.append(
|
||||
self.sys_plugins.dns.add_host(
|
||||
ipv4=addon.ip_address, names=[addon.hostname], write=False
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*add_host_coros)
|
||||
|
||||
# Write hosts files
|
||||
with suppress(CoreDNSError):
|
||||
await self.sys_plugins.dns.write_hosts()
|
@@ -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)$")
|
||||
|
||||
@@ -148,6 +154,7 @@ RE_MACHINE = re.compile(
|
||||
r"|raspberrypi3"
|
||||
r"|raspberrypi4-64"
|
||||
r"|raspberrypi4"
|
||||
r"|raspberrypi5-64"
|
||||
r"|yellow"
|
||||
r"|green"
|
||||
r"|tinker"
|
||||
@@ -265,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,
|
||||
)
|
||||
|
||||
@@ -336,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(),
|
||||
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher
|
||||
|
||||
from ..const import AddonState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
@@ -64,9 +65,10 @@ class RestAPI(CoreSysAttributes):
|
||||
"max_field_size": MAX_LINE_SIZE,
|
||||
},
|
||||
)
|
||||
attach_fast_url_dispatcher(self.webapp, FastUrlDispatcher())
|
||||
|
||||
# service stuff
|
||||
self._runner: web.AppRunner = web.AppRunner(self.webapp)
|
||||
self._runner: web.AppRunner = web.AppRunner(self.webapp, shutdown_timeout=5)
|
||||
self._site: web.TCPSite | None = None
|
||||
|
||||
async def load(self) -> None:
|
||||
@@ -671,9 +673,7 @@ class RestAPI(CoreSysAttributes):
|
||||
async def start(self) -> None:
|
||||
"""Run RESTful API webserver."""
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(
|
||||
self._runner, host="0.0.0.0", port=80, shutdown_timeout=5
|
||||
)
|
||||
self._site = web.TCPSite(self._runner, host="0.0.0.0", port=80)
|
||||
|
||||
try:
|
||||
await self._site.start()
|
||||
|
@@ -8,8 +8,8 @@ from aiohttp import web
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from ..addons import AnyAddon
|
||||
from ..addons.addon import Addon
|
||||
from ..addons.manager import AnyAddon
|
||||
from ..addons.utils import rating_security
|
||||
from ..const import (
|
||||
ATTR_ADDONS,
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -48,6 +48,29 @@ SCHEMA_INGRESS_CREATE_SESSION_DATA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
# from https://github.com/aio-libs/aiohttp/blob/8ae650bee4add9f131d49b96a0a150311ea58cd1/aiohttp/helpers.py#L1059C1-L1079C1
|
||||
def must_be_empty_body(method: str, code: int) -> bool:
|
||||
"""Check if a request must return an empty body."""
|
||||
return (
|
||||
status_code_must_be_empty_body(code)
|
||||
or method_must_be_empty_body(method)
|
||||
or (200 <= code < 300 and method.upper() == hdrs.METH_CONNECT)
|
||||
)
|
||||
|
||||
|
||||
def method_must_be_empty_body(method: str) -> bool:
|
||||
"""Check if a method must return an empty body."""
|
||||
# https://datatracker.ietf.org/doc/html/rfc9112#section-6.3-2.1
|
||||
# https://datatracker.ietf.org/doc/html/rfc9112#section-6.3-2.2
|
||||
return method.upper() == hdrs.METH_HEAD
|
||||
|
||||
|
||||
def status_code_must_be_empty_body(code: int) -> bool:
|
||||
"""Check if a status code must return an empty body."""
|
||||
# https://datatracker.ietf.org/doc/html/rfc9112#section-6.3-2.1
|
||||
return code in {204, 304} or 100 <= code < 200
|
||||
|
||||
|
||||
class APIIngress(CoreSysAttributes):
|
||||
"""Ingress view to handle add-on webui routing."""
|
||||
|
||||
@@ -232,7 +255,11 @@ class APIIngress(CoreSysAttributes):
|
||||
content_type = result.content_type
|
||||
# Simple request
|
||||
if (
|
||||
hdrs.CONTENT_LENGTH in result.headers
|
||||
# empty body responses should not be streamed,
|
||||
# otherwise aiohttp < 3.9.0 may generate
|
||||
# an invalid "0\r\n\r\n" chunk instead of an empty response.
|
||||
must_be_empty_body(request.method, result.status)
|
||||
or hdrs.CONTENT_LENGTH in result.headers
|
||||
and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000
|
||||
):
|
||||
# Return Response
|
||||
|
@@ -6,7 +6,7 @@ from typing import Any
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from ..addons import AnyAddon
|
||||
from ..addons.manager import AnyAddon
|
||||
from ..addons.utils import rating_security
|
||||
from ..api.const import ATTR_SIGNED
|
||||
from ..api.utils import api_process, api_process_raw, api_validate
|
||||
|
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -19,7 +19,7 @@ from securetar import SecureTarFile, atomic_contents_add, secure_path
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from ..addons import Addon
|
||||
from ..addons.manager import Addon
|
||||
from ..const import (
|
||||
ATTR_ADDONS,
|
||||
ATTR_COMPRESSED,
|
||||
@@ -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)
|
||||
|
@@ -6,7 +6,7 @@ import signal
|
||||
|
||||
from colorlog import ColoredFormatter
|
||||
|
||||
from .addons import AddonManager
|
||||
from .addons.manager import AddonManager
|
||||
from .api import RestAPI
|
||||
from .arch import CpuArch
|
||||
from .auth import Auth
|
||||
|
@@ -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"
|
||||
|
@@ -28,7 +28,7 @@ from .homeassistant.core import LANDINGPAGE
|
||||
from .resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
|
||||
from .utils.dt import utcnow
|
||||
from .utils.sentry import capture_exception
|
||||
from .utils.whoami import retrieve_whoami
|
||||
from .utils.whoami import WhoamiData, retrieve_whoami
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -363,6 +363,13 @@ class Core(CoreSysAttributes):
|
||||
self.sys_config.last_boot = self.sys_hardware.helper.last_boot
|
||||
self.sys_config.save_data()
|
||||
|
||||
async def _retrieve_whoami(self, with_ssl: bool) -> WhoamiData | None:
|
||||
try:
|
||||
return await retrieve_whoami(self.sys_websession, with_ssl)
|
||||
except WhoamiSSLError:
|
||||
_LOGGER.info("Whoami service SSL error")
|
||||
return None
|
||||
|
||||
async def _adjust_system_datetime(self):
|
||||
"""Adjust system time/date on startup."""
|
||||
# If no timezone is detect or set
|
||||
@@ -375,21 +382,15 @@ class Core(CoreSysAttributes):
|
||||
|
||||
# Get Timezone data
|
||||
try:
|
||||
data = await retrieve_whoami(self.sys_websession)
|
||||
except WhoamiSSLError:
|
||||
pass
|
||||
data = await self._retrieve_whoami(True)
|
||||
|
||||
# SSL Date Issue & possible time drift
|
||||
if not data:
|
||||
data = await self._retrieve_whoami(False)
|
||||
except WhoamiError as err:
|
||||
_LOGGER.warning("Can't adjust Time/Date settings: %s", err)
|
||||
return
|
||||
|
||||
# SSL Date Issue & possible time drift
|
||||
if not data:
|
||||
try:
|
||||
data = await retrieve_whoami(self.sys_websession, with_ssl=False)
|
||||
except WhoamiError as err:
|
||||
_LOGGER.error("Can't adjust Time/Date settings: %s", err)
|
||||
return
|
||||
|
||||
self.sys_config.timezone = self.sys_config.timezone or data.timezone
|
||||
|
||||
# Calculate if system time is out of sync
|
||||
|
@@ -18,7 +18,7 @@ from .const import ENV_SUPERVISOR_DEV, SERVER_SOFTWARE
|
||||
from .utils.dt import UTC, get_time_zone
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .addons import AddonManager
|
||||
from .addons.manager import AddonManager
|
||||
from .api import RestAPI
|
||||
from .arch import CpuArch
|
||||
from .auth import Auth
|
||||
|
@@ -5,6 +5,7 @@
|
||||
"raspberrypi3-64": ["aarch64", "armv7", "armhf"],
|
||||
"raspberrypi4": ["armv7", "armhf"],
|
||||
"raspberrypi4-64": ["aarch64", "armv7", "armhf"],
|
||||
"raspberrypi5-64": ["aarch64", "armv7", "armhf"],
|
||||
"yellow": ["aarch64", "armv7", "armhf"],
|
||||
"green": ["aarch64", "armv7", "armhf"],
|
||||
"tinker": ["armv7", "armhf"],
|
||||
|
@@ -6,7 +6,7 @@ from typing import Any
|
||||
from awesomeversion import AwesomeVersion
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ...exceptions import DBusError, DBusInterfaceError
|
||||
from ...exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from ..const import (
|
||||
DBUS_ATTR_DIAGNOSTICS,
|
||||
DBUS_ATTR_VERSION,
|
||||
@@ -99,7 +99,7 @@ class OSAgent(DBusInterfaceProxy):
|
||||
await asyncio.gather(*[dbus.connect(bus) for dbus in self.all])
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to OS-Agent")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No OS-Agent support on the host. Some Host functions have been disabled."
|
||||
)
|
||||
|
@@ -3,7 +3,7 @@ import logging
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ..exceptions import DBusError, DBusInterfaceError
|
||||
from ..exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from .const import (
|
||||
DBUS_ATTR_CHASSIS,
|
||||
DBUS_ATTR_DEPLOYMENT,
|
||||
@@ -39,7 +39,7 @@ class Hostname(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to systemd-hostname")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No hostname support on the host. Hostname functions have been disabled."
|
||||
)
|
||||
|
@@ -3,7 +3,7 @@ import logging
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ..exceptions import DBusError, DBusInterfaceError
|
||||
from ..exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from .const import DBUS_NAME_LOGIND, DBUS_OBJECT_LOGIND
|
||||
from .interface import DBusInterface
|
||||
from .utils import dbus_connected
|
||||
@@ -28,8 +28,8 @@ class Logind(DBusInterface):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to systemd-logind")
|
||||
except DBusInterfaceError:
|
||||
_LOGGER.info("No systemd-logind support on the host.")
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning("No systemd-logind support on the host.")
|
||||
|
||||
@dbus_connected
|
||||
async def reboot(self) -> None:
|
||||
|
@@ -9,6 +9,8 @@ from ...exceptions import (
|
||||
DBusError,
|
||||
DBusFatalError,
|
||||
DBusInterfaceError,
|
||||
DBusNoReplyError,
|
||||
DBusServiceUnkownError,
|
||||
HostNotSupportedError,
|
||||
NetworkInterfaceNotFound,
|
||||
)
|
||||
@@ -143,7 +145,7 @@ class NetworkManager(DBusInterfaceProxy):
|
||||
await self.settings.connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to Network Manager")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No Network Manager support on the host. Local network functions have been disabled."
|
||||
)
|
||||
@@ -210,8 +212,22 @@ class NetworkManager(DBusInterfaceProxy):
|
||||
# try to query it. Ignore those cases.
|
||||
_LOGGER.debug("Can't process %s: %s", device, err)
|
||||
continue
|
||||
except (
|
||||
DBusNoReplyError,
|
||||
DBusServiceUnkownError,
|
||||
) as err:
|
||||
# This typically means that NetworkManager disappeared. Give up immeaditly.
|
||||
_LOGGER.error(
|
||||
"NetworkManager not responding while processing %s: %s. Giving up.",
|
||||
device,
|
||||
err,
|
||||
)
|
||||
capture_exception(err)
|
||||
return
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error while processing %s: %s", device, err)
|
||||
_LOGGER.exception(
|
||||
"Unkown error while processing %s: %s", device, err
|
||||
)
|
||||
capture_exception(err)
|
||||
continue
|
||||
|
||||
|
@@ -12,7 +12,7 @@ from ...const import (
|
||||
ATTR_PRIORITY,
|
||||
ATTR_VPN,
|
||||
)
|
||||
from ...exceptions import DBusError, DBusInterfaceError
|
||||
from ...exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from ..const import (
|
||||
DBUS_ATTR_CONFIGURATION,
|
||||
DBUS_ATTR_MODE,
|
||||
@@ -67,7 +67,7 @@ class NetworkManagerDNS(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to DnsManager")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No DnsManager support on the host. Local DNS functions have been disabled."
|
||||
)
|
||||
|
@@ -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")
|
||||
|
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ...exceptions import DBusError, DBusInterfaceError
|
||||
from ...exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from ..const import DBUS_NAME_NM, DBUS_OBJECT_SETTINGS
|
||||
from ..interface import DBusInterface
|
||||
from ..network.setting import NetworkSetting
|
||||
@@ -28,7 +28,7 @@ class NetworkManagerSettings(DBusInterface):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to Network Manager Settings")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No Network Manager Settings support on the host. Local network functions have been disabled."
|
||||
)
|
||||
|
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ..exceptions import DBusError, DBusInterfaceError
|
||||
from ..exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from ..utils.dbus import DBusSignalWrapper
|
||||
from .const import (
|
||||
DBUS_ATTR_BOOT_SLOT,
|
||||
@@ -49,7 +49,7 @@ class Rauc(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to rauc")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning("Host has no rauc support. OTA updates have been disabled.")
|
||||
|
||||
@property
|
||||
|
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ..exceptions import DBusError, DBusInterfaceError
|
||||
from ..exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from .const import (
|
||||
DBUS_ATTR_CACHE_STATISTICS,
|
||||
DBUS_ATTR_CURRENT_DNS_SERVER,
|
||||
@@ -59,7 +59,7 @@ class Resolved(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to systemd-resolved.")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"Host has no systemd-resolved support. DNS will not work correctly."
|
||||
)
|
||||
|
@@ -10,6 +10,7 @@ from ..exceptions import (
|
||||
DBusError,
|
||||
DBusFatalError,
|
||||
DBusInterfaceError,
|
||||
DBusServiceUnkownError,
|
||||
DBusSystemdNoSuchUnit,
|
||||
)
|
||||
from .const import (
|
||||
@@ -86,7 +87,7 @@ class Systemd(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to systemd")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No systemd support on the host. Host control has been disabled."
|
||||
)
|
||||
|
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from ..exceptions import DBusError, DBusInterfaceError
|
||||
from ..exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError
|
||||
from ..utils.dt import utc_from_timestamp
|
||||
from .const import (
|
||||
DBUS_ATTR_NTP,
|
||||
@@ -63,7 +63,7 @@ class TimeDate(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to systemd-timedate")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No timedate support on the host. Time/Date functions have been disabled."
|
||||
)
|
||||
|
@@ -6,7 +6,12 @@ from typing import Any
|
||||
from awesomeversion import AwesomeVersion
|
||||
from dbus_fast.aio import MessageBus
|
||||
|
||||
from ...exceptions import DBusError, DBusInterfaceError, DBusObjectError
|
||||
from ...exceptions import (
|
||||
DBusError,
|
||||
DBusInterfaceError,
|
||||
DBusObjectError,
|
||||
DBusServiceUnkownError,
|
||||
)
|
||||
from ..const import (
|
||||
DBUS_ATTR_SUPPORTED_FILESYSTEMS,
|
||||
DBUS_ATTR_VERSION,
|
||||
@@ -45,7 +50,7 @@ class UDisks2(DBusInterfaceProxy):
|
||||
await super().connect(bus)
|
||||
except DBusError:
|
||||
_LOGGER.warning("Can't connect to udisks2")
|
||||
except DBusInterfaceError:
|
||||
except (DBusServiceUnkownError, DBusInterfaceError):
|
||||
_LOGGER.warning(
|
||||
"No udisks2 support on the host. Host control has been disabled."
|
||||
)
|
||||
|
@@ -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="/addon_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 using the new mapping to /config still
|
||||
if MAP_HOMEASSISTANT_CONFIG in addon_mapping:
|
||||
# Map Home Assistant config in new way
|
||||
if MappingType.HOMEASSISTANT_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_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(
|
||||
|
@@ -53,9 +53,10 @@ class DockerHomeAssistant(DockerInterface):
|
||||
@property
|
||||
def timeout(self) -> int:
|
||||
"""Return timeout for Docker actions."""
|
||||
# Synchronized homeassistant's S6_SERVICES_GRACETIME
|
||||
# to avoid killing Home Assistant Core
|
||||
return 220 + 20
|
||||
# Synchronized with the homeassistant core container's S6_SERVICES_GRACETIME
|
||||
# to avoid killing Home Assistant Core, see
|
||||
# https://github.com/home-assistant/core/tree/dev/Dockerfile
|
||||
return 240 + 20
|
||||
|
||||
@property
|
||||
def ip_address(self) -> IPv4Address:
|
||||
|
@@ -335,6 +335,10 @@ class DBusNotConnectedError(HostNotSupportedError):
|
||||
"""D-Bus is not connected and call a method."""
|
||||
|
||||
|
||||
class DBusServiceUnkownError(HassioNotSupportedError):
|
||||
"""D-Bus service was not available."""
|
||||
|
||||
|
||||
class DBusInterfaceError(HassioNotSupportedError):
|
||||
"""D-Bus interface not connected."""
|
||||
|
||||
@@ -363,6 +367,10 @@ class DBusTimeoutError(DBusError):
|
||||
"""D-Bus call timed out."""
|
||||
|
||||
|
||||
class DBusNoReplyError(DBusError):
|
||||
"""D-Bus remote didn't reply/disconnected."""
|
||||
|
||||
|
||||
class DBusFatalError(DBusError):
|
||||
"""D-Bus call going wrong.
|
||||
|
||||
@@ -585,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."""
|
||||
|
@@ -140,8 +140,7 @@ class HomeAssistantAPI(CoreSysAttributes):
|
||||
return None
|
||||
|
||||
# Check if port is up
|
||||
if not await self.sys_run_in_executor(
|
||||
check_port,
|
||||
if not await check_port(
|
||||
self.sys_homeassistant.ip_address,
|
||||
self.sys_homeassistant.api_port,
|
||||
):
|
||||
|
@@ -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
|
||||
|
@@ -88,7 +88,7 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
now = utcnow()
|
||||
|
||||
sessions = {}
|
||||
sessions_data: dict[str, IngressSessionData] = {}
|
||||
sessions_data: dict[str, dict[str, str | None]] = {}
|
||||
for session, valid in self.sessions.items():
|
||||
# check if timestamp valid, to avoid crash on malformed timestamp
|
||||
try:
|
||||
@@ -102,7 +102,8 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
# Is valid
|
||||
sessions[session] = valid
|
||||
sessions_data[session] = self.sessions_data.get(session)
|
||||
if session_data := self.sessions_data.get(session):
|
||||
sessions_data[session] = session_data
|
||||
|
||||
# Write back
|
||||
self.sessions.clear()
|
||||
@@ -153,7 +154,7 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
return True
|
||||
|
||||
def get_dynamic_port(self, addon_slug: str) -> int:
|
||||
async def get_dynamic_port(self, addon_slug: str) -> int:
|
||||
"""Get/Create a dynamic port from range."""
|
||||
if addon_slug in self.ports:
|
||||
return self.ports[addon_slug]
|
||||
@@ -162,7 +163,7 @@ class Ingress(FileConfiguration, CoreSysAttributes):
|
||||
while (
|
||||
port is None
|
||||
or port in self.ports.values()
|
||||
or check_port(self.sys_docker.network.gateway, port)
|
||||
or await check_port(self.sys_docker.network.gateway, port)
|
||||
):
|
||||
port = random.randint(62000, 65500)
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -170,6 +170,16 @@ class Mount(CoreSysAttributes, ABC):
|
||||
elif self.state != UnitActiveState.ACTIVE:
|
||||
await self.reload()
|
||||
|
||||
async def update_state(self) -> None:
|
||||
"""Update mount unit state."""
|
||||
try:
|
||||
self._state = await self.unit.get_active_state()
|
||||
except DBusError as err:
|
||||
capture_exception(err)
|
||||
raise MountError(
|
||||
f"Could not get active state of mount due to: {err!s}"
|
||||
) from err
|
||||
|
||||
async def update(self) -> None:
|
||||
"""Update info about mount from dbus."""
|
||||
try:
|
||||
@@ -182,13 +192,7 @@ class Mount(CoreSysAttributes, ABC):
|
||||
capture_exception(err)
|
||||
raise MountError(f"Could not get mount unit due to: {err!s}") from err
|
||||
|
||||
try:
|
||||
self._state = await self.unit.get_active_state()
|
||||
except DBusError as err:
|
||||
capture_exception(err)
|
||||
raise MountError(
|
||||
f"Could not get active state of mount due to: {err!s}"
|
||||
) from err
|
||||
await self.update_state()
|
||||
|
||||
# If active, dismiss corresponding failed mount issue if found
|
||||
if (
|
||||
@@ -197,6 +201,20 @@ class Mount(CoreSysAttributes, ABC):
|
||||
):
|
||||
self.sys_resolution.dismiss_issue(self.failed_issue)
|
||||
|
||||
async def _update_state_await(self, expected_states: list[UnitActiveState]) -> None:
|
||||
"""Update state info about mount from dbus. Wait up to 30 seconds for the state to appear."""
|
||||
for i in range(5):
|
||||
await self.update_state()
|
||||
if self.state in expected_states:
|
||||
return
|
||||
await asyncio.sleep(i**2)
|
||||
|
||||
_LOGGER.warning(
|
||||
"Mount %s still in state %s after waiting for 30 seconods to complete",
|
||||
self.name,
|
||||
str(self.state).lower(),
|
||||
)
|
||||
|
||||
async def _update_await_activating(self):
|
||||
"""Update info about mount from dbus. If 'activating' wait up to 30 seconds."""
|
||||
await self.update()
|
||||
@@ -269,10 +287,15 @@ class Mount(CoreSysAttributes, ABC):
|
||||
await self.update()
|
||||
|
||||
try:
|
||||
if self.state != UnitActiveState.FAILED:
|
||||
await self.sys_dbus.systemd.stop_unit(self.unit_name, StopUnitMode.FAIL)
|
||||
|
||||
await self._update_state_await(
|
||||
[UnitActiveState.INACTIVE, UnitActiveState.FAILED]
|
||||
)
|
||||
|
||||
if self.state == UnitActiveState.FAILED:
|
||||
await self.sys_dbus.systemd.reset_failed_unit(self.unit_name)
|
||||
else:
|
||||
await self.sys_dbus.systemd.stop_unit(self.unit_name, StopUnitMode.FAIL)
|
||||
except DBusSystemdNoSuchUnit:
|
||||
_LOGGER.info("Mount %s is not mounted, skipping unmount", self.name)
|
||||
except DBusError as err:
|
||||
|
@@ -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
|
||||
|
@@ -39,20 +39,19 @@ def process_lock(method):
|
||||
return wrap_api
|
||||
|
||||
|
||||
def check_port(address: IPv4Address, port: int) -> bool:
|
||||
async def check_port(address: IPv4Address, port: int) -> bool:
|
||||
"""Check if port is mapped."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.5)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
result = sock.connect_ex((str(address), port))
|
||||
sock.close()
|
||||
|
||||
# Check if the port is available
|
||||
if result == 0:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
async with asyncio.timeout(0.5):
|
||||
await asyncio.get_running_loop().sock_connect(sock, (str(address), port))
|
||||
except (OSError, TimeoutError):
|
||||
return False
|
||||
finally:
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
return True
|
||||
|
||||
|
||||
def check_exception_chain(err: Exception, object_type: Any) -> bool:
|
||||
@@ -105,30 +104,36 @@ async def remove_folder(
|
||||
if any(item.match(exclude) for exclude in excludes):
|
||||
moved_files.append(item.rename(temp_path / item.name))
|
||||
|
||||
del_folder = f"{folder}" + "/{,.[!.],..?}*" if content_only else f"{folder}"
|
||||
find_args = []
|
||||
if content_only:
|
||||
find_args.extend(["-mindepth", "1"])
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"bash",
|
||||
"-c",
|
||||
f"rm -rf --one-file-system {del_folder}",
|
||||
"/usr/bin/find",
|
||||
folder,
|
||||
"-xdev",
|
||||
*find_args,
|
||||
"-delete",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=clean_env(),
|
||||
)
|
||||
|
||||
_, error_msg = await proc.communicate()
|
||||
except OSError as err:
|
||||
error_msg = str(err)
|
||||
_LOGGER.exception("Can't remove folder %s: %s", folder, err)
|
||||
else:
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
_LOGGER.error(
|
||||
"Can't remove folder %s: %s", folder, error_msg.decode("utf-8").strip()
|
||||
)
|
||||
finally:
|
||||
if excludes:
|
||||
for item in moved_files:
|
||||
item.rename(folder / item.name)
|
||||
temp.cleanup()
|
||||
|
||||
_LOGGER.error("Can't remove folder %s: %s", folder, error_msg)
|
||||
|
||||
|
||||
def clean_env() -> dict[str, str]:
|
||||
"""Return a clean env from system."""
|
||||
|
@@ -15,18 +15,21 @@ from dbus_fast import (
|
||||
)
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
from dbus_fast.aio.proxy_object import ProxyInterface, ProxyObject
|
||||
from dbus_fast.errors import DBusError
|
||||
from dbus_fast.errors import DBusError as DBusFastDBusError
|
||||
from dbus_fast.introspection import Node
|
||||
|
||||
from ..exceptions import (
|
||||
DBusError,
|
||||
DBusFatalError,
|
||||
DBusInterfaceError,
|
||||
DBusInterfaceMethodError,
|
||||
DBusInterfacePropertyError,
|
||||
DBusInterfaceSignalError,
|
||||
DBusNoReplyError,
|
||||
DBusNotConnectedError,
|
||||
DBusObjectError,
|
||||
DBusParseError,
|
||||
DBusServiceUnkownError,
|
||||
DBusTimeoutError,
|
||||
HassioNotSupportedError,
|
||||
)
|
||||
@@ -62,9 +65,11 @@ class DBus:
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def from_dbus_error(err: DBusError) -> HassioNotSupportedError | DBusError:
|
||||
def from_dbus_error(err: DBusFastDBusError) -> HassioNotSupportedError | DBusError:
|
||||
"""Return correct dbus error based on type."""
|
||||
if err.type in {ErrorType.SERVICE_UNKNOWN, ErrorType.UNKNOWN_INTERFACE}:
|
||||
if err.type == ErrorType.SERVICE_UNKNOWN:
|
||||
return DBusServiceUnkownError(err.text)
|
||||
if err.type == ErrorType.UNKNOWN_INTERFACE:
|
||||
return DBusInterfaceError(err.text)
|
||||
if err.type in {
|
||||
ErrorType.UNKNOWN_METHOD,
|
||||
@@ -80,6 +85,8 @@ class DBus:
|
||||
return DBusNotConnectedError(err.text)
|
||||
if err.type == ErrorType.TIMEOUT:
|
||||
return DBusTimeoutError(err.text)
|
||||
if err.type == ErrorType.NO_REPLY:
|
||||
return DBusNoReplyError(err.text)
|
||||
return DBusFatalError(err.text, type_=err.type)
|
||||
|
||||
@staticmethod
|
||||
@@ -102,7 +109,7 @@ class DBus:
|
||||
*args, unpack_variants=True
|
||||
)
|
||||
return await getattr(proxy_interface, method)(*args)
|
||||
except DBusError as err:
|
||||
except DBusFastDBusError as err:
|
||||
raise DBus.from_dbus_error(err)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
capture_exception(err)
|
||||
@@ -126,6 +133,8 @@ class DBus:
|
||||
raise DBusParseError(
|
||||
f"Can't parse introspect data: {err}", _LOGGER.error
|
||||
) from err
|
||||
except DBusFastDBusError as err:
|
||||
raise DBus.from_dbus_error(err)
|
||||
except (EOFError, TimeoutError):
|
||||
_LOGGER.warning(
|
||||
"Busy system at %s - %s", self.bus_name, self.object_path
|
||||
|
@@ -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
|
||||
|
73
supervisor/utils/logging.py
Normal file
73
supervisor/utils/logging.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Logging utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import queue
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SupervisorQueueHandler(logging.handlers.QueueHandler):
|
||||
"""Process the log in another thread."""
|
||||
|
||||
listener: logging.handlers.QueueListener | None = None
|
||||
|
||||
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
||||
"""Prepare a record for queuing.
|
||||
|
||||
This is added as a workaround for https://bugs.python.org/issue46755
|
||||
"""
|
||||
record = super().prepare(record)
|
||||
record.stack_info = None
|
||||
return record
|
||||
|
||||
def handle(self, record: logging.LogRecord) -> Any:
|
||||
"""Conditionally emit the specified logging record.
|
||||
|
||||
Depending on which filters have been added to the handler, push the new
|
||||
records onto the backing Queue.
|
||||
|
||||
The default python logger Handler acquires a lock
|
||||
in the parent class which we do not need as
|
||||
SimpleQueue is already thread safe.
|
||||
|
||||
See https://bugs.python.org/issue24645
|
||||
"""
|
||||
return_value = self.filter(record)
|
||||
if return_value:
|
||||
self.emit(record)
|
||||
return return_value
|
||||
|
||||
def close(self) -> None:
|
||||
"""Tidy up any resources used by the handler.
|
||||
|
||||
This adds shutdown of the QueueListener
|
||||
"""
|
||||
super().close()
|
||||
if not self.listener:
|
||||
return
|
||||
self.listener.stop()
|
||||
self.listener = None
|
||||
|
||||
|
||||
def activate_log_queue_handler() -> None:
|
||||
"""Migrate the existing log handlers to use the queue.
|
||||
|
||||
This allows us to avoid blocking I/O and formatting messages
|
||||
in the event loop as log messages are written in another thread.
|
||||
"""
|
||||
simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue()
|
||||
queue_handler = SupervisorQueueHandler(simple_queue)
|
||||
logging.root.addHandler(queue_handler)
|
||||
|
||||
migrated_handlers: list[logging.Handler] = []
|
||||
for handler in logging.root.handlers[:]:
|
||||
if handler is queue_handler:
|
||||
continue
|
||||
logging.root.removeHandler(handler)
|
||||
migrated_handlers.append(handler)
|
||||
|
||||
listener = logging.handlers.QueueListener(simple_queue, *migrated_handlers)
|
||||
queue_handler.listener = listener
|
||||
|
||||
listener.start()
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import errno
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
@@ -683,3 +684,40 @@ async def test_local_example_start(
|
||||
await install_addon_example.start()
|
||||
|
||||
assert addon_config_dir.is_dir()
|
||||
|
||||
|
||||
async def test_local_example_ingress_port_set(
|
||||
coresys: CoreSys,
|
||||
container: MagicMock,
|
||||
tmp_supervisor_data: Path,
|
||||
install_addon_example: Addon,
|
||||
):
|
||||
"""Test start of an addon."""
|
||||
install_addon_example.path_data.mkdir()
|
||||
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
|
||||
|
@@ -176,6 +176,7 @@ def test_valid_machine():
|
||||
"raspberrypi3",
|
||||
"raspberrypi4-64",
|
||||
"raspberrypi4",
|
||||
"raspberrypi5-64",
|
||||
"tinker",
|
||||
]
|
||||
|
||||
@@ -196,6 +197,7 @@ def test_valid_machine():
|
||||
"!raspberrypi3",
|
||||
"!raspberrypi4-64",
|
||||
"!raspberrypi4",
|
||||
"!raspberrypi5-64",
|
||||
"!tinker",
|
||||
]
|
||||
|
||||
@@ -211,6 +213,7 @@ def test_valid_machine():
|
||||
"raspberrypi",
|
||||
"raspberrypi4-64",
|
||||
"raspberrypi4",
|
||||
"raspberrypi5-64",
|
||||
"!tinker",
|
||||
]
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Test API security layer."""
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -109,7 +110,6 @@ async def test_bad_requests(
|
||||
fail_on_query_string,
|
||||
api_system,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
loop,
|
||||
) -> None:
|
||||
"""Test request paths that should be filtered."""
|
||||
|
||||
@@ -121,7 +121,7 @@ async def test_bad_requests(
|
||||
man_params = ""
|
||||
|
||||
http = urllib3.PoolManager()
|
||||
resp = await loop.run_in_executor(
|
||||
resp = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
http.request,
|
||||
"GET",
|
||||
|
@@ -73,10 +73,10 @@ async def test_api_addon_logs(
|
||||
resp = await api_client.get("/addons/local_ssh/logs")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/octet-stream"
|
||||
content = await resp.text()
|
||||
assert content.split("\n")[0:2] == [
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
content = await resp.read()
|
||||
assert content.split(b"\n")[0:2] == [
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
]
|
||||
|
||||
|
||||
|
@@ -10,8 +10,8 @@ async def test_api_audio_logs(api_client: TestClient, docker_logs: MagicMock):
|
||||
resp = await api_client.get("/audio/logs")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/octet-stream"
|
||||
content = await resp.text()
|
||||
assert content.split("\n")[0:2] == [
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
content = await resp.read()
|
||||
assert content.split(b"\n")[0:2] == [
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
]
|
||||
|
@@ -69,8 +69,8 @@ async def test_api_dns_logs(api_client: TestClient, docker_logs: MagicMock):
|
||||
resp = await api_client.get("/dns/logs")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/octet-stream"
|
||||
content = await resp.text()
|
||||
assert content.split("\n")[0:2] == [
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
content = await resp.read()
|
||||
assert content.split(b"\n")[0:2] == [
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
]
|
||||
|
@@ -19,10 +19,10 @@ async def test_api_core_logs(
|
||||
resp = await api_client.get(f"/{'homeassistant' if legacy_route else 'core'}/logs")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/octet-stream"
|
||||
content = await resp.text()
|
||||
assert content.split("\n")[0:2] == [
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
content = await resp.read()
|
||||
assert content.split(b"\n")[0:2] == [
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
]
|
||||
|
||||
|
||||
|
@@ -142,7 +142,7 @@ async def test_api_create_dbus_error_mount_not_added(
|
||||
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
|
||||
]
|
||||
systemd_service.response_start_transient_unit = "/org/freedesktop/systemd1/job/7623"
|
||||
systemd_unit_service.active_state = "failed"
|
||||
systemd_unit_service.active_state = ["failed", "failed", "inactive"]
|
||||
|
||||
resp = await api_client.post(
|
||||
"/mounts",
|
||||
@@ -219,8 +219,16 @@ async def test_api_create_mount_fails_missing_mount_propagation(
|
||||
)
|
||||
|
||||
|
||||
async def test_api_update_mount(api_client: TestClient, coresys: CoreSys, mount):
|
||||
async def test_api_update_mount(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
all_dbus_services: dict[str, DBusServiceMock],
|
||||
mount,
|
||||
):
|
||||
"""Test updating a mount via API."""
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_service.mock_systemd_unit = systemd_unit_service
|
||||
resp = await api_client.put(
|
||||
"/mounts/backup_test",
|
||||
json={
|
||||
@@ -280,6 +288,7 @@ async def test_api_update_dbus_error_mount_remains(
|
||||
"""Test mount remains in list with unsuccessful state if dbus error occurs during update."""
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_unit_service.active_state = ["failed", "inactive"]
|
||||
systemd_service.response_get_unit = [
|
||||
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
|
||||
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
|
||||
@@ -321,7 +330,13 @@ async def test_api_update_dbus_error_mount_remains(
|
||||
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
|
||||
]
|
||||
systemd_service.response_start_transient_unit = "/org/freedesktop/systemd1/job/7623"
|
||||
systemd_unit_service.active_state = "failed"
|
||||
systemd_unit_service.active_state = [
|
||||
"failed",
|
||||
"inactive",
|
||||
"inactive",
|
||||
"failed",
|
||||
"inactive",
|
||||
]
|
||||
|
||||
resp = await api_client.put(
|
||||
"/mounts/backup_test",
|
||||
@@ -385,8 +400,16 @@ async def test_api_reload_error_mount_missing(
|
||||
)
|
||||
|
||||
|
||||
async def test_api_delete_mount(api_client: TestClient, coresys: CoreSys, mount):
|
||||
async def test_api_delete_mount(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
all_dbus_services: dict[str, DBusServiceMock],
|
||||
mount,
|
||||
):
|
||||
"""Test deleting a mount via API."""
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_service.mock_systemd_unit = systemd_unit_service
|
||||
resp = await api_client.delete("/mounts/backup_test")
|
||||
result = await resp.json()
|
||||
assert result["result"] == "ok"
|
||||
@@ -455,9 +478,16 @@ async def test_api_create_backup_mount_sets_default(
|
||||
|
||||
|
||||
async def test_update_backup_mount_changes_default(
|
||||
api_client: TestClient, coresys: CoreSys, mount
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
all_dbus_services: dict[str, DBusServiceMock],
|
||||
mount,
|
||||
):
|
||||
"""Test updating a backup mount may unset the default."""
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_service.mock_systemd_unit = systemd_unit_service
|
||||
|
||||
# Make another backup mount for testing
|
||||
resp = await api_client.post(
|
||||
"/mounts",
|
||||
@@ -502,9 +532,16 @@ async def test_update_backup_mount_changes_default(
|
||||
|
||||
|
||||
async def test_delete_backup_mount_changes_default(
|
||||
api_client: TestClient, coresys: CoreSys, mount
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
all_dbus_services: dict[str, DBusServiceMock],
|
||||
mount,
|
||||
):
|
||||
"""Test deleting a backup mount may unset the default."""
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_service.mock_systemd_unit = systemd_unit_service
|
||||
|
||||
# Make another backup mount for testing
|
||||
resp = await api_client.post(
|
||||
"/mounts",
|
||||
@@ -535,11 +572,15 @@ async def test_delete_backup_mount_changes_default(
|
||||
async def test_backup_mounts_reload_backups(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
all_dbus_services: dict[str, DBusServiceMock],
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
mount_propagation,
|
||||
):
|
||||
"""Test actions on a backup mount reload backups."""
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_service.mock_systemd_unit = systemd_unit_service
|
||||
await coresys.mounts.load()
|
||||
|
||||
with patch.object(BackupManager, "reload") as reload:
|
||||
|
@@ -10,8 +10,8 @@ async def test_api_multicast_logs(api_client: TestClient, docker_logs: MagicMock
|
||||
resp = await api_client.get("/multicast/logs")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/octet-stream"
|
||||
content = await resp.text()
|
||||
assert content.split("\n")[0:2] == [
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
content = await resp.read()
|
||||
assert content.split(b"\n")[0:2] == [
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
]
|
||||
|
@@ -155,10 +155,10 @@ async def test_api_supervisor_logs(api_client: TestClient, docker_logs: MagicMoc
|
||||
resp = await api_client.get("/supervisor/logs")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/octet-stream"
|
||||
content = await resp.text()
|
||||
assert content.split("\n")[0:2] == [
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
content = await resp.read()
|
||||
assert content.split(b"\n")[0:2] == [
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os\x1b[0m",
|
||||
b"\x1b[36m22-10-11 14:04:23 DEBUG (MainThread) [supervisor.utils.dbus] D-Bus call - org.freedesktop.DBus.Properties.call_get_all on /io/hass/os/AppArmor\x1b[0m",
|
||||
]
|
||||
|
||||
|
||||
|
@@ -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
|
||||
@@ -31,6 +39,7 @@ from supervisor.utils.json import read_json_file, write_json_file
|
||||
from tests.const import TEST_ADDON_SLUG
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.systemd import Systemd as SystemdService
|
||||
from tests.dbus_service_mocks.systemd_unit import SystemdUnit as SystemdUnitService
|
||||
|
||||
|
||||
async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh):
|
||||
@@ -197,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()
|
||||
@@ -226,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()
|
||||
@@ -253,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()
|
||||
@@ -277,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],
|
||||
@@ -296,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(
|
||||
@@ -330,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(
|
||||
@@ -365,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)
|
||||
|
||||
|
||||
@@ -386,6 +406,7 @@ async def test_backup_media_with_mounts(
|
||||
):
|
||||
"""Test backing up media folder with mounts."""
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_service.response_get_unit = [
|
||||
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
|
||||
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
|
||||
@@ -422,6 +443,7 @@ async def test_backup_media_with_mounts(
|
||||
backup: Backup = await coresys.backups.do_backup_partial("test", folders=["media"])
|
||||
|
||||
# Remove the mount and wipe the media folder
|
||||
systemd_unit_service.active_state = "inactive"
|
||||
await coresys.mounts.remove_mount("media_test")
|
||||
rmtree(coresys.config.path_media)
|
||||
coresys.config.path_media.mkdir()
|
||||
@@ -445,6 +467,8 @@ async def test_backup_media_with_mounts_retains_files(
|
||||
):
|
||||
"""Test backing up media folder with mounts retains mount files."""
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_unit_service.active_state = ["active", "active", "active", "inactive"]
|
||||
systemd_service.response_get_unit = [
|
||||
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
|
||||
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
|
||||
@@ -496,6 +520,15 @@ async def test_backup_share_with_mounts(
|
||||
):
|
||||
"""Test backing up share folder with mounts."""
|
||||
systemd_service: SystemdService = all_dbus_services["systemd"]
|
||||
systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"]
|
||||
systemd_unit_service.active_state = [
|
||||
"active",
|
||||
"active",
|
||||
"active",
|
||||
"inactive",
|
||||
"active",
|
||||
"inactive",
|
||||
]
|
||||
systemd_service.response_get_unit = [
|
||||
DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"),
|
||||
"/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount",
|
||||
@@ -625,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()
|
||||
|
||||
@@ -1541,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
|
||||
|
@@ -5,11 +5,12 @@ import pytest
|
||||
|
||||
from supervisor.dbus.agent import OSAgent
|
||||
|
||||
from tests.common import mock_dbus_services
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
|
||||
|
||||
|
||||
@pytest.fixture(name="os_agent_service", autouse=True)
|
||||
@pytest.fixture(name="os_agent_service")
|
||||
async def fixture_os_agent_service(
|
||||
os_agent_services: dict[str, DBusServiceMock]
|
||||
) -> OSAgentService:
|
||||
@@ -39,3 +40,36 @@ async def test_dbus_osagent(
|
||||
await os_agent_service.ping()
|
||||
await os_agent_service.ping()
|
||||
assert os_agent.diagnostics is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"skip_service",
|
||||
[
|
||||
"os_agent",
|
||||
"agent_apparmor",
|
||||
"agent_datadisk",
|
||||
],
|
||||
)
|
||||
async def test_dbus_osagent_connect_error(
|
||||
skip_service: str, dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test OS Agent errors during connect."""
|
||||
os_agent_services = {
|
||||
"os_agent": None,
|
||||
"agent_apparmor": None,
|
||||
"agent_cgroup": None,
|
||||
"agent_datadisk": None,
|
||||
"agent_system": None,
|
||||
"agent_boards": None,
|
||||
"agent_boards_yellow": None,
|
||||
}
|
||||
os_agent_services.pop(skip_service)
|
||||
await mock_dbus_services(
|
||||
os_agent_services,
|
||||
dbus_session_bus,
|
||||
)
|
||||
|
||||
os_agent = OSAgent()
|
||||
await os_agent.connect(dbus_session_bus)
|
||||
|
||||
assert "No OS-Agent support on the host" in caplog.text
|
||||
|
@@ -12,7 +12,7 @@ from tests.common import mock_dbus_services
|
||||
from tests.dbus_service_mocks.network_dns_manager import DnsManager as DnsManagerService
|
||||
|
||||
|
||||
@pytest.fixture(name="dns_manager_service", autouse=True)
|
||||
@pytest.fixture(name="dns_manager_service")
|
||||
async def fixture_dns_manager_service(
|
||||
dbus_session_bus: MessageBus,
|
||||
) -> DnsManagerService:
|
||||
@@ -49,3 +49,12 @@ async def test_dns(
|
||||
await dns_manager_service.ping()
|
||||
await dns_manager_service.ping()
|
||||
assert dns_manager.mode == "default"
|
||||
|
||||
|
||||
async def test_dbus_dns_connect_error(
|
||||
dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test connecting to dns error."""
|
||||
dns_manager = NetworkManagerDNS()
|
||||
await dns_manager.connect(dbus_session_bus)
|
||||
assert "No DnsManager support on the host" in caplog.text
|
||||
|
@@ -9,7 +9,12 @@ import pytest
|
||||
from supervisor.dbus.const import ConnectionStateType
|
||||
from supervisor.dbus.network import NetworkManager
|
||||
from supervisor.dbus.network.interface import NetworkInterface
|
||||
from supervisor.exceptions import DBusFatalError, DBusParseError, HostNotSupportedError
|
||||
from supervisor.exceptions import (
|
||||
DBusFatalError,
|
||||
DBusParseError,
|
||||
DBusServiceUnkownError,
|
||||
HostNotSupportedError,
|
||||
)
|
||||
from supervisor.utils.dbus import DBus
|
||||
|
||||
from tests.const import TEST_INTERFACE, TEST_INTERFACE_WLAN
|
||||
@@ -20,7 +25,7 @@ from tests.dbus_service_mocks.network_manager import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="network_manager_service", autouse=True)
|
||||
@pytest.fixture(name="network_manager_service")
|
||||
async def fixture_network_manager_service(
|
||||
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
||||
) -> NetworkManagerService:
|
||||
@@ -134,6 +139,7 @@ async def test_removed_devices_disconnect(
|
||||
|
||||
|
||||
async def test_handling_bad_devices(
|
||||
network_manager_service: NetworkManagerService,
|
||||
network_manager: NetworkManager,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
capture_exception: Mock,
|
||||
@@ -161,7 +167,7 @@ async def test_handling_bad_devices(
|
||||
await network_manager.update(
|
||||
{"Devices": [device := "/org/freedesktop/NetworkManager/Devices/102"]}
|
||||
)
|
||||
assert f"Error while processing {device}" in caplog.text
|
||||
assert f"Unkown error while processing {device}" in caplog.text
|
||||
capture_exception.assert_called_once_with(err)
|
||||
|
||||
# We should be able to debug these situations if necessary
|
||||
@@ -207,3 +213,37 @@ async def test_ignore_veth_only_changes(
|
||||
)
|
||||
await network_manager_service.ping()
|
||||
connect.assert_called_once()
|
||||
|
||||
|
||||
async def test_network_manager_stopped(
|
||||
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
||||
network_manager: NetworkManager,
|
||||
dbus_session_bus: MessageBus,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
capture_exception: Mock,
|
||||
):
|
||||
"""Test network manager stopped and dbus service no longer accessible."""
|
||||
services = list(network_manager_services.values())
|
||||
while services:
|
||||
service = services.pop(0)
|
||||
if isinstance(service, dict):
|
||||
services.extend(service.values())
|
||||
else:
|
||||
dbus_session_bus.unexport(service.object_path, service)
|
||||
await dbus_session_bus.release_name("org.freedesktop.NetworkManager")
|
||||
|
||||
assert network_manager.is_connected is True
|
||||
await network_manager.update(
|
||||
{
|
||||
"Devices": [
|
||||
"/org/freedesktop/NetworkManager/Devices/9",
|
||||
"/org/freedesktop/NetworkManager/Devices/15",
|
||||
"/org/freedesktop/NetworkManager/Devices/20",
|
||||
"/org/freedesktop/NetworkManager/Devices/35",
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
capture_exception.assert_called_once()
|
||||
assert isinstance(capture_exception.call_args.args[0], DBusServiceUnkownError)
|
||||
assert "NetworkManager not responding" in caplog.text
|
||||
|
@@ -11,7 +11,7 @@ from tests.dbus_service_mocks.network_connection_settings import SETTINGS_FIXTUR
|
||||
from tests.dbus_service_mocks.network_settings import Settings as SettingsService
|
||||
|
||||
|
||||
@pytest.fixture(name="settings_service", autouse=True)
|
||||
@pytest.fixture(name="settings_service")
|
||||
async def fixture_settings_service(dbus_session_bus: MessageBus) -> SettingsService:
|
||||
"""Mock Settings service."""
|
||||
yield (
|
||||
@@ -55,3 +55,12 @@ async def test_reload_connections(
|
||||
|
||||
assert await settings.reload_connections() is True
|
||||
assert settings_service.ReloadConnections.calls == [tuple()]
|
||||
|
||||
|
||||
async def test_dbus_network_settings_connect_error(
|
||||
dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test connecting to network settings error."""
|
||||
settings = NetworkManagerSettings()
|
||||
await settings.connect(dbus_session_bus)
|
||||
assert "No Network Manager Settings support on the host" in caplog.text
|
||||
|
@@ -10,7 +10,7 @@ from tests.common import mock_dbus_services
|
||||
from tests.dbus_service_mocks.hostname import Hostname as HostnameService
|
||||
|
||||
|
||||
@pytest.fixture(name="hostname_service", autouse=True)
|
||||
@pytest.fixture(name="hostname_service")
|
||||
async def fixture_hostname_service(dbus_session_bus: MessageBus) -> HostnameService:
|
||||
"""Mock hostname dbus service."""
|
||||
yield (await mock_dbus_services({"hostname": None}, dbus_session_bus))["hostname"]
|
||||
@@ -61,3 +61,12 @@ async def test_dbus_sethostname(
|
||||
assert hostname_service.SetStaticHostname.calls == [("StarWars", False)]
|
||||
await hostname_service.ping()
|
||||
assert hostname.hostname == "StarWars"
|
||||
|
||||
|
||||
async def test_dbus_hostname_connect_error(
|
||||
dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test connecting to hostname error."""
|
||||
hostname = Hostname()
|
||||
await hostname.connect(dbus_session_bus)
|
||||
assert "No hostname support on the host" in caplog.text
|
||||
|
@@ -18,6 +18,7 @@ from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
class TestInterface(DBusServiceMock):
|
||||
"""Test interface."""
|
||||
|
||||
__test__ = False
|
||||
interface = "service.test.TestInterface"
|
||||
|
||||
def __init__(self, object_path: str = "/service/test/TestInterface"):
|
||||
|
@@ -10,7 +10,7 @@ from tests.common import mock_dbus_services
|
||||
from tests.dbus_service_mocks.logind import Logind as LogindService
|
||||
|
||||
|
||||
@pytest.fixture(name="logind_service", autouse=True)
|
||||
@pytest.fixture(name="logind_service")
|
||||
async def fixture_logind_service(dbus_session_bus: MessageBus) -> LogindService:
|
||||
"""Mock logind dbus service."""
|
||||
yield (await mock_dbus_services({"logind": None}, dbus_session_bus))["logind"]
|
||||
@@ -42,3 +42,12 @@ async def test_power_off(logind_service: LogindService, dbus_session_bus: Messag
|
||||
|
||||
assert await logind.power_off() is None
|
||||
assert logind_service.PowerOff.calls == [(False,)]
|
||||
|
||||
|
||||
async def test_dbus_logind_connect_error(
|
||||
dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test connecting to logind error."""
|
||||
logind = Logind()
|
||||
await logind.connect(dbus_session_bus)
|
||||
assert "No systemd-logind support on the host" in caplog.text
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user