mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-11-28 12:08:08 +00:00
Compare commits
20 Commits
remove-unk
...
2025.11.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e06e792e74 | ||
|
|
5f55ab8de4 | ||
|
|
ca521c24cb | ||
|
|
6042694d84 | ||
|
|
2b2aedae60 | ||
|
|
4b4afd081b | ||
|
|
a3dca10fd8 | ||
|
|
d73682ee8a | ||
|
|
032fa4cdc4 | ||
|
|
7244e447ab | ||
|
|
603ba57846 | ||
|
|
0ff12abdf4 | ||
|
|
906838e325 | ||
|
|
3be0c13fc5 | ||
|
|
bb450cad4f | ||
|
|
10af48a65b | ||
|
|
2f334c48c3 | ||
|
|
6d87e8f591 | ||
|
|
4d1dd63248 | ||
|
|
0c2d0cf5c1 |
58
.github/workflows/builder.yml
vendored
58
.github/workflows/builder.yml
vendored
@@ -34,6 +34,9 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DEFAULT_PYTHON: "3.13"
|
DEFAULT_PYTHON: "3.13"
|
||||||
|
COSIGN_VERSION: "v2.5.3"
|
||||||
|
CRANE_VERSION: "v0.20.7"
|
||||||
|
CRANE_SHA256: "8ef3564d264e6b5ca93f7b7f5652704c4dd29d33935aff6947dd5adefd05953e"
|
||||||
BUILD_NAME: supervisor
|
BUILD_NAME: supervisor
|
||||||
BUILD_TYPE: supervisor
|
BUILD_TYPE: supervisor
|
||||||
|
|
||||||
@@ -107,7 +110,7 @@ jobs:
|
|||||||
# home-assistant/wheels doesn't support sha pinning
|
# home-assistant/wheels doesn't support sha pinning
|
||||||
- name: Build wheels
|
- name: Build wheels
|
||||||
if: needs.init.outputs.requirements == 'true'
|
if: needs.init.outputs.requirements == 'true'
|
||||||
uses: home-assistant/wheels@2025.10.0
|
uses: home-assistant/wheels@2025.11.0
|
||||||
with:
|
with:
|
||||||
abi: cp313
|
abi: cp313
|
||||||
tag: musllinux_1_2
|
tag: musllinux_1_2
|
||||||
@@ -126,7 +129,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
@@ -134,7 +137,7 @@ jobs:
|
|||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||||
with:
|
with:
|
||||||
cosign-release: "v2.5.3"
|
cosign-release: ${{ env.COSIGN_VERSION }}
|
||||||
|
|
||||||
- name: Install dirhash and calc hash
|
- name: Install dirhash and calc hash
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
@@ -173,7 +176,7 @@ jobs:
|
|||||||
|
|
||||||
version:
|
version:
|
||||||
name: Update version
|
name: Update version
|
||||||
needs: ["init", "run_supervisor"]
|
needs: ["init", "run_supervisor", "retag_deprecated"]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
@@ -352,3 +355,50 @@ jobs:
|
|||||||
- name: Get supervisor logs on failiure
|
- name: Get supervisor logs on failiure
|
||||||
if: ${{ cancelled() || failure() }}
|
if: ${{ cancelled() || failure() }}
|
||||||
run: docker logs hassio_supervisor
|
run: docker logs hassio_supervisor
|
||||||
|
|
||||||
|
retag_deprecated:
|
||||||
|
needs: ["build", "init"]
|
||||||
|
name: Re-tag deprecated ${{ matrix.arch }} images
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: ["armhf", "armv7", "i386"]
|
||||||
|
env:
|
||||||
|
# Last available release for deprecated architectures
|
||||||
|
FROZEN_VERSION: "2025.11.5"
|
||||||
|
steps:
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Cosign
|
||||||
|
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||||
|
with:
|
||||||
|
cosign-release: ${{ env.COSIGN_VERSION }}
|
||||||
|
|
||||||
|
- name: Install crane
|
||||||
|
run: |
|
||||||
|
curl -sLO https://github.com/google/go-containerregistry/releases/download/${{ env.CRANE_VERSION }}/go-containerregistry_Linux_x86_64.tar.gz
|
||||||
|
echo "${{ env.CRANE_SHA256 }} go-containerregistry_Linux_x86_64.tar.gz" | sha256sum -c -
|
||||||
|
tar xzf go-containerregistry_Linux_x86_64.tar.gz crane
|
||||||
|
sudo mv crane /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Re-tag deprecated image with updated version label
|
||||||
|
run: |
|
||||||
|
crane auth login ghcr.io -u ${{ github.repository_owner }} -p ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
crane mutate \
|
||||||
|
--label io.hass.version=${{ needs.init.outputs.version }} \
|
||||||
|
--tag ghcr.io/home-assistant/${{ matrix.arch }}-hassio-supervisor:${{ needs.init.outputs.version }} \
|
||||||
|
ghcr.io/home-assistant/${{ matrix.arch }}-hassio-supervisor:${{ env.FROZEN_VERSION }}
|
||||||
|
|
||||||
|
- name: Sign image with Cosign
|
||||||
|
run: |
|
||||||
|
cosign sign --yes ghcr.io/home-assistant/${{ matrix.arch }}-hassio-supervisor:${{ needs.init.outputs.version }}
|
||||||
|
|||||||
18
.github/workflows/ci.yaml
vendored
18
.github/workflows/ci.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -215,7 +215,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -259,7 +259,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -295,7 +295,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -341,7 +341,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
@@ -400,7 +400,7 @@ jobs:
|
|||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
|
|||||||
2
.github/workflows/update_frontend.yml
vendored
2
.github/workflows/update_frontend.yml
vendored
@@ -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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||||
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
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
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-2025.11.1
|
||||||
armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22-2025.11.1
|
|
||||||
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22-2025.11.1
|
|
||||||
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-2025.11.1
|
||||||
i386: ghcr.io/home-assistant/i386-base-python:3.13-alpine3.22-2025.11.1
|
|
||||||
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.0.0
|
backports.zstd==1.1.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,8 +25,8 @@ 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.46.0
|
||||||
setuptools==80.9.0
|
setuptools==80.9.0
|
||||||
voluptuous==0.15.2
|
voluptuous==0.15.2
|
||||||
dbus-fast==2.45.1
|
dbus-fast==3.1.2
|
||||||
zlib-fast==0.2.1
|
zlib-fast==0.2.1
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
astroid==4.0.2
|
astroid==4.0.2
|
||||||
coverage==7.12.0
|
coverage==7.12.0
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
pre-commit==4.4.0
|
pre-commit==4.5.0
|
||||||
pylint==4.0.3
|
pylint==4.0.3
|
||||||
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.5
|
ruff==0.14.6
|
||||||
time-machine==3.0.0
|
time-machine==3.1.0
|
||||||
types-docker==7.1.0.20251009
|
types-docker==7.1.0.20251125
|
||||||
types-pyyaml==6.0.12.20250915
|
types-pyyaml==6.0.12.20250915
|
||||||
types-requests==2.32.4.20250913
|
types-requests==2.32.4.20250913
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
|
|||||||
@@ -66,22 +66,13 @@ from ..docker.const import ContainerState
|
|||||||
from ..docker.monitor import DockerContainerStateEvent
|
from ..docker.monitor import DockerContainerStateEvent
|
||||||
from ..docker.stats import DockerStats
|
from ..docker.stats import DockerStats
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
AddonBackupMetadataInvalidError,
|
AddonConfigurationError,
|
||||||
AddonBuildFailedUnknownError,
|
|
||||||
AddonConfigurationInvalidError,
|
|
||||||
AddonNotRunningError,
|
|
||||||
AddonNotSupportedError,
|
AddonNotSupportedError,
|
||||||
AddonNotSupportedWriteStdinError,
|
|
||||||
AddonPrePostBackupCommandReturnedError,
|
|
||||||
AddonsError,
|
AddonsError,
|
||||||
AddonsJobError,
|
AddonsJobError,
|
||||||
AddonUnknownError,
|
|
||||||
BackupRestoreUnknownError,
|
|
||||||
ConfigurationFileError,
|
ConfigurationFileError,
|
||||||
DockerBuildError,
|
|
||||||
DockerError,
|
DockerError,
|
||||||
HostAppArmorError,
|
HostAppArmorError,
|
||||||
StoreAddonNotFoundError,
|
|
||||||
)
|
)
|
||||||
from ..hardware.data import Device
|
from ..hardware.data import Device
|
||||||
from ..homeassistant.const import WSEvent
|
from ..homeassistant.const import WSEvent
|
||||||
@@ -244,7 +235,7 @@ class Addon(AddonModel):
|
|||||||
await self.instance.check_image(self.version, default_image, self.arch)
|
await self.instance.check_image(self.version, default_image, self.arch)
|
||||||
except DockerError:
|
except DockerError:
|
||||||
_LOGGER.info("No %s addon Docker image %s found", self.slug, self.image)
|
_LOGGER.info("No %s addon Docker image %s found", self.slug, self.image)
|
||||||
with suppress(DockerError, AddonNotSupportedError):
|
with suppress(DockerError):
|
||||||
await self.instance.install(self.version, default_image, arch=self.arch)
|
await self.instance.install(self.version, default_image, arch=self.arch)
|
||||||
|
|
||||||
self.persist[ATTR_IMAGE] = default_image
|
self.persist[ATTR_IMAGE] = default_image
|
||||||
@@ -727,16 +718,18 @@ class Addon(AddonModel):
|
|||||||
options = self.schema.validate(self.options)
|
options = self.schema.validate(self.options)
|
||||||
await self.sys_run_in_executor(write_json_file, self.path_options, options)
|
await self.sys_run_in_executor(write_json_file, self.path_options, options)
|
||||||
except vol.Invalid as ex:
|
except vol.Invalid as ex:
|
||||||
raise AddonConfigurationInvalidError(
|
_LOGGER.error(
|
||||||
_LOGGER.error,
|
"Add-on %s has invalid options: %s",
|
||||||
addon=self.slug,
|
self.slug,
|
||||||
validation_error=humanize_error(self.options, ex),
|
humanize_error(self.options, ex),
|
||||||
) from None
|
)
|
||||||
except ConfigurationFileError as err:
|
except ConfigurationFileError:
|
||||||
_LOGGER.error("Add-on %s can't write options", self.slug)
|
_LOGGER.error("Add-on %s can't write options", self.slug)
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
else:
|
||||||
|
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
|
||||||
|
return
|
||||||
|
|
||||||
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
|
raise AddonConfigurationError()
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="addon_unload",
|
name="addon_unload",
|
||||||
@@ -779,7 +772,7 @@ class Addon(AddonModel):
|
|||||||
async def install(self) -> None:
|
async def install(self) -> None:
|
||||||
"""Install and setup this addon."""
|
"""Install and setup this addon."""
|
||||||
if not self.addon_store:
|
if not self.addon_store:
|
||||||
raise StoreAddonNotFoundError(addon=self.slug)
|
raise AddonsError("Missing from store, cannot install!")
|
||||||
|
|
||||||
await self.sys_addons.data.install(self.addon_store)
|
await self.sys_addons.data.install(self.addon_store)
|
||||||
|
|
||||||
@@ -800,17 +793,9 @@ class Addon(AddonModel):
|
|||||||
await self.instance.install(
|
await self.instance.install(
|
||||||
self.latest_version, self.addon_store.image, arch=self.arch
|
self.latest_version, self.addon_store.image, arch=self.arch
|
||||||
)
|
)
|
||||||
except AddonsError:
|
|
||||||
await self.sys_addons.data.uninstall(self)
|
|
||||||
raise
|
|
||||||
except DockerBuildError as err:
|
|
||||||
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
|
|
||||||
await self.sys_addons.data.uninstall(self)
|
|
||||||
raise AddonBuildFailedUnknownError(addon=self.slug) from err
|
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
|
|
||||||
await self.sys_addons.data.uninstall(self)
|
await self.sys_addons.data.uninstall(self)
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
raise AddonsError() from err
|
||||||
|
|
||||||
# Finish initialization and set up listeners
|
# Finish initialization and set up listeners
|
||||||
await self.load()
|
await self.load()
|
||||||
@@ -834,8 +819,7 @@ class Addon(AddonModel):
|
|||||||
try:
|
try:
|
||||||
await self.instance.remove(remove_image=remove_image)
|
await self.instance.remove(remove_image=remove_image)
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err)
|
raise AddonsError() from err
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
|
||||||
|
|
||||||
self.state = AddonState.UNKNOWN
|
self.state = AddonState.UNKNOWN
|
||||||
|
|
||||||
@@ -900,7 +884,7 @@ class Addon(AddonModel):
|
|||||||
if it was running. Else nothing is returned.
|
if it was running. Else nothing is returned.
|
||||||
"""
|
"""
|
||||||
if not self.addon_store:
|
if not self.addon_store:
|
||||||
raise StoreAddonNotFoundError(addon=self.slug)
|
raise AddonsError("Missing from store, cannot update!")
|
||||||
|
|
||||||
old_image = self.image
|
old_image = self.image
|
||||||
# Cache data to prevent races with other updates to global
|
# Cache data to prevent races with other updates to global
|
||||||
@@ -908,12 +892,8 @@ class Addon(AddonModel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await self.instance.update(store.version, store.image, arch=self.arch)
|
await self.instance.update(store.version, store.image, arch=self.arch)
|
||||||
except DockerBuildError as err:
|
|
||||||
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
|
|
||||||
raise AddonBuildFailedUnknownError(addon=self.slug) from err
|
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
|
raise AddonsError() from err
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
|
||||||
|
|
||||||
# Stop the addon if running
|
# Stop the addon if running
|
||||||
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
|
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
|
||||||
@@ -955,23 +935,12 @@ class Addon(AddonModel):
|
|||||||
"""
|
"""
|
||||||
last_state: AddonState = self.state
|
last_state: AddonState = self.state
|
||||||
try:
|
try:
|
||||||
# remove docker container and image but not addon config
|
# remove docker container but not addon config
|
||||||
try:
|
try:
|
||||||
await self.instance.remove()
|
await self.instance.remove()
|
||||||
except DockerError as err:
|
|
||||||
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err)
|
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.instance.install(self.version)
|
await self.instance.install(self.version)
|
||||||
except DockerBuildError as err:
|
|
||||||
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
|
|
||||||
raise AddonBuildFailedUnknownError(addon=self.slug) from err
|
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error(
|
raise AddonsError() from err
|
||||||
"Could not pull image to update addon %s: %s", self.slug, err
|
|
||||||
)
|
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
|
||||||
|
|
||||||
if self.addon_store:
|
if self.addon_store:
|
||||||
await self.sys_addons.data.update(self.addon_store)
|
await self.sys_addons.data.update(self.addon_store)
|
||||||
@@ -1142,9 +1111,8 @@ class Addon(AddonModel):
|
|||||||
try:
|
try:
|
||||||
await self.instance.run()
|
await self.instance.run()
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error("Could not start container for addon %s: %s", self.slug, err)
|
|
||||||
self.state = AddonState.ERROR
|
self.state = AddonState.ERROR
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
raise AddonsError() from err
|
||||||
|
|
||||||
return self.sys_create_task(self._wait_for_startup())
|
return self.sys_create_task(self._wait_for_startup())
|
||||||
|
|
||||||
@@ -1159,9 +1127,8 @@ class Addon(AddonModel):
|
|||||||
try:
|
try:
|
||||||
await self.instance.stop()
|
await self.instance.stop()
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error("Could not stop container for addon %s: %s", self.slug, err)
|
|
||||||
self.state = AddonState.ERROR
|
self.state = AddonState.ERROR
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
raise AddonsError() from err
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="addon_restart",
|
name="addon_restart",
|
||||||
@@ -1194,15 +1161,9 @@ class Addon(AddonModel):
|
|||||||
async def stats(self) -> DockerStats:
|
async def stats(self) -> DockerStats:
|
||||||
"""Return stats of container."""
|
"""Return stats of container."""
|
||||||
try:
|
try:
|
||||||
if not await self.is_running():
|
|
||||||
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
|
|
||||||
|
|
||||||
return await self.instance.stats()
|
return await self.instance.stats()
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error(
|
raise AddonsError() from err
|
||||||
"Could not get stats of container for addon %s: %s", self.slug, err
|
|
||||||
)
|
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="addon_write_stdin",
|
name="addon_write_stdin",
|
||||||
@@ -1212,18 +1173,14 @@ class Addon(AddonModel):
|
|||||||
async def write_stdin(self, data) -> None:
|
async def write_stdin(self, data) -> None:
|
||||||
"""Write data to add-on stdin."""
|
"""Write data to add-on stdin."""
|
||||||
if not self.with_stdin:
|
if not self.with_stdin:
|
||||||
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=self.slug)
|
raise AddonNotSupportedError(
|
||||||
|
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not await self.is_running():
|
return await self.instance.write_stdin(data)
|
||||||
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
|
|
||||||
|
|
||||||
await self.instance.write_stdin(data)
|
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error(
|
raise AddonsError() from err
|
||||||
"Could not write stdin to container for addon %s: %s", self.slug, err
|
|
||||||
)
|
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
|
||||||
|
|
||||||
async def _backup_command(self, command: str) -> None:
|
async def _backup_command(self, command: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -1232,14 +1189,15 @@ class Addon(AddonModel):
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Pre-/Post backup command failed with: %s", command_return.output
|
"Pre-/Post backup command failed with: %s", command_return.output
|
||||||
)
|
)
|
||||||
raise AddonPrePostBackupCommandReturnedError(
|
raise AddonsError(
|
||||||
_LOGGER.error, addon=self.slug, exit_code=command_return.exit_code
|
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
|
||||||
|
_LOGGER.error,
|
||||||
)
|
)
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
_LOGGER.error(
|
raise AddonsError(
|
||||||
"Failed running pre-/post backup command %s: %s", command, err
|
f"Failed running pre-/post backup command {command}: {str(err)}",
|
||||||
)
|
_LOGGER.error,
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
) from err
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="addon_begin_backup",
|
name="addon_begin_backup",
|
||||||
@@ -1328,14 +1286,15 @@ class Addon(AddonModel):
|
|||||||
try:
|
try:
|
||||||
self.instance.export_image(temp_path.joinpath("image.tar"))
|
self.instance.export_image(temp_path.joinpath("image.tar"))
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
raise BackupRestoreUnknownError() from err
|
raise AddonsError() from err
|
||||||
|
|
||||||
# Store local configs/state
|
# Store local configs/state
|
||||||
try:
|
try:
|
||||||
write_json_file(temp_path.joinpath("addon.json"), metadata)
|
write_json_file(temp_path.joinpath("addon.json"), metadata)
|
||||||
except ConfigurationFileError as err:
|
except ConfigurationFileError as err:
|
||||||
_LOGGER.error("Can't save meta for %s: %s", self.slug, err)
|
raise AddonsError(
|
||||||
raise BackupRestoreUnknownError() from err
|
f"Can't save meta for {self.slug}", _LOGGER.error
|
||||||
|
) from err
|
||||||
|
|
||||||
# Store AppArmor Profile
|
# Store AppArmor Profile
|
||||||
if apparmor_profile:
|
if apparmor_profile:
|
||||||
@@ -1345,7 +1304,9 @@ class Addon(AddonModel):
|
|||||||
apparmor_profile, profile_backup_file
|
apparmor_profile, profile_backup_file
|
||||||
)
|
)
|
||||||
except HostAppArmorError as err:
|
except HostAppArmorError as err:
|
||||||
raise BackupRestoreUnknownError() from err
|
raise AddonsError(
|
||||||
|
"Can't backup AppArmor profile", _LOGGER.error
|
||||||
|
) from err
|
||||||
|
|
||||||
# Write tarfile
|
# Write tarfile
|
||||||
with tar_file as backup:
|
with tar_file as backup:
|
||||||
@@ -1399,8 +1360,7 @@ class Addon(AddonModel):
|
|||||||
)
|
)
|
||||||
_LOGGER.info("Finish backup for addon %s", self.slug)
|
_LOGGER.info("Finish backup for addon %s", self.slug)
|
||||||
except (tarfile.TarError, OSError, AddFileError) as err:
|
except (tarfile.TarError, OSError, AddFileError) as err:
|
||||||
_LOGGER.error("Can't write backup tarfile for addon %s: %s", self.slug, err)
|
raise AddonsError(f"Can't write tarfile: {err}", _LOGGER.error) from err
|
||||||
raise BackupRestoreUnknownError() from err
|
|
||||||
finally:
|
finally:
|
||||||
if was_running:
|
if was_running:
|
||||||
wait_for_start = await self.end_backup()
|
wait_for_start = await self.end_backup()
|
||||||
@@ -1442,24 +1402,28 @@ class Addon(AddonModel):
|
|||||||
try:
|
try:
|
||||||
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
|
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
|
||||||
except tarfile.TarError as err:
|
except tarfile.TarError as err:
|
||||||
_LOGGER.error("Can't extract backup tarfile for %s: %s", self.slug, err)
|
raise AddonsError(
|
||||||
raise BackupRestoreUnknownError() from err
|
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
|
||||||
|
) from err
|
||||||
except ConfigurationFileError as err:
|
except ConfigurationFileError as err:
|
||||||
raise AddonUnknownError(addon=self.slug) from err
|
raise AddonsError() from err
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate
|
# Validate
|
||||||
try:
|
try:
|
||||||
data = SCHEMA_ADDON_BACKUP(data)
|
data = SCHEMA_ADDON_BACKUP(data)
|
||||||
except vol.Invalid as err:
|
except vol.Invalid as err:
|
||||||
raise AddonBackupMetadataInvalidError(
|
raise AddonsError(
|
||||||
|
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
|
||||||
_LOGGER.error,
|
_LOGGER.error,
|
||||||
addon=self.slug,
|
|
||||||
validation_error=humanize_error(data, err),
|
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
# Validate availability. Raises if not
|
# If available
|
||||||
self._validate_availability(data[ATTR_SYSTEM], logger=_LOGGER.error)
|
if not self._available(data[ATTR_SYSTEM]):
|
||||||
|
raise AddonNotSupportedError(
|
||||||
|
f"Add-on {self.slug} is not available for this platform",
|
||||||
|
_LOGGER.error,
|
||||||
|
)
|
||||||
|
|
||||||
# Restore local add-on information
|
# Restore local add-on information
|
||||||
_LOGGER.info("Restore config for addon %s", self.slug)
|
_LOGGER.info("Restore config for addon %s", self.slug)
|
||||||
@@ -1518,10 +1482,9 @@ class Addon(AddonModel):
|
|||||||
try:
|
try:
|
||||||
await self.sys_run_in_executor(_restore_data)
|
await self.sys_run_in_executor(_restore_data)
|
||||||
except shutil.Error as err:
|
except shutil.Error as err:
|
||||||
_LOGGER.error(
|
raise AddonsError(
|
||||||
"Can't restore origin data for %s: %s", self.slug, err
|
f"Can't restore origin data: {err}", _LOGGER.error
|
||||||
)
|
) from err
|
||||||
raise BackupRestoreUnknownError() from err
|
|
||||||
|
|
||||||
# Restore AppArmor
|
# Restore AppArmor
|
||||||
profile_file = Path(tmp.name, "apparmor.txt")
|
profile_file = Path(tmp.name, "apparmor.txt")
|
||||||
@@ -1532,11 +1495,10 @@ class Addon(AddonModel):
|
|||||||
)
|
)
|
||||||
except HostAppArmorError as err:
|
except HostAppArmorError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Can't restore AppArmor profile for add-on %s: %s",
|
"Can't restore AppArmor profile for add-on %s",
|
||||||
self.slug,
|
self.slug,
|
||||||
err,
|
|
||||||
)
|
)
|
||||||
raise BackupRestoreUnknownError() from err
|
raise AddonsError() from err
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Is add-on loaded
|
# Is add-on loaded
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -20,20 +19,13 @@ from ..const import (
|
|||||||
)
|
)
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
from ..docker.interface import MAP_ARCH
|
from ..docker.interface import MAP_ARCH
|
||||||
from ..exceptions import (
|
from ..exceptions import ConfigurationFileError, HassioArchNotFound
|
||||||
AddonBuildArchitectureNotSupportedError,
|
|
||||||
AddonBuildDockerfileMissingError,
|
|
||||||
ConfigurationFileError,
|
|
||||||
HassioArchNotFound,
|
|
||||||
)
|
|
||||||
from ..utils.common import FileConfiguration, find_one_filetype
|
from ..utils.common import FileConfiguration, find_one_filetype
|
||||||
from .validate import SCHEMA_BUILD_CONFIG
|
from .validate import SCHEMA_BUILD_CONFIG
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .manager import AnyAddon
|
from .manager import AnyAddon
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonBuild(FileConfiguration, CoreSysAttributes):
|
class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||||
"""Handle build options for add-ons."""
|
"""Handle build options for add-ons."""
|
||||||
@@ -114,7 +106,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
|||||||
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
|
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
|
||||||
return self.addon.path_location.joinpath("Dockerfile")
|
return self.addon.path_location.joinpath("Dockerfile")
|
||||||
|
|
||||||
async def is_valid(self) -> None:
|
async def is_valid(self) -> bool:
|
||||||
"""Return true if the build env is valid."""
|
"""Return true if the build env is valid."""
|
||||||
|
|
||||||
def build_is_valid() -> bool:
|
def build_is_valid() -> bool:
|
||||||
@@ -126,17 +118,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not await self.sys_run_in_executor(build_is_valid):
|
return await self.sys_run_in_executor(build_is_valid)
|
||||||
raise AddonBuildDockerfileMissingError(
|
|
||||||
_LOGGER.error, addon=self.addon.slug
|
|
||||||
)
|
|
||||||
except HassioArchNotFound:
|
except HassioArchNotFound:
|
||||||
raise AddonBuildArchitectureNotSupportedError(
|
return False
|
||||||
_LOGGER.error,
|
|
||||||
addon=self.addon.slug,
|
|
||||||
addon_arch_list=self.addon.supported_arch,
|
|
||||||
system_arch_list=self.sys_arch.supported,
|
|
||||||
) from None
|
|
||||||
|
|
||||||
def get_docker_args(
|
def get_docker_args(
|
||||||
self, version: AwesomeVersion, image_tag: str
|
self, version: AwesomeVersion, image_tag: str
|
||||||
|
|||||||
@@ -100,9 +100,6 @@ from ..const import (
|
|||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..docker.stats import DockerStats
|
from ..docker.stats import DockerStats
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
AddonBootConfigCannotChangeError,
|
|
||||||
AddonConfigurationInvalidError,
|
|
||||||
AddonNotSupportedWriteStdinError,
|
|
||||||
APIAddonNotInstalled,
|
APIAddonNotInstalled,
|
||||||
APIError,
|
APIError,
|
||||||
APIForbidden,
|
APIForbidden,
|
||||||
@@ -128,7 +125,6 @@ SCHEMA_OPTIONS = vol.Schema(
|
|||||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
|
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||||
vol.Optional(ATTR_OPTIONS): vol.Maybe(dict),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -304,20 +300,19 @@ class APIAddons(CoreSysAttributes):
|
|||||||
# Update secrets for validation
|
# Update secrets for validation
|
||||||
await self.sys_homeassistant.secrets.reload()
|
await self.sys_homeassistant.secrets.reload()
|
||||||
|
|
||||||
|
# Extend schema with add-on specific validation
|
||||||
|
addon_schema = SCHEMA_OPTIONS.extend(
|
||||||
|
{vol.Optional(ATTR_OPTIONS): vol.Maybe(addon.schema)}
|
||||||
|
)
|
||||||
|
|
||||||
# Validate/Process Body
|
# Validate/Process Body
|
||||||
body = await api_validate(SCHEMA_OPTIONS, request)
|
body = await api_validate(addon_schema, request)
|
||||||
if ATTR_OPTIONS in body:
|
if ATTR_OPTIONS in body:
|
||||||
try:
|
addon.options = body[ATTR_OPTIONS]
|
||||||
addon.options = addon.schema(body[ATTR_OPTIONS])
|
|
||||||
except vol.Invalid as ex:
|
|
||||||
raise AddonConfigurationInvalidError(
|
|
||||||
addon=addon.slug,
|
|
||||||
validation_error=humanize_error(body[ATTR_OPTIONS], ex),
|
|
||||||
) from None
|
|
||||||
if ATTR_BOOT in body:
|
if ATTR_BOOT in body:
|
||||||
if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
|
if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
|
||||||
raise AddonBootConfigCannotChangeError(
|
raise APIError(
|
||||||
addon=addon.slug, boot_config=addon.boot_config.value
|
f"Addon {addon.slug} boot option is set to {addon.boot_config} so it cannot be changed"
|
||||||
)
|
)
|
||||||
addon.boot = body[ATTR_BOOT]
|
addon.boot = body[ATTR_BOOT]
|
||||||
if ATTR_AUTO_UPDATE in body:
|
if ATTR_AUTO_UPDATE in body:
|
||||||
@@ -481,7 +476,7 @@ class APIAddons(CoreSysAttributes):
|
|||||||
"""Write to stdin of add-on."""
|
"""Write to stdin of add-on."""
|
||||||
addon = self.get_addon_for_request(request)
|
addon = self.get_addon_for_request(request)
|
||||||
if not addon.with_stdin:
|
if not addon.with_stdin:
|
||||||
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=addon.slug)
|
raise APIError(f"STDIN not supported the {addon.slug} add-on")
|
||||||
|
|
||||||
data = await request.read()
|
data = await request.read()
|
||||||
await asyncio.shield(addon.write_stdin(data))
|
await asyncio.shield(addon.write_stdin(data))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import voluptuous as vol
|
|||||||
from ..addons.addon import Addon
|
from ..addons.addon import Addon
|
||||||
from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
|
from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIForbidden, AuthInvalidNonStringValueError
|
from ..exceptions import APIForbidden
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_GROUP_IDS,
|
ATTR_GROUP_IDS,
|
||||||
ATTR_IS_ACTIVE,
|
ATTR_IS_ACTIVE,
|
||||||
@@ -69,9 +69,7 @@ class APIAuth(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
_ = username.encode and password.encode # type: ignore
|
_ = username.encode and password.encode # type: ignore
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise AuthInvalidNonStringValueError(
|
raise HTTPUnauthorized(headers=REALM_HEADER) from None
|
||||||
_LOGGER.error, headers=REALM_HEADER
|
|
||||||
) from None
|
|
||||||
|
|
||||||
return self.sys_auth.check_login(
|
return self.sys_auth.check_login(
|
||||||
addon, cast(str, username), cast(str, password)
|
addon, cast(str, username), cast(str, password)
|
||||||
|
|||||||
@@ -343,10 +343,14 @@ class APIHost(CoreSysAttributes):
|
|||||||
|
|
||||||
disk = self.sys_hardware.disk
|
disk = self.sys_hardware.disk
|
||||||
|
|
||||||
total, used, _ = await self.sys_run_in_executor(
|
total, _, free = await self.sys_run_in_executor(
|
||||||
disk.disk_usage, self.sys_config.path_supervisor
|
disk.disk_usage, self.sys_config.path_supervisor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Calculate used by subtracting free makes sure we include reserved space
|
||||||
|
# in used space reporting.
|
||||||
|
used = total - free
|
||||||
|
|
||||||
known_paths = await self.sys_run_in_executor(
|
known_paths = await self.sys_run_in_executor(
|
||||||
disk.get_dir_sizes,
|
disk.get_dir_sizes,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ from ..const import (
|
|||||||
REQUEST_FROM,
|
REQUEST_FROM,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIError, APIForbidden, APINotFound, StoreAddonNotFoundError
|
from ..exceptions import APIError, APIForbidden, APINotFound
|
||||||
from ..store.addon import AddonStore
|
from ..store.addon import AddonStore
|
||||||
from ..store.repository import Repository
|
from ..store.repository import Repository
|
||||||
from ..store.validate import validate_repository
|
from ..store.validate import validate_repository
|
||||||
@@ -104,7 +104,7 @@ class APIStore(CoreSysAttributes):
|
|||||||
addon_slug: str = request.match_info["addon"]
|
addon_slug: str = request.match_info["addon"]
|
||||||
|
|
||||||
if not (addon := self.sys_addons.get(addon_slug)):
|
if not (addon := self.sys_addons.get(addon_slug)):
|
||||||
raise StoreAddonNotFoundError(addon=addon_slug)
|
raise APINotFound(f"Addon {addon_slug} does not exist")
|
||||||
|
|
||||||
if installed and not addon.is_installed:
|
if installed and not addon.is_installed:
|
||||||
raise APIError(f"Addon {addon_slug} is not installed")
|
raise APIError(f"Addon {addon_slug} is not installed")
|
||||||
@@ -112,7 +112,7 @@ class APIStore(CoreSysAttributes):
|
|||||||
if not installed and addon.is_installed:
|
if not installed and addon.is_installed:
|
||||||
addon = cast(Addon, addon)
|
addon = cast(Addon, addon)
|
||||||
if not addon.addon_store:
|
if not addon.addon_store:
|
||||||
raise StoreAddonNotFoundError(addon=addon_slug)
|
raise APINotFound(f"Addon {addon_slug} does not exist in the store")
|
||||||
return addon.addon_store
|
return addon.addon_store
|
||||||
|
|
||||||
return addon
|
return addon
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Init file for Supervisor util for RESTful API."""
|
"""Init file for Supervisor util for RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable
|
||||||
import json
|
import json
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ from ..const import (
|
|||||||
RESULT_OK,
|
RESULT_OK,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
from ..exceptions import APIError, DockerAPIError, HassioError
|
from ..exceptions import APIError, BackupFileNotFoundError, DockerAPIError, HassioError
|
||||||
from ..jobs import JobSchedulerOptions, SupervisorJob
|
from ..jobs import JobSchedulerOptions, SupervisorJob
|
||||||
from ..utils import check_exception_chain, get_message_from_exception_chain
|
from ..utils import check_exception_chain, get_message_from_exception_chain
|
||||||
from ..utils.json import json_dumps, json_loads as json_loads_util
|
from ..utils.json import json_dumps, json_loads as json_loads_util
|
||||||
@@ -63,16 +63,14 @@ def json_loads(data: Any) -> dict[str, Any]:
|
|||||||
def api_process(method):
|
def api_process(method):
|
||||||
"""Wrap function with true/false calls to rest api."""
|
"""Wrap function with true/false calls to rest api."""
|
||||||
|
|
||||||
async def wrap_api(
|
async def wrap_api(*args, **kwargs) -> web.Response | web.StreamResponse:
|
||||||
api: CoreSysAttributes, *args, **kwargs
|
|
||||||
) -> web.Response | web.StreamResponse:
|
|
||||||
"""Return API information."""
|
"""Return API information."""
|
||||||
try:
|
try:
|
||||||
answer = await method(api, *args, **kwargs)
|
answer = await method(*args, **kwargs)
|
||||||
|
except BackupFileNotFoundError as err:
|
||||||
|
return api_return_error(err, status=404)
|
||||||
except APIError as err:
|
except APIError as err:
|
||||||
return api_return_error(
|
return api_return_error(err, status=err.status, job_id=err.job_id)
|
||||||
err, status=err.status, job_id=err.job_id, headers=err.headers
|
|
||||||
)
|
|
||||||
except HassioError as err:
|
except HassioError as err:
|
||||||
return api_return_error(err)
|
return api_return_error(err)
|
||||||
|
|
||||||
@@ -109,12 +107,10 @@ def api_process_raw(content, *, error_type=None):
|
|||||||
def wrap_method(method):
|
def wrap_method(method):
|
||||||
"""Wrap function with raw output to rest api."""
|
"""Wrap function with raw output to rest api."""
|
||||||
|
|
||||||
async def wrap_api(
|
async def wrap_api(*args, **kwargs) -> web.Response | web.StreamResponse:
|
||||||
api: CoreSysAttributes, *args, **kwargs
|
|
||||||
) -> web.Response | web.StreamResponse:
|
|
||||||
"""Return api information."""
|
"""Return api information."""
|
||||||
try:
|
try:
|
||||||
msg_data = await method(api, *args, **kwargs)
|
msg_data = await method(*args, **kwargs)
|
||||||
except APIError as err:
|
except APIError as err:
|
||||||
return api_return_error(
|
return api_return_error(
|
||||||
err,
|
err,
|
||||||
@@ -143,7 +139,6 @@ def api_return_error(
|
|||||||
error_type: str | None = None,
|
error_type: str | None = None,
|
||||||
status: int = 400,
|
status: int = 400,
|
||||||
*,
|
*,
|
||||||
headers: Mapping[str, str] | None = None,
|
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
"""Return an API error message."""
|
"""Return an API error message."""
|
||||||
@@ -156,15 +151,10 @@ def api_return_error(
|
|||||||
|
|
||||||
match error_type:
|
match error_type:
|
||||||
case const.CONTENT_TYPE_TEXT:
|
case const.CONTENT_TYPE_TEXT:
|
||||||
return web.Response(
|
return web.Response(body=message, content_type=error_type, status=status)
|
||||||
body=message, content_type=error_type, status=status, headers=headers
|
|
||||||
)
|
|
||||||
case const.CONTENT_TYPE_BINARY:
|
case const.CONTENT_TYPE_BINARY:
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=message.encode(),
|
body=message.encode(), content_type=error_type, status=status
|
||||||
content_type=error_type,
|
|
||||||
status=status,
|
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
case _:
|
case _:
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
@@ -182,7 +172,6 @@ def api_return_error(
|
|||||||
result,
|
result,
|
||||||
status=status,
|
status=status,
|
||||||
dumps=json_dumps,
|
dumps=json_dumps,
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ from .addons.addon import Addon
|
|||||||
from .const import ATTR_PASSWORD, ATTR_TYPE, ATTR_USERNAME, FILE_HASSIO_AUTH
|
from .const import ATTR_PASSWORD, ATTR_TYPE, ATTR_USERNAME, FILE_HASSIO_AUTH
|
||||||
from .coresys import CoreSys, CoreSysAttributes
|
from .coresys import CoreSys, CoreSysAttributes
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
AuthHomeAssistantAPIValidationError,
|
AuthError,
|
||||||
AuthInvalidNonStringValueError,
|
|
||||||
AuthListUsersError,
|
AuthListUsersError,
|
||||||
AuthListUsersNoneResponseError,
|
|
||||||
AuthPasswordResetError,
|
AuthPasswordResetError,
|
||||||
HomeAssistantAPIError,
|
HomeAssistantAPIError,
|
||||||
HomeAssistantWSError,
|
HomeAssistantWSError,
|
||||||
@@ -85,8 +83,10 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
|||||||
self, addon: Addon, username: str | None, password: str | None
|
self, addon: Addon, username: str | None, password: str | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check username login."""
|
"""Check username login."""
|
||||||
if username is None or password is None:
|
if password is None:
|
||||||
raise AuthInvalidNonStringValueError(_LOGGER.error)
|
raise AuthError("None as password is not supported!", _LOGGER.error)
|
||||||
|
if username is None:
|
||||||
|
raise AuthError("None as username is not supported!", _LOGGER.error)
|
||||||
|
|
||||||
_LOGGER.info("Auth request from '%s' for '%s'", addon.slug, username)
|
_LOGGER.info("Auth request from '%s' for '%s'", addon.slug, username)
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
|||||||
finally:
|
finally:
|
||||||
self._running.pop(username, None)
|
self._running.pop(username, None)
|
||||||
|
|
||||||
raise AuthHomeAssistantAPIValidationError()
|
raise AuthError()
|
||||||
|
|
||||||
async def change_password(self, username: str, password: str) -> None:
|
async def change_password(self, username: str, password: str) -> None:
|
||||||
"""Change user password login."""
|
"""Change user password login."""
|
||||||
@@ -155,7 +155,7 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
|||||||
except HomeAssistantAPIError as err:
|
except HomeAssistantAPIError as err:
|
||||||
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
|
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
|
||||||
|
|
||||||
raise AuthPasswordResetError(user=username)
|
raise AuthPasswordResetError()
|
||||||
|
|
||||||
async def list_users(self) -> list[dict[str, Any]]:
|
async def list_users(self) -> list[dict[str, Any]]:
|
||||||
"""List users on the Home Assistant instance."""
|
"""List users on the Home Assistant instance."""
|
||||||
@@ -166,12 +166,15 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
|||||||
{ATTR_TYPE: "config/auth/list"}
|
{ATTR_TYPE: "config/auth/list"}
|
||||||
)
|
)
|
||||||
except HomeAssistantWSError as err:
|
except HomeAssistantWSError as err:
|
||||||
_LOGGER.error("Can't request listing users on Home Assistant: %s", err)
|
raise AuthListUsersError(
|
||||||
raise AuthListUsersError() from err
|
f"Can't request listing users on Home Assistant: {err}", _LOGGER.error
|
||||||
|
) from err
|
||||||
|
|
||||||
if users is not None:
|
if users is not None:
|
||||||
return users
|
return users
|
||||||
raise AuthListUsersNoneResponseError(_LOGGER.error)
|
raise AuthListUsersError(
|
||||||
|
"Can't request listing users on Home Assistant!", _LOGGER.error
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _rehash(value: str, salt2: str = "") -> str:
|
def _rehash(value: str, salt2: str = "") -> str:
|
||||||
|
|||||||
@@ -628,6 +628,9 @@ class Backup(JobGroup):
|
|||||||
if start_task := await self._addon_save(addon):
|
if start_task := await self._addon_save(addon):
|
||||||
start_tasks.append(start_task)
|
start_tasks.append(start_task)
|
||||||
except BackupError as err:
|
except BackupError as err:
|
||||||
|
err = BackupError(
|
||||||
|
f"Can't backup add-on {addon.slug}: {str(err)}", _LOGGER.error
|
||||||
|
)
|
||||||
self.sys_jobs.current.capture_error(err)
|
self.sys_jobs.current.capture_error(err)
|
||||||
|
|
||||||
return start_tasks
|
return start_tasks
|
||||||
|
|||||||
@@ -306,6 +306,8 @@ 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):
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class DBusManager(CoreSysAttributes):
|
|||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Connect interfaces to D-Bus."""
|
"""Connect interfaces to D-Bus."""
|
||||||
if not SOCKET_DBUS.exists():
|
if not await self.sys_run_in_executor(SOCKET_DBUS.exists):
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"No D-Bus support on Host. Disabled any kind of host control!"
|
"No D-Bus support on Host. Disabled any kind of host control!"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -134,9 +134,10 @@ 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 await self.connected_dbus.call("check_connectivity")
|
return ConnectivityState(
|
||||||
else:
|
await self.connected_dbus.call("check_connectivity")
|
||||||
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 self.properties[DBUS_ATTR_STATE]
|
return ConnectionStateType(self.properties[DBUS_ATTR_STATE])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state_flags(self) -> set[ConnectionStateFlags]:
|
def state_flags(self) -> set[ConnectionStateFlags]:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -23,6 +24,8 @@ 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.
|
||||||
@@ -57,7 +60,15 @@ class NetworkInterface(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def type(self) -> DeviceType:
|
def type(self) -> DeviceType:
|
||||||
"""Return interface type."""
|
"""Return interface type."""
|
||||||
return self.properties[DBUS_ATTR_DEVICE_TYPE]
|
try:
|
||||||
|
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
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class Resolved(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def current_dns_server(
|
def current_dns_server(
|
||||||
self,
|
self,
|
||||||
) -> list[tuple[int, DNSAddressFamily, bytes]] | None:
|
) -> tuple[int, DNSAddressFamily, bytes] | None:
|
||||||
"""Return current DNS server."""
|
"""Return current DNS server."""
|
||||||
return self.properties[DBUS_ATTR_CURRENT_DNS_SERVER]
|
return self.properties[DBUS_ATTR_CURRENT_DNS_SERVER]
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ class Resolved(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def current_dns_server_ex(
|
def current_dns_server_ex(
|
||||||
self,
|
self,
|
||||||
) -> list[tuple[int, DNSAddressFamily, bytes, int, str]] | None:
|
) -> tuple[int, DNSAddressFamily, bytes, int, str] | None:
|
||||||
"""Return current DNS server including port and server name."""
|
"""Return current DNS server including port and server name."""
|
||||||
return self.properties[DBUS_ATTR_CURRENT_DNS_SERVER_EX]
|
return self.properties[DBUS_ATTR_CURRENT_DNS_SERVER_EX]
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class SystemdUnit(DBusInterface):
|
|||||||
@dbus_connected
|
@dbus_connected
|
||||||
async def get_active_state(self) -> UnitActiveState:
|
async def get_active_state(self) -> UnitActiveState:
|
||||||
"""Get active state of the unit."""
|
"""Get active state of the unit."""
|
||||||
return await self.connected_dbus.Unit.get("active_state")
|
return UnitActiveState(await self.connected_dbus.Unit.get("active_state"))
|
||||||
|
|
||||||
@dbus_connected
|
@dbus_connected
|
||||||
def properties_changed(self) -> DBusSignalWrapper:
|
def properties_changed(self) -> DBusSignalWrapper:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from dbus_fast import Variant
|
|||||||
from .const import EncryptType, EraseMode
|
from .const import EncryptType, EraseMode
|
||||||
|
|
||||||
|
|
||||||
def udisks2_bytes_to_path(path_bytes: bytearray) -> Path:
|
def udisks2_bytes_to_path(path_bytes: bytes) -> Path:
|
||||||
"""Convert bytes to path object without null character on end."""
|
"""Convert bytes to path object without null character on end."""
|
||||||
if path_bytes and path_bytes[-1] == 0:
|
if path_bytes and path_bytes[-1] == 0:
|
||||||
return Path(path_bytes[:-1].decode())
|
return Path(path_bytes[:-1].decode())
|
||||||
@@ -73,7 +73,7 @@ FormatOptionsDataType = TypedDict(
|
|||||||
{
|
{
|
||||||
"label": NotRequired[str],
|
"label": NotRequired[str],
|
||||||
"take-ownership": NotRequired[bool],
|
"take-ownership": NotRequired[bool],
|
||||||
"encrypt.passphrase": NotRequired[bytearray],
|
"encrypt.passphrase": NotRequired[bytes],
|
||||||
"encrypt.type": NotRequired[str],
|
"encrypt.type": NotRequired[str],
|
||||||
"erase": NotRequired[str],
|
"erase": NotRequired[str],
|
||||||
"update-partition-type": NotRequired[bool],
|
"update-partition-type": NotRequired[bool],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
import logging
|
import logging
|
||||||
@@ -34,7 +33,6 @@ from ..coresys import CoreSys
|
|||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
CoreDNSError,
|
CoreDNSError,
|
||||||
DBusError,
|
DBusError,
|
||||||
DockerBuildError,
|
|
||||||
DockerError,
|
DockerError,
|
||||||
DockerJobError,
|
DockerJobError,
|
||||||
DockerNotFound,
|
DockerNotFound,
|
||||||
@@ -682,8 +680,9 @@ class DockerAddon(DockerInterface):
|
|||||||
async def _build(self, version: AwesomeVersion, image: str | None = None) -> None:
|
async def _build(self, version: AwesomeVersion, image: str | None = None) -> None:
|
||||||
"""Build a Docker container."""
|
"""Build a Docker container."""
|
||||||
build_env = await AddonBuild(self.coresys, self.addon).load_config()
|
build_env = await AddonBuild(self.coresys, self.addon).load_config()
|
||||||
# Check if the build environment is valid, raises if not
|
if not await build_env.is_valid():
|
||||||
await build_env.is_valid()
|
_LOGGER.error("Invalid build environment, can't build this add-on!")
|
||||||
|
raise DockerError()
|
||||||
|
|
||||||
_LOGGER.info("Starting build for %s:%s", self.image, version)
|
_LOGGER.info("Starting build for %s:%s", self.image, version)
|
||||||
|
|
||||||
@@ -734,9 +733,8 @@ class DockerAddon(DockerInterface):
|
|||||||
requests.RequestException,
|
requests.RequestException,
|
||||||
aiodocker.DockerError,
|
aiodocker.DockerError,
|
||||||
) as err:
|
) as err:
|
||||||
raise DockerBuildError(
|
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
|
||||||
f"Can't build {self.image}:{version}: {err!s}", _LOGGER.error
|
raise DockerError() from err
|
||||||
) from err
|
|
||||||
|
|
||||||
_LOGGER.info("Build %s:%s done", self.image, version)
|
_LOGGER.info("Build %s:%s done", self.image, version)
|
||||||
|
|
||||||
@@ -794,9 +792,12 @@ class DockerAddon(DockerInterface):
|
|||||||
on_condition=DockerJobError,
|
on_condition=DockerJobError,
|
||||||
concurrency=JobConcurrency.GROUP_REJECT,
|
concurrency=JobConcurrency.GROUP_REJECT,
|
||||||
)
|
)
|
||||||
def write_stdin(self, data: bytes) -> Awaitable[None]:
|
async def write_stdin(self, data: bytes) -> None:
|
||||||
"""Write to add-on stdin."""
|
"""Write to add-on stdin."""
|
||||||
return self.sys_run_in_executor(self._write_stdin, data)
|
if not await self.is_running():
|
||||||
|
raise DockerError()
|
||||||
|
|
||||||
|
await self.sys_run_in_executor(self._write_stdin, data)
|
||||||
|
|
||||||
def _write_stdin(self, data: bytes) -> None:
|
def _write_stdin(self, data: bytes) -> None:
|
||||||
"""Write to add-on stdin.
|
"""Write to add-on stdin.
|
||||||
|
|||||||
@@ -482,34 +482,35 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _get_container(self) -> Container | None:
|
async def is_running(self) -> bool:
|
||||||
"""Get docker container, returns None if not found."""
|
"""Return True if Docker is running."""
|
||||||
try:
|
try:
|
||||||
return await self.sys_run_in_executor(
|
docker_container = await self.sys_run_in_executor(
|
||||||
self.sys_docker.containers.get, self.name
|
self.sys_docker.containers.get, self.name
|
||||||
)
|
)
|
||||||
except docker.errors.NotFound:
|
except docker.errors.NotFound:
|
||||||
return None
|
return False
|
||||||
except docker.errors.DockerException as err:
|
except docker.errors.DockerException as err:
|
||||||
raise DockerAPIError(
|
raise DockerAPIError() from err
|
||||||
f"Docker API error occurred while getting container information: {err!s}"
|
|
||||||
) from err
|
|
||||||
except requests.RequestException as err:
|
except requests.RequestException as err:
|
||||||
raise DockerRequestError(
|
raise DockerRequestError() from err
|
||||||
f"Error communicating with Docker to get container information: {err!s}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
async def is_running(self) -> bool:
|
return docker_container.status == "running"
|
||||||
"""Return True if Docker is running."""
|
|
||||||
if docker_container := await self._get_container():
|
|
||||||
return docker_container.status == "running"
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def current_state(self) -> ContainerState:
|
async def current_state(self) -> ContainerState:
|
||||||
"""Return current state of container."""
|
"""Return current state of container."""
|
||||||
if docker_container := await self._get_container():
|
try:
|
||||||
return _container_state_from_model(docker_container)
|
docker_container = await self.sys_run_in_executor(
|
||||||
return ContainerState.UNKNOWN
|
self.sys_docker.containers.get, self.name
|
||||||
|
)
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
return ContainerState.UNKNOWN
|
||||||
|
except docker.errors.DockerException as err:
|
||||||
|
raise DockerAPIError() from err
|
||||||
|
except requests.RequestException as err:
|
||||||
|
raise DockerRequestError() from err
|
||||||
|
|
||||||
|
return _container_state_from_model(docker_container)
|
||||||
|
|
||||||
@Job(name="docker_interface_attach", concurrency=JobConcurrency.GROUP_QUEUE)
|
@Job(name="docker_interface_attach", concurrency=JobConcurrency.GROUP_QUEUE)
|
||||||
async def attach(
|
async def attach(
|
||||||
@@ -544,9 +545,7 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
|
|
||||||
# Successful?
|
# Successful?
|
||||||
if not self._meta:
|
if not self._meta:
|
||||||
raise DockerError(
|
raise DockerError()
|
||||||
f"Could not get metadata on container or image for {self.name}"
|
|
||||||
)
|
|
||||||
_LOGGER.info("Attaching to %s with version %s", self.image, self.version)
|
_LOGGER.info("Attaching to %s with version %s", self.image, self.version)
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
@@ -751,8 +750,14 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
|
|
||||||
async def is_failed(self) -> bool:
|
async def is_failed(self) -> bool:
|
||||||
"""Return True if Docker is failing state."""
|
"""Return True if Docker is failing state."""
|
||||||
if not (docker_container := await self._get_container()):
|
try:
|
||||||
|
docker_container = await self.sys_run_in_executor(
|
||||||
|
self.sys_docker.containers.get, self.name
|
||||||
|
)
|
||||||
|
except docker.errors.NotFound:
|
||||||
return False
|
return False
|
||||||
|
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||||
|
raise DockerError() from err
|
||||||
|
|
||||||
# container is not running
|
# container is not running
|
||||||
if docker_container.status != "exited":
|
if docker_container.status != "exited":
|
||||||
|
|||||||
@@ -76,15 +76,25 @@ class DockerInfo:
|
|||||||
storage: str = attr.ib()
|
storage: str = attr.ib()
|
||||||
logging: str = attr.ib()
|
logging: str = attr.ib()
|
||||||
cgroup: str = attr.ib()
|
cgroup: str = attr.ib()
|
||||||
|
support_cpu_realtime: bool = attr.ib()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new(data: dict[str, Any]):
|
async def new(data: dict[str, Any]) -> DockerInfo:
|
||||||
"""Create a object from docker info."""
|
"""Create a object from docker info."""
|
||||||
|
# Check if CONFIG_RT_GROUP_SCHED is loaded (blocking I/O in executor)
|
||||||
|
cpu_rt_file_exists = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None, Path("/sys/fs/cgroup/cpu/cpu.rt_runtime_us").exists
|
||||||
|
)
|
||||||
|
cpu_rt_supported = (
|
||||||
|
cpu_rt_file_exists and os.environ.get(ENV_SUPERVISOR_CPU_RT) == "1"
|
||||||
|
)
|
||||||
|
|
||||||
return DockerInfo(
|
return DockerInfo(
|
||||||
AwesomeVersion(data.get("ServerVersion", "0.0.0")),
|
AwesomeVersion(data.get("ServerVersion", "0.0.0")),
|
||||||
data.get("Driver", "unknown"),
|
data.get("Driver", "unknown"),
|
||||||
data.get("LoggingDriver", "unknown"),
|
data.get("LoggingDriver", "unknown"),
|
||||||
data.get("CgroupVersion", "1"),
|
data.get("CgroupVersion", "1"),
|
||||||
|
cpu_rt_supported,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -95,13 +105,6 @@ class DockerInfo:
|
|||||||
except AwesomeVersionCompareException:
|
except AwesomeVersionCompareException:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
|
||||||
def support_cpu_realtime(self) -> bool:
|
|
||||||
"""Return true, if CONFIG_RT_GROUP_SCHED is loaded."""
|
|
||||||
if not Path("/sys/fs/cgroup/cpu/cpu.rt_runtime_us").exists():
|
|
||||||
return False
|
|
||||||
return bool(os.environ.get(ENV_SUPERVISOR_CPU_RT) == "1")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PullProgressDetail:
|
class PullProgressDetail:
|
||||||
@@ -234,7 +237,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
timeout=900,
|
timeout=900,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._info = DockerInfo.new(self.dockerpy.info())
|
self._info = await DockerInfo.new(self.dockerpy.info())
|
||||||
await self.config.read_data()
|
await self.config.read_data()
|
||||||
self._network = await DockerNetwork(self.dockerpy).post_init(
|
self._network = await DockerNetwork(self.dockerpy).post_init(
|
||||||
self.config.enable_ipv6, self.config.mtu
|
self.config.enable_ipv6, self.config.mtu
|
||||||
@@ -578,15 +581,9 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
except aiodocker.DockerError as err:
|
except aiodocker.DockerError as err:
|
||||||
if err.status == HTTPStatus.NOT_FOUND:
|
if err.status == HTTPStatus.NOT_FOUND:
|
||||||
return False
|
return False
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Could not get container {name} or image {image}:{version} to check state: {err!s}",
|
|
||||||
_LOGGER.error,
|
|
||||||
) from err
|
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Could not get container {name} or image {image}:{version} to check state: {err!s}",
|
|
||||||
_LOGGER.error,
|
|
||||||
) from err
|
|
||||||
|
|
||||||
# Check the image is correct and state is good
|
# Check the image is correct and state is good
|
||||||
return (
|
return (
|
||||||
@@ -602,13 +599,9 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except docker_errors.NotFound:
|
||||||
# Generally suppressed so we don't log this
|
|
||||||
raise DockerNotFound() from None
|
raise DockerNotFound() from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Could not get container {name} for stopping: {err!s}",
|
|
||||||
_LOGGER.error,
|
|
||||||
) from err
|
|
||||||
|
|
||||||
if docker_container.status == "running":
|
if docker_container.status == "running":
|
||||||
_LOGGER.info("Stopping %s application", name)
|
_LOGGER.info("Stopping %s application", name)
|
||||||
@@ -648,13 +641,9 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
container: Container = self.containers.get(name)
|
container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except docker_errors.NotFound:
|
||||||
raise DockerNotFound(
|
raise DockerNotFound() from None
|
||||||
f"Container {name} not found for restarting", _LOGGER.warning
|
|
||||||
) from None
|
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Could not get container {name} for restarting: {err!s}", _LOGGER.error
|
|
||||||
) from err
|
|
||||||
|
|
||||||
_LOGGER.info("Restarting %s", name)
|
_LOGGER.info("Restarting %s", name)
|
||||||
try:
|
try:
|
||||||
@@ -667,13 +656,9 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except docker_errors.NotFound:
|
||||||
raise DockerNotFound(
|
raise DockerNotFound() from None
|
||||||
f"Container {name} not found for logs", _LOGGER.warning
|
|
||||||
) from None
|
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Could not get container {name} for logs: {err!s}", _LOGGER.error
|
|
||||||
) from err
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return docker_container.logs(tail=tail, stdout=True, stderr=True)
|
return docker_container.logs(tail=tail, stdout=True, stderr=True)
|
||||||
@@ -687,13 +672,9 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except docker_errors.NotFound:
|
||||||
raise DockerNotFound(
|
raise DockerNotFound() from None
|
||||||
f"Container {name} not found for stats", _LOGGER.warning
|
|
||||||
) from None
|
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Could not inspect container '{name}': {err!s}", _LOGGER.error
|
|
||||||
) from err
|
|
||||||
|
|
||||||
# container is not running
|
# container is not running
|
||||||
if docker_container.status != "running":
|
if docker_container.status != "running":
|
||||||
@@ -711,21 +692,15 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except docker_errors.NotFound:
|
||||||
raise DockerNotFound(
|
raise DockerNotFound() from None
|
||||||
f"Container {name} not found for running command", _LOGGER.warning
|
|
||||||
) from None
|
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Can't get container {name} to run command: {err!s}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
try:
|
try:
|
||||||
code, output = docker_container.exec_run(command)
|
code, output = docker_container.exec_run(command)
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError() from err
|
||||||
f"Can't run command in container {name}: {err!s}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return CommandReturn(code, output)
|
return CommandReturn(code, output)
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
"""Core Exceptions."""
|
"""Core Exceptions."""
|
||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
MESSAGE_CHECK_SUPERVISOR_LOGS = (
|
|
||||||
"Check supervisor logs for details (check with '{logs_command}')"
|
|
||||||
)
|
|
||||||
EXTRA_FIELDS_LOGS_COMMAND = {"logs_command": "ha supervisor logs"}
|
|
||||||
|
|
||||||
|
|
||||||
class HassioError(Exception):
|
class HassioError(Exception):
|
||||||
"""Root exception."""
|
"""Root exception."""
|
||||||
|
|
||||||
error_key: str | None = None
|
error_key: str | None = None
|
||||||
message_template: str | None = None
|
message_template: str | None = None
|
||||||
extra_fields: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, message: str | None = None, logger: Callable[..., None] | None = None
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
logger: Callable[..., None] | None = None,
|
||||||
|
*,
|
||||||
|
extra_fields: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Raise & log."""
|
"""Raise & log."""
|
||||||
|
self.extra_fields = extra_fields or {}
|
||||||
|
|
||||||
if not message and self.message_template:
|
if not message and self.message_template:
|
||||||
message = (
|
message = (
|
||||||
self.message_template.format(**self.extra_fields)
|
self.message_template.format(**self.extra_fields)
|
||||||
@@ -41,94 +41,6 @@ class HassioNotSupportedError(HassioError):
|
|||||||
"""Function is not supported."""
|
"""Function is not supported."""
|
||||||
|
|
||||||
|
|
||||||
# API
|
|
||||||
|
|
||||||
|
|
||||||
class APIError(HassioError, RuntimeError):
|
|
||||||
"""API errors."""
|
|
||||||
|
|
||||||
status = 400
|
|
||||||
headers: Mapping[str, str] | None = None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str | None = None,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
headers: Mapping[str, str] | None = None,
|
|
||||||
job_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise & log, optionally with job."""
|
|
||||||
super().__init__(message, logger)
|
|
||||||
self.headers = headers
|
|
||||||
self.job_id = job_id
|
|
||||||
|
|
||||||
|
|
||||||
class APIUnauthorized(APIError):
|
|
||||||
"""API unauthorized error."""
|
|
||||||
|
|
||||||
status = 401
|
|
||||||
|
|
||||||
|
|
||||||
class APIForbidden(APIError):
|
|
||||||
"""API forbidden error."""
|
|
||||||
|
|
||||||
status = 403
|
|
||||||
|
|
||||||
|
|
||||||
class APINotFound(APIError):
|
|
||||||
"""API not found error."""
|
|
||||||
|
|
||||||
status = 404
|
|
||||||
|
|
||||||
|
|
||||||
class APIGone(APIError):
|
|
||||||
"""API is no longer available."""
|
|
||||||
|
|
||||||
status = 410
|
|
||||||
|
|
||||||
|
|
||||||
class APITooManyRequests(APIError):
|
|
||||||
"""API too many requests error."""
|
|
||||||
|
|
||||||
status = 429
|
|
||||||
|
|
||||||
|
|
||||||
class APIInternalServerError(APIError):
|
|
||||||
"""API internal server error."""
|
|
||||||
|
|
||||||
status = 500
|
|
||||||
|
|
||||||
|
|
||||||
class APIAddonNotInstalled(APIError):
|
|
||||||
"""Not installed addon requested at addons API."""
|
|
||||||
|
|
||||||
|
|
||||||
class APIDBMigrationInProgress(APIError):
|
|
||||||
"""Service is unavailable due to an offline DB migration is in progress."""
|
|
||||||
|
|
||||||
status = 503
|
|
||||||
|
|
||||||
|
|
||||||
class APIUnknownSupervisorError(APIError):
|
|
||||||
"""Unknown error occurred within supervisor. Adds supervisor check logs rider to mesage template."""
|
|
||||||
|
|
||||||
status = 500
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
job_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.message_template = (
|
|
||||||
f"{self.message_template}. {MESSAGE_CHECK_SUPERVISOR_LOGS}"
|
|
||||||
)
|
|
||||||
self.extra_fields = (self.extra_fields or {}) | EXTRA_FIELDS_LOGS_COMMAND
|
|
||||||
super().__init__(None, logger, job_id=job_id)
|
|
||||||
|
|
||||||
|
|
||||||
# JobManager
|
# JobManager
|
||||||
|
|
||||||
|
|
||||||
@@ -210,13 +122,6 @@ class SupervisorAppArmorError(SupervisorError):
|
|||||||
"""Supervisor AppArmor error."""
|
"""Supervisor AppArmor error."""
|
||||||
|
|
||||||
|
|
||||||
class SupervisorUnknownError(SupervisorError, APIUnknownSupervisorError):
|
|
||||||
"""Raise when an unknown error occurs interacting with Supervisor or its container."""
|
|
||||||
|
|
||||||
error_key = "supervisor_unknown_error"
|
|
||||||
message_template = "An unknown error occurred with Supervisor"
|
|
||||||
|
|
||||||
|
|
||||||
class SupervisorJobError(SupervisorError, JobException):
|
class SupervisorJobError(SupervisorError, JobException):
|
||||||
"""Raise on job errors."""
|
"""Raise on job errors."""
|
||||||
|
|
||||||
@@ -345,54 +250,6 @@ class AddonConfigurationError(AddonsError):
|
|||||||
"""Error with add-on configuration."""
|
"""Error with add-on configuration."""
|
||||||
|
|
||||||
|
|
||||||
class AddonConfigurationInvalidError(AddonConfigurationError, APIError):
|
|
||||||
"""Raise if invalid configuration provided for addon."""
|
|
||||||
|
|
||||||
error_key = "addon_configuration_invalid_error"
|
|
||||||
message_template = "Add-on {addon} has invalid options: {validation_error}"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
addon: str,
|
|
||||||
validation_error: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon, "validation_error": validation_error}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonBootConfigCannotChangeError(AddonsError, APIError):
|
|
||||||
"""Raise if user attempts to change addon boot config when it can't be changed."""
|
|
||||||
|
|
||||||
error_key = "addon_boot_config_cannot_change_error"
|
|
||||||
message_template = (
|
|
||||||
"Addon {addon} boot option is set to {boot_config} so it cannot be changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str, boot_config: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon, "boot_config": boot_config}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonNotRunningError(AddonsError, APIError):
|
|
||||||
"""Raise when an addon is not running."""
|
|
||||||
|
|
||||||
error_key = "addon_not_running_error"
|
|
||||||
message_template = "Add-on {addon} is not running"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonNotSupportedError(HassioNotSupportedError):
|
class AddonNotSupportedError(HassioNotSupportedError):
|
||||||
"""Addon doesn't support a function."""
|
"""Addon doesn't support a function."""
|
||||||
|
|
||||||
@@ -411,8 +268,11 @@ class AddonNotSupportedArchitectureError(AddonNotSupportedError):
|
|||||||
architectures: list[str],
|
architectures: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize exception."""
|
"""Initialize exception."""
|
||||||
self.extra_fields = {"slug": slug, "architectures": ", ".join(architectures)}
|
super().__init__(
|
||||||
super().__init__(None, logger)
|
None,
|
||||||
|
logger,
|
||||||
|
extra_fields={"slug": slug, "architectures": ", ".join(architectures)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
|
class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
|
||||||
@@ -429,8 +289,11 @@ class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
|
|||||||
machine_types: list[str],
|
machine_types: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize exception."""
|
"""Initialize exception."""
|
||||||
self.extra_fields = {"slug": slug, "machine_types": ", ".join(machine_types)}
|
super().__init__(
|
||||||
super().__init__(None, logger)
|
None,
|
||||||
|
logger,
|
||||||
|
extra_fields={"slug": slug, "machine_types": ", ".join(machine_types)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
|
class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
|
||||||
@@ -447,96 +310,11 @@ class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
|
|||||||
version: str,
|
version: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize exception."""
|
"""Initialize exception."""
|
||||||
self.extra_fields = {"slug": slug, "version": version}
|
super().__init__(
|
||||||
super().__init__(None, logger)
|
None,
|
||||||
|
logger,
|
||||||
|
extra_fields={"slug": slug, "version": version},
|
||||||
class AddonNotSupportedWriteStdinError(AddonNotSupportedError, APIError):
|
)
|
||||||
"""Addon does not support writing to stdin."""
|
|
||||||
|
|
||||||
error_key = "addon_not_supported_write_stdin_error"
|
|
||||||
message_template = "Add-on {addon} does not support writing to stdin"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonBuildDockerfileMissingError(AddonNotSupportedError, APIError):
|
|
||||||
"""Raise when addon build invalid because dockerfile is missing."""
|
|
||||||
|
|
||||||
error_key = "addon_build_dockerfile_missing_error"
|
|
||||||
message_template = (
|
|
||||||
"Cannot build addon '{addon}' because dockerfile is missing. A repair "
|
|
||||||
"using '{repair_command}' will fix this if the cause is data "
|
|
||||||
"corruption. Otherwise please report this to the addon developer."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon, "repair_command": "ha supervisor repair"}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonBuildArchitectureNotSupportedError(AddonNotSupportedError, APIError):
|
|
||||||
"""Raise when addon cannot be built on system because it doesn't support its architecture."""
|
|
||||||
|
|
||||||
error_key = "addon_build_architecture_not_supported_error"
|
|
||||||
message_template = (
|
|
||||||
"Cannot build addon '{addon}' because its supported architectures "
|
|
||||||
"({addon_arches}) do not match the system supported architectures ({system_arches})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
addon: str,
|
|
||||||
addon_arch_list: list[str],
|
|
||||||
system_arch_list: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {
|
|
||||||
"addon": addon,
|
|
||||||
"addon_arches": ", ".join(addon_arch_list),
|
|
||||||
"system_arches": ", ".join(system_arch_list),
|
|
||||||
}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonUnknownError(AddonsError, APIUnknownSupervisorError):
|
|
||||||
"""Raise when unknown error occurs taking an action for an addon."""
|
|
||||||
|
|
||||||
error_key = "addon_unknown_error"
|
|
||||||
message_template = "An unknown error occurred with addon {addon}"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon}
|
|
||||||
super().__init__(logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonBuildFailedUnknownError(AddonsError, APIUnknownSupervisorError):
|
|
||||||
"""Raise when the build failed for an addon due to an unknown error."""
|
|
||||||
|
|
||||||
error_key = "addon_build_failed_unknown_error"
|
|
||||||
message_template = (
|
|
||||||
"An unknown error occurred while trying to build the image for addon {addon}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon}
|
|
||||||
super().__init__(logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonsJobError(AddonsError, JobException):
|
class AddonsJobError(AddonsError, JobException):
|
||||||
@@ -568,68 +346,13 @@ class AuthError(HassioError):
|
|||||||
"""Auth errors."""
|
"""Auth errors."""
|
||||||
|
|
||||||
|
|
||||||
# This one uses the check logs rider even though its not a 500 error because it
|
class AuthPasswordResetError(HassioError):
|
||||||
# is bad practice to return error specifics from a password reset API.
|
|
||||||
class AuthPasswordResetError(AuthError, APIError):
|
|
||||||
"""Auth error if password reset failed."""
|
"""Auth error if password reset failed."""
|
||||||
|
|
||||||
error_key = "auth_password_reset_error"
|
|
||||||
message_template = (
|
|
||||||
f"Unable to reset password for '{{user}}'. {MESSAGE_CHECK_SUPERVISOR_LOGS}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
class AuthListUsersError(HassioError):
|
||||||
self,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
user: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"user": user} | EXTRA_FIELDS_LOGS_COMMAND
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthListUsersError(AuthError, APIUnknownSupervisorError):
|
|
||||||
"""Auth error if listing users failed."""
|
"""Auth error if listing users failed."""
|
||||||
|
|
||||||
error_key = "auth_list_users_error"
|
|
||||||
message_template = "Can't request listing users on Home Assistant"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthListUsersNoneResponseError(AuthError, APIInternalServerError):
|
|
||||||
"""Auth error if listing users returned invalid None response."""
|
|
||||||
|
|
||||||
error_key = "auth_list_users_none_response_error"
|
|
||||||
message_template = "Home Assistant returned invalid response of `{none}` instead of a list of users. Check Home Assistant logs for details (check with `{logs_command}`)"
|
|
||||||
extra_fields = {"none": "None", "logs_command": "ha core logs"}
|
|
||||||
|
|
||||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthInvalidNonStringValueError(AuthError, APIUnauthorized):
|
|
||||||
"""Auth error if something besides a string provided as username or password."""
|
|
||||||
|
|
||||||
error_key = "auth_invalid_non_string_value_error"
|
|
||||||
message_template = "Username and password must be strings"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
headers: Mapping[str, str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
super().__init__(None, logger, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthHomeAssistantAPIValidationError(AuthError, APIUnknownSupervisorError):
|
|
||||||
"""Error encountered trying to validate auth details via Home Assistant API."""
|
|
||||||
|
|
||||||
error_key = "auth_home_assistant_api_validation_error"
|
|
||||||
message_template = "Unable to validate authentication details with Home Assistant"
|
|
||||||
|
|
||||||
|
|
||||||
# Host
|
# Host
|
||||||
|
|
||||||
@@ -662,6 +385,60 @@ class HostLogError(HostError):
|
|||||||
"""Internal error with host log."""
|
"""Internal error with host log."""
|
||||||
|
|
||||||
|
|
||||||
|
# API
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(HassioError, RuntimeError):
|
||||||
|
"""API errors."""
|
||||||
|
|
||||||
|
status = 400
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str | None = None,
|
||||||
|
logger: Callable[..., None] | None = None,
|
||||||
|
*,
|
||||||
|
job_id: str | None = None,
|
||||||
|
error: HassioError | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Raise & log, optionally with job."""
|
||||||
|
# Allow these to be set from another error here since APIErrors essentially wrap others to add a status
|
||||||
|
self.error_key = error.error_key if error else None
|
||||||
|
self.message_template = error.message_template if error else None
|
||||||
|
super().__init__(
|
||||||
|
message, logger, extra_fields=error.extra_fields if error else None
|
||||||
|
)
|
||||||
|
self.job_id = job_id
|
||||||
|
|
||||||
|
|
||||||
|
class APIForbidden(APIError):
|
||||||
|
"""API forbidden error."""
|
||||||
|
|
||||||
|
status = 403
|
||||||
|
|
||||||
|
|
||||||
|
class APINotFound(APIError):
|
||||||
|
"""API not found error."""
|
||||||
|
|
||||||
|
status = 404
|
||||||
|
|
||||||
|
|
||||||
|
class APIGone(APIError):
|
||||||
|
"""API is no longer available."""
|
||||||
|
|
||||||
|
status = 410
|
||||||
|
|
||||||
|
|
||||||
|
class APIAddonNotInstalled(APIError):
|
||||||
|
"""Not installed addon requested at addons API."""
|
||||||
|
|
||||||
|
|
||||||
|
class APIDBMigrationInProgress(APIError):
|
||||||
|
"""Service is unavailable due to an offline DB migration is in progress."""
|
||||||
|
|
||||||
|
status = 503
|
||||||
|
|
||||||
|
|
||||||
# Service / Discovery
|
# Service / Discovery
|
||||||
|
|
||||||
|
|
||||||
@@ -839,10 +616,6 @@ class DockerError(HassioError):
|
|||||||
"""Docker API/Transport errors."""
|
"""Docker API/Transport errors."""
|
||||||
|
|
||||||
|
|
||||||
class DockerBuildError(DockerError):
|
|
||||||
"""Docker error during build."""
|
|
||||||
|
|
||||||
|
|
||||||
class DockerAPIError(DockerError):
|
class DockerAPIError(DockerError):
|
||||||
"""Docker API error."""
|
"""Docker API error."""
|
||||||
|
|
||||||
@@ -874,7 +647,7 @@ class DockerNoSpaceOnDevice(DockerError):
|
|||||||
super().__init__(None, logger=logger)
|
super().__init__(None, logger=logger)
|
||||||
|
|
||||||
|
|
||||||
class DockerHubRateLimitExceeded(DockerError, APITooManyRequests):
|
class DockerHubRateLimitExceeded(DockerError):
|
||||||
"""Raise for docker hub rate limit exceeded error."""
|
"""Raise for docker hub rate limit exceeded error."""
|
||||||
|
|
||||||
error_key = "dockerhub_rate_limit_exceeded"
|
error_key = "dockerhub_rate_limit_exceeded"
|
||||||
@@ -882,13 +655,16 @@ class DockerHubRateLimitExceeded(DockerError, APITooManyRequests):
|
|||||||
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
|
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
|
||||||
"For more details see {dockerhub_rate_limit_url}"
|
"For more details see {dockerhub_rate_limit_url}"
|
||||||
)
|
)
|
||||||
extra_fields = {
|
|
||||||
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
||||||
"""Raise & log."""
|
"""Raise & log."""
|
||||||
super().__init__(None, logger=logger)
|
super().__init__(
|
||||||
|
None,
|
||||||
|
logger=logger,
|
||||||
|
extra_fields={
|
||||||
|
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DockerJobError(DockerError, JobException):
|
class DockerJobError(DockerError, JobException):
|
||||||
@@ -959,20 +735,6 @@ class StoreNotFound(StoreError):
|
|||||||
"""Raise if slug is not known."""
|
"""Raise if slug is not known."""
|
||||||
|
|
||||||
|
|
||||||
class StoreAddonNotFoundError(StoreError, APINotFound):
|
|
||||||
"""Raise if a requested addon is not in the store."""
|
|
||||||
|
|
||||||
error_key = "store_addon_not_found_error"
|
|
||||||
message_template = "Addon {addon} does not exist in the store"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class StoreJobError(StoreError, JobException):
|
class StoreJobError(StoreError, JobException):
|
||||||
"""Raise on job error with git."""
|
"""Raise on job error with git."""
|
||||||
|
|
||||||
@@ -1008,7 +770,7 @@ class BackupJobError(BackupError, JobException):
|
|||||||
"""Raise on Backup job error."""
|
"""Raise on Backup job error."""
|
||||||
|
|
||||||
|
|
||||||
class BackupFileNotFoundError(BackupError, APINotFound):
|
class BackupFileNotFoundError(BackupError):
|
||||||
"""Raise if the backup file hasn't been found."""
|
"""Raise if the backup file hasn't been found."""
|
||||||
|
|
||||||
|
|
||||||
@@ -1020,55 +782,6 @@ class BackupFileExistError(BackupError):
|
|||||||
"""Raise if the backup file already exists."""
|
"""Raise if the backup file already exists."""
|
||||||
|
|
||||||
|
|
||||||
class AddonBackupMetadataInvalidError(BackupError, APIError):
|
|
||||||
"""Raise if invalid metadata file provided for addon in backup."""
|
|
||||||
|
|
||||||
error_key = "addon_backup_metadata_invalid_error"
|
|
||||||
message_template = (
|
|
||||||
"Metadata file for add-on {addon} in backup is invalid: {validation_error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
logger: Callable[..., None] | None = None,
|
|
||||||
*,
|
|
||||||
addon: str,
|
|
||||||
validation_error: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {"addon": addon, "validation_error": validation_error}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class AddonPrePostBackupCommandReturnedError(BackupError, APIError):
|
|
||||||
"""Raise when addon's pre/post backup command returns an error."""
|
|
||||||
|
|
||||||
error_key = "addon_pre_post_backup_command_returned_error"
|
|
||||||
message_template = (
|
|
||||||
"Pre-/Post backup command for add-on {addon} returned error code: "
|
|
||||||
"{exit_code}. Please report this to the addon developer. Enable debug "
|
|
||||||
"logging to capture complete command output using {debug_logging_command}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, logger: Callable[..., None] | None = None, *, addon: str, exit_code: int
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
self.extra_fields = {
|
|
||||||
"addon": addon,
|
|
||||||
"exit_code": exit_code,
|
|
||||||
"debug_logging_command": "ha supervisor options --logging debug",
|
|
||||||
}
|
|
||||||
super().__init__(None, logger)
|
|
||||||
|
|
||||||
|
|
||||||
class BackupRestoreUnknownError(BackupError, APIUnknownSupervisorError):
|
|
||||||
"""Raise when an unknown error occurs during backup or restore."""
|
|
||||||
|
|
||||||
error_key = "backup_restore_unknown_error"
|
|
||||||
message_template = "An unknown error occurred during backup/restore"
|
|
||||||
|
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from contextvars import Context, ContextVar, Token
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Self
|
from typing import Any, Self, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from attr.validators import gt, lt
|
from attr.validators import gt, lt
|
||||||
@@ -102,17 +102,13 @@ class SupervisorJobError:
|
|||||||
"Unknown error, see Supervisor logs (check with 'ha supervisor logs')"
|
"Unknown error, see Supervisor logs (check with 'ha supervisor logs')"
|
||||||
)
|
)
|
||||||
stage: str | None = None
|
stage: str | None = None
|
||||||
error_key: str | None = None
|
|
||||||
extra_fields: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, str | None]:
|
||||||
"""Return dictionary representation."""
|
"""Return dictionary representation."""
|
||||||
return {
|
return {
|
||||||
"type": self.type_.__name__,
|
"type": self.type_.__name__,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
"stage": self.stage,
|
"stage": self.stage,
|
||||||
"error_key": self.error_key,
|
|
||||||
"extra_fields": self.extra_fields,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -162,9 +158,7 @@ class SupervisorJob:
|
|||||||
def capture_error(self, err: HassioError | None = None) -> None:
|
def capture_error(self, err: HassioError | None = None) -> None:
|
||||||
"""Capture an error or record that an unknown error has occurred."""
|
"""Capture an error or record that an unknown error has occurred."""
|
||||||
if err:
|
if err:
|
||||||
new_error = SupervisorJobError(
|
new_error = SupervisorJobError(type(err), str(err), self.stage)
|
||||||
type(err), str(err), self.stage, err.error_key, err.extra_fields
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
new_error = SupervisorJobError(stage=self.stage)
|
new_error = SupervisorJobError(stage=self.stage)
|
||||||
self.errors += [new_error]
|
self.errors += [new_error]
|
||||||
@@ -202,7 +196,7 @@ class SupervisorJob:
|
|||||||
self,
|
self,
|
||||||
progress: float | None = None,
|
progress: float | None = None,
|
||||||
stage: str | None = None,
|
stage: str | None = None,
|
||||||
extra: dict[str, Any] | None = DEFAULT, # type: ignore
|
extra: dict[str, Any] | None | type[DEFAULT] = DEFAULT,
|
||||||
done: bool | None = None,
|
done: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update multiple fields with one on change event."""
|
"""Update multiple fields with one on change event."""
|
||||||
@@ -213,8 +207,8 @@ class SupervisorJob:
|
|||||||
self.progress = progress
|
self.progress = progress
|
||||||
if stage is not None:
|
if stage is not None:
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
if extra != DEFAULT:
|
if extra is not DEFAULT:
|
||||||
self.extra = extra
|
self.extra = cast(dict[str, Any] | None, extra)
|
||||||
|
|
||||||
# Done has special event. use that to trigger on change if included
|
# Done has special event. use that to trigger on change if included
|
||||||
# If not then just use any other field to trigger
|
# If not then just use any other field to trigger
|
||||||
@@ -312,19 +306,21 @@ class JobManager(FileConfiguration, CoreSysAttributes):
|
|||||||
reference: str | None = None,
|
reference: str | None = None,
|
||||||
initial_stage: str | None = None,
|
initial_stage: str | None = None,
|
||||||
internal: bool = False,
|
internal: bool = False,
|
||||||
parent_id: str | None = DEFAULT, # type: ignore
|
parent_id: str | None | type[DEFAULT] = DEFAULT,
|
||||||
child_job_syncs: list[ChildJobSyncFilter] | None = None,
|
child_job_syncs: list[ChildJobSyncFilter] | None = None,
|
||||||
) -> SupervisorJob:
|
) -> SupervisorJob:
|
||||||
"""Create a new job."""
|
"""Create a new job."""
|
||||||
job = SupervisorJob(
|
kwargs: dict[str, Any] = {
|
||||||
name,
|
"reference": reference,
|
||||||
reference=reference,
|
"stage": initial_stage,
|
||||||
stage=initial_stage,
|
"on_change": self._on_job_change,
|
||||||
on_change=self._on_job_change,
|
"internal": internal,
|
||||||
internal=internal,
|
"child_job_syncs": child_job_syncs,
|
||||||
child_job_syncs=child_job_syncs,
|
}
|
||||||
**({} if parent_id == DEFAULT else {"parent_id": parent_id}), # type: ignore
|
if parent_id is not DEFAULT:
|
||||||
)
|
kwargs["parent_id"] = parent_id
|
||||||
|
|
||||||
|
job = SupervisorJob(name, **kwargs)
|
||||||
|
|
||||||
# Shouldn't happen but inability to find a parent for progress reporting
|
# Shouldn't happen but inability to find a parent for progress reporting
|
||||||
# shouldn't raise and break the active job
|
# shouldn't raise and break the active job
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class Mount(CoreSysAttributes, ABC):
|
|||||||
@property
|
@property
|
||||||
def state(self) -> UnitActiveState | None:
|
def state(self) -> UnitActiveState | None:
|
||||||
"""Get state of mount."""
|
"""Get state of mount."""
|
||||||
return self._state
|
return UnitActiveState(self._state) if self._state is not None else None
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def local_where(self) -> Path:
|
def local_where(self) -> Path:
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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."""
|
||||||
@@ -33,6 +31,4 @@ class EvaluateOperatingSystem(EvaluateBase):
|
|||||||
|
|
||||||
async def evaluate(self) -> bool:
|
async def evaluate(self) -> bool:
|
||||||
"""Run evaluation."""
|
"""Run evaluation."""
|
||||||
if self.sys_os.available:
|
return not self.sys_os.available
|
||||||
return False
|
|
||||||
return self.sys_host.info.operating_system not in SUPPORTED_OS
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ from .exceptions import (
|
|||||||
DockerError,
|
DockerError,
|
||||||
HostAppArmorError,
|
HostAppArmorError,
|
||||||
SupervisorAppArmorError,
|
SupervisorAppArmorError,
|
||||||
|
SupervisorError,
|
||||||
SupervisorJobError,
|
SupervisorJobError,
|
||||||
SupervisorUnknownError,
|
|
||||||
SupervisorUpdateError,
|
SupervisorUpdateError,
|
||||||
)
|
)
|
||||||
from .jobs.const import JobCondition, JobThrottle
|
from .jobs.const import JobCondition, JobThrottle
|
||||||
@@ -261,7 +261,7 @@ class Supervisor(CoreSysAttributes):
|
|||||||
try:
|
try:
|
||||||
return await self.instance.stats()
|
return await self.instance.stats()
|
||||||
except DockerError as err:
|
except DockerError as err:
|
||||||
raise SupervisorUnknownError() from err
|
raise SupervisorError() from err
|
||||||
|
|
||||||
async def repair(self):
|
async def repair(self):
|
||||||
"""Repair local Supervisor data."""
|
"""Repair local Supervisor data."""
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Protocol, cast
|
from typing import Any, Protocol, cast
|
||||||
|
|
||||||
from dbus_fast import (
|
from dbus_fast import ErrorType, InvalidIntrospectionError, Message, MessageType
|
||||||
ErrorType,
|
|
||||||
InvalidIntrospectionError,
|
|
||||||
Message,
|
|
||||||
MessageType,
|
|
||||||
Variant,
|
|
||||||
)
|
|
||||||
from dbus_fast.aio.message_bus import MessageBus
|
from dbus_fast.aio.message_bus import MessageBus
|
||||||
from dbus_fast.aio.proxy_object import ProxyInterface, ProxyObject
|
from dbus_fast.aio.proxy_object import ProxyInterface, ProxyObject
|
||||||
from dbus_fast.errors import DBusError as DBusFastDBusError
|
from dbus_fast.errors import DBusError as DBusFastDBusError
|
||||||
@@ -265,7 +259,7 @@ class DBus:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
async def sync_property_change(
|
async def sync_property_change(
|
||||||
prop_interface: str, changed: dict[str, Variant], invalidated: list[str]
|
prop_interface: str, changed: dict[str, Any], invalidated: list[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sync property changes to cache."""
|
"""Sync property changes to cache."""
|
||||||
if interface != prop_interface:
|
if interface != prop_interface:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from datetime import timedelta
|
|||||||
import errno
|
import errno
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import MagicMock, PropertyMock, call, patch
|
from unittest.mock import MagicMock, PropertyMock, call, patch
|
||||||
|
|
||||||
import aiodocker
|
import aiodocker
|
||||||
@@ -24,13 +23,7 @@ from supervisor.docker.addon import DockerAddon
|
|||||||
from supervisor.docker.const import ContainerState
|
from supervisor.docker.const import ContainerState
|
||||||
from supervisor.docker.manager import CommandReturn, DockerAPI
|
from supervisor.docker.manager import CommandReturn, DockerAPI
|
||||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||||
from supervisor.exceptions import (
|
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
|
||||||
AddonPrePostBackupCommandReturnedError,
|
|
||||||
AddonsJobError,
|
|
||||||
AddonUnknownError,
|
|
||||||
AudioUpdateError,
|
|
||||||
HassioError,
|
|
||||||
)
|
|
||||||
from supervisor.hardware.helper import HwHelper
|
from supervisor.hardware.helper import HwHelper
|
||||||
from supervisor.ingress import Ingress
|
from supervisor.ingress import Ingress
|
||||||
from supervisor.store.repository import Repository
|
from supervisor.store.repository import Repository
|
||||||
@@ -509,26 +502,31 @@ async def test_backup_with_pre_post_command(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("container_get_side_effect", "exec_run_side_effect", "exc_type_raised"),
|
"get_error,exception_on_exec",
|
||||||
[
|
[
|
||||||
(NotFound("missing"), [(1, None)], AddonUnknownError),
|
(NotFound("missing"), False),
|
||||||
(DockerException(), [(1, None)], AddonUnknownError),
|
(DockerException(), False),
|
||||||
(None, DockerException(), AddonUnknownError),
|
(None, True),
|
||||||
(None, [(1, None)], AddonPrePostBackupCommandReturnedError),
|
(None, False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
|
|
||||||
async def test_backup_with_pre_command_error(
|
async def test_backup_with_pre_command_error(
|
||||||
coresys: CoreSys,
|
coresys: CoreSys,
|
||||||
install_addon_ssh: Addon,
|
install_addon_ssh: Addon,
|
||||||
container: MagicMock,
|
container: MagicMock,
|
||||||
container_get_side_effect: DockerException | None,
|
get_error: DockerException | None,
|
||||||
exec_run_side_effect: DockerException | list[tuple[int, Any]],
|
exception_on_exec: bool,
|
||||||
exc_type_raised: type[HassioError],
|
tmp_supervisor_data,
|
||||||
|
path_extern,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test backing up an addon with error running pre command."""
|
"""Test backing up an addon with error running pre command."""
|
||||||
coresys.docker.containers.get.side_effect = container_get_side_effect
|
if get_error:
|
||||||
container.exec_run.side_effect = exec_run_side_effect
|
coresys.docker.containers.get.side_effect = get_error
|
||||||
|
|
||||||
|
if exception_on_exec:
|
||||||
|
container.exec_run.side_effect = DockerException()
|
||||||
|
else:
|
||||||
|
container.exec_run.return_value = (1, None)
|
||||||
|
|
||||||
install_addon_ssh.path_data.mkdir()
|
install_addon_ssh.path_data.mkdir()
|
||||||
await install_addon_ssh.load()
|
await install_addon_ssh.load()
|
||||||
@@ -537,7 +535,7 @@ async def test_backup_with_pre_command_error(
|
|||||||
with (
|
with (
|
||||||
patch.object(DockerAddon, "is_running", return_value=True),
|
patch.object(DockerAddon, "is_running", return_value=True),
|
||||||
patch.object(Addon, "backup_pre", new=PropertyMock(return_value="backup_pre")),
|
patch.object(Addon, "backup_pre", new=PropertyMock(return_value="backup_pre")),
|
||||||
pytest.raises(exc_type_raised),
|
pytest.raises(AddonsError),
|
||||||
):
|
):
|
||||||
assert await install_addon_ssh.backup(tarfile) is None
|
assert await install_addon_ssh.backup(tarfile) is None
|
||||||
|
|
||||||
@@ -949,7 +947,7 @@ async def test_addon_load_succeeds_with_docker_errors(
|
|||||||
)
|
)
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
await install_addon_ssh.load()
|
await install_addon_ssh.load()
|
||||||
assert "Cannot build addon 'local_ssh' because dockerfile is missing" in caplog.text
|
assert "Invalid build environment" in caplog.text
|
||||||
|
|
||||||
# Image build failure
|
# Image build failure
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
from unittest.mock import PropertyMock, patch
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
import pytest
|
|
||||||
|
|
||||||
from supervisor.addons.addon import Addon
|
from supervisor.addons.addon import Addon
|
||||||
from supervisor.addons.build import AddonBuild
|
from supervisor.addons.build import AddonBuild
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.exceptions import AddonBuildDockerfileMissingError
|
|
||||||
|
|
||||||
from tests.common import is_in_list
|
from tests.common import is_in_list
|
||||||
|
|
||||||
@@ -104,11 +102,11 @@ async def test_build_valid(coresys: CoreSys, install_addon_ssh: Addon):
|
|||||||
type(coresys.arch), "default", new=PropertyMock(return_value="aarch64")
|
type(coresys.arch), "default", new=PropertyMock(return_value="aarch64")
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
assert (await build.is_valid()) is None
|
assert await build.is_valid()
|
||||||
|
|
||||||
|
|
||||||
async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
|
async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
|
||||||
"""Test build not supported because Dockerfile missing for specified architecture."""
|
"""Test platform set in docker args."""
|
||||||
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
build = await AddonBuild(coresys, install_addon_ssh).load_config()
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
@@ -117,6 +115,5 @@ async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
|
|||||||
patch.object(
|
patch.object(
|
||||||
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
|
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
|
||||||
),
|
),
|
||||||
pytest.raises(AddonBuildDockerfileMissingError),
|
|
||||||
):
|
):
|
||||||
await build.is_valid()
|
assert not await build.is_valid()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock, PropertyMock, patch
|
|||||||
|
|
||||||
from aiohttp import ClientResponse
|
from aiohttp import ClientResponse
|
||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
from docker.errors import DockerException
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from supervisor.addons.addon import Addon
|
from supervisor.addons.addon import Addon
|
||||||
@@ -472,11 +471,6 @@ async def test_addon_options_boot_mode_manual_only_invalid(
|
|||||||
body["message"]
|
body["message"]
|
||||||
== "Addon local_example boot option is set to manual_only so it cannot be changed"
|
== "Addon local_example boot option is set to manual_only so it cannot be changed"
|
||||||
)
|
)
|
||||||
assert body["error_key"] == "addon_boot_config_cannot_change_error"
|
|
||||||
assert body["extra_fields"] == {
|
|
||||||
"addon": "local_example",
|
|
||||||
"boot_config": "manual_only",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_message(resp: ClientResponse, json_expected: bool) -> str:
|
async def get_message(resp: ClientResponse, json_expected: bool) -> str:
|
||||||
@@ -545,131 +539,3 @@ async def test_addon_not_installed(
|
|||||||
resp = await api_client.request(method, url)
|
resp = await api_client.request(method, url)
|
||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
assert await get_message(resp, json_expected) == "Addon is not installed"
|
assert await get_message(resp, json_expected) == "Addon is not installed"
|
||||||
|
|
||||||
|
|
||||||
async def test_addon_set_options(api_client: TestClient, install_addon_example: Addon):
|
|
||||||
"""Test setting options for an addon."""
|
|
||||||
resp = await api_client.post(
|
|
||||||
"/addons/local_example/options", json={"options": {"message": "test"}}
|
|
||||||
)
|
|
||||||
assert resp.status == 200
|
|
||||||
assert install_addon_example.options == {"message": "test"}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_addon_set_options_error(
|
|
||||||
api_client: TestClient, install_addon_example: Addon
|
|
||||||
):
|
|
||||||
"""Test setting options for an addon."""
|
|
||||||
resp = await api_client.post(
|
|
||||||
"/addons/local_example/options", json={"options": {"message": True}}
|
|
||||||
)
|
|
||||||
assert resp.status == 400
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "Add-on local_example has invalid options: not a valid value. Got {'message': True}"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "addon_configuration_invalid_error"
|
|
||||||
assert body["extra_fields"] == {
|
|
||||||
"addon": "local_example",
|
|
||||||
"validation_error": "not a valid value. Got {'message': True}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_addon_start_options_error(
|
|
||||||
api_client: TestClient,
|
|
||||||
install_addon_example: Addon,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
):
|
|
||||||
"""Test error writing options when trying to start addon."""
|
|
||||||
install_addon_example.options = {"message": "hello"}
|
|
||||||
|
|
||||||
# Simulate OS error trying to write the file
|
|
||||||
with patch("supervisor.utils.json.atomic_write", side_effect=OSError("fail")):
|
|
||||||
resp = await api_client.post("/addons/local_example/start")
|
|
||||||
assert resp.status == 500
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "An unknown error occurred with addon local_example. Check supervisor logs for details (check with 'ha supervisor logs')"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "addon_unknown_error"
|
|
||||||
assert body["extra_fields"] == {
|
|
||||||
"addon": "local_example",
|
|
||||||
"logs_command": "ha supervisor logs",
|
|
||||||
}
|
|
||||||
assert "Add-on local_example can't write options" in caplog.text
|
|
||||||
|
|
||||||
# Simulate an update with a breaking change for options schema creating failure on start
|
|
||||||
caplog.clear()
|
|
||||||
install_addon_example.data["schema"] = {"message": "bool"}
|
|
||||||
resp = await api_client.post("/addons/local_example/start")
|
|
||||||
assert resp.status == 400
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "Add-on local_example has invalid options: expected boolean. Got {'message': 'hello'}"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "addon_configuration_invalid_error"
|
|
||||||
assert body["extra_fields"] == {
|
|
||||||
"addon": "local_example",
|
|
||||||
"validation_error": "expected boolean. Got {'message': 'hello'}",
|
|
||||||
}
|
|
||||||
assert (
|
|
||||||
"Add-on local_example has invalid options: expected boolean. Got {'message': 'hello'}"
|
|
||||||
in caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("method", "action"), [("get", "stats"), ("post", "stdin")])
|
|
||||||
@pytest.mark.usefixtures("install_addon_example")
|
|
||||||
async def test_addon_not_running_error(
|
|
||||||
api_client: TestClient, method: str, action: str
|
|
||||||
):
|
|
||||||
"""Test addon not running error for endpoints that require that."""
|
|
||||||
with patch.object(
|
|
||||||
Addon, "with_stdin", return_value=PropertyMock(return_value=True)
|
|
||||||
):
|
|
||||||
resp = await api_client.request(method, f"/addons/local_example/{action}")
|
|
||||||
|
|
||||||
assert resp.status == 400
|
|
||||||
body = await resp.json()
|
|
||||||
assert body["message"] == "Add-on local_example is not running"
|
|
||||||
assert body["error_key"] == "addon_not_running_error"
|
|
||||||
assert body["extra_fields"] == {"addon": "local_example"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("install_addon_example")
|
|
||||||
async def test_addon_write_stdin_not_supported_error(api_client: TestClient):
|
|
||||||
"""Test error when trying to write stdin to addon that does not support it."""
|
|
||||||
resp = await api_client.post("/addons/local_example/stdin")
|
|
||||||
assert resp.status == 400
|
|
||||||
body = await resp.json()
|
|
||||||
assert body["message"] == "Add-on local_example does not support writing to stdin"
|
|
||||||
assert body["error_key"] == "addon_not_supported_write_stdin_error"
|
|
||||||
assert body["extra_fields"] == {"addon": "local_example"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("install_addon_ssh")
|
|
||||||
async def test_addon_rebuild_fails_error(api_client: TestClient, coresys: CoreSys):
|
|
||||||
"""Test error when build fails during rebuild for addon."""
|
|
||||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
||||||
coresys.docker.containers.run.side_effect = DockerException("fail")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["aarch64"])),
|
|
||||||
patch.object(CpuArch, "default", new=PropertyMock(return_value="aarch64")),
|
|
||||||
patch.object(AddonBuild, "get_docker_args", return_value={}),
|
|
||||||
):
|
|
||||||
resp = await api_client.post("/addons/local_ssh/rebuild")
|
|
||||||
assert resp.status == 500
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "An unknown error occurred while trying to build the image for addon local_ssh. Check supervisor logs for details (check with 'ha supervisor logs')"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "addon_build_failed_unknown_error"
|
|
||||||
assert body["extra_fields"] == {
|
|
||||||
"addon": "local_ssh",
|
|
||||||
"logs_command": "ha supervisor logs",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
from aiohttp.hdrs import WWW_AUTHENTICATE
|
from aiohttp.hdrs import WWW_AUTHENTICATE
|
||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
import pytest
|
import pytest
|
||||||
from securetar import Any
|
|
||||||
|
|
||||||
from supervisor.addons.addon import Addon
|
from supervisor.addons.addon import Addon
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.exceptions import HomeAssistantAPIError, HomeAssistantWSError
|
|
||||||
from supervisor.homeassistant.api import HomeAssistantAPI
|
|
||||||
|
|
||||||
from tests.common import MockResponse
|
from tests.common import MockResponse
|
||||||
from tests.const import TEST_ADDON_SLUG
|
from tests.const import TEST_ADDON_SLUG
|
||||||
@@ -103,52 +100,6 @@ async def test_password_reset(
|
|||||||
assert "Successful password reset for 'john'" in caplog.text
|
assert "Successful password reset for 'john'" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("post_mock", "expected_log"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
MagicMock(return_value=MockResponse(status=400)),
|
|
||||||
"The user 'john' is not registered",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
MagicMock(side_effect=HomeAssistantAPIError("fail")),
|
|
||||||
"Can't request password reset on Home Assistant: fail",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_failed_password_reset(
|
|
||||||
api_client: TestClient,
|
|
||||||
coresys: CoreSys,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
websession: MagicMock,
|
|
||||||
post_mock: MagicMock,
|
|
||||||
expected_log: str,
|
|
||||||
):
|
|
||||||
"""Test failed password reset."""
|
|
||||||
coresys.homeassistant.api.access_token = "abc123"
|
|
||||||
# pylint: disable-next=protected-access
|
|
||||||
coresys.homeassistant.api._access_token_expires = datetime.now(tz=UTC) + timedelta(
|
|
||||||
days=1
|
|
||||||
)
|
|
||||||
|
|
||||||
websession.post = post_mock
|
|
||||||
resp = await api_client.post(
|
|
||||||
"/auth/reset", json={"username": "john", "password": "doe"}
|
|
||||||
)
|
|
||||||
assert resp.status == 400
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "Unable to reset password for 'john'. Check supervisor logs for details (check with 'ha supervisor logs')"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "auth_password_reset_error"
|
|
||||||
assert body["extra_fields"] == {
|
|
||||||
"user": "john",
|
|
||||||
"logs_command": "ha supervisor logs",
|
|
||||||
}
|
|
||||||
assert expected_log in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_users(
|
async def test_list_users(
|
||||||
api_client: TestClient, coresys: CoreSys, ha_ws_client: AsyncMock
|
api_client: TestClient, coresys: CoreSys, ha_ws_client: AsyncMock
|
||||||
):
|
):
|
||||||
@@ -169,48 +120,6 @@ async def test_list_users(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("send_command_mock", "error_response", "expected_log"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
AsyncMock(return_value=None),
|
|
||||||
{
|
|
||||||
"result": "error",
|
|
||||||
"message": "Home Assistant returned invalid response of `None` instead of a list of users. Check Home Assistant logs for details (check with `ha core logs`)",
|
|
||||||
"error_key": "auth_list_users_none_response_error",
|
|
||||||
"extra_fields": {"none": "None", "logs_command": "ha core logs"},
|
|
||||||
},
|
|
||||||
"Home Assistant returned invalid response of `None` instead of a list of users. Check Home Assistant logs for details (check with `ha core logs`)",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
AsyncMock(side_effect=HomeAssistantWSError("fail")),
|
|
||||||
{
|
|
||||||
"result": "error",
|
|
||||||
"message": "Can't request listing users on Home Assistant. Check supervisor logs for details (check with 'ha supervisor logs')",
|
|
||||||
"error_key": "auth_list_users_error",
|
|
||||||
"extra_fields": {"logs_command": "ha supervisor logs"},
|
|
||||||
},
|
|
||||||
"Can't request listing users on Home Assistant: fail",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_list_users_failure(
|
|
||||||
api_client: TestClient,
|
|
||||||
ha_ws_client: AsyncMock,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
send_command_mock: AsyncMock,
|
|
||||||
error_response: dict[str, Any],
|
|
||||||
expected_log: str,
|
|
||||||
):
|
|
||||||
"""Test failure listing users via API."""
|
|
||||||
ha_ws_client.async_send_command = send_command_mock
|
|
||||||
resp = await api_client.get("/auth/list")
|
|
||||||
assert resp.status == 500
|
|
||||||
result = await resp.json()
|
|
||||||
assert result == error_response
|
|
||||||
assert expected_log in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("field", "api_client"),
|
("field", "api_client"),
|
||||||
[("username", TEST_ADDON_SLUG), ("user", TEST_ADDON_SLUG)],
|
[("username", TEST_ADDON_SLUG), ("user", TEST_ADDON_SLUG)],
|
||||||
@@ -247,13 +156,6 @@ async def test_auth_json_failure_none(
|
|||||||
mock_check_login.return_value = True
|
mock_check_login.return_value = True
|
||||||
resp = await api_client.post("/auth", json={"username": user, "password": password})
|
resp = await api_client.post("/auth", json={"username": user, "password": password})
|
||||||
assert resp.status == 401
|
assert resp.status == 401
|
||||||
assert (
|
|
||||||
resp.headers["WWW-Authenticate"]
|
|
||||||
== 'Basic realm="Home Assistant Authentication"'
|
|
||||||
)
|
|
||||||
body = await resp.json()
|
|
||||||
assert body["message"] == "Username and password must be strings"
|
|
||||||
assert body["error_key"] == "auth_invalid_non_string_value_error"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
|
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
|
||||||
@@ -365,26 +267,3 @@ async def test_non_addon_token_no_auth_access(api_client: TestClient):
|
|||||||
"""Test auth where add-on is not allowed to access auth API."""
|
"""Test auth where add-on is not allowed to access auth API."""
|
||||||
resp = await api_client.post("/auth", json={"username": "test", "password": "pass"})
|
resp = await api_client.post("/auth", json={"username": "test", "password": "pass"})
|
||||||
assert resp.status == 403
|
assert resp.status == 403
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
|
|
||||||
@pytest.mark.usefixtures("install_addon_ssh")
|
|
||||||
async def test_auth_backend_login_failure(api_client: TestClient):
|
|
||||||
"""Test backend login failure on auth."""
|
|
||||||
with (
|
|
||||||
patch.object(HomeAssistantAPI, "check_api_state", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
HomeAssistantAPI, "make_request", side_effect=HomeAssistantAPIError("fail")
|
|
||||||
),
|
|
||||||
):
|
|
||||||
resp = await api_client.post(
|
|
||||||
"/auth", json={"username": "test", "password": "pass"}
|
|
||||||
)
|
|
||||||
assert resp.status == 500
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "Unable to validate authentication details with Home Assistant. Check supervisor logs for details (check with 'ha supervisor logs')"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "auth_home_assistant_api_validation_error"
|
|
||||||
assert body["extra_fields"] == {"logs_command": "ha supervisor logs"}
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from supervisor.const import CoreState
|
|||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.docker.manager import DockerAPI
|
from supervisor.docker.manager import DockerAPI
|
||||||
from supervisor.exceptions import (
|
from supervisor.exceptions import (
|
||||||
AddonPrePostBackupCommandReturnedError,
|
|
||||||
AddonsError,
|
AddonsError,
|
||||||
BackupInvalidError,
|
BackupInvalidError,
|
||||||
HomeAssistantBackupError,
|
HomeAssistantBackupError,
|
||||||
@@ -25,7 +24,6 @@ from supervisor.exceptions import (
|
|||||||
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.homeassistant.websocket import HomeAssistantWebSocket
|
from supervisor.homeassistant.websocket import HomeAssistantWebSocket
|
||||||
from supervisor.jobs import SupervisorJob
|
|
||||||
from supervisor.mounts.mount import Mount
|
from supervisor.mounts.mount import Mount
|
||||||
from supervisor.supervisor import Supervisor
|
from supervisor.supervisor import Supervisor
|
||||||
|
|
||||||
@@ -403,8 +401,6 @@ async def test_api_backup_errors(
|
|||||||
"type": "BackupError",
|
"type": "BackupError",
|
||||||
"message": str(err),
|
"message": str(err),
|
||||||
"stage": None,
|
"stage": None,
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert job["child_jobs"][2]["name"] == "backup_store_folders"
|
assert job["child_jobs"][2]["name"] == "backup_store_folders"
|
||||||
@@ -441,8 +437,6 @@ async def test_api_backup_errors(
|
|||||||
"type": "HomeAssistantBackupError",
|
"type": "HomeAssistantBackupError",
|
||||||
"message": "Backup error",
|
"message": "Backup error",
|
||||||
"stage": "home_assistant",
|
"stage": "home_assistant",
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
|
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
|
||||||
@@ -451,8 +445,6 @@ async def test_api_backup_errors(
|
|||||||
"type": "HomeAssistantBackupError",
|
"type": "HomeAssistantBackupError",
|
||||||
"message": "Backup error",
|
"message": "Backup error",
|
||||||
"stage": None,
|
"stage": None,
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert len(job["child_jobs"]) == 1
|
assert len(job["child_jobs"]) == 1
|
||||||
@@ -757,8 +749,6 @@ async def test_backup_to_multiple_locations_error_on_copy(
|
|||||||
"type": "BackupError",
|
"type": "BackupError",
|
||||||
"message": "Could not copy backup to .cloud_backup due to: ",
|
"message": "Could not copy backup to .cloud_backup due to: ",
|
||||||
"stage": None,
|
"stage": None,
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1493,44 +1483,3 @@ async def test_immediate_list_after_missing_file_restore(
|
|||||||
result = await resp.json()
|
result = await resp.json()
|
||||||
assert len(result["data"]["backups"]) == 1
|
assert len(result["data"]["backups"]) == 1
|
||||||
assert result["data"]["backups"][0]["slug"] == "93b462f8"
|
assert result["data"]["backups"][0]["slug"] == "93b462f8"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("command", ["backup_pre", "backup_post"])
|
|
||||||
@pytest.mark.usefixtures("install_addon_example", "tmp_supervisor_data")
|
|
||||||
async def test_pre_post_backup_command_error(
|
|
||||||
api_client: TestClient, coresys: CoreSys, container: MagicMock, command: str
|
|
||||||
):
|
|
||||||
"""Test pre/post backup command error."""
|
|
||||||
await coresys.core.set_state(CoreState.RUNNING)
|
|
||||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
||||||
|
|
||||||
container.status = "running"
|
|
||||||
container.exec_run.return_value = (1, b"")
|
|
||||||
with patch.object(Addon, command, return_value=PropertyMock(return_value="test")):
|
|
||||||
resp = await api_client.post(
|
|
||||||
"/backups/new/partial", json={"addons": ["local_example"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.status == 200
|
|
||||||
body = await resp.json()
|
|
||||||
job_id = body["data"]["job_id"]
|
|
||||||
job: SupervisorJob | None = None
|
|
||||||
for j in coresys.jobs.jobs:
|
|
||||||
if j.name == "backup_store_addons" and j.parent_id == job_id:
|
|
||||||
job = j
|
|
||||||
break
|
|
||||||
|
|
||||||
assert job
|
|
||||||
assert job.done is True
|
|
||||||
assert job.errors[0].type_ == AddonPrePostBackupCommandReturnedError
|
|
||||||
assert job.errors[0].message == (
|
|
||||||
"Pre-/Post backup command for add-on local_example returned error code: "
|
|
||||||
"1. Please report this to the addon developer. Enable debug "
|
|
||||||
"logging to capture complete command output using ha supervisor options --logging debug"
|
|
||||||
)
|
|
||||||
assert job.errors[0].error_key == "addon_pre_post_backup_command_returned_error"
|
|
||||||
assert job.errors[0].extra_fields == {
|
|
||||||
"addon": "local_example",
|
|
||||||
"exit_code": 1,
|
|
||||||
"debug_logging_command": "ha supervisor options --logging debug",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -374,8 +374,6 @@ async def test_job_with_error(
|
|||||||
"type": "SupervisorError",
|
"type": "SupervisorError",
|
||||||
"message": "bad",
|
"message": "bad",
|
||||||
"stage": "test",
|
"stage": "test",
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"child_jobs": [
|
"child_jobs": [
|
||||||
@@ -393,8 +391,6 @@ async def test_job_with_error(
|
|||||||
"type": "SupervisorError",
|
"type": "SupervisorError",
|
||||||
"message": "bad",
|
"message": "bad",
|
||||||
"stage": None,
|
"stage": None,
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"child_jobs": [],
|
"child_jobs": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
|
from aiohttp import ClientResponse
|
||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
import pytest
|
import pytest
|
||||||
@@ -289,6 +290,14 @@ async def test_api_detached_addon_documentation(
|
|||||||
assert result == "Addon local_ssh does not exist in the store"
|
assert result == "Addon local_ssh does not exist in the store"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_message(resp: ClientResponse, json_expected: bool) -> str:
|
||||||
|
"""Get message from response based on response type."""
|
||||||
|
if json_expected:
|
||||||
|
body = await resp.json()
|
||||||
|
return body["message"]
|
||||||
|
return await resp.text()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("method", "url", "json_expected"),
|
("method", "url", "json_expected"),
|
||||||
[
|
[
|
||||||
@@ -314,13 +323,7 @@ async def test_store_addon_not_found(
|
|||||||
"""Test store addon not found error."""
|
"""Test store addon not found error."""
|
||||||
resp = await api_client.request(method, url)
|
resp = await api_client.request(method, url)
|
||||||
assert resp.status == 404
|
assert resp.status == 404
|
||||||
if json_expected:
|
assert await get_message(resp, json_expected) == "Addon bad does not exist"
|
||||||
body = await resp.json()
|
|
||||||
assert body["message"] == "Addon bad does not exist in the store"
|
|
||||||
assert body["error_key"] == "store_addon_not_found_error"
|
|
||||||
assert body["extra_fields"] == {"addon": "bad"}
|
|
||||||
else:
|
|
||||||
assert await resp.text() == "Addon bad does not exist in the store"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
|||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from blockbuster import BlockingError
|
from blockbuster import BlockingError
|
||||||
from docker.errors import DockerException
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from supervisor.const import CoreState
|
from supervisor.const import CoreState
|
||||||
@@ -408,37 +407,3 @@ async def test_api_progress_updates_supervisor_update(
|
|||||||
"done": True,
|
"done": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_stats(api_client: TestClient, coresys: CoreSys):
|
|
||||||
"""Test supervisor stats."""
|
|
||||||
coresys.docker.containers.get.return_value.status = "running"
|
|
||||||
coresys.docker.containers.get.return_value.stats.return_value = load_json_fixture(
|
|
||||||
"container_stats.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = await api_client.get("/supervisor/stats")
|
|
||||||
assert resp.status == 200
|
|
||||||
result = await resp.json()
|
|
||||||
assert result["data"]["cpu_percent"] == 90.0
|
|
||||||
assert result["data"]["memory_usage"] == 59700000
|
|
||||||
assert result["data"]["memory_limit"] == 4000000000
|
|
||||||
assert result["data"]["memory_percent"] == 1.49
|
|
||||||
|
|
||||||
|
|
||||||
async def test_supervisor_api_stats_failure(
|
|
||||||
api_client: TestClient, coresys: CoreSys, caplog: pytest.LogCaptureFixture
|
|
||||||
):
|
|
||||||
"""Test supervisor stats failure."""
|
|
||||||
coresys.docker.containers.get.side_effect = DockerException("fail")
|
|
||||||
|
|
||||||
resp = await api_client.get("/supervisor/stats")
|
|
||||||
assert resp.status == 500
|
|
||||||
body = await resp.json()
|
|
||||||
assert (
|
|
||||||
body["message"]
|
|
||||||
== "An unknown error occurred with Supervisor. Check supervisor logs for details (check with 'ha supervisor logs')"
|
|
||||||
)
|
|
||||||
assert body["error_key"] == "supervisor_unknown_error"
|
|
||||||
assert body["extra_fields"] == {"logs_command": "ha supervisor logs"}
|
|
||||||
assert "Could not inspect container 'hassio_supervisor': fail" in caplog.text
|
|
||||||
|
|||||||
@@ -184,3 +184,20 @@ 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
|
||||||
|
|||||||
@@ -41,51 +41,51 @@ async def test_dbus_resolved_info(
|
|||||||
assert resolved.dns_over_tls == DNSOverTLSEnabled.NO
|
assert resolved.dns_over_tls == DNSOverTLSEnabled.NO
|
||||||
|
|
||||||
assert len(resolved.dns) == 2
|
assert len(resolved.dns) == 2
|
||||||
assert resolved.dns[0] == [0, 2, inet_aton("127.0.0.1")]
|
assert resolved.dns[0] == (0, 2, inet_aton("127.0.0.1"))
|
||||||
assert resolved.dns[1] == [0, 10, inet_pton(AF_INET6, "::1")]
|
assert resolved.dns[1] == (0, 10, inet_pton(AF_INET6, "::1"))
|
||||||
assert len(resolved.dns_ex) == 2
|
assert len(resolved.dns_ex) == 2
|
||||||
assert resolved.dns_ex[0] == [0, 2, inet_aton("127.0.0.1"), 0, ""]
|
assert resolved.dns_ex[0] == (0, 2, inet_aton("127.0.0.1"), 0, "")
|
||||||
assert resolved.dns_ex[1] == [0, 10, inet_pton(AF_INET6, "::1"), 0, ""]
|
assert resolved.dns_ex[1] == (0, 10, inet_pton(AF_INET6, "::1"), 0, "")
|
||||||
|
|
||||||
assert len(resolved.fallback_dns) == 2
|
assert len(resolved.fallback_dns) == 2
|
||||||
assert resolved.fallback_dns[0] == [0, 2, inet_aton("1.1.1.1")]
|
assert resolved.fallback_dns[0] == (0, 2, inet_aton("1.1.1.1"))
|
||||||
assert resolved.fallback_dns[1] == [
|
assert resolved.fallback_dns[1] == (
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
inet_pton(AF_INET6, "2606:4700:4700::1111"),
|
inet_pton(AF_INET6, "2606:4700:4700::1111"),
|
||||||
]
|
)
|
||||||
assert len(resolved.fallback_dns_ex) == 2
|
assert len(resolved.fallback_dns_ex) == 2
|
||||||
assert resolved.fallback_dns_ex[0] == [
|
assert resolved.fallback_dns_ex[0] == (
|
||||||
0,
|
0,
|
||||||
2,
|
2,
|
||||||
inet_aton("1.1.1.1"),
|
inet_aton("1.1.1.1"),
|
||||||
0,
|
0,
|
||||||
"cloudflare-dns.com",
|
"cloudflare-dns.com",
|
||||||
]
|
)
|
||||||
assert resolved.fallback_dns_ex[1] == [
|
assert resolved.fallback_dns_ex[1] == (
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
inet_pton(AF_INET6, "2606:4700:4700::1111"),
|
inet_pton(AF_INET6, "2606:4700:4700::1111"),
|
||||||
0,
|
0,
|
||||||
"cloudflare-dns.com",
|
"cloudflare-dns.com",
|
||||||
]
|
)
|
||||||
|
|
||||||
assert resolved.current_dns_server == [0, 2, inet_aton("127.0.0.1")]
|
assert resolved.current_dns_server == (0, 2, inet_aton("127.0.0.1"))
|
||||||
assert resolved.current_dns_server_ex == [
|
assert resolved.current_dns_server_ex == (
|
||||||
0,
|
0,
|
||||||
2,
|
2,
|
||||||
inet_aton("127.0.0.1"),
|
inet_aton("127.0.0.1"),
|
||||||
0,
|
0,
|
||||||
"",
|
"",
|
||||||
]
|
)
|
||||||
|
|
||||||
assert len(resolved.domains) == 1
|
assert len(resolved.domains) == 1
|
||||||
assert resolved.domains[0] == [0, "local.hass.io", False]
|
assert resolved.domains[0] == (0, "local.hass.io", False)
|
||||||
|
|
||||||
assert resolved.transaction_statistics == [0, 100000]
|
assert resolved.transaction_statistics == (0, 100000)
|
||||||
assert resolved.cache_statistics == [10, 50000, 10000]
|
assert resolved.cache_statistics == (10, 50000, 10000)
|
||||||
assert resolved.dnssec == DNSSECValidation.NO
|
assert resolved.dnssec == DNSSECValidation.NO
|
||||||
assert resolved.dnssec_statistics == [0, 0, 0, 0]
|
assert resolved.dnssec_statistics == (0, 0, 0, 0)
|
||||||
assert resolved.dnssec_supported is False
|
assert resolved.dnssec_supported is False
|
||||||
assert resolved.dnssec_negative_trust_anchors == [
|
assert resolved.dnssec_negative_trust_anchors == [
|
||||||
"168.192.in-addr.arpa",
|
"168.192.in-addr.arpa",
|
||||||
|
|||||||
@@ -185,10 +185,10 @@ async def test_start_transient_unit(
|
|||||||
"tmp-test.mount",
|
"tmp-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Description", Variant("s", "Test")],
|
("Description", Variant("s", "Test")),
|
||||||
["What", Variant("s", "//homeassistant/config")],
|
("What", Variant("s", "//homeassistant/config")),
|
||||||
["Type", Variant("s", "cifs")],
|
("Type", Variant("s", "cifs")),
|
||||||
["Options", Variant("s", "username=homeassistant,password=password")],
|
("Options", Variant("s", "username=homeassistant,password=password")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ class Resolved(DBusServiceMock):
|
|||||||
def DNS(self) -> "a(iiay)":
|
def DNS(self) -> "a(iiay)":
|
||||||
"""Get DNS."""
|
"""Get DNS."""
|
||||||
return [
|
return [
|
||||||
[0, 2, bytes([127, 0, 0, 1])],
|
(0, 2, bytes([127, 0, 0, 1])),
|
||||||
[
|
(
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
bytes(
|
bytes(
|
||||||
@@ -69,15 +69,15 @@ class Resolved(DBusServiceMock):
|
|||||||
0x1,
|
0x1,
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def DNSEx(self) -> "a(iiayqs)":
|
def DNSEx(self) -> "a(iiayqs)":
|
||||||
"""Get DNSEx."""
|
"""Get DNSEx."""
|
||||||
return [
|
return [
|
||||||
[0, 2, bytes([127, 0, 0, 1]), 0, ""],
|
(0, 2, bytes([127, 0, 0, 1]), 0, ""),
|
||||||
[
|
(
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
bytes(
|
bytes(
|
||||||
@@ -102,15 +102,15 @@ class Resolved(DBusServiceMock):
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
"",
|
"",
|
||||||
],
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def FallbackDNS(self) -> "a(iiay)":
|
def FallbackDNS(self) -> "a(iiay)":
|
||||||
"""Get FallbackDNS."""
|
"""Get FallbackDNS."""
|
||||||
return [
|
return [
|
||||||
[0, 2, bytes([1, 1, 1, 1])],
|
(0, 2, bytes([1, 1, 1, 1])),
|
||||||
[
|
(
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
bytes(
|
bytes(
|
||||||
@@ -133,15 +133,15 @@ class Resolved(DBusServiceMock):
|
|||||||
0x11,
|
0x11,
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def FallbackDNSEx(self) -> "a(iiayqs)":
|
def FallbackDNSEx(self) -> "a(iiayqs)":
|
||||||
"""Get FallbackDNSEx."""
|
"""Get FallbackDNSEx."""
|
||||||
return [
|
return [
|
||||||
[0, 2, bytes([1, 1, 1, 1]), 0, "cloudflare-dns.com"],
|
(0, 2, bytes([1, 1, 1, 1]), 0, "cloudflare-dns.com"),
|
||||||
[
|
(
|
||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
bytes(
|
bytes(
|
||||||
@@ -166,33 +166,33 @@ class Resolved(DBusServiceMock):
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
"cloudflare-dns.com",
|
"cloudflare-dns.com",
|
||||||
],
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def CurrentDNSServer(self) -> "(iiay)":
|
def CurrentDNSServer(self) -> "(iiay)":
|
||||||
"""Get CurrentDNSServer."""
|
"""Get CurrentDNSServer."""
|
||||||
return [0, 2, bytes([127, 0, 0, 1])]
|
return (0, 2, bytes([127, 0, 0, 1]))
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def CurrentDNSServerEx(self) -> "(iiayqs)":
|
def CurrentDNSServerEx(self) -> "(iiayqs)":
|
||||||
"""Get CurrentDNSServerEx."""
|
"""Get CurrentDNSServerEx."""
|
||||||
return [0, 2, bytes([127, 0, 0, 1]), 0, ""]
|
return (0, 2, bytes([127, 0, 0, 1]), 0, "")
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def Domains(self) -> "a(isb)":
|
def Domains(self) -> "a(isb)":
|
||||||
"""Get Domains."""
|
"""Get Domains."""
|
||||||
return [[0, "local.hass.io", False]]
|
return [(0, "local.hass.io", False)]
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def TransactionStatistics(self) -> "(tt)":
|
def TransactionStatistics(self) -> "(tt)":
|
||||||
"""Get TransactionStatistics."""
|
"""Get TransactionStatistics."""
|
||||||
return [0, 100000]
|
return (0, 100000)
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def CacheStatistics(self) -> "(ttt)":
|
def CacheStatistics(self) -> "(ttt)":
|
||||||
"""Get CacheStatistics."""
|
"""Get CacheStatistics."""
|
||||||
return [10, 50000, 10000]
|
return (10, 50000, 10000)
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def DNSSEC(self) -> "s":
|
def DNSSEC(self) -> "s":
|
||||||
@@ -202,7 +202,7 @@ class Resolved(DBusServiceMock):
|
|||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def DNSSECStatistics(self) -> "(tttt)":
|
def DNSSECStatistics(self) -> "(tttt)":
|
||||||
"""Get DNSSECStatistics."""
|
"""Get DNSSECStatistics."""
|
||||||
return [0, 0, 0, 0]
|
return (0, 0, 0, 0)
|
||||||
|
|
||||||
@dbus_property(access=PropertyAccess.READ)
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
def DNSSECSupported(self) -> "b":
|
def DNSSECSupported(self) -> "b":
|
||||||
|
|||||||
@@ -200,8 +200,6 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock):
|
|||||||
"type": "HassioError",
|
"type": "HassioError",
|
||||||
"message": "Unknown error, see Supervisor logs (check with 'ha supervisor logs')",
|
"message": "Unknown error, see Supervisor logs (check with 'ha supervisor logs')",
|
||||||
"stage": "test",
|
"stage": "test",
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"created": ANY,
|
"created": ANY,
|
||||||
@@ -230,8 +228,6 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock):
|
|||||||
"type": "HassioError",
|
"type": "HassioError",
|
||||||
"message": "Unknown error, see Supervisor logs (check with 'ha supervisor logs')",
|
"message": "Unknown error, see Supervisor logs (check with 'ha supervisor logs')",
|
||||||
"stage": "test",
|
"stage": "test",
|
||||||
"error_key": None,
|
|
||||||
"extra_fields": None,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"created": ANY,
|
"created": ANY,
|
||||||
|
|||||||
@@ -119,10 +119,10 @@ async def test_load(
|
|||||||
"mnt-data-supervisor-mounts-backup_test.mount",
|
"mnt-data-supervisor-mounts-backup_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "noserverino,guest")],
|
("Options", Variant("s", "noserverino,guest")),
|
||||||
["Type", Variant("s", "cifs")],
|
("Type", Variant("s", "cifs")),
|
||||||
["Description", Variant("s", "Supervisor cifs mount: backup_test")],
|
("Description", Variant("s", "Supervisor cifs mount: backup_test")),
|
||||||
["What", Variant("s", "//backup.local/backups")],
|
("What", Variant("s", "//backup.local/backups")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
@@ -130,10 +130,10 @@ async def test_load(
|
|||||||
"mnt-data-supervisor-mounts-media_test.mount",
|
"mnt-data-supervisor-mounts-media_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "soft,timeo=200")],
|
("Options", Variant("s", "soft,timeo=200")),
|
||||||
["Type", Variant("s", "nfs")],
|
("Type", Variant("s", "nfs")),
|
||||||
["Description", Variant("s", "Supervisor nfs mount: media_test")],
|
("Description", Variant("s", "Supervisor nfs mount: media_test")),
|
||||||
["What", Variant("s", "media.local:/media")],
|
("What", Variant("s", "media.local:/media")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
@@ -141,12 +141,12 @@ async def test_load(
|
|||||||
"mnt-data-supervisor-media-media_test.mount",
|
"mnt-data-supervisor-media-media_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "bind")],
|
("Options", Variant("s", "bind")),
|
||||||
[
|
(
|
||||||
"Description",
|
"Description",
|
||||||
Variant("s", "Supervisor bind mount: bind_media_test"),
|
Variant("s", "Supervisor bind mount: bind_media_test"),
|
||||||
],
|
),
|
||||||
["What", Variant("s", "/mnt/data/supervisor/mounts/media_test")],
|
("What", Variant("s", "/mnt/data/supervisor/mounts/media_test")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
@@ -198,10 +198,10 @@ async def test_load_share_mount(
|
|||||||
"mnt-data-supervisor-mounts-share_test.mount",
|
"mnt-data-supervisor-mounts-share_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "soft,timeo=200")],
|
("Options", Variant("s", "soft,timeo=200")),
|
||||||
["Type", Variant("s", "nfs")],
|
("Type", Variant("s", "nfs")),
|
||||||
["Description", Variant("s", "Supervisor nfs mount: share_test")],
|
("Description", Variant("s", "Supervisor nfs mount: share_test")),
|
||||||
["What", Variant("s", "share.local:/share")],
|
("What", Variant("s", "share.local:/share")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
@@ -209,9 +209,9 @@ async def test_load_share_mount(
|
|||||||
"mnt-data-supervisor-share-share_test.mount",
|
"mnt-data-supervisor-share-share_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "bind")],
|
("Options", Variant("s", "bind")),
|
||||||
["Description", Variant("s", "Supervisor bind mount: bind_share_test")],
|
("Description", Variant("s", "Supervisor bind mount: bind_share_test")),
|
||||||
["What", Variant("s", "/mnt/data/supervisor/mounts/share_test")],
|
("What", Variant("s", "/mnt/data/supervisor/mounts/share_test")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
@@ -318,12 +318,12 @@ async def test_mount_failed_during_load(
|
|||||||
"mnt-data-supervisor-media-media_test.mount",
|
"mnt-data-supervisor-media-media_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "ro,bind")],
|
("Options", Variant("s", "ro,bind")),
|
||||||
[
|
(
|
||||||
"Description",
|
"Description",
|
||||||
Variant("s", "Supervisor bind mount: emergency_media_test"),
|
Variant("s", "Supervisor bind mount: emergency_media_test"),
|
||||||
],
|
),
|
||||||
["What", Variant("s", "/mnt/data/supervisor/emergency/media_test")],
|
("What", Variant("s", "/mnt/data/supervisor/emergency/media_test")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -634,10 +634,10 @@ async def test_reload_mounts_attempts_initial_mount(
|
|||||||
"mnt-data-supervisor-mounts-media_test.mount",
|
"mnt-data-supervisor-mounts-media_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "soft,timeo=200")],
|
("Options", Variant("s", "soft,timeo=200")),
|
||||||
["Type", Variant("s", "nfs")],
|
("Type", Variant("s", "nfs")),
|
||||||
["Description", Variant("s", "Supervisor nfs mount: media_test")],
|
("Description", Variant("s", "Supervisor nfs mount: media_test")),
|
||||||
["What", Variant("s", "media.local:/media")],
|
("What", Variant("s", "media.local:/media")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
@@ -645,9 +645,9 @@ async def test_reload_mounts_attempts_initial_mount(
|
|||||||
"mnt-data-supervisor-media-media_test.mount",
|
"mnt-data-supervisor-media-media_test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "bind")],
|
("Options", Variant("s", "bind")),
|
||||||
["Description", Variant("s", "Supervisor bind mount: bind_media_test")],
|
("Description", Variant("s", "Supervisor bind mount: bind_media_test")),
|
||||||
["What", Variant("s", "/mnt/data/supervisor/mounts/media_test")],
|
("What", Variant("s", "/mnt/data/supervisor/mounts/media_test")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def test_cifs_mount(
|
|||||||
"mnt-data-supervisor-mounts-test.mount",
|
"mnt-data-supervisor-mounts-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
[
|
(
|
||||||
"Options",
|
"Options",
|
||||||
Variant(
|
Variant(
|
||||||
"s",
|
"s",
|
||||||
@@ -117,10 +117,10 @@ async def test_cifs_mount(
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
["Type", Variant("s", "cifs")],
|
("Type", Variant("s", "cifs")),
|
||||||
["Description", Variant("s", "Supervisor cifs mount: test")],
|
("Description", Variant("s", "Supervisor cifs mount: test")),
|
||||||
["What", Variant("s", "//test.local/camera")],
|
("What", Variant("s", "//test.local/camera")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -177,10 +177,10 @@ async def test_cifs_mount_read_only(
|
|||||||
"mnt-data-supervisor-mounts-test.mount",
|
"mnt-data-supervisor-mounts-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "ro,noserverino,guest")],
|
("Options", Variant("s", "ro,noserverino,guest")),
|
||||||
["Type", Variant("s", "cifs")],
|
("Type", Variant("s", "cifs")),
|
||||||
["Description", Variant("s", "Supervisor cifs mount: test")],
|
("Description", Variant("s", "Supervisor cifs mount: test")),
|
||||||
["What", Variant("s", "//test.local/camera")],
|
("What", Variant("s", "//test.local/camera")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -237,10 +237,10 @@ async def test_nfs_mount(
|
|||||||
"mnt-data-supervisor-mounts-test.mount",
|
"mnt-data-supervisor-mounts-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "port=1234,soft,timeo=200")],
|
("Options", Variant("s", "port=1234,soft,timeo=200")),
|
||||||
["Type", Variant("s", "nfs")],
|
("Type", Variant("s", "nfs")),
|
||||||
["Description", Variant("s", "Supervisor nfs mount: test")],
|
("Description", Variant("s", "Supervisor nfs mount: test")),
|
||||||
["What", Variant("s", "test.local:/media/camera")],
|
("What", Variant("s", "test.local:/media/camera")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -283,10 +283,10 @@ async def test_nfs_mount_read_only(
|
|||||||
"mnt-data-supervisor-mounts-test.mount",
|
"mnt-data-supervisor-mounts-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "ro,port=1234,soft,timeo=200")],
|
("Options", Variant("s", "ro,port=1234,soft,timeo=200")),
|
||||||
["Type", Variant("s", "nfs")],
|
("Type", Variant("s", "nfs")),
|
||||||
["Description", Variant("s", "Supervisor nfs mount: test")],
|
("Description", Variant("s", "Supervisor nfs mount: test")),
|
||||||
["What", Variant("s", "test.local:/media/camera")],
|
("What", Variant("s", "test.local:/media/camera")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -331,10 +331,10 @@ async def test_load(
|
|||||||
"mnt-data-supervisor-mounts-test.mount",
|
"mnt-data-supervisor-mounts-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "noserverino,guest")],
|
("Options", Variant("s", "noserverino,guest")),
|
||||||
["Type", Variant("s", "cifs")],
|
("Type", Variant("s", "cifs")),
|
||||||
["Description", Variant("s", "Supervisor cifs mount: test")],
|
("Description", Variant("s", "Supervisor cifs mount: test")),
|
||||||
["What", Variant("s", "//test.local/share")],
|
("What", Variant("s", "//test.local/share")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -736,10 +736,10 @@ async def test_mount_fails_if_down(
|
|||||||
"mnt-data-supervisor-mounts-test.mount",
|
"mnt-data-supervisor-mounts-test.mount",
|
||||||
"fail",
|
"fail",
|
||||||
[
|
[
|
||||||
["Options", Variant("s", "port=1234,soft,timeo=200")],
|
("Options", Variant("s", "port=1234,soft,timeo=200")),
|
||||||
["Type", Variant("s", "nfs")],
|
("Type", Variant("s", "nfs")),
|
||||||
["Description", Variant("s", "Supervisor nfs mount: test")],
|
("Description", Variant("s", "Supervisor nfs mount: test")),
|
||||||
["What", Variant("s", "test.local:/media/camera")],
|
("What", Variant("s", "test.local:/media/camera")),
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ 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 (
|
from supervisor.resolution.evaluations.operating_system import EvaluateOperatingSystem
|
||||||
SUPPORTED_OS,
|
|
||||||
EvaluateOperatingSystem,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_evaluation(coresys: CoreSys):
|
async def test_evaluation(coresys: CoreSys):
|
||||||
@@ -25,13 +22,7 @@ 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
|
||||||
await operating_system()
|
assert coresys.os.available
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user