mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-11-27 11:38:07 +00:00
Compare commits
4 Commits
2025.11.5
...
check-fron
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
febfaf8db1 | ||
|
|
5d3a568b48 | ||
|
|
894f8ea226 | ||
|
|
96e6c0b15b |
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
requirements: ${{ steps.requirements.outputs.changed }}
|
requirements: ${{ steps.requirements.outputs.changed }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Initialize git
|
- name: Initialize git
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
# home-assistant/builder doesn't support sha pinning
|
# home-assistant/builder doesn't support sha pinning
|
||||||
- name: Build the Supervisor
|
- name: Build the Supervisor
|
||||||
|
|||||||
20
.github/workflows/ci.yaml
vendored
20
.github/workflows/ci.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
name: Prepare Python dependencies
|
name: Prepare Python dependencies
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Register hadolint problem matcher
|
- name: Register hadolint problem matcher
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -213,7 +213,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -257,7 +257,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -293,7 +293,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -339,7 +339,7 @@ jobs:
|
|||||||
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
@@ -398,7 +398,7 @@ jobs:
|
|||||||
needs: ["pytest", "prepare"]
|
needs: ["pytest", "prepare"]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
id: python
|
id: python
|
||||||
|
|||||||
2
.github/workflows/release-drafter.yml
vendored
2
.github/workflows/release-drafter.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Release Drafter
|
name: Release Drafter
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/sentry.yaml
vendored
2
.github/workflows/sentry.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Sentry Release
|
- name: Sentry Release
|
||||||
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3.4.0
|
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3.4.0
|
||||||
env:
|
env:
|
||||||
|
|||||||
6
.github/workflows/update_frontend.yml
vendored
6
.github/workflows/update_frontend.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
latest_version: ${{ steps.latest_frontend_version.outputs.latest_tag }}
|
latest_version: ${{ steps.latest_frontend_version.outputs.latest_tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Get latest frontend release
|
- name: Get latest frontend release
|
||||||
id: latest_frontend_version
|
id: latest_frontend_version
|
||||||
uses: abatilo/release-info-action@32cb932219f1cee3fc4f4a298fd65ead5d35b661 # v1.3.3
|
uses: abatilo/release-info-action@32cb932219f1cee3fc4f4a298fd65ead5d35b661 # v1.3.3
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
if: needs.check-version.outputs.skip != 'true'
|
if: needs.check-version.outputs.skip != 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Clear www folder
|
- name: Clear www folder
|
||||||
run: |
|
run: |
|
||||||
rm -rf supervisor/api/panel/*
|
rm -rf supervisor/api/panel/*
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
|
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
|
||||||
- name: Create PR
|
- name: Create PR
|
||||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||||
with:
|
with:
|
||||||
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
|
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
|
||||||
branch: autoupdate-frontend
|
branch: autoupdate-frontend
|
||||||
|
|||||||
13
build.yaml
13
build.yaml
@@ -1,10 +1,13 @@
|
|||||||
image: ghcr.io/home-assistant/{arch}-hassio-supervisor
|
image: ghcr.io/home-assistant/{arch}-hassio-supervisor
|
||||||
build_from:
|
build_from:
|
||||||
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22-2025.11.1
|
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22
|
||||||
armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22-2025.11.1
|
armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22
|
||||||
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22-2025.11.1
|
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22
|
||||||
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22-2025.11.1
|
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22
|
||||||
i386: ghcr.io/home-assistant/i386-base-python:3.13-alpine3.22-2025.11.1
|
i386: ghcr.io/home-assistant/i386-base-python:3.13-alpine3.22
|
||||||
|
codenotary:
|
||||||
|
signer: notary@home-assistant.io
|
||||||
|
base_image: notary@home-assistant.io
|
||||||
cosign:
|
cosign:
|
||||||
base_identity: https://github.com/home-assistant/docker-base/.*
|
base_identity: https://github.com/home-assistant/docker-base/.*
|
||||||
identity: https://github.com/home-assistant/supervisor/.*
|
identity: https://github.com/home-assistant/supervisor/.*
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ aiohttp==3.13.2
|
|||||||
atomicwrites-homeassistant==1.4.1
|
atomicwrites-homeassistant==1.4.1
|
||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
awesomeversion==25.8.0
|
awesomeversion==25.8.0
|
||||||
backports.zstd==1.1.0
|
backports.zstd==1.0.0
|
||||||
blockbuster==1.5.25
|
blockbuster==1.5.25
|
||||||
brotli==1.2.0
|
brotli==1.2.0
|
||||||
ciso8601==2.3.3
|
ciso8601==2.3.3
|
||||||
@@ -25,7 +25,7 @@ pyudev==0.24.4
|
|||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
securetar==2025.2.1
|
securetar==2025.2.1
|
||||||
sentry-sdk==2.45.0
|
sentry-sdk==2.44.0
|
||||||
setuptools==80.9.0
|
setuptools==80.9.0
|
||||||
voluptuous==0.15.2
|
voluptuous==0.15.2
|
||||||
dbus-fast==2.45.1
|
dbus-fast==2.45.1
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
astroid==4.0.2
|
astroid==4.0.2
|
||||||
coverage==7.12.0
|
coverage==7.11.3
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
pre-commit==4.5.0
|
pre-commit==4.4.0
|
||||||
pylint==4.0.3
|
pylint==4.0.2
|
||||||
pytest-aiohttp==1.1.0
|
pytest-aiohttp==1.1.0
|
||||||
pytest-asyncio==1.3.0
|
pytest-asyncio==1.3.0
|
||||||
pytest-cov==7.0.0
|
pytest-cov==7.0.0
|
||||||
pytest-timeout==2.4.0
|
pytest-timeout==2.4.0
|
||||||
pytest==9.0.1
|
pytest==9.0.1
|
||||||
ruff==0.14.6
|
ruff==0.14.4
|
||||||
time-machine==3.1.0
|
time-machine==2.19.0
|
||||||
types-docker==7.1.0.20251009
|
types-docker==7.1.0.20251009
|
||||||
types-pyyaml==6.0.12.20250915
|
types-pyyaml==6.0.12.20250915
|
||||||
types-requests==2.32.4.20250913
|
types-requests==2.32.4.20250913
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self._api_host.advanced_logs,
|
self._api_host.advanced_logs,
|
||||||
identifier=syslog_identifier,
|
identifier=syslog_identifier,
|
||||||
latest=True,
|
latest=True,
|
||||||
no_colors=True,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
web.get(
|
web.get(
|
||||||
@@ -450,7 +449,6 @@ class RestAPI(CoreSysAttributes):
|
|||||||
await async_capture_exception(err)
|
await async_capture_exception(err)
|
||||||
kwargs.pop("follow", None) # Follow is not supported for Docker logs
|
kwargs.pop("follow", None) # Follow is not supported for Docker logs
|
||||||
kwargs.pop("latest", None) # Latest is not supported for Docker logs
|
kwargs.pop("latest", None) # Latest is not supported for Docker logs
|
||||||
kwargs.pop("no_colors", None) # no_colors not supported for Docker logs
|
|
||||||
return await api_supervisor.logs(*args, **kwargs)
|
return await api_supervisor.logs(*args, **kwargs)
|
||||||
|
|
||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
@@ -462,7 +460,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
),
|
),
|
||||||
web.get(
|
web.get(
|
||||||
"/supervisor/logs/latest",
|
"/supervisor/logs/latest",
|
||||||
partial(get_supervisor_logs, latest=True, no_colors=True),
|
partial(get_supervisor_logs, latest=True),
|
||||||
),
|
),
|
||||||
web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs),
|
web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs),
|
||||||
web.get(
|
web.get(
|
||||||
@@ -578,7 +576,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
),
|
),
|
||||||
web.get(
|
web.get(
|
||||||
"/addons/{addon}/logs/latest",
|
"/addons/{addon}/logs/latest",
|
||||||
partial(get_addon_logs, latest=True, no_colors=True),
|
partial(get_addon_logs, latest=True),
|
||||||
),
|
),
|
||||||
web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs),
|
web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs),
|
||||||
web.get(
|
web.get(
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ class APIHost(CoreSysAttributes):
|
|||||||
identifier: str | None = None,
|
identifier: str | None = None,
|
||||||
follow: bool = False,
|
follow: bool = False,
|
||||||
latest: bool = False,
|
latest: bool = False,
|
||||||
no_colors: bool = False,
|
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
"""Return systemd-journald logs."""
|
"""Return systemd-journald logs."""
|
||||||
log_formatter = LogFormatter.PLAIN
|
log_formatter = LogFormatter.PLAIN
|
||||||
@@ -252,9 +251,6 @@ class APIHost(CoreSysAttributes):
|
|||||||
if "verbose" in request.query or request.headers[ACCEPT] == CONTENT_TYPE_X_LOG:
|
if "verbose" in request.query or request.headers[ACCEPT] == CONTENT_TYPE_X_LOG:
|
||||||
log_formatter = LogFormatter.VERBOSE
|
log_formatter = LogFormatter.VERBOSE
|
||||||
|
|
||||||
if "no_colors" in request.query:
|
|
||||||
no_colors = True
|
|
||||||
|
|
||||||
if "lines" in request.query:
|
if "lines" in request.query:
|
||||||
lines = request.query.get("lines", DEFAULT_LINES)
|
lines = request.query.get("lines", DEFAULT_LINES)
|
||||||
try:
|
try:
|
||||||
@@ -284,9 +280,7 @@ class APIHost(CoreSysAttributes):
|
|||||||
response = web.StreamResponse()
|
response = web.StreamResponse()
|
||||||
response.content_type = CONTENT_TYPE_TEXT
|
response.content_type = CONTENT_TYPE_TEXT
|
||||||
headers_returned = False
|
headers_returned = False
|
||||||
async for cursor, line in journal_logs_reader(
|
async for cursor, line in journal_logs_reader(resp, log_formatter):
|
||||||
resp, log_formatter, no_colors
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
if not headers_returned:
|
if not headers_returned:
|
||||||
if cursor:
|
if cursor:
|
||||||
@@ -324,12 +318,9 @@ class APIHost(CoreSysAttributes):
|
|||||||
identifier: str | None = None,
|
identifier: str | None = None,
|
||||||
follow: bool = False,
|
follow: bool = False,
|
||||||
latest: bool = False,
|
latest: bool = False,
|
||||||
no_colors: bool = False,
|
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
"""Return systemd-journald logs. Wrapped as standard API handler."""
|
"""Return systemd-journald logs. Wrapped as standard API handler."""
|
||||||
return await self.advanced_logs_handler(
|
return await self.advanced_logs_handler(request, identifier, follow, latest)
|
||||||
request, identifier, follow, latest, no_colors
|
|
||||||
)
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def disk_usage(self, request: web.Request) -> dict:
|
async def disk_usage(self, request: web.Request) -> dict:
|
||||||
@@ -343,7 +334,7 @@ class APIHost(CoreSysAttributes):
|
|||||||
|
|
||||||
disk = self.sys_hardware.disk
|
disk = self.sys_hardware.disk
|
||||||
|
|
||||||
total, _, free = await self.sys_run_in_executor(
|
total, used, _ = await self.sys_run_in_executor(
|
||||||
disk.disk_usage, self.sys_config.path_supervisor
|
disk.disk_usage, self.sys_config.path_supervisor
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -365,13 +356,12 @@ class APIHost(CoreSysAttributes):
|
|||||||
"id": "root",
|
"id": "root",
|
||||||
"label": "Root",
|
"label": "Root",
|
||||||
"total_bytes": total,
|
"total_bytes": total,
|
||||||
"used_bytes": total - free,
|
"used_bytes": used,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "system",
|
"id": "system",
|
||||||
"label": "System",
|
"label": "System",
|
||||||
"used_bytes": total
|
"used_bytes": used
|
||||||
- free
|
|
||||||
- sum(path["used_bytes"] for path in known_paths),
|
- sum(path["used_bytes"] for path in known_paths),
|
||||||
},
|
},
|
||||||
*known_paths,
|
*known_paths,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from datetime import UTC, datetime, tzinfo
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import TYPE_CHECKING, Any, Self, TypeVar
|
from typing import TYPE_CHECKING, Any, Self, TypeVar
|
||||||
|
|
||||||
@@ -656,14 +655,8 @@ class CoreSys:
|
|||||||
if kwargs:
|
if kwargs:
|
||||||
funct = partial(funct, **kwargs)
|
funct = partial(funct, **kwargs)
|
||||||
|
|
||||||
# Convert datetime to event loop time base
|
|
||||||
# If datetime is in the past, delay will be negative and call_at will
|
|
||||||
# schedule the call as soon as possible.
|
|
||||||
delay = when.timestamp() - time.time()
|
|
||||||
loop_time = self.loop.time() + delay
|
|
||||||
|
|
||||||
return self.loop.call_at(
|
return self.loop.call_at(
|
||||||
loop_time, funct, *args, context=self._create_context()
|
when.timestamp(), funct, *args, context=self._create_context()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -306,8 +306,6 @@ class DeviceType(IntEnum):
|
|||||||
VLAN = 11
|
VLAN = 11
|
||||||
TUN = 16
|
TUN = 16
|
||||||
VETH = 20
|
VETH = 20
|
||||||
WIREGUARD = 29
|
|
||||||
LOOPBACK = 32
|
|
||||||
|
|
||||||
|
|
||||||
class WirelessMethodType(IntEnum):
|
class WirelessMethodType(IntEnum):
|
||||||
|
|||||||
@@ -134,10 +134,9 @@ class NetworkManager(DBusInterfaceProxy):
|
|||||||
async def check_connectivity(self, *, force: bool = False) -> ConnectivityState:
|
async def check_connectivity(self, *, force: bool = False) -> ConnectivityState:
|
||||||
"""Check the connectivity of the host."""
|
"""Check the connectivity of the host."""
|
||||||
if force:
|
if force:
|
||||||
return ConnectivityState(
|
return await self.connected_dbus.call("check_connectivity")
|
||||||
await self.connected_dbus.call("check_connectivity")
|
else:
|
||||||
)
|
return await self.connected_dbus.get("connectivity")
|
||||||
return ConnectivityState(await self.connected_dbus.get("connectivity"))
|
|
||||||
|
|
||||||
async def connect(self, bus: MessageBus) -> None:
|
async def connect(self, bus: MessageBus) -> None:
|
||||||
"""Connect to system's D-Bus."""
|
"""Connect to system's D-Bus."""
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class NetworkConnection(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def state(self) -> ConnectionStateType:
|
def state(self) -> ConnectionStateType:
|
||||||
"""Return the state of the connection."""
|
"""Return the state of the connection."""
|
||||||
return ConnectionStateType(self.properties[DBUS_ATTR_STATE])
|
return self.properties[DBUS_ATTR_STATE]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state_flags(self) -> set[ConnectionStateFlags]:
|
def state_flags(self) -> set[ConnectionStateFlags]:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""NetworkInterface object for Network Manager."""
|
"""NetworkInterface object for Network Manager."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from dbus_fast.aio.message_bus import MessageBus
|
from dbus_fast.aio.message_bus import MessageBus
|
||||||
@@ -24,8 +23,6 @@ from .connection import NetworkConnection
|
|||||||
from .setting import NetworkSetting
|
from .setting import NetworkSetting
|
||||||
from .wireless import NetworkWireless
|
from .wireless import NetworkWireless
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkInterface(DBusInterfaceProxy):
|
class NetworkInterface(DBusInterfaceProxy):
|
||||||
"""NetworkInterface object represents Network Manager Device objects.
|
"""NetworkInterface object represents Network Manager Device objects.
|
||||||
@@ -60,15 +57,7 @@ class NetworkInterface(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def type(self) -> DeviceType:
|
def type(self) -> DeviceType:
|
||||||
"""Return interface type."""
|
"""Return interface type."""
|
||||||
try:
|
return self.properties[DBUS_ATTR_DEVICE_TYPE]
|
||||||
return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE])
|
|
||||||
except ValueError:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Unknown device type %s for %s, treating as UNKNOWN",
|
|
||||||
self.properties[DBUS_ATTR_DEVICE_TYPE],
|
|
||||||
self.object_path,
|
|
||||||
)
|
|
||||||
return DeviceType.UNKNOWN
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
|
|||||||
@@ -310,8 +310,6 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
if (
|
if (
|
||||||
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
|
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
|
||||||
and reference.progress_detail
|
and reference.progress_detail
|
||||||
and reference.progress_detail.current is not None
|
|
||||||
and reference.progress_detail.total is not None
|
|
||||||
):
|
):
|
||||||
job.update(
|
job.update(
|
||||||
progress=progress,
|
progress=progress,
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ class HomeAssistantAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
async def get_config(self) -> dict[str, Any]:
|
async def get_config(self) -> dict[str, Any]:
|
||||||
"""Return Home Assistant config."""
|
"""Return Home Assistant config."""
|
||||||
return await self._get_json("api/config")
|
config = await self._get_json("api/config")
|
||||||
|
if config is None or not isinstance(config, dict):
|
||||||
|
raise HomeAssistantAPIError("No config received from Home Assistant API")
|
||||||
|
return config
|
||||||
|
|
||||||
async def get_core_state(self) -> dict[str, Any]:
|
async def get_core_state(self) -> dict[str, Any]:
|
||||||
"""Return Home Assistant core state."""
|
"""Return Home Assistant core state."""
|
||||||
@@ -219,3 +222,36 @@ class HomeAssistantAPI(CoreSysAttributes):
|
|||||||
if state := await self.get_api_state():
|
if state := await self.get_api_state():
|
||||||
return state.core_state == "RUNNING" or state.offline_db_migration
|
return state.core_state == "RUNNING" or state.offline_db_migration
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def check_frontend_available(self) -> bool:
|
||||||
|
"""Check if the frontend is accessible by fetching the root path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the frontend responds successfully, False otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Skip check on landingpage
|
||||||
|
if (
|
||||||
|
self.sys_homeassistant.version is None
|
||||||
|
or self.sys_homeassistant.version == LANDINGPAGE
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.make_request("get", "", timeout=30) as resp:
|
||||||
|
# Frontend should return HTML content
|
||||||
|
if resp.status == 200:
|
||||||
|
content_type = resp.headers.get(hdrs.CONTENT_TYPE, "")
|
||||||
|
if "text/html" in content_type:
|
||||||
|
_LOGGER.debug("Frontend is accessible and serving HTML")
|
||||||
|
return True
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Frontend responded but with unexpected content type: %s",
|
||||||
|
content_type,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
_LOGGER.warning("Frontend returned status %s", resp.status)
|
||||||
|
return False
|
||||||
|
except HomeAssistantAPIError as err:
|
||||||
|
_LOGGER.debug("Cannot reach frontend: %s", err)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -303,12 +303,18 @@ class HomeAssistantCore(JobGroup):
|
|||||||
except HomeAssistantError:
|
except HomeAssistantError:
|
||||||
# The API stoped responding between the up checks an now
|
# The API stoped responding between the up checks an now
|
||||||
self._error_state = True
|
self._error_state = True
|
||||||
data = None
|
return
|
||||||
|
|
||||||
# Verify that the frontend is loaded
|
# Verify that the frontend is loaded
|
||||||
if data and "frontend" not in data.get("components", []):
|
if "frontend" not in data.get("components", []):
|
||||||
_LOGGER.error("API responds but frontend is not loaded")
|
_LOGGER.error("API responds but frontend is not loaded")
|
||||||
self._error_state = True
|
self._error_state = True
|
||||||
|
# Check that the frontend is actually accessible
|
||||||
|
elif not await self.sys_homeassistant.api.check_frontend_available():
|
||||||
|
_LOGGER.error(
|
||||||
|
"Frontend component loaded but frontend is not accessible"
|
||||||
|
)
|
||||||
|
self._error_state = True
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -321,12 +327,12 @@ class HomeAssistantCore(JobGroup):
|
|||||||
|
|
||||||
# Make a copy of the current log file if it exists
|
# Make a copy of the current log file if it exists
|
||||||
logfile = self.sys_config.path_homeassistant / "home-assistant.log"
|
logfile = self.sys_config.path_homeassistant / "home-assistant.log"
|
||||||
if logfile.exists():
|
if await self.sys_run_in_executor(logfile.exists):
|
||||||
rollback_log = (
|
rollback_log = (
|
||||||
self.sys_config.path_homeassistant / "home-assistant-rollback.log"
|
self.sys_config.path_homeassistant / "home-assistant-rollback.log"
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.copy(logfile, rollback_log)
|
await self.sys_run_in_executor(shutil.copy, logfile, rollback_log)
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"A backup of the logfile is stored in /config/home-assistant-rollback.log"
|
"A backup of the logfile is stored in /config/home-assistant-rollback.log"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ class JobCondition(StrEnum):
|
|||||||
PLUGINS_UPDATED = "plugins_updated"
|
PLUGINS_UPDATED = "plugins_updated"
|
||||||
RUNNING = "running"
|
RUNNING = "running"
|
||||||
SUPERVISOR_UPDATED = "supervisor_updated"
|
SUPERVISOR_UPDATED = "supervisor_updated"
|
||||||
ARCHITECTURE_SUPPORTED = "architecture_supported"
|
|
||||||
|
|
||||||
|
|
||||||
class JobConcurrency(StrEnum):
|
class JobConcurrency(StrEnum):
|
||||||
|
|||||||
@@ -441,14 +441,6 @@ class Job(CoreSysAttributes):
|
|||||||
raise JobConditionException(
|
raise JobConditionException(
|
||||||
f"'{method_name}' blocked from execution, supervisor needs to be updated first"
|
f"'{method_name}' blocked from execution, supervisor needs to be updated first"
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
JobCondition.ARCHITECTURE_SUPPORTED in used_conditions
|
|
||||||
and UnsupportedReason.SYSTEM_ARCHITECTURE
|
|
||||||
in coresys.sys_resolution.unsupported
|
|
||||||
):
|
|
||||||
raise JobConditionException(
|
|
||||||
f"'{method_name}' blocked from execution, unsupported system architecture"
|
|
||||||
)
|
|
||||||
|
|
||||||
if JobCondition.PLUGINS_UPDATED in used_conditions and (
|
if JobCondition.PLUGINS_UPDATED in used_conditions and (
|
||||||
out_of_date := [
|
out_of_date := [
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ class Tasks(CoreSysAttributes):
|
|||||||
JobCondition.INTERNET_HOST,
|
JobCondition.INTERNET_HOST,
|
||||||
JobCondition.OS_SUPPORTED,
|
JobCondition.OS_SUPPORTED,
|
||||||
JobCondition.RUNNING,
|
JobCondition.RUNNING,
|
||||||
JobCondition.ARCHITECTURE_SUPPORTED,
|
|
||||||
],
|
],
|
||||||
concurrency=JobConcurrency.REJECT,
|
concurrency=JobConcurrency.REJECT,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,5 +23,4 @@ PLUGIN_UPDATE_CONDITIONS = [
|
|||||||
JobCondition.HEALTHY,
|
JobCondition.HEALTHY,
|
||||||
JobCondition.INTERNET_HOST,
|
JobCondition.INTERNET_HOST,
|
||||||
JobCondition.SUPERVISOR_UPDATED,
|
JobCondition.SUPERVISOR_UPDATED,
|
||||||
JobCondition.ARCHITECTURE_SUPPORTED,
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ class UnsupportedReason(StrEnum):
|
|||||||
SYSTEMD_JOURNAL = "systemd_journal"
|
SYSTEMD_JOURNAL = "systemd_journal"
|
||||||
SYSTEMD_RESOLVED = "systemd_resolved"
|
SYSTEMD_RESOLVED = "systemd_resolved"
|
||||||
VIRTUALIZATION_IMAGE = "virtualization_image"
|
VIRTUALIZATION_IMAGE = "virtualization_image"
|
||||||
SYSTEM_ARCHITECTURE = "system_architecture"
|
|
||||||
|
|
||||||
|
|
||||||
class UnhealthyReason(StrEnum):
|
class UnhealthyReason(StrEnum):
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from ...coresys import CoreSys
|
|||||||
from ..const import UnsupportedReason
|
from ..const import UnsupportedReason
|
||||||
from .base import EvaluateBase
|
from .base import EvaluateBase
|
||||||
|
|
||||||
|
SUPPORTED_OS = ["Debian GNU/Linux 12 (bookworm)"]
|
||||||
|
|
||||||
|
|
||||||
def setup(coresys: CoreSys) -> EvaluateBase:
|
def setup(coresys: CoreSys) -> EvaluateBase:
|
||||||
"""Initialize evaluation-setup function."""
|
"""Initialize evaluation-setup function."""
|
||||||
@@ -31,4 +33,6 @@ class EvaluateOperatingSystem(EvaluateBase):
|
|||||||
|
|
||||||
async def evaluate(self) -> bool:
|
async def evaluate(self) -> bool:
|
||||||
"""Run evaluation."""
|
"""Run evaluation."""
|
||||||
return not self.sys_os.available
|
if self.sys_os.available:
|
||||||
|
return False
|
||||||
|
return self.sys_host.info.operating_system not in SUPPORTED_OS
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Evaluation class for system architecture support."""
|
|
||||||
|
|
||||||
from ...const import CoreState
|
|
||||||
from ...coresys import CoreSys
|
|
||||||
from ..const import UnsupportedReason
|
|
||||||
from .base import EvaluateBase
|
|
||||||
|
|
||||||
|
|
||||||
def setup(coresys: CoreSys) -> EvaluateBase:
|
|
||||||
"""Initialize evaluation-setup function."""
|
|
||||||
return EvaluateSystemArchitecture(coresys)
|
|
||||||
|
|
||||||
|
|
||||||
class EvaluateSystemArchitecture(EvaluateBase):
|
|
||||||
"""Evaluate if the current Supervisor architecture is supported."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def reason(self) -> UnsupportedReason:
|
|
||||||
"""Return a UnsupportedReason enum."""
|
|
||||||
return UnsupportedReason.SYSTEM_ARCHITECTURE
|
|
||||||
|
|
||||||
@property
|
|
||||||
def on_failure(self) -> str:
|
|
||||||
"""Return a string that is printed when self.evaluate is True."""
|
|
||||||
return "System architecture is no longer supported. Move to a supported system architecture."
|
|
||||||
|
|
||||||
@property
|
|
||||||
def states(self) -> list[CoreState]:
|
|
||||||
"""Return a list of valid states when this evaluation can run."""
|
|
||||||
return [CoreState.INITIALIZE]
|
|
||||||
|
|
||||||
async def evaluate(self):
|
|
||||||
"""Run evaluation."""
|
|
||||||
return self.sys_host.info.sys_arch.supervisor in {
|
|
||||||
"i386",
|
|
||||||
"armhf",
|
|
||||||
"armv7",
|
|
||||||
}
|
|
||||||
@@ -242,10 +242,9 @@ class Updater(FileConfiguration, CoreSysAttributes):
|
|||||||
@Job(
|
@Job(
|
||||||
name="updater_fetch_data",
|
name="updater_fetch_data",
|
||||||
conditions=[
|
conditions=[
|
||||||
JobCondition.ARCHITECTURE_SUPPORTED,
|
|
||||||
JobCondition.INTERNET_SYSTEM,
|
JobCondition.INTERNET_SYSTEM,
|
||||||
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
|
|
||||||
JobCondition.OS_SUPPORTED,
|
JobCondition.OS_SUPPORTED,
|
||||||
|
JobCondition.HOME_ASSISTANT_CORE_SUPPORTED,
|
||||||
],
|
],
|
||||||
on_condition=UpdaterJobError,
|
on_condition=UpdaterJobError,
|
||||||
throttle_period=timedelta(seconds=30),
|
throttle_period=timedelta(seconds=30),
|
||||||
|
|||||||
@@ -5,20 +5,12 @@ from collections.abc import AsyncGenerator
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
|
|
||||||
from aiohttp import ClientResponse
|
from aiohttp import ClientResponse
|
||||||
|
|
||||||
from supervisor.exceptions import MalformedBinaryEntryError
|
from supervisor.exceptions import MalformedBinaryEntryError
|
||||||
from supervisor.host.const import LogFormatter
|
from supervisor.host.const import LogFormatter
|
||||||
|
|
||||||
_RE_ANSI_CSI_COLORS_PATTERN = re.compile(r"\x1B\[[0-9;]*m")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_ansi_colors(message: str) -> str:
|
|
||||||
"""Remove ANSI color codes from a message string."""
|
|
||||||
return _RE_ANSI_CSI_COLORS_PATTERN.sub("", message)
|
|
||||||
|
|
||||||
|
|
||||||
def formatter(required_fields: list[str]):
|
def formatter(required_fields: list[str]):
|
||||||
"""Decorate journal entry formatters with list of required fields.
|
"""Decorate journal entry formatters with list of required fields.
|
||||||
@@ -39,9 +31,9 @@ def formatter(required_fields: list[str]):
|
|||||||
|
|
||||||
|
|
||||||
@formatter(["MESSAGE"])
|
@formatter(["MESSAGE"])
|
||||||
def journal_plain_formatter(entries: dict[str, str], no_colors: bool = False) -> str:
|
def journal_plain_formatter(entries: dict[str, str]) -> str:
|
||||||
"""Format parsed journal entries as a plain message."""
|
"""Format parsed journal entries as a plain message."""
|
||||||
return _strip_ansi_colors(entries["MESSAGE"]) if no_colors else entries["MESSAGE"]
|
return entries["MESSAGE"]
|
||||||
|
|
||||||
|
|
||||||
@formatter(
|
@formatter(
|
||||||
@@ -53,7 +45,7 @@ def journal_plain_formatter(entries: dict[str, str], no_colors: bool = False) ->
|
|||||||
"MESSAGE",
|
"MESSAGE",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
def journal_verbose_formatter(entries: dict[str, str], no_colors: bool = False) -> str:
|
def journal_verbose_formatter(entries: dict[str, str]) -> str:
|
||||||
"""Format parsed journal entries to a journalctl-like format."""
|
"""Format parsed journal entries to a journalctl-like format."""
|
||||||
ts = datetime.fromtimestamp(
|
ts = datetime.fromtimestamp(
|
||||||
int(entries["__REALTIME_TIMESTAMP"]) / 1e6, UTC
|
int(entries["__REALTIME_TIMESTAMP"]) / 1e6, UTC
|
||||||
@@ -66,24 +58,14 @@ def journal_verbose_formatter(entries: dict[str, str], no_colors: bool = False)
|
|||||||
else entries.get("SYSLOG_IDENTIFIER", "_UNKNOWN_")
|
else entries.get("SYSLOG_IDENTIFIER", "_UNKNOWN_")
|
||||||
)
|
)
|
||||||
|
|
||||||
message = (
|
return f"{ts} {entries.get('_HOSTNAME', '')} {identifier}: {entries.get('MESSAGE', '')}"
|
||||||
_strip_ansi_colors(entries.get("MESSAGE", ""))
|
|
||||||
if no_colors
|
|
||||||
else entries.get("MESSAGE", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"{ts} {entries.get('_HOSTNAME', '')} {identifier}: {message}"
|
|
||||||
|
|
||||||
|
|
||||||
async def journal_logs_reader(
|
async def journal_logs_reader(
|
||||||
journal_logs: ClientResponse,
|
journal_logs: ClientResponse, log_formatter: LogFormatter = LogFormatter.PLAIN
|
||||||
log_formatter: LogFormatter = LogFormatter.PLAIN,
|
|
||||||
no_colors: bool = False,
|
|
||||||
) -> AsyncGenerator[tuple[str | None, str]]:
|
) -> AsyncGenerator[tuple[str | None, str]]:
|
||||||
"""Read logs from systemd journal line by line, formatted using the given formatter.
|
"""Read logs from systemd journal line by line, formatted using the given formatter.
|
||||||
|
|
||||||
Optionally strip ANSI color codes from the entries' messages.
|
|
||||||
|
|
||||||
Returns a generator of (cursor, formatted_entry) tuples.
|
Returns a generator of (cursor, formatted_entry) tuples.
|
||||||
"""
|
"""
|
||||||
match log_formatter:
|
match log_formatter:
|
||||||
@@ -102,10 +84,7 @@ async def journal_logs_reader(
|
|||||||
# at EOF (likely race between at_eof and EOF check in readuntil)
|
# at EOF (likely race between at_eof and EOF check in readuntil)
|
||||||
if line == b"\n" or not line:
|
if line == b"\n" or not line:
|
||||||
if entries:
|
if entries:
|
||||||
yield (
|
yield entries.get("__CURSOR"), formatter_(entries)
|
||||||
entries.get("__CURSOR"),
|
|
||||||
formatter_(entries, no_colors=no_colors),
|
|
||||||
)
|
|
||||||
entries = {}
|
entries = {}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,95 @@
|
|||||||
"""Test for API calls."""
|
"""Test for API calls."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from aiohttp.test_utils import TestClient
|
||||||
|
|
||||||
|
from supervisor.coresys import CoreSys
|
||||||
|
from supervisor.host.const import LogFormat
|
||||||
|
|
||||||
|
DEFAULT_LOG_RANGE = "entries=:-99:100"
|
||||||
|
DEFAULT_LOG_RANGE_FOLLOW = "entries=:-99:18446744073709551615"
|
||||||
|
|
||||||
|
|
||||||
|
async def common_test_api_advanced_logs(
|
||||||
|
path_prefix: str,
|
||||||
|
syslog_identifier: str,
|
||||||
|
api_client: TestClient,
|
||||||
|
journald_logs: MagicMock,
|
||||||
|
coresys: CoreSys,
|
||||||
|
os_available: None,
|
||||||
|
):
|
||||||
|
"""Template for tests of endpoints using advanced logs."""
|
||||||
|
resp = await api_client.get(f"{path_prefix}/logs")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == "text/plain"
|
||||||
|
|
||||||
|
journald_logs.assert_called_once_with(
|
||||||
|
params={"SYSLOG_IDENTIFIER": syslog_identifier},
|
||||||
|
range_header=DEFAULT_LOG_RANGE,
|
||||||
|
accept=LogFormat.JOURNAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
journald_logs.reset_mock()
|
||||||
|
|
||||||
|
resp = await api_client.get(f"{path_prefix}/logs/follow")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == "text/plain"
|
||||||
|
|
||||||
|
journald_logs.assert_called_once_with(
|
||||||
|
params={"SYSLOG_IDENTIFIER": syslog_identifier, "follow": ""},
|
||||||
|
range_header=DEFAULT_LOG_RANGE_FOLLOW,
|
||||||
|
accept=LogFormat.JOURNAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
journald_logs.reset_mock()
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = AsyncMock(
|
||||||
|
return_value='{"CONTAINER_LOG_EPOCH": "12345"}\n{"CONTAINER_LOG_EPOCH": "12345"}\n'
|
||||||
|
)
|
||||||
|
journald_logs.return_value.__aenter__.return_value = mock_response
|
||||||
|
|
||||||
|
resp = await api_client.get(f"{path_prefix}/logs/latest")
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
assert journald_logs.call_count == 2
|
||||||
|
|
||||||
|
# Check the first call for getting epoch
|
||||||
|
epoch_call = journald_logs.call_args_list[0]
|
||||||
|
assert epoch_call[1]["params"] == {"CONTAINER_NAME": syslog_identifier}
|
||||||
|
assert epoch_call[1]["range_header"] == "entries=:-1:2"
|
||||||
|
|
||||||
|
# Check the second call for getting logs with the epoch
|
||||||
|
logs_call = journald_logs.call_args_list[1]
|
||||||
|
assert logs_call[1]["params"]["SYSLOG_IDENTIFIER"] == syslog_identifier
|
||||||
|
assert logs_call[1]["params"]["CONTAINER_LOG_EPOCH"] == "12345"
|
||||||
|
assert logs_call[1]["range_header"] == "entries=:0:18446744073709551615"
|
||||||
|
|
||||||
|
journald_logs.reset_mock()
|
||||||
|
|
||||||
|
resp = await api_client.get(f"{path_prefix}/logs/boots/0")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == "text/plain"
|
||||||
|
|
||||||
|
journald_logs.assert_called_once_with(
|
||||||
|
params={"SYSLOG_IDENTIFIER": syslog_identifier, "_BOOT_ID": "ccc"},
|
||||||
|
range_header=DEFAULT_LOG_RANGE,
|
||||||
|
accept=LogFormat.JOURNAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
journald_logs.reset_mock()
|
||||||
|
|
||||||
|
resp = await api_client.get(f"{path_prefix}/logs/boots/0/follow")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == "text/plain"
|
||||||
|
|
||||||
|
journald_logs.assert_called_once_with(
|
||||||
|
params={
|
||||||
|
"SYSLOG_IDENTIFIER": syslog_identifier,
|
||||||
|
"_BOOT_ID": "ccc",
|
||||||
|
"follow": "",
|
||||||
|
},
|
||||||
|
range_header=DEFAULT_LOG_RANGE_FOLLOW,
|
||||||
|
accept=LogFormat.JOURNAL,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
"""Fixtures for API tests."""
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
|
||||||
|
|
||||||
from aiohttp.test_utils import TestClient
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from supervisor.coresys import CoreSys
|
|
||||||
from supervisor.host.const import LogFormat, LogFormatter
|
|
||||||
|
|
||||||
DEFAULT_LOG_RANGE = "entries=:-99:100"
|
|
||||||
DEFAULT_LOG_RANGE_FOLLOW = "entries=:-99:18446744073709551615"
|
|
||||||
|
|
||||||
|
|
||||||
async def _common_test_api_advanced_logs(
|
|
||||||
path_prefix: str,
|
|
||||||
syslog_identifier: str,
|
|
||||||
api_client: TestClient,
|
|
||||||
journald_logs: MagicMock,
|
|
||||||
coresys: CoreSys,
|
|
||||||
os_available: None,
|
|
||||||
journal_logs_reader: MagicMock,
|
|
||||||
):
|
|
||||||
"""Template for tests of endpoints using advanced logs."""
|
|
||||||
resp = await api_client.get(f"{path_prefix}/logs")
|
|
||||||
assert resp.status == 200
|
|
||||||
assert resp.content_type == "text/plain"
|
|
||||||
|
|
||||||
journald_logs.assert_called_once_with(
|
|
||||||
params={"SYSLOG_IDENTIFIER": syslog_identifier},
|
|
||||||
range_header=DEFAULT_LOG_RANGE,
|
|
||||||
accept=LogFormat.JOURNAL,
|
|
||||||
)
|
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.PLAIN, False)
|
|
||||||
|
|
||||||
journald_logs.reset_mock()
|
|
||||||
journal_logs_reader.reset_mock()
|
|
||||||
|
|
||||||
resp = await api_client.get(f"{path_prefix}/logs?no_colors")
|
|
||||||
assert resp.status == 200
|
|
||||||
assert resp.content_type == "text/plain"
|
|
||||||
|
|
||||||
journald_logs.assert_called_once_with(
|
|
||||||
params={"SYSLOG_IDENTIFIER": syslog_identifier},
|
|
||||||
range_header=DEFAULT_LOG_RANGE,
|
|
||||||
accept=LogFormat.JOURNAL,
|
|
||||||
)
|
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.PLAIN, True)
|
|
||||||
|
|
||||||
journald_logs.reset_mock()
|
|
||||||
journal_logs_reader.reset_mock()
|
|
||||||
|
|
||||||
resp = await api_client.get(f"{path_prefix}/logs/follow")
|
|
||||||
assert resp.status == 200
|
|
||||||
assert resp.content_type == "text/plain"
|
|
||||||
|
|
||||||
journald_logs.assert_called_once_with(
|
|
||||||
params={"SYSLOG_IDENTIFIER": syslog_identifier, "follow": ""},
|
|
||||||
range_header=DEFAULT_LOG_RANGE_FOLLOW,
|
|
||||||
accept=LogFormat.JOURNAL,
|
|
||||||
)
|
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.PLAIN, False)
|
|
||||||
|
|
||||||
journald_logs.reset_mock()
|
|
||||||
journal_logs_reader.reset_mock()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.text = AsyncMock(
|
|
||||||
return_value='{"CONTAINER_LOG_EPOCH": "12345"}\n{"CONTAINER_LOG_EPOCH": "12345"}\n'
|
|
||||||
)
|
|
||||||
journald_logs.return_value.__aenter__.return_value = mock_response
|
|
||||||
|
|
||||||
resp = await api_client.get(f"{path_prefix}/logs/latest")
|
|
||||||
assert resp.status == 200
|
|
||||||
|
|
||||||
assert journald_logs.call_count == 2
|
|
||||||
|
|
||||||
# Check the first call for getting epoch
|
|
||||||
epoch_call = journald_logs.call_args_list[0]
|
|
||||||
assert epoch_call[1]["params"] == {"CONTAINER_NAME": syslog_identifier}
|
|
||||||
assert epoch_call[1]["range_header"] == "entries=:-1:2"
|
|
||||||
|
|
||||||
# Check the second call for getting logs with the epoch
|
|
||||||
logs_call = journald_logs.call_args_list[1]
|
|
||||||
assert logs_call[1]["params"]["SYSLOG_IDENTIFIER"] == syslog_identifier
|
|
||||||
assert logs_call[1]["params"]["CONTAINER_LOG_EPOCH"] == "12345"
|
|
||||||
assert logs_call[1]["range_header"] == "entries=:0:18446744073709551615"
|
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.PLAIN, True)
|
|
||||||
|
|
||||||
journald_logs.reset_mock()
|
|
||||||
journal_logs_reader.reset_mock()
|
|
||||||
|
|
||||||
resp = await api_client.get(f"{path_prefix}/logs/boots/0")
|
|
||||||
assert resp.status == 200
|
|
||||||
assert resp.content_type == "text/plain"
|
|
||||||
|
|
||||||
journald_logs.assert_called_once_with(
|
|
||||||
params={"SYSLOG_IDENTIFIER": syslog_identifier, "_BOOT_ID": "ccc"},
|
|
||||||
range_header=DEFAULT_LOG_RANGE,
|
|
||||||
accept=LogFormat.JOURNAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
journald_logs.reset_mock()
|
|
||||||
|
|
||||||
resp = await api_client.get(f"{path_prefix}/logs/boots/0/follow")
|
|
||||||
assert resp.status == 200
|
|
||||||
assert resp.content_type == "text/plain"
|
|
||||||
|
|
||||||
journald_logs.assert_called_once_with(
|
|
||||||
params={
|
|
||||||
"SYSLOG_IDENTIFIER": syslog_identifier,
|
|
||||||
"_BOOT_ID": "ccc",
|
|
||||||
"follow": "",
|
|
||||||
},
|
|
||||||
range_header=DEFAULT_LOG_RANGE_FOLLOW,
|
|
||||||
accept=LogFormat.JOURNAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def advanced_logs_tester(
|
|
||||||
api_client: TestClient,
|
|
||||||
journald_logs: MagicMock,
|
|
||||||
coresys: CoreSys,
|
|
||||||
os_available,
|
|
||||||
journal_logs_reader: MagicMock,
|
|
||||||
) -> Callable[[str, str], Awaitable[None]]:
|
|
||||||
"""Fixture that returns a function to test advanced logs endpoints.
|
|
||||||
|
|
||||||
This allows tests to avoid explicitly passing all the required fixtures.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
async def test_my_logs(advanced_logs_tester):
|
|
||||||
await advanced_logs_tester("/path/prefix", "syslog_identifier")
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def test_logs(path_prefix: str, syslog_identifier: str):
|
|
||||||
await _common_test_api_advanced_logs(
|
|
||||||
path_prefix,
|
|
||||||
syslog_identifier,
|
|
||||||
api_client,
|
|
||||||
journald_logs,
|
|
||||||
coresys,
|
|
||||||
os_available,
|
|
||||||
journal_logs_reader,
|
|
||||||
)
|
|
||||||
|
|
||||||
return test_logs
|
|
||||||
@@ -20,6 +20,7 @@ from supervisor.exceptions import HassioError
|
|||||||
from supervisor.store.repository import Repository
|
from supervisor.store.repository import Repository
|
||||||
|
|
||||||
from ..const import TEST_ADDON_SLUG
|
from ..const import TEST_ADDON_SLUG
|
||||||
|
from . import common_test_api_advanced_logs
|
||||||
|
|
||||||
|
|
||||||
def _create_test_event(name: str, state: ContainerState) -> DockerContainerStateEvent:
|
def _create_test_event(name: str, state: ContainerState) -> DockerContainerStateEvent:
|
||||||
@@ -71,11 +72,21 @@ async def test_addons_info_not_installed(
|
|||||||
|
|
||||||
|
|
||||||
async def test_api_addon_logs(
|
async def test_api_addon_logs(
|
||||||
advanced_logs_tester,
|
api_client: TestClient,
|
||||||
|
journald_logs: MagicMock,
|
||||||
|
coresys: CoreSys,
|
||||||
|
os_available,
|
||||||
install_addon_ssh: Addon,
|
install_addon_ssh: Addon,
|
||||||
):
|
):
|
||||||
"""Test addon logs."""
|
"""Test addon logs."""
|
||||||
await advanced_logs_tester("/addons/local_ssh", "addon_local_ssh")
|
await common_test_api_advanced_logs(
|
||||||
|
"/addons/local_ssh",
|
||||||
|
"addon_local_ssh",
|
||||||
|
api_client,
|
||||||
|
journald_logs,
|
||||||
|
coresys,
|
||||||
|
os_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_api_addon_logs_not_installed(api_client: TestClient):
|
async def test_api_addon_logs_not_installed(api_client: TestClient):
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
"""Test audio api."""
|
"""Test audio api."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
async def test_api_audio_logs(advanced_logs_tester) -> None:
|
from aiohttp.test_utils import TestClient
|
||||||
|
|
||||||
|
from supervisor.coresys import CoreSys
|
||||||
|
|
||||||
|
from tests.api import common_test_api_advanced_logs
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_audio_logs(
|
||||||
|
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
|
||||||
|
):
|
||||||
"""Test audio logs."""
|
"""Test audio logs."""
|
||||||
await advanced_logs_tester("/audio", "hassio_audio")
|
await common_test_api_advanced_logs(
|
||||||
|
"/audio", "hassio_audio", api_client, journald_logs, coresys, os_available
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Test DNS API."""
|
"""Test DNS API."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
|
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.dbus.resolved import Resolved
|
from supervisor.dbus.resolved import Resolved
|
||||||
|
|
||||||
|
from tests.api import common_test_api_advanced_logs
|
||||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||||
from tests.dbus_service_mocks.resolved import Resolved as ResolvedService
|
from tests.dbus_service_mocks.resolved import Resolved as ResolvedService
|
||||||
|
|
||||||
@@ -65,6 +66,15 @@ async def test_options(api_client: TestClient, coresys: CoreSys):
|
|||||||
restart.assert_called_once()
|
restart.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
async def test_api_dns_logs(advanced_logs_tester):
|
async def test_api_dns_logs(
|
||||||
|
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
|
||||||
|
):
|
||||||
"""Test dns logs."""
|
"""Test dns logs."""
|
||||||
await advanced_logs_tester("/dns", "hassio_dns")
|
await common_test_api_advanced_logs(
|
||||||
|
"/dns",
|
||||||
|
"hassio_dns",
|
||||||
|
api_client,
|
||||||
|
journald_logs,
|
||||||
|
coresys,
|
||||||
|
os_available,
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,19 +17,29 @@ from supervisor.homeassistant.api import APIState, HomeAssistantAPI
|
|||||||
from supervisor.homeassistant.const import WSEvent
|
from supervisor.homeassistant.const import WSEvent
|
||||||
from supervisor.homeassistant.core import HomeAssistantCore
|
from supervisor.homeassistant.core import HomeAssistantCore
|
||||||
from supervisor.homeassistant.module import HomeAssistant
|
from supervisor.homeassistant.module import HomeAssistant
|
||||||
|
from supervisor.resolution.const import ContextType, IssueType
|
||||||
|
from supervisor.resolution.data import Issue
|
||||||
|
|
||||||
|
from tests.api import common_test_api_advanced_logs
|
||||||
from tests.common import AsyncIterator, load_json_fixture
|
from tests.common import AsyncIterator, load_json_fixture
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("legacy_route", [True, False])
|
@pytest.mark.parametrize("legacy_route", [True, False])
|
||||||
async def test_api_core_logs(
|
async def test_api_core_logs(
|
||||||
advanced_logs_tester: AsyncMock,
|
api_client: TestClient,
|
||||||
|
journald_logs: MagicMock,
|
||||||
|
coresys: CoreSys,
|
||||||
|
os_available,
|
||||||
legacy_route: bool,
|
legacy_route: bool,
|
||||||
):
|
):
|
||||||
"""Test core logs."""
|
"""Test core logs."""
|
||||||
await advanced_logs_tester(
|
await common_test_api_advanced_logs(
|
||||||
f"/{'homeassistant' if legacy_route else 'core'}",
|
f"/{'homeassistant' if legacy_route else 'core'}",
|
||||||
"homeassistant",
|
"homeassistant",
|
||||||
|
api_client,
|
||||||
|
journald_logs,
|
||||||
|
coresys,
|
||||||
|
os_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -359,3 +369,73 @@ async def test_api_progress_updates_home_assistant_update(
|
|||||||
"done": True,
|
"done": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_frontend_check_success(api_client: TestClient, coresys: CoreSys):
|
||||||
|
"""Test that update succeeds when frontend check passes."""
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
DockerHomeAssistant,
|
||||||
|
"version",
|
||||||
|
new=PropertyMock(return_value=AwesomeVersion("2025.8.0")),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]}
|
||||||
|
),
|
||||||
|
patch.object(HomeAssistantAPI, "check_frontend_available", return_value=True),
|
||||||
|
):
|
||||||
|
resp = await api_client.post("/core/update", json={"version": "2025.8.3"})
|
||||||
|
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_frontend_check_fails_triggers_rollback(
|
||||||
|
api_client: TestClient,
|
||||||
|
coresys: CoreSys,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
tmp_supervisor_data: Path,
|
||||||
|
):
|
||||||
|
"""Test that update triggers rollback when frontend check fails."""
|
||||||
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
# Mock successful first update, failed frontend check, then successful rollback
|
||||||
|
update_call_count = 0
|
||||||
|
|
||||||
|
async def mock_update(*args, **kwargs):
|
||||||
|
nonlocal update_call_count
|
||||||
|
update_call_count += 1
|
||||||
|
if update_call_count == 1:
|
||||||
|
# First update succeeds
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.3")
|
||||||
|
elif update_call_count == 2:
|
||||||
|
# Rollback succeeds
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(DockerInterface, "update", new=mock_update),
|
||||||
|
patch.object(
|
||||||
|
DockerHomeAssistant,
|
||||||
|
"version",
|
||||||
|
new=PropertyMock(return_value=AwesomeVersion("2025.8.0")),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]}
|
||||||
|
),
|
||||||
|
patch.object(HomeAssistantAPI, "check_frontend_available", return_value=False),
|
||||||
|
):
|
||||||
|
resp = await api_client.post("/core/update", json={"version": "2025.8.3"})
|
||||||
|
|
||||||
|
# Update should trigger rollback, which succeeds and returns 200
|
||||||
|
assert resp.status == 200
|
||||||
|
assert "Frontend component loaded but frontend is not accessible" in caplog.text
|
||||||
|
assert "HomeAssistant update failed -> rollback!" in caplog.text
|
||||||
|
# Should have called update twice (once for update, once for rollback)
|
||||||
|
assert update_call_count == 2
|
||||||
|
# An update_rollback issue should be created
|
||||||
|
assert (
|
||||||
|
Issue(IssueType.UPDATE_ROLLBACK, ContextType.CORE) in coresys.resolution.issues
|
||||||
|
)
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ async def test_advaced_logs_query_parameters(
|
|||||||
range_header=DEFAULT_RANGE,
|
range_header=DEFAULT_RANGE,
|
||||||
accept=LogFormat.JOURNAL,
|
accept=LogFormat.JOURNAL,
|
||||||
)
|
)
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE, False)
|
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE)
|
||||||
|
|
||||||
journal_logs_reader.reset_mock()
|
journal_logs_reader.reset_mock()
|
||||||
journald_logs.reset_mock()
|
journald_logs.reset_mock()
|
||||||
@@ -290,19 +290,7 @@ async def test_advaced_logs_query_parameters(
|
|||||||
range_header="entries=:-52:53",
|
range_header="entries=:-52:53",
|
||||||
accept=LogFormat.JOURNAL,
|
accept=LogFormat.JOURNAL,
|
||||||
)
|
)
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE, False)
|
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE)
|
||||||
|
|
||||||
journal_logs_reader.reset_mock()
|
|
||||||
journald_logs.reset_mock()
|
|
||||||
|
|
||||||
# Check no_colors query parameter
|
|
||||||
await api_client.get("/host/logs?no_colors")
|
|
||||||
journald_logs.assert_called_once_with(
|
|
||||||
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
|
|
||||||
range_header=DEFAULT_RANGE,
|
|
||||||
accept=LogFormat.JOURNAL,
|
|
||||||
)
|
|
||||||
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE, True)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_advanced_logs_boot_id_offset(
|
async def test_advanced_logs_boot_id_offset(
|
||||||
@@ -355,24 +343,24 @@ async def test_advanced_logs_formatters(
|
|||||||
"""Test advanced logs formatters varying on Accept header."""
|
"""Test advanced logs formatters varying on Accept header."""
|
||||||
|
|
||||||
await api_client.get("/host/logs")
|
await api_client.get("/host/logs")
|
||||||
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
|
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE)
|
||||||
|
|
||||||
journal_logs_reader.reset_mock()
|
journal_logs_reader.reset_mock()
|
||||||
|
|
||||||
headers = {"Accept": "text/x-log"}
|
headers = {"Accept": "text/x-log"}
|
||||||
await api_client.get("/host/logs", headers=headers)
|
await api_client.get("/host/logs", headers=headers)
|
||||||
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
|
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE)
|
||||||
|
|
||||||
journal_logs_reader.reset_mock()
|
journal_logs_reader.reset_mock()
|
||||||
|
|
||||||
await api_client.get("/host/logs/identifiers/test")
|
await api_client.get("/host/logs/identifiers/test")
|
||||||
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.PLAIN, False)
|
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.PLAIN)
|
||||||
|
|
||||||
journal_logs_reader.reset_mock()
|
journal_logs_reader.reset_mock()
|
||||||
|
|
||||||
headers = {"Accept": "text/x-log"}
|
headers = {"Accept": "text/x-log"}
|
||||||
await api_client.get("/host/logs/identifiers/test", headers=headers)
|
await api_client.get("/host/logs/identifiers/test", headers=headers)
|
||||||
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
|
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE)
|
||||||
|
|
||||||
|
|
||||||
async def test_advanced_logs_errors(coresys: CoreSys, api_client: TestClient):
|
async def test_advanced_logs_errors(coresys: CoreSys, api_client: TestClient):
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
"""Test multicast api."""
|
"""Test multicast api."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
async def test_api_multicast_logs(advanced_logs_tester):
|
from aiohttp.test_utils import TestClient
|
||||||
|
|
||||||
|
from supervisor.coresys import CoreSys
|
||||||
|
|
||||||
|
from tests.api import common_test_api_advanced_logs
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_multicast_logs(
|
||||||
|
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
|
||||||
|
):
|
||||||
"""Test multicast logs."""
|
"""Test multicast logs."""
|
||||||
await advanced_logs_tester("/multicast", "hassio_multicast")
|
await common_test_api_advanced_logs(
|
||||||
|
"/multicast",
|
||||||
|
"hassio_multicast",
|
||||||
|
api_client,
|
||||||
|
journald_logs,
|
||||||
|
coresys,
|
||||||
|
os_available,
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from supervisor.store.repository import Repository
|
|||||||
from supervisor.supervisor import Supervisor
|
from supervisor.supervisor import Supervisor
|
||||||
from supervisor.updater import Updater
|
from supervisor.updater import Updater
|
||||||
|
|
||||||
|
from tests.api import common_test_api_advanced_logs
|
||||||
from tests.common import AsyncIterator, load_json_fixture
|
from tests.common import AsyncIterator, load_json_fixture
|
||||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||||
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
|
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
|
||||||
@@ -154,9 +155,18 @@ async def test_api_supervisor_options_diagnostics(
|
|||||||
assert coresys.dbus.agent.diagnostics is False
|
assert coresys.dbus.agent.diagnostics is False
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_logs(advanced_logs_tester):
|
async def test_api_supervisor_logs(
|
||||||
|
api_client: TestClient, journald_logs: MagicMock, coresys: CoreSys, os_available
|
||||||
|
):
|
||||||
"""Test supervisor logs."""
|
"""Test supervisor logs."""
|
||||||
await advanced_logs_tester("/supervisor", "hassio_supervisor")
|
await common_test_api_advanced_logs(
|
||||||
|
"/supervisor",
|
||||||
|
"hassio_supervisor",
|
||||||
|
api_client,
|
||||||
|
journald_logs,
|
||||||
|
coresys,
|
||||||
|
os_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_fallback(
|
async def test_api_supervisor_fallback(
|
||||||
|
|||||||
@@ -184,20 +184,3 @@ async def test_interface_becomes_unmanaged(
|
|||||||
assert wireless.is_connected is False
|
assert wireless.is_connected is False
|
||||||
assert eth0.connection is None
|
assert eth0.connection is None
|
||||||
assert connection.is_connected is False
|
assert connection.is_connected is False
|
||||||
|
|
||||||
|
|
||||||
async def test_unknown_device_type(
|
|
||||||
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
|
|
||||||
):
|
|
||||||
"""Test unknown device types are handled gracefully."""
|
|
||||||
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
|
|
||||||
await interface.connect(dbus_session_bus)
|
|
||||||
|
|
||||||
# Emit an unknown device type (e.g., 1000 which doesn't exist in the enum)
|
|
||||||
device_eth0_service.emit_properties_changed({"DeviceType": 1000})
|
|
||||||
await device_eth0_service.ping()
|
|
||||||
|
|
||||||
# Should return UNKNOWN instead of crashing
|
|
||||||
assert interface.type == DeviceType.UNKNOWN
|
|
||||||
# Wireless should be None since it's not a wireless device
|
|
||||||
assert interface.wireless is None
|
|
||||||
|
|||||||
@@ -445,23 +445,28 @@ async def test_install_progress_rounding_does_not_cause_misses(
|
|||||||
]
|
]
|
||||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||||
|
|
||||||
# Schedule job so we can listen for the end. Then we can assert against the WS mock
|
with (
|
||||||
event = asyncio.Event()
|
patch.object(
|
||||||
job, install_task = coresys.jobs.schedule_job(
|
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
|
||||||
test_docker_interface.install,
|
),
|
||||||
JobSchedulerOptions(),
|
):
|
||||||
AwesomeVersion("1.2.3"),
|
# Schedule job so we can listen for the end. Then we can assert against the WS mock
|
||||||
"test",
|
event = asyncio.Event()
|
||||||
)
|
job, install_task = coresys.jobs.schedule_job(
|
||||||
|
test_docker_interface.install,
|
||||||
|
JobSchedulerOptions(),
|
||||||
|
AwesomeVersion("1.2.3"),
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
|
||||||
async def listen_for_job_end(reference: SupervisorJob):
|
async def listen_for_job_end(reference: SupervisorJob):
|
||||||
if reference.uuid != job.uuid:
|
if reference.uuid != job.uuid:
|
||||||
return
|
return
|
||||||
event.set()
|
event.set()
|
||||||
|
|
||||||
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end)
|
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end)
|
||||||
await install_task
|
await install_task
|
||||||
await event.wait()
|
await event.wait()
|
||||||
|
|
||||||
capture_exception.assert_not_called()
|
capture_exception.assert_not_called()
|
||||||
|
|
||||||
@@ -659,64 +664,3 @@ async def test_install_progress_handles_layers_skipping_download(
|
|||||||
assert job.done is True
|
assert job.done is True
|
||||||
assert job.progress == 100
|
assert job.progress == 100
|
||||||
capture_exception.assert_not_called()
|
capture_exception.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
async def test_missing_total_handled_gracefully(
|
|
||||||
coresys: CoreSys,
|
|
||||||
test_docker_interface: DockerInterface,
|
|
||||||
ha_ws_client: AsyncMock,
|
|
||||||
capture_exception: Mock,
|
|
||||||
):
|
|
||||||
"""Test missing 'total' fields in progress details handled gracefully."""
|
|
||||||
coresys.core.set_state(CoreState.RUNNING)
|
|
||||||
|
|
||||||
# Progress details with missing 'total' fields observed in real-world pulls
|
|
||||||
logs = [
|
|
||||||
{
|
|
||||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
|
||||||
"id": "2025.7.1",
|
|
||||||
},
|
|
||||||
{"status": "Pulling fs layer", "progressDetail": {}, "id": "1e214cd6d7d0"},
|
|
||||||
{
|
|
||||||
"status": "Downloading",
|
|
||||||
"progressDetail": {"current": 436480882},
|
|
||||||
"progress": "[===================================================] 436.5MB/436.5MB",
|
|
||||||
"id": "1e214cd6d7d0",
|
|
||||||
},
|
|
||||||
{"status": "Verifying Checksum", "progressDetail": {}, "id": "1e214cd6d7d0"},
|
|
||||||
{"status": "Download complete", "progressDetail": {}, "id": "1e214cd6d7d0"},
|
|
||||||
{
|
|
||||||
"status": "Extracting",
|
|
||||||
"progressDetail": {"current": 436480882},
|
|
||||||
"progress": "[===================================================] 436.5MB/436.5MB",
|
|
||||||
"id": "1e214cd6d7d0",
|
|
||||||
},
|
|
||||||
{"status": "Pull complete", "progressDetail": {}, "id": "1e214cd6d7d0"},
|
|
||||||
{
|
|
||||||
"status": "Digest: sha256:7d97da645f232f82a768d0a537e452536719d56d484d419836e53dbe3e4ec736"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
|
||||||
|
|
||||||
# Schedule job so we can listen for the end. Then we can assert against the WS mock
|
|
||||||
event = asyncio.Event()
|
|
||||||
job, install_task = coresys.jobs.schedule_job(
|
|
||||||
test_docker_interface.install,
|
|
||||||
JobSchedulerOptions(),
|
|
||||||
AwesomeVersion("1.2.3"),
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def listen_for_job_end(reference: SupervisorJob):
|
|
||||||
if reference.uuid != job.uuid:
|
|
||||||
return
|
|
||||||
event.set()
|
|
||||||
|
|
||||||
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end)
|
|
||||||
await install_task
|
|
||||||
await event.wait()
|
|
||||||
|
|
||||||
capture_exception.assert_not_called()
|
|
||||||
|
|||||||
110
tests/homeassistant/test_api.py
Normal file
110
tests/homeassistant/test_api.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Test Home Assistant API."""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from aiohttp import hdrs
|
||||||
|
from awesomeversion import AwesomeVersion
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from supervisor.coresys import CoreSys
|
||||||
|
from supervisor.exceptions import HomeAssistantAPIError
|
||||||
|
from supervisor.homeassistant.const import LANDINGPAGE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_frontend_available_success(coresys: CoreSys):
|
||||||
|
"""Test frontend availability check succeeds with valid HTML response."""
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.headers = {hdrs.CONTENT_TYPE: "text/html; charset=utf-8"}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_make_request(*args, **kwargs):
|
||||||
|
yield mock_response
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
type(coresys.homeassistant.api), "make_request", new=mock_make_request
|
||||||
|
):
|
||||||
|
result = await coresys.homeassistant.api.check_frontend_available()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_frontend_available_wrong_status(coresys: CoreSys):
|
||||||
|
"""Test frontend availability check fails with non-200 status."""
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status = 404
|
||||||
|
mock_response.headers = {hdrs.CONTENT_TYPE: "text/html"}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_make_request(*args, **kwargs):
|
||||||
|
yield mock_response
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
type(coresys.homeassistant.api), "make_request", new=mock_make_request
|
||||||
|
):
|
||||||
|
result = await coresys.homeassistant.api.check_frontend_available()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_frontend_available_wrong_content_type(
|
||||||
|
coresys: CoreSys, caplog: pytest.LogCaptureFixture
|
||||||
|
):
|
||||||
|
"""Test frontend availability check fails with wrong content type."""
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.headers = {hdrs.CONTENT_TYPE: "application/json"}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_make_request(*args, **kwargs):
|
||||||
|
yield mock_response
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
type(coresys.homeassistant.api), "make_request", new=mock_make_request
|
||||||
|
):
|
||||||
|
result = await coresys.homeassistant.api.check_frontend_available()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert "unexpected content type" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_frontend_available_api_error(coresys: CoreSys):
|
||||||
|
"""Test frontend availability check handles API errors gracefully."""
|
||||||
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_make_request(*args, **kwargs):
|
||||||
|
raise HomeAssistantAPIError("Connection failed")
|
||||||
|
yield # pragma: no cover
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
type(coresys.homeassistant.api), "make_request", new=mock_make_request
|
||||||
|
):
|
||||||
|
result = await coresys.homeassistant.api.check_frontend_available()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_frontend_available_landingpage(coresys: CoreSys):
|
||||||
|
"""Test frontend availability check returns False for landingpage."""
|
||||||
|
coresys.homeassistant.version = LANDINGPAGE
|
||||||
|
|
||||||
|
result = await coresys.homeassistant.api.check_frontend_available()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_frontend_available_no_version(coresys: CoreSys):
|
||||||
|
"""Test frontend availability check returns False when no version set."""
|
||||||
|
coresys.homeassistant.version = None
|
||||||
|
|
||||||
|
result = await coresys.homeassistant.api.check_frontend_available()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
@@ -90,49 +90,6 @@ async def test_logs_coloured(journald_gateway: MagicMock, coresys: CoreSys):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_logs_no_colors(journald_gateway: MagicMock, coresys: CoreSys):
|
|
||||||
"""Test ANSI color codes being stripped when no_colors=True."""
|
|
||||||
journald_gateway.content.feed_data(
|
|
||||||
load_fixture("logs_export_supervisor.txt").encode("utf-8")
|
|
||||||
)
|
|
||||||
journald_gateway.content.feed_eof()
|
|
||||||
|
|
||||||
async with coresys.host.logs.journald_logs() as resp:
|
|
||||||
cursor, line = await anext(journal_logs_reader(resp, no_colors=True))
|
|
||||||
assert (
|
|
||||||
cursor
|
|
||||||
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
|
|
||||||
)
|
|
||||||
# Colors should be stripped
|
|
||||||
assert (
|
|
||||||
line == "24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_logs_verbose_no_colors(journald_gateway: MagicMock, coresys: CoreSys):
|
|
||||||
"""Test ANSI color codes being stripped from verbose formatted logs when no_colors=True."""
|
|
||||||
journald_gateway.content.feed_data(
|
|
||||||
load_fixture("logs_export_supervisor.txt").encode("utf-8")
|
|
||||||
)
|
|
||||||
journald_gateway.content.feed_eof()
|
|
||||||
|
|
||||||
async with coresys.host.logs.journald_logs() as resp:
|
|
||||||
cursor, line = await anext(
|
|
||||||
journal_logs_reader(
|
|
||||||
resp, log_formatter=LogFormatter.VERBOSE, no_colors=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
cursor
|
|
||||||
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
|
|
||||||
)
|
|
||||||
# Colors should be stripped in verbose format too
|
|
||||||
assert (
|
|
||||||
line
|
|
||||||
== "2024-03-04 22:56:56.709 ha-hloub hassio_supervisor[466]: 24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_boot_ids(
|
async def test_boot_ids(
|
||||||
journald_gateway: MagicMock,
|
journald_gateway: MagicMock,
|
||||||
coresys: CoreSys,
|
coresys: CoreSys,
|
||||||
|
|||||||
@@ -1179,6 +1179,7 @@ async def test_job_scheduled_delay(coresys: CoreSys):
|
|||||||
|
|
||||||
async def test_job_scheduled_at(coresys: CoreSys):
|
async def test_job_scheduled_at(coresys: CoreSys):
|
||||||
"""Test job that schedules a job to start at a specified time."""
|
"""Test job that schedules a job to start at a specified time."""
|
||||||
|
dt = datetime.now()
|
||||||
|
|
||||||
class TestClass:
|
class TestClass:
|
||||||
"""Test class."""
|
"""Test class."""
|
||||||
@@ -1188,12 +1189,10 @@ async def test_job_scheduled_at(coresys: CoreSys):
|
|||||||
self.coresys = coresys
|
self.coresys = coresys
|
||||||
|
|
||||||
@Job(name="test_job_scheduled_at_job_scheduler")
|
@Job(name="test_job_scheduled_at_job_scheduler")
|
||||||
async def job_scheduler(
|
async def job_scheduler(self) -> tuple[SupervisorJob, asyncio.TimerHandle]:
|
||||||
self, scheduled_time: datetime
|
|
||||||
) -> tuple[SupervisorJob, asyncio.TimerHandle]:
|
|
||||||
"""Schedule a job to run at specified time."""
|
"""Schedule a job to run at specified time."""
|
||||||
return self.coresys.jobs.schedule_job(
|
return self.coresys.jobs.schedule_job(
|
||||||
self.job_task, JobSchedulerOptions(start_at=scheduled_time)
|
self.job_task, JobSchedulerOptions(start_at=dt + timedelta(seconds=0.1))
|
||||||
)
|
)
|
||||||
|
|
||||||
@Job(name="test_job_scheduled_at_job_task")
|
@Job(name="test_job_scheduled_at_job_task")
|
||||||
@@ -1202,28 +1201,29 @@ async def test_job_scheduled_at(coresys: CoreSys):
|
|||||||
self.coresys.jobs.current.stage = "work"
|
self.coresys.jobs.current.stage = "work"
|
||||||
|
|
||||||
test = TestClass(coresys)
|
test = TestClass(coresys)
|
||||||
|
job_started = asyncio.Event()
|
||||||
# Schedule job to run 0.1 seconds from now
|
job_ended = asyncio.Event()
|
||||||
scheduled_time = datetime.now() + timedelta(seconds=0.1)
|
|
||||||
job, _ = await test.job_scheduler(scheduled_time)
|
|
||||||
started = False
|
|
||||||
ended = False
|
|
||||||
|
|
||||||
async def start_listener(evt_job: SupervisorJob):
|
async def start_listener(evt_job: SupervisorJob):
|
||||||
nonlocal started
|
if evt_job.uuid == job.uuid:
|
||||||
started = started or evt_job.uuid == job.uuid
|
job_started.set()
|
||||||
|
|
||||||
async def end_listener(evt_job: SupervisorJob):
|
async def end_listener(evt_job: SupervisorJob):
|
||||||
nonlocal ended
|
if evt_job.uuid == job.uuid:
|
||||||
ended = ended or evt_job.uuid == job.uuid
|
job_ended.set()
|
||||||
|
|
||||||
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
|
async with time_machine.travel(dt):
|
||||||
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)
|
job, _ = await test.job_scheduler()
|
||||||
|
|
||||||
await asyncio.sleep(0.2)
|
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
|
||||||
|
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)
|
||||||
|
|
||||||
|
# Advance time to exactly when job should start and wait for completion
|
||||||
|
async with time_machine.travel(dt + timedelta(seconds=0.1)):
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(job_started.wait(), job_ended.wait()), timeout=1.0
|
||||||
|
)
|
||||||
|
|
||||||
assert started
|
|
||||||
assert ended
|
|
||||||
assert job.done
|
assert job.done
|
||||||
assert job.name == "test_job_scheduled_at_job_task"
|
assert job.name == "test_job_scheduled_at_job_task"
|
||||||
assert job.stage == "work"
|
assert job.stage == "work"
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from supervisor.const import CoreState
|
from supervisor.const import CoreState
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.resolution.evaluations.operating_system import EvaluateOperatingSystem
|
from supervisor.resolution.evaluations.operating_system import (
|
||||||
|
SUPPORTED_OS,
|
||||||
|
EvaluateOperatingSystem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_evaluation(coresys: CoreSys):
|
async def test_evaluation(coresys: CoreSys):
|
||||||
@@ -22,7 +25,13 @@ async def test_evaluation(coresys: CoreSys):
|
|||||||
assert operating_system.reason in coresys.resolution.unsupported
|
assert operating_system.reason in coresys.resolution.unsupported
|
||||||
|
|
||||||
coresys.os._available = True
|
coresys.os._available = True
|
||||||
assert coresys.os.available
|
await operating_system()
|
||||||
|
assert operating_system.reason not in coresys.resolution.unsupported
|
||||||
|
coresys.os._available = False
|
||||||
|
|
||||||
|
coresys.host._info = MagicMock(
|
||||||
|
operating_system=SUPPORTED_OS[0], timezone=None, timezone_tzinfo=None
|
||||||
|
)
|
||||||
await operating_system()
|
await operating_system()
|
||||||
assert operating_system.reason not in coresys.resolution.unsupported
|
assert operating_system.reason not in coresys.resolution.unsupported
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Test evaluation supported system architectures."""
|
|
||||||
|
|
||||||
from unittest.mock import PropertyMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from supervisor.const import CoreState
|
|
||||||
from supervisor.coresys import CoreSys
|
|
||||||
from supervisor.resolution.evaluations.system_architecture import (
|
|
||||||
EvaluateSystemArchitecture,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("arch", ["i386", "armhf", "armv7"])
|
|
||||||
async def test_evaluation_unsupported_architectures(
|
|
||||||
coresys: CoreSys,
|
|
||||||
arch: str,
|
|
||||||
):
|
|
||||||
"""Test evaluation of unsupported system architectures."""
|
|
||||||
system_architecture = EvaluateSystemArchitecture(coresys)
|
|
||||||
await coresys.core.set_state(CoreState.INITIALIZE)
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
type(coresys.supervisor), "arch", PropertyMock(return_value=arch)
|
|
||||||
):
|
|
||||||
await system_architecture()
|
|
||||||
assert system_architecture.reason in coresys.resolution.unsupported
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("arch", ["amd64", "aarch64"])
|
|
||||||
async def test_evaluation_supported_architectures(
|
|
||||||
coresys: CoreSys,
|
|
||||||
arch: str,
|
|
||||||
):
|
|
||||||
"""Test evaluation of supported system architectures."""
|
|
||||||
system_architecture = EvaluateSystemArchitecture(coresys)
|
|
||||||
await coresys.core.set_state(CoreState.INITIALIZE)
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
type(coresys.supervisor), "arch", PropertyMock(return_value=arch)
|
|
||||||
):
|
|
||||||
await system_architecture()
|
|
||||||
assert system_architecture.reason not in coresys.resolution.unsupported
|
|
||||||
@@ -86,22 +86,6 @@ def test_format_verbose_newlines():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_format_verbose_colors():
|
|
||||||
"""Test verbose formatter with ANSI colors in message."""
|
|
||||||
fields = {
|
|
||||||
"__REALTIME_TIMESTAMP": "1379403171000000",
|
|
||||||
"_HOSTNAME": "homeassistant",
|
|
||||||
"SYSLOG_IDENTIFIER": "python",
|
|
||||||
"_PID": "666",
|
|
||||||
"MESSAGE": "\x1b[32mHello, world!\x1b[0m",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert (
|
|
||||||
journal_verbose_formatter(fields)
|
|
||||||
== "2013-09-17 07:32:51.000 homeassistant python[666]: \x1b[32mHello, world!\x1b[0m"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_parsing_simple():
|
async def test_parsing_simple():
|
||||||
"""Test plain formatter."""
|
"""Test plain formatter."""
|
||||||
journal_logs, stream = _journal_logs_mock()
|
journal_logs, stream = _journal_logs_mock()
|
||||||
@@ -313,54 +297,3 @@ async def test_parsing_non_utf8_in_binary_message():
|
|||||||
)
|
)
|
||||||
_, line = await anext(journal_logs_reader(journal_logs))
|
_, line = await anext(journal_logs_reader(journal_logs))
|
||||||
assert line == "Hello, \ufffd world!"
|
assert line == "Hello, \ufffd world!"
|
||||||
|
|
||||||
|
|
||||||
def test_format_plain_no_colors():
|
|
||||||
"""Test plain formatter strips ANSI color codes when no_colors=True."""
|
|
||||||
fields = {"MESSAGE": "\x1b[32mHello, world!\x1b[0m"}
|
|
||||||
assert journal_plain_formatter(fields, no_colors=True) == "Hello, world!"
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_verbose_no_colors():
|
|
||||||
"""Test verbose formatter strips ANSI color codes when no_colors=True."""
|
|
||||||
fields = {
|
|
||||||
"__REALTIME_TIMESTAMP": "1379403171000000",
|
|
||||||
"_HOSTNAME": "homeassistant",
|
|
||||||
"SYSLOG_IDENTIFIER": "python",
|
|
||||||
"_PID": "666",
|
|
||||||
"MESSAGE": "\x1b[32mHello, world!\x1b[0m",
|
|
||||||
}
|
|
||||||
assert (
|
|
||||||
journal_verbose_formatter(fields, no_colors=True)
|
|
||||||
== "2013-09-17 07:32:51.000 homeassistant python[666]: Hello, world!"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_parsing_colored_logs_verbose_no_colors():
|
|
||||||
"""Test verbose formatter strips colors from colored logs."""
|
|
||||||
journal_logs, stream = _journal_logs_mock()
|
|
||||||
stream.feed_data(
|
|
||||||
b"__REALTIME_TIMESTAMP=1379403171000000\n"
|
|
||||||
b"_HOSTNAME=homeassistant\n"
|
|
||||||
b"SYSLOG_IDENTIFIER=python\n"
|
|
||||||
b"_PID=666\n"
|
|
||||||
b"MESSAGE\n\x0e\x00\x00\x00\x00\x00\x00\x00\x1b[31mERROR\x1b[0m\n"
|
|
||||||
b"AFTER=after\n\n"
|
|
||||||
)
|
|
||||||
_, line = await anext(
|
|
||||||
journal_logs_reader(
|
|
||||||
journal_logs, log_formatter=LogFormatter.VERBOSE, no_colors=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert line == "2013-09-17 07:32:51.000 homeassistant python[666]: ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_parsing_multiple_color_codes():
|
|
||||||
"""Test stripping multiple ANSI color codes in single message."""
|
|
||||||
journal_logs, stream = _journal_logs_mock()
|
|
||||||
stream.feed_data(
|
|
||||||
b"MESSAGE\n\x29\x00\x00\x00\x00\x00\x00\x00\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m\n"
|
|
||||||
b"AFTER=after\n\n"
|
|
||||||
)
|
|
||||||
_, line = await anext(journal_logs_reader(journal_logs, no_colors=True))
|
|
||||||
assert line == "Red Green Blue"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user