mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-09-01 12:20:22 +00:00
Compare commits
52 Commits
2024.09.0
...
trigger-sy
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e415923553 | ||
![]() |
95c638991d | ||
![]() |
e2ada42001 | ||
![]() |
22e50b4ace | ||
![]() |
334484de7f | ||
![]() |
180a7c3990 | ||
![]() |
d5f33de808 | ||
![]() |
6539f0df6f | ||
![]() |
1504278223 | ||
![]() |
9f3767b23d | ||
![]() |
e0d7985369 | ||
![]() |
2968a5717c | ||
![]() |
e2b25fe7ce | ||
![]() |
8601f5c49a | ||
![]() |
42279461e0 | ||
![]() |
409447d6ca | ||
![]() |
5b313db49d | ||
![]() |
d64618600d | ||
![]() |
1ee01b1d5e | ||
![]() |
af590202c3 | ||
![]() |
12ca2fb624 | ||
![]() |
ea95f83742 | ||
![]() |
e4d4da601c | ||
![]() |
0582f6fd39 | ||
![]() |
f254af8326 | ||
![]() |
3333770246 | ||
![]() |
ee5ded29ac | ||
![]() |
f530db98ff | ||
![]() |
911f9d661f | ||
![]() |
9935eac146 | ||
![]() |
eae2c9e221 | ||
![]() |
1a67fe8a83 | ||
![]() |
3af565267b | ||
![]() |
d09460a971 | ||
![]() |
c65329442a | ||
![]() |
48430dfa28 | ||
![]() |
70e2de372d | ||
![]() |
75784480ab | ||
![]() |
8a70ba841d | ||
![]() |
77733829d7 | ||
![]() |
d4b67f1946 | ||
![]() |
51ab138bb1 | ||
![]() |
b81413c8b2 | ||
![]() |
2ec33c6ef3 | ||
![]() |
68b2c38c7c | ||
![]() |
1ca22799d1 | ||
![]() |
549dddcb11 | ||
![]() |
131af90469 | ||
![]() |
c7c39da7c6 | ||
![]() |
8310c426f0 | ||
![]() |
bb8f91e39a | ||
![]() |
a359b9a3d5 |
10
.github/workflows/builder.yml
vendored
10
.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.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
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.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: sigstore/cosign-installer@v3.6.0
|
||||
uses: sigstore/cosign-installer@v3.7.0
|
||||
with:
|
||||
cosign-release: "v2.4.0"
|
||||
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
|
||||
- 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.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
|
||||
- name: Build the Supervisor
|
||||
if: needs.init.outputs.publish != 'true'
|
||||
|
51
.github/workflows/ci.yaml
vendored
51
.github/workflows/ci.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
name: Prepare Python dependencies
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@v5.2.0
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements_tests.txt
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
lookup-only: true
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
key: |
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -288,19 +288,19 @@ jobs:
|
||||
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.6.0
|
||||
uses: sigstore/cosign-installer@v3.7.0
|
||||
with:
|
||||
cosign-release: "v2.4.0"
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -335,10 +335,11 @@ jobs:
|
||||
-o console_output_style=count \
|
||||
tests
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: .coverage
|
||||
include-hidden-files: true
|
||||
|
||||
coverage:
|
||||
name: Process test coverage
|
||||
@@ -346,7 +347,7 @@ jobs:
|
||||
needs: ["pytest", "prepare"]
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v5.2.0
|
||||
id: python
|
||||
@@ -354,7 +355,7 @@ jobs:
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.1
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -373,4 +374,4 @@ jobs:
|
||||
coverage report
|
||||
coverage xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
uses: codecov/codecov-action@v4.6.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.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
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.7
|
||||
uses: actions/checkout@v4.2.1
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.7.0
|
||||
env:
|
||||
|
@@ -30,3 +30,5 @@ Releases are done in 3 stages (channels) with this structure:
|
||||
|
||||
[development]: https://developers.home-assistant.io/docs/supervisor/development
|
||||
[stable]: https://github.com/home-assistant/version/blob/master/stable.json
|
||||
|
||||
[](https://www.openhomefoundation.org/)
|
||||
|
@@ -215,6 +215,9 @@ expected-line-ending-format = "LF"
|
||||
[tool.pylint.EXCEPTIONS]
|
||||
overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"]
|
||||
|
||||
[tool.pylint.DESIGN]
|
||||
max-positional-arguments = 10
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
norecursedirs = [".git"]
|
||||
|
@@ -1,29 +1,29 @@
|
||||
aiodns==3.2.0
|
||||
aiohttp==3.10.5
|
||||
aiohttp==3.10.10
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==24.2.0
|
||||
awesomeversion==24.6.0
|
||||
brotli==1.1.0
|
||||
ciso8601==2.3.1
|
||||
colorlog==6.8.2
|
||||
cpe==1.3.0
|
||||
cpe==1.3.1
|
||||
cryptography==43.0.1
|
||||
debugpy==1.8.5
|
||||
debugpy==1.8.7
|
||||
deepmerge==2.0
|
||||
dirhash==0.5.0
|
||||
docker==7.1.0
|
||||
faust-cchardet==2.1.19
|
||||
gitpython==3.1.43
|
||||
jinja2==3.1.4
|
||||
orjson==3.10.6
|
||||
orjson==3.10.7
|
||||
pulsectl==24.8.0
|
||||
pyudev==0.24.3
|
||||
PyYAML==6.0.2
|
||||
requests==2.32.3
|
||||
securetar==2024.2.1
|
||||
sentry-sdk==2.13.0
|
||||
setuptools==74.1.2
|
||||
sentry-sdk==2.16.0
|
||||
setuptools==75.1.0
|
||||
voluptuous==0.15.2
|
||||
dbus-fast==2.24.0
|
||||
dbus-fast==2.24.3
|
||||
typing_extensions==4.12.2
|
||||
zlib-fast==0.2.0
|
||||
|
@@ -1,12 +1,12 @@
|
||||
coverage==7.6.1
|
||||
pre-commit==3.8.0
|
||||
pylint==3.2.7
|
||||
coverage==7.6.3
|
||||
pre-commit==4.0.1
|
||||
pylint==3.3.1
|
||||
pytest-aiohttp==1.0.5
|
||||
pytest-asyncio==0.23.6
|
||||
pytest-cov==5.0.0
|
||||
pytest-timeout==2.3.1
|
||||
pytest==8.3.2
|
||||
ruff==0.6.3
|
||||
time-machine==2.15.0
|
||||
pytest==8.3.3
|
||||
ruff==0.6.9
|
||||
time-machine==2.16.0
|
||||
typing_extensions==4.12.2
|
||||
urllib3==2.2.2
|
||||
urllib3==2.2.3
|
||||
|
@@ -61,7 +61,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
IDENTIFIER = "identifier"
|
||||
BOOTID = "bootid"
|
||||
DEFAULT_RANGE = 100
|
||||
DEFAULT_LINES = 100
|
||||
|
||||
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str})
|
||||
|
||||
@@ -226,13 +226,26 @@ class APIHost(CoreSysAttributes):
|
||||
log_formatter = LogFormatter.VERBOSE
|
||||
|
||||
if "lines" in request.query:
|
||||
lines = request.query.get("lines", DEFAULT_RANGE)
|
||||
lines = request.query.get("lines", DEFAULT_LINES)
|
||||
try:
|
||||
lines = int(lines)
|
||||
except ValueError:
|
||||
# If the user passed a non-integer value, just use the default instead of error.
|
||||
lines = DEFAULT_LINES
|
||||
finally:
|
||||
# We can't use the entries= Range header syntax to refer to the last 1 line,
|
||||
# and passing 1 to the calculation below would return the 1st line of the logs
|
||||
# instead. Since this is really an edge case that doesn't matter much, we'll just
|
||||
# return 2 lines at minimum.
|
||||
lines = max(2, lines)
|
||||
# entries=cursor[[:num_skip]:num_entries]
|
||||
range_header = f"entries=:-{lines}:"
|
||||
range_header = f"entries=:-{lines-1}:{'' if follow else lines}"
|
||||
elif RANGE in request.headers:
|
||||
range_header = request.headers.get(RANGE)
|
||||
else:
|
||||
range_header = f"entries=:-{DEFAULT_RANGE}:"
|
||||
range_header = (
|
||||
f"entries=:-{DEFAULT_LINES-1}:{'' if follow else DEFAULT_LINES}"
|
||||
)
|
||||
|
||||
async with self.sys_host.logs.journald_logs(
|
||||
params=params, range_header=range_header, accept=LogFormat.JOURNAL
|
||||
@@ -240,8 +253,13 @@ class APIHost(CoreSysAttributes):
|
||||
try:
|
||||
response = web.StreamResponse()
|
||||
response.content_type = CONTENT_TYPE_TEXT
|
||||
await response.prepare(request)
|
||||
async for line in journal_logs_reader(resp, log_formatter):
|
||||
headers_returned = False
|
||||
async for cursor, line in journal_logs_reader(resp, log_formatter):
|
||||
if not headers_returned:
|
||||
if cursor:
|
||||
response.headers["X-First-Cursor"] = cursor
|
||||
await response.prepare(request)
|
||||
headers_returned = True
|
||||
await response.write(line.encode("utf-8") + b"\n")
|
||||
except ConnectionResetError as ex:
|
||||
raise APIError(
|
||||
|
@@ -9,6 +9,8 @@ from aiohttp.web import Request, RequestHandler, Response, middleware
|
||||
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from supervisor.homeassistant.const import LANDINGPAGE
|
||||
|
||||
from ...addons.const import RE_SLUG
|
||||
from ...const import (
|
||||
REQUEST_FROM,
|
||||
@@ -288,8 +290,10 @@ class SecurityMiddleware(CoreSysAttributes):
|
||||
@middleware
|
||||
async def core_proxy(self, request: Request, handler: RequestHandler) -> Response:
|
||||
"""Validate user from Core API proxy."""
|
||||
if request[REQUEST_FROM] != self.sys_homeassistant or version_is_new_enough(
|
||||
self.sys_homeassistant.version, _CORE_VERSION
|
||||
if (
|
||||
request[REQUEST_FROM] != self.sys_homeassistant
|
||||
or self.sys_homeassistant.version == LANDINGPAGE
|
||||
or version_is_new_enough(self.sys_homeassistant.version, _CORE_VERSION)
|
||||
):
|
||||
return await handler(request)
|
||||
|
||||
|
@@ -10,7 +10,10 @@ from pathlib import Path
|
||||
|
||||
from ..addons.addon import Addon
|
||||
from ..const import (
|
||||
ATTR_DATA,
|
||||
ATTR_DAYS_UNTIL_STALE,
|
||||
ATTR_SLUG,
|
||||
ATTR_TYPE,
|
||||
FILE_HASSIO_BACKUPS,
|
||||
FOLDER_HOMEASSISTANT,
|
||||
CoreState,
|
||||
@@ -21,7 +24,9 @@ from ..exceptions import (
|
||||
BackupInvalidError,
|
||||
BackupJobError,
|
||||
BackupMountDownError,
|
||||
HomeAssistantWSError,
|
||||
)
|
||||
from ..homeassistant.const import WSType
|
||||
from ..jobs.const import JOB_GROUP_BACKUP_MANAGER, JobCondition, JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from ..jobs.job_group import JobGroup
|
||||
@@ -299,6 +304,18 @@ class BackupManager(FileConfiguration, JobGroup):
|
||||
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
||||
await asyncio.gather(*addon_start_tasks, return_exceptions=True)
|
||||
|
||||
try:
|
||||
await self.sys_homeassistant.websocket.async_send_command(
|
||||
{
|
||||
ATTR_TYPE: WSType.BACKUP_SYNC,
|
||||
ATTR_DATA: {
|
||||
ATTR_SLUG: backup.slug,
|
||||
},
|
||||
},
|
||||
)
|
||||
except HomeAssistantWSError as err:
|
||||
_LOGGER.error("Can't send backup sync to Home Assistant: %s", err)
|
||||
|
||||
return backup
|
||||
finally:
|
||||
self.sys_core.state = CoreState.RUNNING
|
||||
|
@@ -63,7 +63,7 @@ class CoreSys:
|
||||
|
||||
# External objects
|
||||
self._loop: asyncio.BaseEventLoop = asyncio.get_running_loop()
|
||||
self._websession: aiohttp.ClientSession = aiohttp.ClientSession()
|
||||
self._websession = None
|
||||
|
||||
# Global objects
|
||||
self._config: CoreConfig = CoreConfig()
|
||||
@@ -96,10 +96,8 @@ class CoreSys:
|
||||
self._bus: Bus | None = None
|
||||
self._mounts: MountManager | None = None
|
||||
|
||||
# Set default header for aiohttp
|
||||
self._websession._default_headers = MappingProxyType(
|
||||
{aiohttp.hdrs.USER_AGENT: SERVER_SOFTWARE}
|
||||
)
|
||||
# Setup aiohttp session
|
||||
self.create_websession()
|
||||
|
||||
# Task factory attributes
|
||||
self._set_task_context: list[Callable[[Context], Context]] = []
|
||||
@@ -548,6 +546,16 @@ class CoreSys:
|
||||
|
||||
return self.loop.run_in_executor(None, funct, *args)
|
||||
|
||||
def create_websession(self) -> None:
|
||||
"""Create a new aiohttp session."""
|
||||
if self._websession:
|
||||
self.create_task(self._websession.close())
|
||||
|
||||
# Create session and set default header for aiohttp
|
||||
self._websession: aiohttp.ClientSession = aiohttp.ClientSession(
|
||||
headers=MappingProxyType({aiohttp.hdrs.USER_AGENT: SERVER_SOFTWARE})
|
||||
)
|
||||
|
||||
def _create_context(self) -> Context:
|
||||
"""Create a new context for a task."""
|
||||
context = copy_context()
|
||||
|
@@ -199,15 +199,17 @@ def get_connection_from_interface(
|
||||
elif interface.type == InterfaceType.WIRELESS:
|
||||
wireless = {
|
||||
CONF_ATTR_802_WIRELESS_ASSIGNED_MAC: Variant("s", "preserve"),
|
||||
CONF_ATTR_802_WIRELESS_SSID: Variant(
|
||||
"ay", interface.wifi.ssid.encode("UTF-8")
|
||||
),
|
||||
CONF_ATTR_802_WIRELESS_MODE: Variant("s", "infrastructure"),
|
||||
CONF_ATTR_802_WIRELESS_POWERSAVE: Variant("i", 1),
|
||||
}
|
||||
if interface.wifi and interface.wifi.ssid:
|
||||
wireless[CONF_ATTR_802_WIRELESS_SSID] = Variant(
|
||||
"ay", interface.wifi.ssid.encode("UTF-8")
|
||||
)
|
||||
|
||||
conn[CONF_ATTR_802_WIRELESS] = wireless
|
||||
|
||||
if interface.wifi.auth != "open":
|
||||
if interface.wifi and interface.wifi.auth != "open":
|
||||
wireless["security"] = Variant("s", CONF_ATTR_802_WIRELESS_SECURITY)
|
||||
wireless_security = {}
|
||||
if interface.wifi.auth == "wep":
|
||||
|
@@ -32,6 +32,7 @@ class WSType(StrEnum):
|
||||
SUPERVISOR_EVENT = "supervisor/event"
|
||||
BACKUP_START = "backup/start"
|
||||
BACKUP_END = "backup/end"
|
||||
BACKUP_SYNC = "backup/sync"
|
||||
|
||||
|
||||
class WSEvent(StrEnum):
|
||||
|
@@ -66,6 +66,7 @@ HOMEASSISTANT_BACKUP_EXCLUDE = [
|
||||
"*.log",
|
||||
"*.log.*",
|
||||
"OZW_Log.txt",
|
||||
"tts/*",
|
||||
]
|
||||
HOMEASSISTANT_BACKUP_EXCLUDE_DATABASE = [
|
||||
"home-assistant_v?.db",
|
||||
|
@@ -34,6 +34,7 @@ MIN_VERSION = {
|
||||
WSType.SUPERVISOR_EVENT: "2021.2.4",
|
||||
WSType.BACKUP_START: "2022.1.0",
|
||||
WSType.BACKUP_END: "2022.1.0",
|
||||
WSType.BACKUP_SYNC: "2024.10.99",
|
||||
}
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
@@ -10,7 +10,9 @@ from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..dbus.const import (
|
||||
DBUS_ATTR_CONNECTION_ENABLED,
|
||||
DBUS_ATTR_CONNECTIVITY,
|
||||
DBUS_ATTR_PRIMARY_CONNECTION,
|
||||
DBUS_IFACE_NM,
|
||||
DBUS_OBJECT_BASE,
|
||||
DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED,
|
||||
ConnectionStateType,
|
||||
ConnectivityState,
|
||||
@@ -148,6 +150,15 @@ class NetworkManager(CoreSysAttributes):
|
||||
connectivity_check: bool | None = changed.get(DBUS_ATTR_CONNECTION_ENABLED)
|
||||
connectivity: bool | None = changed.get(DBUS_ATTR_CONNECTIVITY)
|
||||
|
||||
# This potentially updated the DNS configuration. Make sure the DNS plug-in
|
||||
# picks up the latest settings.
|
||||
if (
|
||||
DBUS_ATTR_PRIMARY_CONNECTION in changed
|
||||
and changed[DBUS_ATTR_PRIMARY_CONNECTION]
|
||||
and changed[DBUS_ATTR_PRIMARY_CONNECTION] != DBUS_OBJECT_BASE
|
||||
):
|
||||
await self.sys_plugins.dns.restart()
|
||||
|
||||
if (
|
||||
connectivity_check is True
|
||||
or DBUS_ATTR_CONNECTION_ENABLED in invalidated
|
||||
@@ -236,7 +247,10 @@ class NetworkManager(CoreSysAttributes):
|
||||
) from err
|
||||
|
||||
# Remove config from interface
|
||||
elif inet and inet.settings and not interface.enabled:
|
||||
elif inet and not interface.enabled:
|
||||
if not inet.settings:
|
||||
_LOGGER.debug("Interface %s is already disabled.", interface.name)
|
||||
return
|
||||
try:
|
||||
await inet.settings.delete()
|
||||
except DBusError as err:
|
||||
|
@@ -5,23 +5,11 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path, PurePath
|
||||
from warnings import catch_warnings, simplefilter
|
||||
|
||||
import aiohttp
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
|
||||
# Fix for https://github.com/home-assistant/supervisor/issues/5226
|
||||
# Remove when CPE updated with https://github.com/nilp0inter/cpe/pull/57
|
||||
# Continue logging the warning on dev systems at least as this is still a problem
|
||||
with catch_warnings():
|
||||
if not os.environ.get("SUPERVISOR_DEV") and not logging.getLogger(
|
||||
__name__
|
||||
).isEnabledFor(logging.DEBUG):
|
||||
simplefilter("ignore", SyntaxWarning)
|
||||
|
||||
from cpe import CPE
|
||||
from cpe import CPE
|
||||
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..dbus.agent.boards.const import BOARD_NAME_SUPERVISED
|
||||
|
@@ -5,7 +5,7 @@ from ...coresys import CoreSys
|
||||
from ..const import UnsupportedReason
|
||||
from .base import EvaluateBase
|
||||
|
||||
SUPPORTED_OS = ["Debian GNU/Linux 11 (bullseye)", "Debian GNU/Linux 12 (bookworm)"]
|
||||
SUPPORTED_OS = ["Debian GNU/Linux 12 (bookworm)"]
|
||||
|
||||
|
||||
def setup(coresys: CoreSys) -> EvaluateBase:
|
||||
|
@@ -41,7 +41,7 @@ def _check_connectivity_throttle_period(coresys: CoreSys, *_) -> timedelta:
|
||||
if coresys.supervisor.connectivity:
|
||||
return timedelta(minutes=10)
|
||||
|
||||
return timedelta()
|
||||
return timedelta(seconds=30)
|
||||
|
||||
|
||||
class Supervisor(CoreSysAttributes):
|
||||
@@ -274,6 +274,8 @@ class Supervisor(CoreSysAttributes):
|
||||
"https://checkonline.home-assistant.io/online.txt", timeout=timeout
|
||||
)
|
||||
except (ClientError, TimeoutError):
|
||||
# Need to recreate the websession to avoid stale connection checks
|
||||
self.coresys.create_websession()
|
||||
self.connectivity = False
|
||||
else:
|
||||
self.connectivity = True
|
||||
|
@@ -22,7 +22,7 @@ def formatter(required_fields: list[str]):
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper.required_fields = required_fields
|
||||
wrapper.required_fields = ["__CURSOR"] + required_fields
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -60,10 +60,12 @@ def journal_verbose_formatter(entries: dict[str, str]) -> str:
|
||||
|
||||
|
||||
async def journal_logs_reader(
|
||||
journal_logs: ClientResponse,
|
||||
log_formatter: LogFormatter = LogFormatter.PLAIN,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Read logs from systemd journal line by line, formatted using the given formatter."""
|
||||
journal_logs: ClientResponse, log_formatter: LogFormatter = LogFormatter.PLAIN
|
||||
) -> AsyncGenerator[(str | None, str), None]:
|
||||
"""Read logs from systemd journal line by line, formatted using the given formatter.
|
||||
|
||||
Returns a generator of (cursor, formatted_entry) tuples.
|
||||
"""
|
||||
match log_formatter:
|
||||
case LogFormatter.PLAIN:
|
||||
formatter_ = journal_plain_formatter
|
||||
@@ -80,7 +82,7 @@ async def journal_logs_reader(
|
||||
# at EOF (likely race between at_eof and EOF check in readuntil)
|
||||
if line == b"\n" or not line:
|
||||
if entries:
|
||||
yield formatter_(entries)
|
||||
yield entries.get("__CURSOR"), formatter_(entries)
|
||||
entries = {}
|
||||
continue
|
||||
|
||||
|
@@ -6,7 +6,8 @@ from aiohttp.test_utils import TestClient
|
||||
|
||||
from supervisor.host.const import LogFormat
|
||||
|
||||
DEFAULT_LOG_RANGE = "entries=:-100:"
|
||||
DEFAULT_LOG_RANGE = "entries=:-99:100"
|
||||
DEFAULT_LOG_RANGE_FOLLOW = "entries=:-99:"
|
||||
|
||||
|
||||
async def common_test_api_advanced_logs(
|
||||
@@ -34,7 +35,7 @@ async def common_test_api_advanced_logs(
|
||||
|
||||
journald_logs.assert_called_once_with(
|
||||
params={"SYSLOG_IDENTIFIER": syslog_identifier, "follow": ""},
|
||||
range_header=DEFAULT_LOG_RANGE,
|
||||
range_header=DEFAULT_LOG_RANGE_FOLLOW,
|
||||
accept=LogFormat.JOURNAL,
|
||||
)
|
||||
|
||||
@@ -62,6 +63,6 @@ async def common_test_api_advanced_logs(
|
||||
"_BOOT_ID": "ccc",
|
||||
"follow": "",
|
||||
},
|
||||
range_header=DEFAULT_LOG_RANGE,
|
||||
range_header=DEFAULT_LOG_RANGE_FOLLOW,
|
||||
accept=LogFormat.JOURNAL,
|
||||
)
|
||||
|
@@ -14,7 +14,8 @@ from supervisor.host.control import SystemControl
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.systemd import Systemd as SystemdService
|
||||
|
||||
DEFAULT_RANGE = "entries=:-100:"
|
||||
DEFAULT_RANGE = "entries=:-99:100"
|
||||
DEFAULT_RANGE_FOLLOW = "entries=:-99:"
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
@@ -233,7 +234,7 @@ async def test_advanced_logs(
|
||||
"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers,
|
||||
"follow": "",
|
||||
},
|
||||
range_header=DEFAULT_RANGE,
|
||||
range_header=DEFAULT_RANGE_FOLLOW,
|
||||
accept=LogFormat.JOURNAL,
|
||||
)
|
||||
|
||||
@@ -249,7 +250,7 @@ async def test_advaced_logs_query_parameters(
|
||||
await api_client.get("/host/logs?lines=53")
|
||||
journald_logs.assert_called_once_with(
|
||||
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
|
||||
range_header="entries=:-53:",
|
||||
range_header="entries=:-52:53",
|
||||
accept=LogFormat.JOURNAL,
|
||||
)
|
||||
|
||||
@@ -277,7 +278,7 @@ async def test_advaced_logs_query_parameters(
|
||||
)
|
||||
journald_logs.assert_called_once_with(
|
||||
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
|
||||
range_header="entries=:-53:",
|
||||
range_header="entries=:-52:53",
|
||||
accept=LogFormat.JOURNAL,
|
||||
)
|
||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE)
|
||||
|
@@ -234,7 +234,7 @@ async def test_api_network_interface_update_ethernet(
|
||||
|
||||
|
||||
async def test_api_network_interface_update_wifi(api_client: TestClient):
|
||||
"""Test network manager api."""
|
||||
"""Test network interface WiFi API."""
|
||||
resp = await api_client.post(
|
||||
f"/network/interface/{TEST_INTERFACE_WLAN_NAME}/update",
|
||||
json={
|
||||
@@ -252,6 +252,30 @@ async def test_api_network_interface_update_wifi(api_client: TestClient):
|
||||
assert result["result"] == "ok"
|
||||
|
||||
|
||||
async def test_api_network_interface_update_wifi_error(api_client: TestClient):
|
||||
"""Test network interface WiFi API error handling."""
|
||||
# Simulate frontend WiFi interface edit where the user did not select
|
||||
# a WiFi SSID.
|
||||
resp = await api_client.post(
|
||||
f"/network/interface/{TEST_INTERFACE_WLAN_NAME}/update",
|
||||
json={
|
||||
"enabled": True,
|
||||
"ipv4": {
|
||||
"method": "auto",
|
||||
},
|
||||
"ipv6": {
|
||||
"method": "auto",
|
||||
},
|
||||
},
|
||||
)
|
||||
result = await resp.json()
|
||||
assert result["result"] == "error"
|
||||
assert (
|
||||
result["message"]
|
||||
== "Can't create config and activate wlan0: A 'wireless' setting with a valid SSID is required if no AP path was given."
|
||||
)
|
||||
|
||||
|
||||
async def test_api_network_interface_update_remove(api_client: TestClient):
|
||||
"""Test network manager api."""
|
||||
resp = await api_client.post(
|
||||
|
@@ -1169,6 +1169,40 @@ async def test_backup_progress(
|
||||
]
|
||||
|
||||
|
||||
async def test_backup_sync_notification(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
ha_ws_client: AsyncMock,
|
||||
tmp_supervisor_data,
|
||||
path_extern,
|
||||
):
|
||||
"""Test backup sync notification."""
|
||||
container.status = "running"
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
coresys.core.state = CoreState.RUNNING
|
||||
ha_ws_client.ha_version = AwesomeVersion("9999.9.9")
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
|
||||
ha_ws_client.async_send_command.reset_mock()
|
||||
partial_backup: Backup = await coresys.backups.do_backup_partial()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
messages = [
|
||||
call.args[0]
|
||||
for call in ha_ws_client.async_send_command.call_args_list
|
||||
if call.args[0]["type"] == WSType.BACKUP_SYNC
|
||||
]
|
||||
assert messages == [
|
||||
{
|
||||
"data": {
|
||||
"slug": partial_backup.slug,
|
||||
},
|
||||
"type": WSType.BACKUP_SYNC,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_restore_progress(
|
||||
request: pytest.FixtureRequest,
|
||||
coresys: CoreSys,
|
||||
|
@@ -1,12 +1,15 @@
|
||||
"""Common test functions."""
|
||||
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from inspect import getclosurevars
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
from supervisor.jobs.decorator import Job
|
||||
from supervisor.resolution.validate import get_valid_modules
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
|
||||
@@ -82,3 +85,17 @@ async def mock_dbus_services(
|
||||
services[module] = service_module.setup(to_mock[module]).export(bus)
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def get_job_decorator(func) -> Job:
|
||||
"""Get Job object of decorated function."""
|
||||
# Access the closure of the wrapper function
|
||||
job = getclosurevars(func).nonlocals["self"]
|
||||
if not isinstance(job, Job):
|
||||
raise TypeError(f"{func.__qualname__} is not a Job")
|
||||
return job
|
||||
|
||||
|
||||
def reset_last_call(func, group: str | None = None) -> None:
|
||||
"""Reset last call for a function using the Job decorator."""
|
||||
get_job_decorator(func).set_last_call(datetime.min, group)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Mock of Network Manager service."""
|
||||
|
||||
from dbus_fast import DBusError
|
||||
from dbus_fast.service import PropertyAccess, dbus_property, signal
|
||||
|
||||
from .base import DBusServiceMock, dbus_method
|
||||
@@ -227,6 +228,18 @@ class NetworkManager(DBusServiceMock):
|
||||
self, connection: "a{sa{sv}}", device: "o", speciic_object: "o"
|
||||
) -> "oo":
|
||||
"""Do AddAndActivateConnection method."""
|
||||
if connection["connection"]["type"].value == "802-11-wireless":
|
||||
if "802-11-wireless" not in connection:
|
||||
raise DBusError(
|
||||
"org.freedesktop.NetworkManager.Device.InvalidConnection",
|
||||
"A 'wireless' setting is required if no AP path was given.",
|
||||
)
|
||||
if "ssid" not in connection["802-11-wireless"]:
|
||||
raise DBusError(
|
||||
"org.freedesktop.NetworkManager.Device.InvalidConnection",
|
||||
"A 'wireless' setting with a valid SSID is required if no AP path was given.",
|
||||
)
|
||||
|
||||
return [
|
||||
"/org/freedesktop/NetworkManager/Settings/1",
|
||||
"/org/freedesktop/NetworkManager/ActiveConnection/1",
|
||||
|
@@ -7,6 +7,7 @@ from unittest.mock import PropertyMock, patch
|
||||
import pytest
|
||||
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.plugins.dns import PluginDns
|
||||
|
||||
from tests.dbus_service_mocks.network_manager import (
|
||||
NetworkManager as NetworkManagerService,
|
||||
@@ -84,3 +85,20 @@ async def test_connectivity_events(coresys: CoreSys, force: bool):
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_dns_restart_on_connection_change(
|
||||
coresys: CoreSys, network_manager_service: NetworkManagerService
|
||||
):
|
||||
"""Test dns plugin is restarted when primary connection changes."""
|
||||
await coresys.host.network.load()
|
||||
with patch.object(PluginDns, "restart") as restart:
|
||||
network_manager_service.emit_properties_changed({"PrimaryConnection": "/"})
|
||||
await network_manager_service.ping()
|
||||
restart.assert_not_called()
|
||||
|
||||
network_manager_service.emit_properties_changed(
|
||||
{"PrimaryConnection": "/org/freedesktop/NetworkManager/ActiveConnection/2"}
|
||||
)
|
||||
await network_manager_service.ping()
|
||||
restart.assert_called_once()
|
||||
|
@@ -40,9 +40,13 @@ async def test_logs(coresys: CoreSys, journald_gateway: MagicMock):
|
||||
journald_gateway.feed_eof()
|
||||
|
||||
async with coresys.host.logs.journald_logs() as resp:
|
||||
line = await anext(
|
||||
cursor, line = await anext(
|
||||
journal_logs_reader(resp, log_formatter=LogFormatter.VERBOSE)
|
||||
)
|
||||
assert (
|
||||
cursor
|
||||
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=203f2ce;b=f5a5c442fa6548cf97474d2d57c920b3;m=3191a3c620;t=612ccd299e7af;x=8675b540119d10bb"
|
||||
)
|
||||
assert (
|
||||
line
|
||||
== "2024-03-04 02:52:56.193 homeassistant systemd[1]: Started Hostname Service."
|
||||
@@ -64,7 +68,11 @@ async def test_logs_coloured(coresys: CoreSys, journald_gateway: MagicMock):
|
||||
journald_gateway.feed_eof()
|
||||
|
||||
async with coresys.host.logs.journald_logs() as resp:
|
||||
line = await anext(journal_logs_reader(resp))
|
||||
cursor, line = await anext(journal_logs_reader(resp))
|
||||
assert (
|
||||
cursor
|
||||
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
|
||||
)
|
||||
assert (
|
||||
line
|
||||
== "\x1b[32m24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor\x1b[0m"
|
||||
|
@@ -26,8 +26,11 @@ from supervisor.jobs.job_group import JobGroup
|
||||
from supervisor.os.manager import OSManager
|
||||
from supervisor.plugins.audio import PluginAudio
|
||||
from supervisor.resolution.const import UnhealthyReason
|
||||
from supervisor.supervisor import Supervisor
|
||||
from supervisor.utils.dt import utcnow
|
||||
|
||||
from tests.common import reset_last_call
|
||||
|
||||
|
||||
async def test_healthy(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test the healty decorator."""
|
||||
@@ -73,6 +76,7 @@ async def test_internet(
|
||||
):
|
||||
"""Test the internet decorator."""
|
||||
coresys.core.state = CoreState.RUNNING
|
||||
reset_last_call(Supervisor.check_connectivity)
|
||||
|
||||
class TestClass:
|
||||
"""Test class."""
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Test supervisor object."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import errno
|
||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
||||
|
||||
@@ -8,6 +8,7 @@ from aiohttp import ClientTimeout
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
from time_machine import travel
|
||||
|
||||
from supervisor.const import UpdateChannel
|
||||
from supervisor.coresys import CoreSys
|
||||
@@ -22,6 +23,8 @@ from supervisor.resolution.const import ContextType, IssueType
|
||||
from supervisor.resolution.data import Issue
|
||||
from supervisor.supervisor import Supervisor
|
||||
|
||||
from tests.common import reset_last_call
|
||||
|
||||
|
||||
@pytest.fixture(name="websession", scope="function")
|
||||
async def fixture_webession(coresys: CoreSys) -> AsyncMock:
|
||||
@@ -58,21 +61,33 @@ async def test_connectivity_check(
|
||||
assert supervisor_unthrottled.connectivity is connectivity
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side_effect,call_count", [(ClientError(), 3), (None, 1)])
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect,call_interval,throttled",
|
||||
[
|
||||
(None, timedelta(minutes=5), True),
|
||||
(None, timedelta(minutes=15), False),
|
||||
(ClientError(), timedelta(seconds=20), True),
|
||||
(ClientError(), timedelta(seconds=40), False),
|
||||
],
|
||||
)
|
||||
async def test_connectivity_check_throttling(
|
||||
coresys: CoreSys,
|
||||
websession: AsyncMock,
|
||||
side_effect: Exception | None,
|
||||
call_count: int,
|
||||
call_interval: timedelta,
|
||||
throttled: bool,
|
||||
):
|
||||
"""Test connectivity check throttled when checks succeed."""
|
||||
coresys.supervisor.connectivity = None
|
||||
websession.head.side_effect = side_effect
|
||||
|
||||
for _ in range(3):
|
||||
reset_last_call(Supervisor.check_connectivity)
|
||||
with travel(datetime.now(), tick=False) as traveller:
|
||||
await coresys.supervisor.check_connectivity()
|
||||
traveller.shift(call_interval)
|
||||
await coresys.supervisor.check_connectivity()
|
||||
|
||||
assert websession.head.call_count == call_count
|
||||
assert websession.head.call_count == (1 if throttled else 2)
|
||||
|
||||
|
||||
async def test_update_failed(coresys: CoreSys, capture_exception: Mock):
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Test systemd journal utilities."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -89,7 +89,7 @@ async def test_parsing_simple():
|
||||
"""Test plain formatter."""
|
||||
journal_logs, stream = _journal_logs_mock()
|
||||
stream.feed_data(b"MESSAGE=Hello, world!\n\n")
|
||||
line = await anext(journal_logs_reader(journal_logs))
|
||||
_, line = await anext(journal_logs_reader(journal_logs))
|
||||
assert line == "Hello, world!"
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_parsing_verbose():
|
||||
b"_PID=666\n"
|
||||
b"MESSAGE=Hello, world!\n\n"
|
||||
)
|
||||
line = await anext(
|
||||
_, line = await anext(
|
||||
journal_logs_reader(journal_logs, log_formatter=LogFormatter.VERBOSE)
|
||||
)
|
||||
assert line == "2013-09-17 07:32:51.000 homeassistant python[666]: Hello, world!"
|
||||
@@ -118,7 +118,7 @@ async def test_parsing_newlines_in_message():
|
||||
b"AFTER=after\n\n"
|
||||
)
|
||||
|
||||
line = await anext(journal_logs_reader(journal_logs))
|
||||
_, line = await anext(journal_logs_reader(journal_logs))
|
||||
assert line == "Hello,\nworld!"
|
||||
|
||||
|
||||
@@ -135,8 +135,8 @@ async def test_parsing_newlines_in_multiple_fields():
|
||||
b"AFTER=after\n\n"
|
||||
)
|
||||
|
||||
assert await anext(journal_logs_reader(journal_logs)) == "Hello,\nworld!\n"
|
||||
assert await anext(journal_logs_reader(journal_logs)) == "Hello,\nworld!"
|
||||
assert await anext(journal_logs_reader(journal_logs)) == (ANY, "Hello,\nworld!\n")
|
||||
assert await anext(journal_logs_reader(journal_logs)) == (ANY, "Hello,\nworld!")
|
||||
|
||||
|
||||
async def test_parsing_two_messages():
|
||||
@@ -151,8 +151,31 @@ async def test_parsing_two_messages():
|
||||
stream.feed_eof()
|
||||
|
||||
reader = journal_logs_reader(journal_logs)
|
||||
assert await anext(reader) == "Hello, world!"
|
||||
assert await anext(reader) == "Hello again, world!"
|
||||
assert await anext(reader) == (ANY, "Hello, world!")
|
||||
assert await anext(reader) == (ANY, "Hello again, world!")
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(reader)
|
||||
|
||||
|
||||
async def test_cursor_parsing():
|
||||
"""Test cursor is extracted correctly."""
|
||||
journal_logs, stream = _journal_logs_mock()
|
||||
stream.feed_data(
|
||||
b"__CURSOR=cursor1\n"
|
||||
b"MESSAGE=Hello, world!\n"
|
||||
b"ID=1\n\n"
|
||||
b"__CURSOR=cursor2\n"
|
||||
b"MESSAGE=Hello again, world!\n"
|
||||
b"ID=2\n\n"
|
||||
b"MESSAGE=No cursor\n"
|
||||
b"ID=2\n\n"
|
||||
)
|
||||
stream.feed_eof()
|
||||
|
||||
reader = journal_logs_reader(journal_logs)
|
||||
assert await anext(reader) == ("cursor1", "Hello, world!")
|
||||
assert await anext(reader) == ("cursor2", "Hello again, world!")
|
||||
assert await anext(reader) == (None, "No cursor")
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(reader)
|
||||
|
||||
@@ -174,7 +197,7 @@ async def test_parsing_journal_host_logs():
|
||||
"""Test parsing of real host logs."""
|
||||
journal_logs, stream = _journal_logs_mock()
|
||||
stream.feed_data(load_fixture("logs_export_host.txt").encode("utf-8"))
|
||||
line = await anext(journal_logs_reader(journal_logs))
|
||||
_, line = await anext(journal_logs_reader(journal_logs))
|
||||
assert line == "Started Hostname Service."
|
||||
|
||||
|
||||
@@ -182,7 +205,7 @@ async def test_parsing_colored_supervisor_logs():
|
||||
"""Test parsing of real logs with ANSI escape sequences."""
|
||||
journal_logs, stream = _journal_logs_mock()
|
||||
stream.feed_data(load_fixture("logs_export_supervisor.txt").encode("utf-8"))
|
||||
line = await anext(journal_logs_reader(journal_logs))
|
||||
_, line = await anext(journal_logs_reader(journal_logs))
|
||||
assert (
|
||||
line
|
||||
== "\x1b[32m24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor\x1b[0m"
|
||||
|
Reference in New Issue
Block a user