mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-15 12:09:21 +00:00
Compare commits
69 Commits
need-updat
...
2024.08.0
Author | SHA1 | Date | |
---|---|---|---|
![]() |
17ee234be4 | ||
![]() |
61034dfa7b | ||
![]() |
185cd362fb | ||
![]() |
e2ca357774 | ||
![]() |
3dea7fc4e8 | ||
![]() |
01ba591bc9 | ||
![]() |
640b7d46e3 | ||
![]() |
d6560c51ee | ||
![]() |
3e9b1938c6 | ||
![]() |
44ce8de71f | ||
![]() |
0bbd15bfda | ||
![]() |
591b9a4d87 | ||
![]() |
5ee7d16687 | ||
![]() |
4ab4350c58 | ||
![]() |
4ea7133fa8 | ||
![]() |
627d67f9d0 | ||
![]() |
eb37655598 | ||
![]() |
19b62dd0d4 | ||
![]() |
b2ad1ceea3 | ||
![]() |
c1545b5b78 | ||
![]() |
2c2f04ba85 | ||
![]() |
77e7bf51b7 | ||
![]() |
a42d71dcef | ||
![]() |
1ff0432f4d | ||
![]() |
54afd6e1c8 | ||
![]() |
458c493a74 | ||
![]() |
8ac8ecb17e | ||
![]() |
eac167067e | ||
![]() |
aa7f4aafeb | ||
![]() |
d2183fa12b | ||
![]() |
928f32bb4f | ||
![]() |
cbe21303c4 | ||
![]() |
94987c04b8 | ||
![]() |
d4ba46a846 | ||
![]() |
1a22d83895 | ||
![]() |
6b73bf5c28 | ||
![]() |
c9c9451c36 | ||
![]() |
1882d448ea | ||
![]() |
2f11c9c9e3 | ||
![]() |
02bdc4b555 | ||
![]() |
1a1ee50d9d | ||
![]() |
50dc09d1a9 | ||
![]() |
130efd340c | ||
![]() |
00bc13c049 | ||
![]() |
3caad67f61 | ||
![]() |
13783f0d4a | ||
![]() |
eae97ba3f4 | ||
![]() |
134dad7357 | ||
![]() |
1c4d2e8dec | ||
![]() |
f2d7be3aac | ||
![]() |
d06edb2dd6 | ||
![]() |
7fa15b334a | ||
![]() |
ffb4e2d6d7 | ||
![]() |
bd8047ae9c | ||
![]() |
49bc0624af | ||
![]() |
5e1d764eb3 | ||
![]() |
0064d93d75 | ||
![]() |
5a838ecfe7 | ||
![]() |
c37b5effd7 | ||
![]() |
ca7f3e8acb | ||
![]() |
b0cdb91d5e | ||
![]() |
4829eb8ae1 | ||
![]() |
1bb814b793 | ||
![]() |
918fcb7d62 | ||
![]() |
bbfd899564 | ||
![]() |
12c4d9da87 | ||
![]() |
6b4fd9b6b8 | ||
![]() |
07c22f4a60 | ||
![]() |
252e1e2ac0 |
@@ -4,8 +4,12 @@
|
||||
"containerEnv": {
|
||||
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"PATH": "${containerEnv:VIRTUAL_ENV}/bin:${containerEnv:PATH}"
|
||||
},
|
||||
"appPort": ["9123:8123", "7357:4357"],
|
||||
"postCreateCommand": "bash devcontainer_bootstrap",
|
||||
"postCreateCommand": "bash devcontainer_setup",
|
||||
"postStartCommand": "bash devcontainer_bootstrap",
|
||||
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
@@ -19,17 +23,21 @@
|
||||
"GitHub.vscode-pull-request-github"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
|
||||
"python.pythonPath": "/home/vscode/.local/ha-venv/bin/python",
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"zsh": {
|
||||
"path": "/usr/bin/zsh"
|
||||
}
|
||||
},
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"python.pythonPath": "/usr/local/bin/python3",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
|
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
requirements: ${{ steps.requirements.outputs.changed }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Build wheels
|
||||
if: needs.init.outputs.requirements == 'true'
|
||||
uses: home-assistant/wheels@2024.01.0
|
||||
uses: home-assistant/wheels@2024.07.1
|
||||
with:
|
||||
abi: cp312
|
||||
tag: musllinux_1_2
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: docker/login-action@v3.2.0
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Initialize git
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Build the Supervisor
|
||||
if: needs.init.outputs.publish != 'true'
|
||||
|
40
.github/workflows/ci.yaml
vendored
40
.github/workflows/ci.yaml
vendored
@@ -25,10 +25,10 @@ jobs:
|
||||
name: Prepare Python dependencies
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -67,9 +67,9 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -110,9 +110,9 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
@@ -168,9 +168,9 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -212,9 +212,9 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -256,9 +256,9 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -288,9 +288,9 @@ jobs:
|
||||
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -335,7 +335,7 @@ jobs:
|
||||
-o console_output_style=count \
|
||||
tests
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4.3.3
|
||||
uses: actions/upload-artifact@v4.3.5
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: .coverage
|
||||
@@ -346,9 +346,9 @@ jobs:
|
||||
needs: ["pytest", "prepare"]
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.1.0
|
||||
uses: actions/setup-python@v5.1.1
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
echo "Failed to restore Python virtual environment from cache"
|
||||
exit 1
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
- name: Combine coverage results
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -373,4 +373,4 @@ jobs:
|
||||
coverage report
|
||||
coverage xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4.4.1
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
|
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Release Drafter
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
2
.github/workflows/sentry.yaml
vendored
2
.github/workflows/sentry.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.6
|
||||
uses: actions/checkout@v4.1.7
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.7.0
|
||||
env:
|
||||
|
14
Dockerfile
14
Dockerfile
@@ -4,7 +4,8 @@ FROM ${BUILD_FROM}
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=10000 \
|
||||
SUPERVISOR_API=http://localhost \
|
||||
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1
|
||||
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1 \
|
||||
UV_SYSTEM_PYTHON=true
|
||||
|
||||
ARG \
|
||||
COSIGN_VERSION \
|
||||
@@ -26,14 +27,17 @@ RUN \
|
||||
yaml \
|
||||
\
|
||||
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
|
||||
&& chmod a+x /usr/bin/cosign
|
||||
&& chmod a+x /usr/bin/cosign \
|
||||
&& pip3 install uv==0.2.21
|
||||
|
||||
# Install requirements
|
||||
COPY requirements.txt .
|
||||
RUN \
|
||||
export MAKEFLAGS="-j$(nproc)" \
|
||||
&& pip3 install --only-binary=:all: \
|
||||
-r ./requirements.txt \
|
||||
if [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
linux32 uv pip install --no-build -r requirements.txt; \
|
||||
else \
|
||||
uv pip install --no-build -r requirements.txt; \
|
||||
fi \
|
||||
&& rm -f requirements.txt
|
||||
|
||||
# Install Home Assistant Supervisor
|
||||
|
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.12-alpine3.19
|
||||
armhf: ghcr.io/home-assistant/armhf-base-python:3.12-alpine3.19
|
||||
armv7: ghcr.io/home-assistant/armv7-base-python:3.12-alpine3.19
|
||||
amd64: ghcr.io/home-assistant/amd64-base-python:3.12-alpine3.19
|
||||
i386: ghcr.io/home-assistant/i386-base-python:3.12-alpine3.19
|
||||
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.12-alpine3.20
|
||||
armhf: ghcr.io/home-assistant/armhf-base-python:3.12-alpine3.20
|
||||
armv7: ghcr.io/home-assistant/armv7-base-python:3.12-alpine3.20
|
||||
amd64: ghcr.io/home-assistant/amd64-base-python:3.12-alpine3.20
|
||||
i386: ghcr.io/home-assistant/i386-base-python:3.12-alpine3.20
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
@@ -31,7 +31,7 @@ include-package-data = true
|
||||
include = ["supervisor*"]
|
||||
|
||||
[tool.pylint.MAIN]
|
||||
py-version = "3.11"
|
||||
py-version = "3.12"
|
||||
# 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
|
||||
@@ -234,6 +234,7 @@ select = [
|
||||
"B014", # Exception handler with duplicate exception
|
||||
"B023", # Function definition does not bind loop variable {name}
|
||||
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
|
||||
"B904", # Use raise from to specify exception cause
|
||||
"C", # complexity
|
||||
"COM818", # Trailing comma on bare tuple prohibited
|
||||
"D", # docstrings
|
||||
@@ -247,7 +248,6 @@ select = [
|
||||
"N804", # First argument of a class method should be named cls
|
||||
"N805", # First argument of a method should be named self
|
||||
"N815", # Variable {name} in class scope should not be mixedCase
|
||||
"PGH001", # No builtin eval() allowed
|
||||
"PGH004", # Use specific rule codes when using noqa
|
||||
"PLC0414", # Useless import alias. Import alias does not rename original package.
|
||||
"PLC", # pylint
|
||||
@@ -286,7 +286,6 @@ select = [
|
||||
"T20", # flake8-print
|
||||
"TID251", # Banned imports
|
||||
"TRY004", # Prefer TypeError exception for invalid type
|
||||
"TRY200", # Use raise from to specify exception cause
|
||||
"TRY302", # Remove exception handler; error is immediately re-raised
|
||||
"UP", # pyupgrade
|
||||
"W", # pycodestyle
|
||||
|
@@ -1,17 +1,17 @@
|
||||
aiodns==3.2.0
|
||||
aiohttp==3.9.5
|
||||
aiohttp-fast-url-dispatcher==0.3.0
|
||||
aiohttp-fast-url-dispatcher==0.3.1
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==23.2.0
|
||||
awesomeversion==24.2.0
|
||||
attrs==24.1.0
|
||||
awesomeversion==24.6.0
|
||||
brotli==1.1.0
|
||||
ciso8601==2.3.1
|
||||
colorlog==6.8.2
|
||||
cpe==1.2.1
|
||||
cryptography==42.0.8
|
||||
debugpy==1.8.1
|
||||
cryptography==43.0.0
|
||||
debugpy==1.8.2
|
||||
deepmerge==1.1.1
|
||||
dirhash==0.4.0
|
||||
dirhash==0.5.0
|
||||
docker==7.1.0
|
||||
faust-cchardet==2.1.19
|
||||
gitpython==3.1.43
|
||||
@@ -22,9 +22,9 @@ pyudev==0.24.3
|
||||
PyYAML==6.0.1
|
||||
requests==2.32.3
|
||||
securetar==2024.2.1
|
||||
sentry-sdk==2.5.1
|
||||
setuptools==70.0.0
|
||||
voluptuous==0.14.2
|
||||
dbus-fast==2.21.3
|
||||
sentry-sdk==2.10.0
|
||||
setuptools==72.1.0
|
||||
voluptuous==0.15.2
|
||||
dbus-fast==2.22.1
|
||||
typing_extensions==4.12.2
|
||||
zlib-fast==0.2.0
|
||||
|
@@ -1,12 +1,12 @@
|
||||
coverage==7.5.3
|
||||
pre-commit==3.7.1
|
||||
pylint==3.2.3
|
||||
coverage==7.6.1
|
||||
pre-commit==3.8.0
|
||||
pylint==3.2.6
|
||||
pytest-aiohttp==1.0.5
|
||||
pytest-asyncio==0.23.6
|
||||
pytest-cov==5.0.0
|
||||
pytest-timeout==2.3.1
|
||||
pytest==8.2.2
|
||||
ruff==0.4.8
|
||||
time-machine==2.14.1
|
||||
pytest==8.3.2
|
||||
ruff==0.5.5
|
||||
time-machine==2.14.2
|
||||
typing_extensions==4.12.2
|
||||
urllib3==2.2.1
|
||||
urllib3==2.2.2
|
||||
|
@@ -36,6 +36,7 @@ ATTR_DT_UTC = "dt_utc"
|
||||
ATTR_EJECTABLE = "ejectable"
|
||||
ATTR_FALLBACK = "fallback"
|
||||
ATTR_FILESYSTEMS = "filesystems"
|
||||
ATTR_FORCE = "force"
|
||||
ATTR_GROUP_IDS = "group_ids"
|
||||
ATTR_IDENTIFIERS = "identifiers"
|
||||
ATTR_IS_ACTIVE = "is_active"
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
@@ -34,9 +35,9 @@ from ..const import (
|
||||
ATTR_WATCHDOG,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..exceptions import APIDBMigrationInProgress, APIError
|
||||
from ..validate import docker_image, network_port, version_tag
|
||||
from .const import ATTR_SAFE_MODE
|
||||
from .const import ATTR_FORCE, ATTR_SAFE_MODE
|
||||
from .utils import api_process, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -66,6 +67,13 @@ SCHEMA_UPDATE = vol.Schema(
|
||||
SCHEMA_RESTART = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_SAFE_MODE, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_FORCE, default=False): vol.Boolean(),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_STOP = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_FORCE, default=False): vol.Boolean(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,6 +81,17 @@ SCHEMA_RESTART = vol.Schema(
|
||||
class APIHomeAssistant(CoreSysAttributes):
|
||||
"""Handle RESTful API for Home Assistant functions."""
|
||||
|
||||
async def _check_offline_migration(self, force: bool = False) -> None:
|
||||
"""Check and raise if there's an offline DB migration in progress."""
|
||||
if (
|
||||
not force
|
||||
and (state := await self.sys_homeassistant.api.get_api_state())
|
||||
and state.offline_db_migration
|
||||
):
|
||||
raise APIDBMigrationInProgress(
|
||||
"Offline database migration in progress, try again after it has completed"
|
||||
)
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Return host information."""
|
||||
@@ -154,6 +173,7 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
async def update(self, request: web.Request) -> None:
|
||||
"""Update Home Assistant."""
|
||||
body = await api_validate(SCHEMA_UPDATE, request)
|
||||
await self._check_offline_migration()
|
||||
|
||||
await asyncio.shield(
|
||||
self.sys_homeassistant.core.update(
|
||||
@@ -163,9 +183,12 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
)
|
||||
|
||||
@api_process
|
||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||
async def stop(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Stop Home Assistant."""
|
||||
return asyncio.shield(self.sys_homeassistant.core.stop())
|
||||
body = await api_validate(SCHEMA_STOP, request)
|
||||
await self._check_offline_migration(force=body[ATTR_FORCE])
|
||||
|
||||
return await asyncio.shield(self.sys_homeassistant.core.stop())
|
||||
|
||||
@api_process
|
||||
def start(self, request: web.Request) -> Awaitable[None]:
|
||||
@@ -176,6 +199,7 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
async def restart(self, request: web.Request) -> None:
|
||||
"""Restart Home Assistant."""
|
||||
body = await api_validate(SCHEMA_RESTART, request)
|
||||
await self._check_offline_migration(force=body[ATTR_FORCE])
|
||||
|
||||
await asyncio.shield(
|
||||
self.sys_homeassistant.core.restart(safe_mode=body[ATTR_SAFE_MODE])
|
||||
@@ -185,6 +209,7 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
async def rebuild(self, request: web.Request) -> None:
|
||||
"""Rebuild Home Assistant."""
|
||||
body = await api_validate(SCHEMA_RESTART, request)
|
||||
await self._check_offline_migration(force=body[ATTR_FORCE])
|
||||
|
||||
await asyncio.shield(
|
||||
self.sys_homeassistant.core.rebuild(safe_mode=body[ATTR_SAFE_MODE])
|
||||
|
@@ -28,7 +28,7 @@ from ..const import (
|
||||
ATTR_TIMEZONE,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError, HostLogError
|
||||
from ..exceptions import APIDBMigrationInProgress, APIError, HostLogError
|
||||
from ..host.const import (
|
||||
PARAM_BOOT_ID,
|
||||
PARAM_FOLLOW,
|
||||
@@ -46,6 +46,7 @@ from .const import (
|
||||
ATTR_BROADCAST_MDNS,
|
||||
ATTR_DT_SYNCHRONIZED,
|
||||
ATTR_DT_UTC,
|
||||
ATTR_FORCE,
|
||||
ATTR_IDENTIFIERS,
|
||||
ATTR_LLMNR_HOSTNAME,
|
||||
ATTR_STARTUP_TIME,
|
||||
@@ -64,10 +65,29 @@ DEFAULT_RANGE = 100
|
||||
|
||||
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str})
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_SHUTDOWN = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_FORCE, default=False): vol.Boolean(),
|
||||
}
|
||||
)
|
||||
# pylint: enable=no-value-for-parameter
|
||||
|
||||
|
||||
class APIHost(CoreSysAttributes):
|
||||
"""Handle RESTful API for host functions."""
|
||||
|
||||
async def _check_ha_offline_migration(self, force: bool) -> None:
|
||||
"""Check if HA has an offline migration in progress and raise if not forced."""
|
||||
if (
|
||||
not force
|
||||
and (state := await self.sys_homeassistant.api.get_api_state())
|
||||
and state.offline_db_migration
|
||||
):
|
||||
raise APIDBMigrationInProgress(
|
||||
"Home Assistant offline database migration in progress, please wait until complete before shutting down host"
|
||||
)
|
||||
|
||||
@api_process
|
||||
async def info(self, request):
|
||||
"""Return host information."""
|
||||
@@ -109,14 +129,20 @@ class APIHost(CoreSysAttributes):
|
||||
)
|
||||
|
||||
@api_process
|
||||
def reboot(self, request):
|
||||
async def reboot(self, request):
|
||||
"""Reboot host."""
|
||||
return asyncio.shield(self.sys_host.control.reboot())
|
||||
body = await api_validate(SCHEMA_SHUTDOWN, request)
|
||||
await self._check_ha_offline_migration(force=body[ATTR_FORCE])
|
||||
|
||||
return await asyncio.shield(self.sys_host.control.reboot())
|
||||
|
||||
@api_process
|
||||
def shutdown(self, request):
|
||||
async def shutdown(self, request):
|
||||
"""Poweroff host."""
|
||||
return asyncio.shield(self.sys_host.control.shutdown())
|
||||
body = await api_validate(SCHEMA_SHUTDOWN, request)
|
||||
await self._check_ha_offline_migration(force=body[ATTR_FORCE])
|
||||
|
||||
return await asyncio.shield(self.sys_host.control.shutdown())
|
||||
|
||||
@api_process
|
||||
def reload(self, request):
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Backup manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -259,11 +260,6 @@ class BackupManager(FileConfiguration, JobGroup):
|
||||
self.sys_core.state = CoreState.FREEZE
|
||||
|
||||
async with backup:
|
||||
# Backup add-ons
|
||||
if addon_list:
|
||||
self._change_stage(BackupJobStage.ADDONS, backup)
|
||||
addon_start_tasks = await backup.store_addons(addon_list)
|
||||
|
||||
# HomeAssistant Folder is for v1
|
||||
if homeassistant:
|
||||
self._change_stage(BackupJobStage.HOME_ASSISTANT, backup)
|
||||
@@ -273,6 +269,11 @@ class BackupManager(FileConfiguration, JobGroup):
|
||||
else homeassistant_exclude_database
|
||||
)
|
||||
|
||||
# Backup add-ons
|
||||
if addon_list:
|
||||
self._change_stage(BackupJobStage.ADDONS, backup)
|
||||
addon_start_tasks = await backup.store_addons(addon_list)
|
||||
|
||||
# Backup folders
|
||||
if folder_list:
|
||||
self._change_stage(BackupJobStage.FOLDERS, backup)
|
||||
|
@@ -113,8 +113,11 @@ class CoreSys:
|
||||
"""Return system timezone."""
|
||||
if self.config.timezone:
|
||||
return self.config.timezone
|
||||
# pylint bug with python 3.12.4 (https://github.com/pylint-dev/pylint/issues/9811)
|
||||
# pylint: disable=no-member
|
||||
if self.host.info.timezone:
|
||||
return self.host.info.timezone
|
||||
# pylint: enable=no-member
|
||||
return "UTC"
|
||||
|
||||
@property
|
||||
|
@@ -44,6 +44,7 @@ IPV4_6_IGNORE_FIELDS = [
|
||||
"addresses",
|
||||
"address-data",
|
||||
"dns",
|
||||
"dns-data",
|
||||
"gateway",
|
||||
"method",
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Core Exceptions."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
@@ -339,6 +340,12 @@ class APIAddonNotInstalled(APIError):
|
||||
"""Not installed addon requested at addons API."""
|
||||
|
||||
|
||||
class APIDBMigrationInProgress(APIError):
|
||||
"""Service is unavailable due to an offline DB migration is in progress."""
|
||||
|
||||
status = 503
|
||||
|
||||
|
||||
# Service / Discovery
|
||||
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Home Assistant control object."""
|
||||
import asyncio
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -21,6 +22,14 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
GET_CORE_STATE_MIN_VERSION: AwesomeVersion = AwesomeVersion("2023.8.0.dev20230720")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class APIState:
|
||||
"""Container for API state response."""
|
||||
|
||||
core_state: str
|
||||
offline_db_migration: bool
|
||||
|
||||
|
||||
class HomeAssistantAPI(CoreSysAttributes):
|
||||
"""Home Assistant core object for handle it."""
|
||||
|
||||
@@ -132,7 +141,7 @@ class HomeAssistantAPI(CoreSysAttributes):
|
||||
"""Return Home Assistant core state."""
|
||||
return await self._get_json("api/core/state")
|
||||
|
||||
async def get_api_state(self) -> str | None:
|
||||
async def get_api_state(self) -> APIState | None:
|
||||
"""Return state of Home Assistant Core or None."""
|
||||
# Skip check on landingpage
|
||||
if (
|
||||
@@ -161,12 +170,17 @@ class HomeAssistantAPI(CoreSysAttributes):
|
||||
data = await self.get_config()
|
||||
# Older versions of home assistant does not expose the state
|
||||
if data:
|
||||
return data.get("state", "RUNNING")
|
||||
state = data.get("state", "RUNNING")
|
||||
# Recorder state was added in HA Core 2024.8
|
||||
recorder_state = data.get("recorder_state", {})
|
||||
migrating = recorder_state.get("migration_in_progress", False)
|
||||
live_migration = recorder_state.get("migration_is_live", False)
|
||||
return APIState(state, migrating and not live_migration)
|
||||
|
||||
return None
|
||||
|
||||
async def check_api_state(self) -> bool:
|
||||
"""Return Home Assistant Core state if up."""
|
||||
if state := await self.get_api_state():
|
||||
return state == "RUNNING"
|
||||
return state.core_state == "RUNNING" or state.offline_db_migration
|
||||
return False
|
||||
|
@@ -7,7 +7,9 @@ from awesomeversion import AwesomeVersion
|
||||
|
||||
from ..const import CoreState
|
||||
|
||||
ATTR_ERROR = "error"
|
||||
ATTR_OVERRIDE_IMAGE = "override_image"
|
||||
ATTR_SUCCESS = "success"
|
||||
LANDINGPAGE: AwesomeVersion = AwesomeVersion("landingpage")
|
||||
WATCHDOG_RETRY_SECONDS = 10
|
||||
WATCHDOG_MAX_ATTEMPTS = 5
|
||||
|
@@ -49,6 +49,10 @@ SECONDS_BETWEEN_API_CHECKS: Final[int] = 5
|
||||
STARTUP_API_RESPONSE_TIMEOUT: Final[timedelta] = timedelta(minutes=3)
|
||||
# All stages plus event start timeout and some wiggle rooom
|
||||
STARTUP_API_CHECK_RUNNING_TIMEOUT: Final[timedelta] = timedelta(minutes=15)
|
||||
# While database migration is running, the timeout will be extended
|
||||
DATABASE_MIGRATION_TIMEOUT: Final[timedelta] = timedelta(
|
||||
seconds=SECONDS_BETWEEN_API_CHECKS * 10
|
||||
)
|
||||
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
|
||||
|
||||
|
||||
@@ -490,11 +494,15 @@ class HomeAssistantCore(JobGroup):
|
||||
_LOGGER.info("Home Assistant Core state changed to %s", state)
|
||||
last_state = state
|
||||
|
||||
if state == "RUNNING":
|
||||
if state.core_state == "RUNNING":
|
||||
_LOGGER.info("Detect a running Home Assistant instance")
|
||||
self._error_state = False
|
||||
return
|
||||
|
||||
if state.offline_db_migration:
|
||||
# Keep extended the deadline while database migration is active
|
||||
deadline = datetime.now() + DATABASE_MIGRATION_TIMEOUT
|
||||
|
||||
self._error_state = True
|
||||
if timeout:
|
||||
raise HomeAssistantStartupTimeout(
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Home Assistant control object."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import errno
|
||||
@@ -22,6 +23,7 @@ from ..const import (
|
||||
ATTR_BACKUPS_EXCLUDE_DATABASE,
|
||||
ATTR_BOOT,
|
||||
ATTR_IMAGE,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_PORT,
|
||||
ATTR_REFRESH_TOKEN,
|
||||
ATTR_SSL,
|
||||
@@ -48,7 +50,7 @@ from ..utils import remove_folder
|
||||
from ..utils.common import FileConfiguration
|
||||
from ..utils.json import read_json_file, write_json_file
|
||||
from .api import HomeAssistantAPI
|
||||
from .const import ATTR_OVERRIDE_IMAGE, LANDINGPAGE, WSType
|
||||
from .const import ATTR_ERROR, ATTR_OVERRIDE_IMAGE, ATTR_SUCCESS, LANDINGPAGE, WSType
|
||||
from .core import HomeAssistantCore
|
||||
from .secrets import HomeAssistantSecrets
|
||||
from .validate import SCHEMA_HASS_CONFIG
|
||||
@@ -345,21 +347,38 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
|
||||
async def begin_backup(self) -> None:
|
||||
"""Inform Home Assistant a backup is beginning."""
|
||||
try:
|
||||
await self.websocket.async_send_command({ATTR_TYPE: WSType.BACKUP_START})
|
||||
except HomeAssistantWSError:
|
||||
_LOGGER.warning(
|
||||
"Preparing backup of Home Assistant Core failed. Check HA Core logs."
|
||||
resp = await self.websocket.async_send_command(
|
||||
{ATTR_TYPE: WSType.BACKUP_START}
|
||||
)
|
||||
except HomeAssistantWSError as err:
|
||||
raise HomeAssistantBackupError(
|
||||
"Preparing backup of Home Assistant Core failed. Check HA Core logs.",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
if resp and not resp.get(ATTR_SUCCESS):
|
||||
raise HomeAssistantBackupError(
|
||||
f"Preparing backup of Home Assistant Core failed due to: {resp.get(ATTR_ERROR, {}).get(ATTR_MESSAGE, "")}. Check HA Core logs.",
|
||||
_LOGGER.error,
|
||||
)
|
||||
|
||||
@Job(name="home_assistant_module_end_backup")
|
||||
async def end_backup(self) -> None:
|
||||
"""Inform Home Assistant the backup is ending."""
|
||||
try:
|
||||
await self.websocket.async_send_command({ATTR_TYPE: WSType.BACKUP_END})
|
||||
resp = await self.websocket.async_send_command(
|
||||
{ATTR_TYPE: WSType.BACKUP_END}
|
||||
)
|
||||
except HomeAssistantWSError:
|
||||
_LOGGER.warning(
|
||||
"Error during Home Assistant Core backup. Check HA Core logs."
|
||||
"Error resuming normal operations after backup of Home Assistant Core. Check HA Core logs."
|
||||
)
|
||||
else:
|
||||
if resp and not resp.get(ATTR_SUCCESS):
|
||||
_LOGGER.warning(
|
||||
"Error resuming normal operations after backup of Home Assistant Core due to: %s. Check HA Core logs.",
|
||||
resp.get(ATTR_ERROR, {}).get(ATTR_MESSAGE, ""),
|
||||
)
|
||||
|
||||
@Job(name="home_assistant_module_backup")
|
||||
async def backup(
|
||||
|
@@ -191,7 +191,7 @@ class Interface:
|
||||
mode = WifiMode(inet.settings.wireless.mode)
|
||||
|
||||
# Signal
|
||||
if inet.wireless:
|
||||
if inet.wireless and inet.wireless.active:
|
||||
signal = inet.wireless.active.strength
|
||||
else:
|
||||
signal = None
|
||||
|
@@ -174,17 +174,6 @@ class Tasks(CoreSysAttributes):
|
||||
self._cache[HASS_WATCHDOG_API_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 Core, failed all %s attempts.",
|
||||
reanimate_fails,
|
||||
)
|
||||
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] += 1
|
||||
return
|
||||
|
||||
# Init cache data
|
||||
api_fails = self._cache.get(HASS_WATCHDOG_API_FAILURES, 0)
|
||||
|
||||
@@ -195,16 +184,38 @@ class Tasks(CoreSysAttributes):
|
||||
_LOGGER.warning("Watchdog missed an Home Assistant Core API response.")
|
||||
return
|
||||
|
||||
_LOGGER.error(
|
||||
"Watchdog missed %s Home Assistant Core API responses in a row. Restarting Home Assistant Core API!",
|
||||
HASS_WATCHDOG_MAX_API_ATTEMPTS,
|
||||
)
|
||||
# After 5 reanimation attempts switch to safe mode. If that fails, give up
|
||||
reanimate_fails = self._cache.get(HASS_WATCHDOG_REANIMATE_FAILURES, 0)
|
||||
if reanimate_fails > HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS:
|
||||
return
|
||||
|
||||
if safe_mode := reanimate_fails == HASS_WATCHDOG_MAX_REANIMATE_ATTEMPTS:
|
||||
_LOGGER.critical(
|
||||
"Watchdog cannot reanimate Home Assistant Core, failed all %s attempts. Restarting into safe mode",
|
||||
reanimate_fails,
|
||||
)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Watchdog missed %s Home Assistant Core API responses in a row. Restarting Home Assistant Core!",
|
||||
HASS_WATCHDOG_MAX_API_ATTEMPTS,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.sys_homeassistant.core.restart()
|
||||
if safe_mode:
|
||||
await self.sys_homeassistant.core.rebuild(safe_mode=True)
|
||||
else:
|
||||
await self.sys_homeassistant.core.restart()
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error("Home Assistant watchdog reanimation failed!")
|
||||
if reanimate_fails == 0:
|
||||
if reanimate_fails == 0 or safe_mode:
|
||||
capture_exception(err)
|
||||
|
||||
if safe_mode:
|
||||
_LOGGER.critical(
|
||||
"Safe mode restart failed. Watchdog cannot bring Home Assistant online."
|
||||
)
|
||||
else:
|
||||
_LOGGER.error("Home Assistant watchdog reanimation failed!")
|
||||
|
||||
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = reanimate_fails + 1
|
||||
else:
|
||||
self._cache[HASS_WATCHDOG_REANIMATE_FAILURES] = 0
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
Code: https://github.com/home-assistant/plugin-audio
|
||||
"""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
from pathlib import Path, PurePath
|
||||
@@ -73,7 +74,9 @@ class PluginAudio(PluginBase):
|
||||
@property
|
||||
def default_image(self) -> str:
|
||||
"""Return default image for audio plugin."""
|
||||
return self.sys_updater.image_audio
|
||||
if self.sys_updater.image_audio:
|
||||
return self.sys_updater.image_audio
|
||||
return super().default_image
|
||||
|
||||
@property
|
||||
def latest_version(self) -> AwesomeVersion | None:
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
Code: https://github.com/home-assistant/plugin-cli
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
import secrets
|
||||
@@ -42,7 +43,9 @@ class PluginCli(PluginBase):
|
||||
@property
|
||||
def default_image(self) -> str:
|
||||
"""Return default image for cli plugin."""
|
||||
return self.sys_updater.image_cli
|
||||
if self.sys_updater.image_cli:
|
||||
return self.sys_updater.image_cli
|
||||
return super().default_image
|
||||
|
||||
@property
|
||||
def latest_version(self) -> AwesomeVersion | None:
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
Code: https://github.com/home-assistant/plugin-dns
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import errno
|
||||
@@ -111,7 +112,9 @@ class PluginDns(PluginBase):
|
||||
@property
|
||||
def default_image(self) -> str:
|
||||
"""Return default image for dns plugin."""
|
||||
return self.sys_updater.image_dns
|
||||
if self.sys_updater.image_dns:
|
||||
return self.sys_updater.image_dns
|
||||
return super().default_image
|
||||
|
||||
@property
|
||||
def latest_version(self) -> AwesomeVersion | None:
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
Code: https://github.com/home-assistant/plugin-multicast
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -44,7 +45,9 @@ class PluginMulticast(PluginBase):
|
||||
@property
|
||||
def default_image(self) -> str:
|
||||
"""Return default image for multicast plugin."""
|
||||
return self.sys_updater.image_multicast
|
||||
if self.sys_updater.image_multicast:
|
||||
return self.sys_updater.image_multicast
|
||||
return super().default_image
|
||||
|
||||
@property
|
||||
def latest_version(self) -> AwesomeVersion | None:
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
Code: https://github.com/home-assistant/plugin-observer
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
@@ -47,7 +48,9 @@ class PluginObserver(PluginBase):
|
||||
@property
|
||||
def default_image(self) -> str:
|
||||
"""Return default image for observer plugin."""
|
||||
return self.sys_updater.image_observer
|
||||
if self.sys_updater.image_observer:
|
||||
return self.sys_updater.image_observer
|
||||
return super().default_image
|
||||
|
||||
@property
|
||||
def latest_version(self) -> AwesomeVersion | None:
|
||||
|
@@ -26,7 +26,7 @@ class FixupAddonExecuteRemove(FixupBase):
|
||||
# Remove addon
|
||||
_LOGGER.info("Remove addon: %s", reference)
|
||||
try:
|
||||
addon.uninstall()
|
||||
await addon.uninstall(remove_config=False)
|
||||
except AddonsError as err:
|
||||
_LOGGER.error("Could not remove %s due to %s", reference, err)
|
||||
raise ResolutionFixupError() from None
|
||||
|
@@ -8,11 +8,13 @@ from .const import StoreType
|
||||
|
||||
URL_COMMUNITY_ADDONS = "https://github.com/hassio-addons/repository"
|
||||
URL_ESPHOME = "https://github.com/esphome/home-assistant-addon"
|
||||
URL_MUSIC_ASSISTANT = "https://github.com/music-assistant/home-assistant-addon"
|
||||
BUILTIN_REPOSITORIES = {
|
||||
StoreType.CORE,
|
||||
StoreType.LOCAL,
|
||||
URL_COMMUNITY_ADDONS,
|
||||
URL_ESPHOME,
|
||||
URL_MUSIC_ASSISTANT,
|
||||
}
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
|
@@ -348,15 +348,15 @@ async def test_api_backup_errors(
|
||||
assert job["done"] is True
|
||||
assert job["reference"] == slug
|
||||
assert job["errors"] == []
|
||||
assert job["child_jobs"][0]["name"] == "backup_store_addons"
|
||||
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
|
||||
assert job["child_jobs"][0]["reference"] == slug
|
||||
assert job["child_jobs"][0]["child_jobs"][0]["name"] == "backup_addon_save"
|
||||
assert job["child_jobs"][0]["child_jobs"][0]["reference"] == "local_ssh"
|
||||
assert job["child_jobs"][0]["child_jobs"][0]["errors"] == [
|
||||
assert job["child_jobs"][1]["name"] == "backup_store_addons"
|
||||
assert job["child_jobs"][1]["reference"] == slug
|
||||
assert job["child_jobs"][1]["child_jobs"][0]["name"] == "backup_addon_save"
|
||||
assert job["child_jobs"][1]["child_jobs"][0]["reference"] == "local_ssh"
|
||||
assert job["child_jobs"][1]["child_jobs"][0]["errors"] == [
|
||||
{"type": "BackupError", "message": "Can't create backup for local_ssh"}
|
||||
]
|
||||
assert job["child_jobs"][1]["name"] == "backup_store_homeassistant"
|
||||
assert job["child_jobs"][1]["reference"] == slug
|
||||
assert job["child_jobs"][2]["name"] == "backup_store_folders"
|
||||
assert job["child_jobs"][2]["reference"] == slug
|
||||
assert {j["reference"] for j in job["child_jobs"][2]["child_jobs"]} == {
|
||||
@@ -366,9 +366,14 @@ async def test_api_backup_errors(
|
||||
"ssl",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
HomeAssistant, "backup", side_effect=HomeAssistantBackupError("Backup error")
|
||||
), patch.object(Addon, "backup"):
|
||||
with (
|
||||
patch.object(
|
||||
HomeAssistant,
|
||||
"backup",
|
||||
side_effect=HomeAssistantBackupError("Backup error"),
|
||||
),
|
||||
patch.object(Addon, "backup"),
|
||||
):
|
||||
resp = await api_client.post(
|
||||
f"/backups/new/{backup_type}",
|
||||
json={"name": f"{backup_type} backup"} | options,
|
||||
@@ -384,10 +389,9 @@ async def test_api_backup_errors(
|
||||
assert job["errors"] == (
|
||||
err := [{"type": "HomeAssistantBackupError", "message": "Backup error"}]
|
||||
)
|
||||
assert job["child_jobs"][0]["name"] == "backup_store_addons"
|
||||
assert job["child_jobs"][1]["name"] == "backup_store_homeassistant"
|
||||
assert job["child_jobs"][1]["errors"] == err
|
||||
assert len(job["child_jobs"]) == 2
|
||||
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
|
||||
assert job["child_jobs"][0]["errors"] == err
|
||||
assert len(job["child_jobs"]) == 1
|
||||
|
||||
|
||||
async def test_backup_immediate_errors(api_client: TestClient, coresys: CoreSys):
|
||||
@@ -426,14 +430,17 @@ async def test_restore_immediate_errors(
|
||||
assert resp.status == 400
|
||||
assert "only a partial backup" in (await resp.json())["message"]
|
||||
|
||||
with patch.object(
|
||||
Backup,
|
||||
"supervisor_version",
|
||||
new=PropertyMock(return_value=AwesomeVersion("2024.01.0")),
|
||||
), patch.object(
|
||||
Supervisor,
|
||||
"version",
|
||||
new=PropertyMock(return_value=AwesomeVersion("2023.12.0")),
|
||||
with (
|
||||
patch.object(
|
||||
Backup,
|
||||
"supervisor_version",
|
||||
new=PropertyMock(return_value=AwesomeVersion("2024.01.0")),
|
||||
),
|
||||
patch.object(
|
||||
Supervisor,
|
||||
"version",
|
||||
new=PropertyMock(return_value=AwesomeVersion("2023.12.0")),
|
||||
),
|
||||
):
|
||||
resp = await api_client.post(
|
||||
f"/backups/{mock_partial_backup.slug}/restore/partial",
|
||||
@@ -442,9 +449,10 @@ async def test_restore_immediate_errors(
|
||||
assert resp.status == 400
|
||||
assert "Must update supervisor" in (await resp.json())["message"]
|
||||
|
||||
with patch.object(
|
||||
Backup, "protected", new=PropertyMock(return_value=True)
|
||||
), patch.object(Backup, "set_password", return_value=False):
|
||||
with (
|
||||
patch.object(Backup, "protected", new=PropertyMock(return_value=True)),
|
||||
patch.object(Backup, "set_password", return_value=False),
|
||||
):
|
||||
resp = await api_client.post(
|
||||
f"/backups/{mock_partial_backup.slug}/restore/partial",
|
||||
json={"background": True, "homeassistant": True},
|
||||
|
@@ -8,6 +8,7 @@ from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.homeassistant.api import APIState
|
||||
from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
|
||||
@@ -142,3 +143,48 @@ async def test_api_rebuild(
|
||||
assert container.remove.call_count == 4
|
||||
assert container.start.call_count == 2
|
||||
assert safe_mode_marker.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["rebuild", "restart", "stop", "update"])
|
||||
async def test_migration_blocks_stopping_core(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
action: str,
|
||||
):
|
||||
"""Test that an offline db migration in progress stops users from stopping/restarting core."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
resp = await api_client.post(f"/homeassistant/{action}")
|
||||
assert resp.status == 503
|
||||
result = await resp.json()
|
||||
assert (
|
||||
result["message"]
|
||||
== "Offline database migration in progress, try again after it has completed"
|
||||
)
|
||||
|
||||
|
||||
async def test_force_rebuild_during_migration(api_client: TestClient, coresys: CoreSys):
|
||||
"""Test force option rebuilds even during a migration."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
with patch.object(HomeAssistantCore, "rebuild") as rebuild:
|
||||
await api_client.post("/homeassistant/rebuild", json={"force": True})
|
||||
rebuild.assert_called_once()
|
||||
|
||||
|
||||
async def test_force_restart_during_migration(api_client: TestClient, coresys: CoreSys):
|
||||
"""Test force option restarts even during a migration."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
with patch.object(HomeAssistantCore, "restart") as restart:
|
||||
await api_client.post("/homeassistant/restart", json={"force": True})
|
||||
restart.assert_called_once()
|
||||
|
||||
|
||||
async def test_force_stop_during_migration(api_client: TestClient, coresys: CoreSys):
|
||||
"""Test force option stops even during a migration."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
with patch.object(HomeAssistantCore, "stop") as stop:
|
||||
await api_client.post("/homeassistant/stop", json={"force": True})
|
||||
stop.assert_called_once()
|
||||
|
@@ -1,13 +1,15 @@
|
||||
"""Test Host API."""
|
||||
|
||||
from unittest.mock import ANY, MagicMock
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.dbus.resolved import Resolved
|
||||
from supervisor.homeassistant.api import APIState
|
||||
from supervisor.host.const import LogFormat, LogFormatter
|
||||
from supervisor.host.control import SystemControl
|
||||
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.systemd import Systemd as SystemdService
|
||||
@@ -324,3 +326,41 @@ async def test_advanced_logs_errors(api_client: TestClient):
|
||||
content
|
||||
== "Invalid content type requested. Only text/plain and text/x-log supported for now."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["reboot", "shutdown"])
|
||||
async def test_migration_blocks_shutdown(
|
||||
api_client: TestClient,
|
||||
coresys: CoreSys,
|
||||
action: str,
|
||||
):
|
||||
"""Test that an offline db migration in progress stops users from shuting down or rebooting system."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
resp = await api_client.post(f"/host/{action}")
|
||||
assert resp.status == 503
|
||||
result = await resp.json()
|
||||
assert (
|
||||
result["message"]
|
||||
== "Home Assistant offline database migration in progress, please wait until complete before shutting down host"
|
||||
)
|
||||
|
||||
|
||||
async def test_force_reboot_during_migration(api_client: TestClient, coresys: CoreSys):
|
||||
"""Test force option reboots even during a migration."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
with patch.object(SystemControl, "reboot") as reboot:
|
||||
await api_client.post("/host/reboot", json={"force": True})
|
||||
reboot.assert_called_once()
|
||||
|
||||
|
||||
async def test_force_shutdown_during_migration(
|
||||
api_client: TestClient, coresys: CoreSys
|
||||
):
|
||||
"""Test force option shutdown even during a migration."""
|
||||
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
|
||||
|
||||
with patch.object(SystemControl, "shutdown") as shutdown:
|
||||
await api_client.post("/host/shutdown", json={"force": True})
|
||||
shutdown.assert_called_once()
|
||||
|
@@ -31,6 +31,7 @@ from supervisor.exceptions import (
|
||||
DockerError,
|
||||
)
|
||||
from supervisor.homeassistant.api import HomeAssistantAPI
|
||||
from supervisor.homeassistant.const import WSType
|
||||
from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.jobs.const import JobCondition
|
||||
@@ -335,9 +336,14 @@ async def test_fail_invalid_full_backup(
|
||||
|
||||
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")
|
||||
), pytest.raises(BackupInvalidError):
|
||||
with (
|
||||
patch.object(
|
||||
type(coresys.supervisor),
|
||||
"version",
|
||||
new=PropertyMock(return_value="2022.08.3"),
|
||||
),
|
||||
pytest.raises(BackupInvalidError),
|
||||
):
|
||||
await manager.do_restore_full(backup_instance)
|
||||
|
||||
|
||||
@@ -364,9 +370,14 @@ async def test_fail_invalid_partial_backup(
|
||||
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")
|
||||
), pytest.raises(BackupInvalidError):
|
||||
with (
|
||||
patch.object(
|
||||
type(coresys.supervisor),
|
||||
"version",
|
||||
new=PropertyMock(return_value="2022.08.3"),
|
||||
),
|
||||
pytest.raises(BackupInvalidError),
|
||||
):
|
||||
await manager.do_restore_partial(backup_instance)
|
||||
|
||||
|
||||
@@ -766,7 +777,11 @@ async def test_backup_to_local_with_default(
|
||||
|
||||
|
||||
async def test_backup_to_default(
|
||||
coresys: CoreSys, tmp_supervisor_data, path_extern, mount_propagation, mock_is_mount
|
||||
coresys: CoreSys,
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
mount_propagation,
|
||||
mock_is_mount,
|
||||
):
|
||||
"""Test making backup to default mount."""
|
||||
# Add a default backup mount
|
||||
@@ -926,9 +941,15 @@ async def test_backup_with_healthcheck(
|
||||
nonlocal _container_events_task
|
||||
_container_events_task = asyncio.create_task(container_events())
|
||||
|
||||
with patch.object(DockerAddon, "run", new=container_events_task), patch.object(
|
||||
AddonModel, "backup_mode", new=PropertyMock(return_value=AddonBackupMode.COLD)
|
||||
), patch.object(DockerAddon, "is_running", side_effect=[True, False, False]):
|
||||
with (
|
||||
patch.object(DockerAddon, "run", new=container_events_task),
|
||||
patch.object(
|
||||
AddonModel,
|
||||
"backup_mode",
|
||||
new=PropertyMock(return_value=AddonBackupMode.COLD),
|
||||
),
|
||||
patch.object(DockerAddon, "is_running", side_effect=[True, False, False]),
|
||||
):
|
||||
backup = await coresys.backups.do_backup_partial(
|
||||
homeassistant=False, addons=["local_ssh"]
|
||||
)
|
||||
@@ -1000,10 +1021,11 @@ async def test_restore_with_healthcheck(
|
||||
nonlocal _container_events_task
|
||||
_container_events_task = asyncio.create_task(container_events())
|
||||
|
||||
with patch.object(DockerAddon, "run", new=container_events_task), patch.object(
|
||||
DockerAddon, "is_running", return_value=False
|
||||
), patch.object(AddonModel, "_validate_availability"), patch.object(
|
||||
Addon, "with_ingress", new=PropertyMock(return_value=False)
|
||||
with (
|
||||
patch.object(DockerAddon, "run", new=container_events_task),
|
||||
patch.object(DockerAddon, "is_running", return_value=False),
|
||||
patch.object(AddonModel, "_validate_availability"),
|
||||
patch.object(Addon, "with_ingress", new=PropertyMock(return_value=False)),
|
||||
):
|
||||
await coresys.backups.do_restore_partial(backup, addons=["local_ssh"])
|
||||
|
||||
@@ -1054,16 +1076,22 @@ async def test_backup_progress(
|
||||
coresys.core.state = CoreState.RUNNING
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
|
||||
with patch.object(
|
||||
AddonModel, "backup_mode", new=PropertyMock(return_value=AddonBackupMode.COLD)
|
||||
), patch("supervisor.addons.addon.asyncio.Event.wait"):
|
||||
with (
|
||||
patch.object(
|
||||
AddonModel,
|
||||
"backup_mode",
|
||||
new=PropertyMock(return_value=AddonBackupMode.COLD),
|
||||
),
|
||||
patch("supervisor.addons.addon.asyncio.Event.wait"),
|
||||
):
|
||||
full_backup: Backup = await coresys.backups.do_backup_full()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
messages = [
|
||||
call.args[0]
|
||||
for call in ha_ws_client.async_send_command.call_args_list
|
||||
if call.args[0]["data"].get("data", {}).get("name")
|
||||
if call.args[0]["type"] == WSType.SUPERVISOR_EVENT
|
||||
and call.args[0]["data"].get("data", {}).get("name")
|
||||
== "backup_manager_full_backup"
|
||||
]
|
||||
assert messages == [
|
||||
@@ -1075,10 +1103,10 @@ async def test_backup_progress(
|
||||
_make_backup_message_for_assert(
|
||||
reference=full_backup.slug, stage="docker_config"
|
||||
),
|
||||
_make_backup_message_for_assert(reference=full_backup.slug, stage="addons"),
|
||||
_make_backup_message_for_assert(
|
||||
reference=full_backup.slug, stage="home_assistant"
|
||||
),
|
||||
_make_backup_message_for_assert(reference=full_backup.slug, stage="addons"),
|
||||
_make_backup_message_for_assert(reference=full_backup.slug, stage="folders"),
|
||||
_make_backup_message_for_assert(
|
||||
reference=full_backup.slug, stage="finishing_file"
|
||||
@@ -1100,7 +1128,8 @@ async def test_backup_progress(
|
||||
messages = [
|
||||
call.args[0]
|
||||
for call in ha_ws_client.async_send_command.call_args_list
|
||||
if call.args[0]["data"].get("data", {}).get("name")
|
||||
if call.args[0]["type"] == WSType.SUPERVISOR_EVENT
|
||||
and call.args[0]["data"].get("data", {}).get("name")
|
||||
== "backup_manager_partial_backup"
|
||||
]
|
||||
assert messages == [
|
||||
@@ -1162,18 +1191,21 @@ async def test_restore_progress(
|
||||
|
||||
# Install another addon to be uninstalled
|
||||
request.getfixturevalue("install_addon_example")
|
||||
with patch("supervisor.addons.addon.asyncio.Event.wait"), patch.object(
|
||||
HomeAssistant, "restore"
|
||||
), patch.object(HomeAssistantCore, "update"), patch.object(
|
||||
AddonModel, "_validate_availability"
|
||||
), patch.object(AddonModel, "with_ingress", new=PropertyMock(return_value=False)):
|
||||
with (
|
||||
patch("supervisor.addons.addon.asyncio.Event.wait"),
|
||||
patch.object(HomeAssistant, "restore"),
|
||||
patch.object(HomeAssistantCore, "update"),
|
||||
patch.object(AddonModel, "_validate_availability"),
|
||||
patch.object(AddonModel, "with_ingress", new=PropertyMock(return_value=False)),
|
||||
):
|
||||
await coresys.backups.do_restore_full(full_backup)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
messages = [
|
||||
call.args[0]
|
||||
for call in ha_ws_client.async_send_command.call_args_list
|
||||
if call.args[0]["data"].get("data", {}).get("name")
|
||||
if call.args[0]["type"] == WSType.SUPERVISOR_EVENT
|
||||
and call.args[0]["data"].get("data", {}).get("name")
|
||||
== "backup_manager_full_restore"
|
||||
]
|
||||
assert messages == [
|
||||
@@ -1242,7 +1274,8 @@ async def test_restore_progress(
|
||||
messages = [
|
||||
call.args[0]
|
||||
for call in ha_ws_client.async_send_command.call_args_list
|
||||
if call.args[0]["data"].get("data", {}).get("name")
|
||||
if call.args[0]["type"] == WSType.SUPERVISOR_EVENT
|
||||
and call.args[0]["data"].get("data", {}).get("name")
|
||||
== "backup_manager_partial_restore"
|
||||
]
|
||||
assert messages == [
|
||||
@@ -1277,8 +1310,9 @@ async def test_restore_progress(
|
||||
addon_backup: Backup = await coresys.backups.do_backup_partial(addons=["local_ssh"])
|
||||
|
||||
ha_ws_client.async_send_command.reset_mock()
|
||||
with patch.object(AddonModel, "_validate_availability"), patch.object(
|
||||
HomeAssistantCore, "start"
|
||||
with (
|
||||
patch.object(AddonModel, "_validate_availability"),
|
||||
patch.object(HomeAssistantCore, "start"),
|
||||
):
|
||||
await coresys.backups.do_restore_partial(addon_backup, addons=["local_ssh"])
|
||||
await asyncio.sleep(0)
|
||||
@@ -1286,7 +1320,8 @@ async def test_restore_progress(
|
||||
messages = [
|
||||
call.args[0]
|
||||
for call in ha_ws_client.async_send_command.call_args_list
|
||||
if call.args[0]["data"].get("data", {}).get("name")
|
||||
if call.args[0]["type"] == WSType.SUPERVISOR_EVENT
|
||||
and call.args[0]["data"].get("data", {}).get("name")
|
||||
== "backup_manager_partial_restore"
|
||||
]
|
||||
assert messages == [
|
||||
@@ -1338,10 +1373,13 @@ async def test_freeze_thaw(
|
||||
container.exec_run.return_value = (0, None)
|
||||
ha_ws_client.ha_version = AwesomeVersion("2022.1.0")
|
||||
|
||||
with patch.object(
|
||||
AddonModel, "backup_pre", new=PropertyMock(return_value="pre_backup")
|
||||
), patch.object(
|
||||
AddonModel, "backup_post", new=PropertyMock(return_value="post_backup")
|
||||
with (
|
||||
patch.object(
|
||||
AddonModel, "backup_pre", new=PropertyMock(return_value="pre_backup")
|
||||
),
|
||||
patch.object(
|
||||
AddonModel, "backup_post", new=PropertyMock(return_value="post_backup")
|
||||
),
|
||||
):
|
||||
# Run the freeze
|
||||
await coresys.backups.freeze_all()
|
||||
@@ -1465,11 +1503,12 @@ async def test_restore_only_reloads_ingress_on_change(
|
||||
async def mock_is_running(*_) -> bool:
|
||||
return True
|
||||
|
||||
with patch.object(
|
||||
HomeAssistantCore, "is_running", new=mock_is_running
|
||||
), patch.object(AddonModel, "_validate_availability"), patch.object(
|
||||
DockerAddon, "attach"
|
||||
), patch.object(HomeAssistantAPI, "make_request") as make_request:
|
||||
with (
|
||||
patch.object(HomeAssistantCore, "is_running", new=mock_is_running),
|
||||
patch.object(AddonModel, "_validate_availability"),
|
||||
patch.object(DockerAddon, "attach"),
|
||||
patch.object(HomeAssistantAPI, "make_request") as make_request,
|
||||
):
|
||||
make_request.return_value.__aenter__.return_value.status = 200
|
||||
|
||||
# Has ingress before and after - not called
|
||||
@@ -1518,8 +1557,9 @@ async def test_restore_new_addon(
|
||||
await coresys.addons.uninstall("local_example")
|
||||
assert "local_example" not in coresys.addons.local
|
||||
|
||||
with patch.object(AddonModel, "_validate_availability"), patch.object(
|
||||
DockerAddon, "attach"
|
||||
with (
|
||||
patch.object(AddonModel, "_validate_availability"),
|
||||
patch.object(DockerAddon, "attach"),
|
||||
):
|
||||
assert await coresys.backups.do_restore_partial(
|
||||
backup, addons=["local_example"]
|
||||
@@ -1554,8 +1594,9 @@ async def test_restore_preserves_data_config(
|
||||
assert install_addon_example.path_config.exists()
|
||||
assert test_config2.exists()
|
||||
|
||||
with patch.object(AddonModel, "_validate_availability"), patch.object(
|
||||
DockerAddon, "attach"
|
||||
with (
|
||||
patch.object(AddonModel, "_validate_availability"),
|
||||
patch.object(DockerAddon, "attach"),
|
||||
):
|
||||
assert await coresys.backups.do_restore_partial(
|
||||
backup, addons=["local_example"]
|
||||
@@ -1660,8 +1701,9 @@ async def test_skip_homeassistant_database(
|
||||
write_json_file(test_db, {"hello": "world"})
|
||||
write_json_file(test_db_wal, {"hello": "world"})
|
||||
|
||||
with patch.object(HomeAssistantCore, "update"), patch.object(
|
||||
HomeAssistantCore, "start"
|
||||
with (
|
||||
patch.object(HomeAssistantCore, "update"),
|
||||
patch.object(HomeAssistantCore, "start"),
|
||||
):
|
||||
await coresys.backups.do_restore_partial(backup, homeassistant=True)
|
||||
|
||||
@@ -1735,8 +1777,9 @@ async def test_reload_error(
|
||||
)
|
||||
|
||||
mock_is_mount.return_value = False
|
||||
with patch("supervisor.backups.manager.Path.is_dir", new=mock_is_dir), patch(
|
||||
"supervisor.backups.manager.Path.glob", return_value=[]
|
||||
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()
|
||||
@@ -1787,3 +1830,39 @@ async def test_monitoring_after_partial_restore(
|
||||
backup_instance.restore_addons.assert_called_once_with([TEST_ADDON_SLUG])
|
||||
assert coresys.core.state == CoreState.RUNNING
|
||||
coresys.docker.unload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pre_backup_error",
|
||||
[
|
||||
{
|
||||
"code": "pre_backup_actions_failed",
|
||||
"message": "Database migration in progress",
|
||||
},
|
||||
{"code": "unknown_command", "message": "Unknown command."},
|
||||
],
|
||||
)
|
||||
async def test_core_pre_backup_actions_failed(
|
||||
coresys: CoreSys,
|
||||
ha_ws_client: AsyncMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
pre_backup_error: dict[str, str],
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
):
|
||||
"""Test pre-backup actions failed in HA core stops backup."""
|
||||
coresys.core.state = CoreState.RUNNING
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
ha_ws_client.ha_version = AwesomeVersion("2024.7.0")
|
||||
ha_ws_client.async_send_command.return_value = {
|
||||
"error": pre_backup_error,
|
||||
"id": 1,
|
||||
"success": False,
|
||||
"type": "result",
|
||||
}
|
||||
|
||||
assert not await coresys.backups.do_backup_full()
|
||||
assert (
|
||||
f"Preparing backup of Home Assistant Core failed due to: {pre_backup_error['message']}"
|
||||
in caplog.text
|
||||
)
|
||||
|
@@ -42,6 +42,7 @@ from supervisor.coresys import CoreSys
|
||||
from supervisor.dbus.network import NetworkManager
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.docker.monitor import DockerMonitor
|
||||
from supervisor.homeassistant.api import APIState
|
||||
from supervisor.host.logs import LogsControl
|
||||
from supervisor.os.manager import OSManager
|
||||
from supervisor.store.addon import AddonStore
|
||||
@@ -360,7 +361,9 @@ async def coresys(
|
||||
)
|
||||
|
||||
# WebSocket
|
||||
coresys_obj.homeassistant.api.get_api_state = AsyncMock(return_value="RUNNING")
|
||||
coresys_obj.homeassistant.api.get_api_state = AsyncMock(
|
||||
return_value=APIState("RUNNING", False)
|
||||
)
|
||||
coresys_obj.homeassistant._websocket._client = AsyncMock(
|
||||
ha_version=AwesomeVersion("2021.2.4")
|
||||
)
|
||||
|
@@ -67,6 +67,7 @@ async def test_update(
|
||||
assert settings["ipv4"]["method"] == Variant("s", "auto")
|
||||
assert "gateway" not in settings["ipv4"]
|
||||
assert "dns" not in settings["ipv4"]
|
||||
assert "dns-data" not in settings["ipv4"]
|
||||
assert "address-data" not in settings["ipv4"]
|
||||
assert "addresses" not in settings["ipv4"]
|
||||
assert len(settings["ipv4"]["route-data"].value) == 1
|
||||
@@ -83,6 +84,7 @@ async def test_update(
|
||||
assert settings["ipv6"]["method"] == Variant("s", "auto")
|
||||
assert "gateway" not in settings["ipv6"]
|
||||
assert "dns" not in settings["ipv6"]
|
||||
assert "dns-data" not in settings["ipv6"]
|
||||
assert "address-data" not in settings["ipv6"]
|
||||
assert "addresses" not in settings["ipv6"]
|
||||
assert settings["ipv6"]["addr-gen-mode"] == Variant("i", 0)
|
||||
|
@@ -31,6 +31,7 @@ SETTINGS_FIXTURE: dict[str, dict[str, Variant]] = {
|
||||
),
|
||||
"addresses": Variant("aau", [[2483202240, 24, 16951488]]),
|
||||
"dns": Variant("au", [16951488]),
|
||||
"dns-data": Variant("as", ["192.168.2.1"]),
|
||||
"dns-search": Variant("as", []),
|
||||
"gateway": Variant("s", "192.168.2.1"),
|
||||
"method": Variant("s", "auto"),
|
||||
|
5
tests/fixtures/addons/git/d5369777/repository.json
vendored
Normal file
5
tests/fixtures/addons/git/d5369777/repository.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Music Assistant",
|
||||
"url": "https://github.com/music-assistant/core",
|
||||
"maintainer": "Music Assistant <marcelveldt@users.noreply.github.com>"
|
||||
}
|
@@ -21,6 +21,7 @@ from supervisor.exceptions import (
|
||||
HomeAssistantError,
|
||||
HomeAssistantJobError,
|
||||
)
|
||||
from supervisor.homeassistant.api import APIState
|
||||
from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.updater import Updater
|
||||
@@ -316,6 +317,42 @@ async def test_api_check_success(
|
||||
assert "Detect a running Home Assistant instance" in caplog.text
|
||||
|
||||
|
||||
async def test_api_check_database_migration(
|
||||
coresys: CoreSys, container: MagicMock, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test attempts to contact the API timeout."""
|
||||
calls = []
|
||||
|
||||
def mock_api_state(*args):
|
||||
calls.append(None)
|
||||
if len(calls) > 50:
|
||||
return APIState("RUNNING", False)
|
||||
else:
|
||||
return APIState("NOT_RUNNING", True)
|
||||
|
||||
container.status = "stopped"
|
||||
coresys.homeassistant.version = AwesomeVersion("2023.9.0")
|
||||
coresys.homeassistant.api.get_api_state.side_effect = mock_api_state
|
||||
|
||||
async def mock_instance_start(*_):
|
||||
container.status = "running"
|
||||
|
||||
with (
|
||||
patch.object(DockerHomeAssistant, "start", new=mock_instance_start),
|
||||
patch.object(DockerAPI, "container_is_initialized", return_value=True),
|
||||
travel(datetime(2023, 10, 2, 0, 0, 0), tick=False) as traveller,
|
||||
):
|
||||
|
||||
async def mock_sleep(*args):
|
||||
traveller.shift(timedelta(minutes=1))
|
||||
|
||||
with patch("supervisor.homeassistant.core.asyncio.sleep", new=mock_sleep):
|
||||
await coresys.homeassistant.core.start()
|
||||
|
||||
assert coresys.homeassistant.api.get_api_state.call_count == 51
|
||||
assert "Detect a running Home Assistant instance" in caplog.text
|
||||
|
||||
|
||||
async def test_core_loads_wrong_image_for_machine(
|
||||
coresys: CoreSys, container: MagicMock
|
||||
):
|
||||
|
@@ -5,12 +5,17 @@ import errno
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pytest import LogCaptureFixture
|
||||
from pytest import LogCaptureFixture, raises
|
||||
|
||||
from supervisor.const import CoreState
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.interface import DockerInterface
|
||||
from supervisor.exceptions import (
|
||||
HomeAssistantBackupError,
|
||||
HomeAssistantWSConnectionError,
|
||||
)
|
||||
from supervisor.homeassistant.secrets import HomeAssistantSecrets
|
||||
from supervisor.homeassistant.websocket import HomeAssistantWebSocket
|
||||
|
||||
|
||||
async def test_load(
|
||||
@@ -21,12 +26,14 @@ async def test_load(
|
||||
secrets.write("hello: world\n")
|
||||
|
||||
# Unwrap read_secrets to prevent throttling between tests
|
||||
with patch.object(DockerInterface, "attach") as attach, patch.object(
|
||||
DockerInterface, "check_image"
|
||||
) as check_image, patch.object(
|
||||
HomeAssistantSecrets,
|
||||
"_read_secrets",
|
||||
new=HomeAssistantSecrets._read_secrets.__wrapped__,
|
||||
with (
|
||||
patch.object(DockerInterface, "attach") as attach,
|
||||
patch.object(DockerInterface, "check_image") as check_image,
|
||||
patch.object(
|
||||
HomeAssistantSecrets,
|
||||
"_read_secrets",
|
||||
new=HomeAssistantSecrets._read_secrets.__wrapped__,
|
||||
),
|
||||
):
|
||||
await coresys.homeassistant.load()
|
||||
|
||||
@@ -70,3 +77,34 @@ def test_write_pulse_error(coresys: CoreSys, caplog: LogCaptureFixture):
|
||||
|
||||
assert "can't write pulse/client.config" in caplog.text
|
||||
assert coresys.core.healthy is False
|
||||
|
||||
|
||||
async def test_begin_backup_ws_error(coresys: CoreSys):
|
||||
"""Test WS error when beginning backup."""
|
||||
# pylint: disable-next=protected-access
|
||||
coresys.homeassistant.websocket._client.async_send_command.side_effect = (
|
||||
HomeAssistantWSConnectionError
|
||||
)
|
||||
with (
|
||||
patch.object(HomeAssistantWebSocket, "_can_send", return_value=True),
|
||||
raises(
|
||||
HomeAssistantBackupError,
|
||||
match="Preparing backup of Home Assistant Core failed. Check HA Core logs.",
|
||||
),
|
||||
):
|
||||
await coresys.homeassistant.begin_backup()
|
||||
|
||||
|
||||
async def test_end_backup_ws_error(coresys: CoreSys, caplog: LogCaptureFixture):
|
||||
"""Test WS error when ending backup."""
|
||||
# pylint: disable-next=protected-access
|
||||
coresys.homeassistant.websocket._client.async_send_command.side_effect = (
|
||||
HomeAssistantWSConnectionError
|
||||
)
|
||||
with patch.object(HomeAssistantWebSocket, "_can_send", return_value=True):
|
||||
await coresys.homeassistant.end_backup()
|
||||
|
||||
assert (
|
||||
"Error resuming normal operations after backup of Home Assistant Core. Check HA Core logs."
|
||||
in caplog.text
|
||||
)
|
||||
|
@@ -46,7 +46,7 @@ async def test_watchdog_homeassistant_api(
|
||||
restart.assert_called_once()
|
||||
assert "Watchdog missed an Home Assistant Core API response." not in caplog.text
|
||||
assert (
|
||||
"Watchdog missed 2 Home Assistant Core API responses in a row. Restarting Home Assistant Core API!"
|
||||
"Watchdog missed 2 Home Assistant Core API responses in a row. Restarting Home Assistant Core!"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
@@ -109,31 +109,48 @@ async def test_watchdog_homeassistant_api_reanimation_limit(
|
||||
HomeAssistantAPI, "check_api_state", return_value=False
|
||||
), patch.object(
|
||||
HomeAssistantCore, "restart", side_effect=(err := HomeAssistantError())
|
||||
) as restart:
|
||||
) as restart, patch.object(
|
||||
HomeAssistantCore, "rebuild", side_effect=err
|
||||
) as rebuild:
|
||||
for _ in range(5):
|
||||
await tasks._watchdog_homeassistant_api()
|
||||
restart.assert_not_called()
|
||||
|
||||
await tasks._watchdog_homeassistant_api()
|
||||
restart.assert_called_once()
|
||||
restart.assert_called_once_with()
|
||||
assert "Home Assistant watchdog reanimation failed!" in caplog.text
|
||||
|
||||
rebuild.assert_not_called()
|
||||
restart.reset_mock()
|
||||
|
||||
capture_exception.assert_called_once_with(err)
|
||||
|
||||
# Next time it should try safe mode
|
||||
caplog.clear()
|
||||
await tasks._watchdog_homeassistant_api()
|
||||
rebuild.assert_not_called()
|
||||
|
||||
await tasks._watchdog_homeassistant_api()
|
||||
|
||||
rebuild.assert_called_once_with(safe_mode=True)
|
||||
restart.assert_not_called()
|
||||
assert "Watchdog missed an Home Assistant Core API response." not in caplog.text
|
||||
assert "Watchdog found a problem with Home Assistant API!" not in caplog.text
|
||||
assert (
|
||||
"Watchdog cannot reanimate Home Assistant Core, failed all 5 attempts."
|
||||
"Watchdog cannot reanimate Home Assistant Core, failed all 5 attempts. Restarting into safe mode"
|
||||
in caplog.text
|
||||
)
|
||||
assert (
|
||||
"Safe mode restart failed. Watchdog cannot bring Home Assistant online."
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
# After safe mode has failed too, no more restart attempts
|
||||
rebuild.reset_mock()
|
||||
caplog.clear()
|
||||
await tasks._watchdog_homeassistant_api()
|
||||
assert "Watchdog missed an Home Assistant Core API response." in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
await tasks._watchdog_homeassistant_api()
|
||||
restart.assert_not_called()
|
||||
assert not caplog.text
|
||||
restart.assert_not_called()
|
||||
rebuild.assert_not_called()
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Test base plugin functionality."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, Mock, PropertyMock, patch
|
||||
|
||||
@@ -60,14 +61,17 @@ async def fixture_plugin(
|
||||
)
|
||||
async def test_plugin_watchdog(coresys: CoreSys, plugin: PluginBase) -> None:
|
||||
"""Test plugin watchdog works correctly."""
|
||||
with patch.object(type(plugin.instance), "attach"), patch.object(
|
||||
type(plugin.instance), "is_running", return_value=True
|
||||
with (
|
||||
patch.object(type(plugin.instance), "attach"),
|
||||
patch.object(type(plugin.instance), "is_running", return_value=True),
|
||||
):
|
||||
await plugin.load()
|
||||
|
||||
with patch.object(type(plugin), "rebuild") as rebuild, patch.object(
|
||||
type(plugin), "start"
|
||||
) as start, patch.object(type(plugin.instance), "current_state") as current_state:
|
||||
with (
|
||||
patch.object(type(plugin), "rebuild") as rebuild,
|
||||
patch.object(type(plugin), "start") as start,
|
||||
patch.object(type(plugin.instance), "current_state") as current_state,
|
||||
):
|
||||
current_state.return_value = ContainerState.UNHEALTHY
|
||||
coresys.bus.fire_event(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
||||
@@ -168,9 +172,10 @@ async def test_plugin_watchdog_max_failed_attempts(
|
||||
|
||||
container.status = "stopped"
|
||||
container.attrs = {"State": {"ExitCode": 1}}
|
||||
with patch("supervisor.plugins.base.WATCHDOG_RETRY_SECONDS", 0), patch.object(
|
||||
type(plugin), "start", side_effect=error
|
||||
) as start:
|
||||
with (
|
||||
patch("supervisor.plugins.base.WATCHDOG_RETRY_SECONDS", 0),
|
||||
patch.object(type(plugin), "start", side_effect=error) as start,
|
||||
):
|
||||
await plugin.watchdog_container(
|
||||
DockerContainerStateEvent(
|
||||
name=plugin.instance.name,
|
||||
@@ -198,17 +203,18 @@ async def test_plugin_load_running_container(
|
||||
) -> None:
|
||||
"""Test plugins load and attach to a running container."""
|
||||
test_version = AwesomeVersion("2022.7.3")
|
||||
with patch.object(
|
||||
type(coresys.bus), "register_event"
|
||||
) as register_event, patch.object(
|
||||
type(plugin.instance), "attach"
|
||||
) as attach, patch.object(type(plugin), "install") as install, patch.object(
|
||||
type(plugin), "start"
|
||||
) as start, patch.object(
|
||||
type(plugin.instance),
|
||||
"get_latest_version",
|
||||
return_value=test_version,
|
||||
), patch.object(type(plugin.instance), "is_running", return_value=True):
|
||||
with (
|
||||
patch.object(type(coresys.bus), "register_event") as register_event,
|
||||
patch.object(type(plugin.instance), "attach") as attach,
|
||||
patch.object(type(plugin), "install") as install,
|
||||
patch.object(type(plugin), "start") as start,
|
||||
patch.object(
|
||||
type(plugin.instance),
|
||||
"get_latest_version",
|
||||
return_value=test_version,
|
||||
),
|
||||
patch.object(type(plugin.instance), "is_running", return_value=True),
|
||||
):
|
||||
await plugin.load()
|
||||
register_event.assert_any_call(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, plugin.watchdog_container
|
||||
@@ -230,17 +236,18 @@ async def test_plugin_load_stopped_container(
|
||||
) -> None:
|
||||
"""Test plugins load and start existing container."""
|
||||
test_version = AwesomeVersion("2022.7.3")
|
||||
with patch.object(
|
||||
type(coresys.bus), "register_event"
|
||||
) as register_event, patch.object(
|
||||
type(plugin.instance), "attach"
|
||||
) as attach, patch.object(type(plugin), "install") as install, patch.object(
|
||||
type(plugin), "start"
|
||||
) as start, patch.object(
|
||||
type(plugin.instance),
|
||||
"get_latest_version",
|
||||
return_value=test_version,
|
||||
), patch.object(type(plugin.instance), "is_running", return_value=False):
|
||||
with (
|
||||
patch.object(type(coresys.bus), "register_event") as register_event,
|
||||
patch.object(type(plugin.instance), "attach") as attach,
|
||||
patch.object(type(plugin), "install") as install,
|
||||
patch.object(type(plugin), "start") as start,
|
||||
patch.object(
|
||||
type(plugin.instance),
|
||||
"get_latest_version",
|
||||
return_value=test_version,
|
||||
),
|
||||
patch.object(type(plugin.instance), "is_running", return_value=False),
|
||||
):
|
||||
await plugin.load()
|
||||
register_event.assert_any_call(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, plugin.watchdog_container
|
||||
@@ -262,17 +269,20 @@ async def test_plugin_load_missing_container(
|
||||
) -> None:
|
||||
"""Test plugins load and create and start container."""
|
||||
test_version = AwesomeVersion("2022.7.3")
|
||||
with patch.object(
|
||||
type(coresys.bus), "register_event"
|
||||
) as register_event, patch.object(
|
||||
type(plugin.instance), "attach", side_effect=DockerError()
|
||||
) as attach, patch.object(type(plugin), "install") as install, patch.object(
|
||||
type(plugin), "start"
|
||||
) as start, patch.object(
|
||||
type(plugin.instance),
|
||||
"get_latest_version",
|
||||
return_value=test_version,
|
||||
), patch.object(type(plugin.instance), "is_running", return_value=False):
|
||||
with (
|
||||
patch.object(type(coresys.bus), "register_event") as register_event,
|
||||
patch.object(
|
||||
type(plugin.instance), "attach", side_effect=DockerError()
|
||||
) as attach,
|
||||
patch.object(type(plugin), "install") as install,
|
||||
patch.object(type(plugin), "start") as start,
|
||||
patch.object(
|
||||
type(plugin.instance),
|
||||
"get_latest_version",
|
||||
return_value=test_version,
|
||||
),
|
||||
patch.object(type(plugin.instance), "is_running", return_value=False),
|
||||
):
|
||||
await plugin.load()
|
||||
register_event.assert_any_call(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, plugin.watchdog_container
|
||||
@@ -301,9 +311,12 @@ async def test_update_fails_if_out_of_date(
|
||||
"""Test update of plugins fail when supervisor is out of date."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
|
||||
with patch.object(
|
||||
type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True)
|
||||
), pytest.raises(error):
|
||||
with (
|
||||
patch.object(
|
||||
type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True)
|
||||
),
|
||||
pytest.raises(error),
|
||||
):
|
||||
await plugin.update()
|
||||
|
||||
|
||||
@@ -316,10 +329,14 @@ async def test_repair_failed(
|
||||
coresys: CoreSys, capture_exception: Mock, plugin: PluginBase
|
||||
):
|
||||
"""Test repair failed."""
|
||||
with patch.object(DockerInterface, "exists", return_value=False), patch.object(
|
||||
DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64)
|
||||
), patch(
|
||||
"supervisor.security.module.cas_validate", side_effect=CodeNotaryUntrusted
|
||||
with (
|
||||
patch.object(DockerInterface, "exists", return_value=False),
|
||||
patch.object(
|
||||
DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64)
|
||||
),
|
||||
patch(
|
||||
"supervisor.security.module.cas_validate", side_effect=CodeNotaryUntrusted
|
||||
),
|
||||
):
|
||||
await plugin.repair()
|
||||
|
||||
@@ -360,3 +377,16 @@ async def test_load_with_incorrect_image(
|
||||
platform="linux/amd64",
|
||||
)
|
||||
assert plugin.image == correct_image
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin",
|
||||
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_default_image_fallback(
|
||||
coresys: CoreSys, container: MagicMock, plugin: PluginBase
|
||||
):
|
||||
"""Test default image falls back to hard-coded constant if we fail to fetch version file."""
|
||||
assert getattr(coresys.updater, f"image_{plugin.slug}") is None
|
||||
assert plugin.default_image == f"ghcr.io/home-assistant/amd64-hassio-{plugin.slug}"
|
||||
|
@@ -163,6 +163,7 @@ async def test_preinstall_valid_repository(
|
||||
assert store_manager.get("local").validate()
|
||||
assert store_manager.get("a0d7b954").validate()
|
||||
assert store_manager.get("5c53de3b").validate()
|
||||
assert store_manager.get("d5369777").validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_update", [True, False])
|
||||
|
@@ -39,11 +39,11 @@ async def test_default_load(coresys: CoreSys):
|
||||
):
|
||||
await store_manager.load()
|
||||
|
||||
assert len(store_manager.all) == 4
|
||||
assert len(store_manager.all) == 5
|
||||
assert isinstance(store_manager.get("core"), Repository)
|
||||
assert isinstance(store_manager.get("local"), Repository)
|
||||
|
||||
assert len(store_manager.repository_urls) == 2
|
||||
assert len(store_manager.repository_urls) == 3
|
||||
assert (
|
||||
"https://github.com/hassio-addons/repository" in store_manager.repository_urls
|
||||
)
|
||||
@@ -51,6 +51,11 @@ async def test_default_load(coresys: CoreSys):
|
||||
"https://github.com/esphome/home-assistant-addon"
|
||||
in store_manager.repository_urls
|
||||
)
|
||||
assert (
|
||||
"https://github.com/music-assistant/home-assistant-addon"
|
||||
in store_manager.repository_urls
|
||||
)
|
||||
# NOTE: When adding new stores, make sure to add it to tests/fixtures/addons/git/
|
||||
assert refresh_cache_calls == {"local_ssh", "local_example", "core_samba"}
|
||||
|
||||
|
||||
@@ -77,11 +82,11 @@ async def test_load_with_custom_repository(coresys: CoreSys):
|
||||
):
|
||||
await store_manager.load()
|
||||
|
||||
assert len(store_manager.all) == 5
|
||||
assert len(store_manager.all) == 6
|
||||
assert isinstance(store_manager.get("core"), Repository)
|
||||
assert isinstance(store_manager.get("local"), Repository)
|
||||
|
||||
assert len(store_manager.repository_urls) == 3
|
||||
assert len(store_manager.repository_urls) == 4
|
||||
assert (
|
||||
"https://github.com/hassio-addons/repository" in store_manager.repository_urls
|
||||
)
|
||||
@@ -89,6 +94,10 @@ async def test_load_with_custom_repository(coresys: CoreSys):
|
||||
"https://github.com/esphome/home-assistant-addon"
|
||||
in store_manager.repository_urls
|
||||
)
|
||||
assert (
|
||||
"https://github.com/music-assistant/home-assistant-addon"
|
||||
in store_manager.repository_urls
|
||||
)
|
||||
assert "http://example.com" in store_manager.repository_urls
|
||||
|
||||
|
||||
@@ -105,11 +114,11 @@ async def test_load_from_core_config(coresys: CoreSys):
|
||||
), patch("pathlib.Path.exists", return_value=True):
|
||||
await coresys.store.load()
|
||||
|
||||
assert len(coresys.store.all) == 5
|
||||
assert len(coresys.store.all) == 6
|
||||
assert isinstance(coresys.store.get("core"), Repository)
|
||||
assert isinstance(coresys.store.get("local"), Repository)
|
||||
|
||||
assert len(coresys.store.repository_urls) == 3
|
||||
assert len(coresys.store.repository_urls) == 4
|
||||
assert (
|
||||
"https://github.com/hassio-addons/repository" in coresys.store.repository_urls
|
||||
)
|
||||
@@ -117,6 +126,10 @@ async def test_load_from_core_config(coresys: CoreSys):
|
||||
"https://github.com/esphome/home-assistant-addon"
|
||||
in coresys.store.repository_urls
|
||||
)
|
||||
assert (
|
||||
"https://github.com/music-assistant/home-assistant-addon"
|
||||
in coresys.store.repository_urls
|
||||
)
|
||||
assert "http://example.com" in coresys.store.repository_urls
|
||||
|
||||
assert coresys.config.addons_repositories == []
|
||||
@@ -243,12 +256,12 @@ async def test_install_unavailable_addon(
|
||||
async def test_reload(coresys: CoreSys):
|
||||
"""Test store reload."""
|
||||
await coresys.store.load()
|
||||
assert len(coresys.store.all) == 4
|
||||
assert len(coresys.store.all) == 5
|
||||
|
||||
with patch.object(GitRepo, "pull") as git_pull:
|
||||
await coresys.store.reload()
|
||||
|
||||
assert git_pull.call_count == 3
|
||||
assert git_pull.call_count == 4
|
||||
|
||||
|
||||
async def test_addon_version_timestamp(coresys: CoreSys, install_addon_example: Addon):
|
||||
|
@@ -49,12 +49,13 @@ async def test_repository_validate(repo_list: list[str], valid: bool):
|
||||
"""Test repository list validate."""
|
||||
if valid:
|
||||
processed = repositories(repo_list)
|
||||
assert len(processed) == 4
|
||||
assert len(processed) == 5
|
||||
assert set(repositories(repo_list)) == {
|
||||
"core",
|
||||
"local",
|
||||
"https://github.com/hassio-addons/repository",
|
||||
"https://github.com/esphome/home-assistant-addon",
|
||||
"https://github.com/music-assistant/home-assistant-addon",
|
||||
}
|
||||
else:
|
||||
with pytest.raises(Invalid):
|
||||
|
Reference in New Issue
Block a user