Compare commits

..

2 Commits

Author SHA1 Message Date
Paulus Schoutsen
ec897081cd Update supervisor/api/__init__.py 2022-01-20 14:58:00 -08:00
Paulus Schoutsen
839361133a Add refresh updates API endpoint 2022-01-20 10:14:37 -08:00
1922 changed files with 18814 additions and 542158 deletions

View File

@@ -1,9 +1,6 @@
{ {
"name": "Supervisor dev", "name": "Supervisor dev",
"image": "ghcr.io/home-assistant/devcontainer:supervisor", "image": "ghcr.io/home-assistant/devcontainer:supervisor",
"containerEnv": {
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
},
"appPort": ["9123:8123", "7357:4357"], "appPort": ["9123:8123", "7357:4357"],
"postCreateCommand": "bash devcontainer_bootstrap", "postCreateCommand": "bash devcontainer_bootstrap",
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
@@ -13,7 +10,6 @@
"visualstudioexptteam.vscodeintellicode", "visualstudioexptteam.vscodeintellicode",
"esbenp.prettier-vscode" "esbenp.prettier-vscode"
], ],
"mounts": ["type=volume,target=/var/lib/docker"],
"settings": { "settings": {
"terminal.integrated.profiles.linux": { "terminal.integrated.profiles.linux": {
"zsh": { "zsh": {
@@ -29,7 +25,7 @@
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": true,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.formatting.provider": "black", "python.formatting.provider": "black",
"python.formatting.blackArgs": ["--target-version", "py310"], "python.formatting.blackArgs": ["--target-version", "py39"],
"python.formatting.blackPath": "/usr/local/bin/black", "python.formatting.blackPath": "/usr/local/bin/black",
"python.linting.banditPath": "/usr/local/bin/bandit", "python.linting.banditPath": "/usr/local/bin/bandit",
"python.linting.flake8Path": "/usr/local/bin/flake8", "python.linting.flake8Path": "/usr/local/bin/flake8",

View File

@@ -20,14 +20,22 @@ body:
attributes: attributes:
value: | value: |
## Environment ## Environment
- type: input
validations:
required: true
attributes:
label: What is the used version of the Supervisor?
placeholder: supervisor-
description: >
Can be found in the Supervisor panel -> System tab. Starts with
`supervisor-....`.
- type: dropdown - type: dropdown
validations: validations:
required: true required: true
attributes: attributes:
label: What type of installation are you running? label: What type of installation are you running?
description: > description: >
If you don't know, can be found in [Settings -> System -> Repairs -> System Information](https://my.home-assistant.io/redirect/system_health/). If you don't know, you can find it in: Configuration panel -> Info.
It is listed as the `Installation Type` value.
options: options:
- Home Assistant OS - Home Assistant OS
- Home Assistant Supervised - Home Assistant Supervised
@@ -40,6 +48,22 @@ body:
- Home Assistant Operating System - Home Assistant Operating System
- Debian - Debian
- Other (e.g., Raspbian/Raspberry Pi OS/Fedora) - Other (e.g., Raspbian/Raspberry Pi OS/Fedora)
- type: input
validations:
required: true
attributes:
label: What is the version of your installed operating system?
placeholder: "5.11"
description: Can be found in the Supervisor panel -> System tab.
- type: input
validations:
required: true
attributes:
label: What version of Home Assistant Core is installed?
placeholder: core-
description: >
Can be found in the Supervisor panel -> System tab. Starts with
`core-....`.
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
@@ -63,30 +87,8 @@ body:
attributes: attributes:
label: Anything in the Supervisor logs that might be useful for us? label: Anything in the Supervisor logs that might be useful for us?
description: > description: >
Supervisor Logs can be found in [Settings -> System -> Logs](https://my.home-assistant.io/redirect/logs/) The Supervisor logs can be found in the Supervisor panel -> System tab.
then choose `Supervisor` in the top right.
[![Open your Home Assistant instance and show your Supervisor system logs.](https://my.home-assistant.io/badges/supervisor_logs.svg)](https://my.home-assistant.io/redirect/supervisor_logs/)
render: txt render: txt
- type: textarea
validations:
required: true
attributes:
label: System Health information
description: >
System Health information can be found in the top right menu in [Settings -> System -> Repairs](https://my.home-assistant.io/redirect/repairs/).
Click the copy button at the bottom of the pop-up and paste it here.
[![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/)
- type: textarea
attributes:
label: Supervisor diagnostics
placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)"
description: >-
Supervisor diagnostics can be found in [Settings -> Integrations](https://my.home-assistant.io/redirect/integrations/).
Find the card that says `Home Assistant Supervisor`, open its menu and select 'Download diagnostics'.
**Please drag-and-drop the downloaded file into the textbox below. Do not copy and paste its contents.**
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information

View File

@@ -31,7 +31,6 @@ categories:
- title: ":arrow_up: Dependency Updates" - title: ":arrow_up: Dependency Updates"
label: "dependencies" label: "dependencies"
collapse-after: 1
include-labels: include-labels:
- "breaking-change" - "breaking-change"

View File

@@ -33,13 +33,9 @@ on:
- setup.py - setup.py
env: env:
DEFAULT_PYTHON: "3.11"
BUILD_NAME: supervisor BUILD_NAME: supervisor
BUILD_TYPE: supervisor BUILD_TYPE: supervisor
WHEELS_TAG: 3.9-alpine3.14
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
jobs: jobs:
init: init:
@@ -53,7 +49,7 @@ jobs:
requirements: ${{ steps.requirements.outputs.changed }} requirements: ${{ steps.requirements.outputs.changed }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -83,38 +79,26 @@ jobs:
name: Build ${{ matrix.arch }} supervisor name: Build ${{ matrix.arch }} supervisor
needs: init needs: init
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
strategy: strategy:
matrix: matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Write env-file
if: needs.init.outputs.requirements == 'true'
run: |
(
# Fix out of memory issues with rust
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
) > .env_file
- name: Build wheels - name: Build wheels
if: needs.init.outputs.requirements == 'true' if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@2023.10.5 uses: home-assistant/wheels@master
with: with:
abi: cp311 tag: ${{ env.WHEELS_TAG }}
tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-host: wheels.hass.io
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
apk: "libffi-dev;openssl-dev;yaml-dev" wheels-user: wheels
apk: "build-base;libffi-dev;openssl-dev;cargo"
skip-binary: aiohttp skip-binary: aiohttp
env-file: true
requirements: "requirements.txt" requirements: "requirements.txt"
- name: Set version - name: Set version
@@ -123,33 +107,16 @@ jobs:
with: with:
type: ${{ env.BUILD_TYPE }} type: ${{ env.BUILD_TYPE }}
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Login to DockerHub
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@v4.7.1 uses: docker/login-action@v1.12.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Install Cosign
if: needs.init.outputs.publish == 'true'
uses: sigstore/cosign-installer@v3.1.2
with:
cosign-release: "v2.0.2"
- name: Install dirhash and calc hash
if: needs.init.outputs.publish == 'true'
run: |
pip3 install dirhash
dir_hash="$(dirhash "${{ github.workspace }}/supervisor" -a sha256 --match "*.py")"
echo "${dir_hash}" > rootfs/supervisor.sha256
- name: Sign supervisor SHA256
if: needs.init.outputs.publish == 'true'
run: |
cosign sign-blob --yes rootfs/supervisor.sha256 --bundle rootfs/supervisor.sha256.sig
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: docker/login-action@v3.0.0 uses: docker/login-action@v1.12.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -160,17 +127,42 @@ jobs:
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
- name: Build supervisor - name: Build supervisor
uses: home-assistant/builder@2023.09.0 uses: home-assistant/builder@2021.12.0
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
--${{ matrix.arch }} \ --${{ matrix.arch }} \
--target /data \ --target /data \
--cosign \
--generic ${{ needs.init.outputs.version }} --generic ${{ needs.init.outputs.version }}
env: env:
CAS_API_KEY: ${{ secrets.CAS_TOKEN }} CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
codenotary:
name: CodeNotary signature
needs: init
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
if: needs.init.outputs.publish == 'true'
uses: actions/checkout@v2.4.0
with:
fetch-depth: 0
- name: Set version
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version@master
with:
type: ${{ env.BUILD_TYPE }}
- name: Signing image
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/codenotary@master
with:
source: dir://${{ github.workspace }}
user: ${{ secrets.VCN_USER }}
password: ${{ secrets.VCN_PASSWORD }}
organisation: ${{ secrets.VCN_ORG }}
version: version:
name: Update version name: Update version
needs: ["init", "run_supervisor"] needs: ["init", "run_supervisor"]
@@ -178,7 +170,7 @@ jobs:
steps: steps:
- name: Checkout the repository - name: Checkout the repository
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Initialize git - name: Initialize git
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
@@ -199,15 +191,15 @@ jobs:
run_supervisor: run_supervisor:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Run the Supervisor name: Run the Supervisor
needs: ["build", "init"] needs: ["build", "codenotary", "init"]
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Build the Supervisor - name: Build the Supervisor
if: needs.init.outputs.publish != 'true' if: needs.init.outputs.publish != 'true'
uses: home-assistant/builder@2023.09.0 uses: home-assistant/builder@2021.12.0
with: with:
args: | args: |
--test \ --test \
@@ -219,7 +211,7 @@ jobs:
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
run: | run: |
docker pull ghcr.io/home-assistant/amd64-hassio-supervisor:${{ needs.init.outputs.version }} docker pull ghcr.io/home-assistant/amd64-hassio-supervisor:${{ needs.init.outputs.version }}
docker tag ghcr.io/home-assistant/amd64-hassio-supervisor:${{ needs.init.outputs.version }} ghcr.io/home-assistant/amd64-hassio-supervisor:runner docker tag ghcr.io/home-assistant/amd64-hassio-supervisor:${{ needs.init.outputs.version }} homeassistant/amd64-hassio-supervisor:runner
- name: Create the Supervisor - name: Create the Supervisor
run: | run: |
@@ -236,7 +228,7 @@ jobs:
-e SUPERVISOR_NAME=hassio_supervisor \ -e SUPERVISOR_NAME=hassio_supervisor \
-e SUPERVISOR_DEV=1 \ -e SUPERVISOR_DEV=1 \
-e SUPERVISOR_MACHINE="qemux86-64" \ -e SUPERVISOR_MACHINE="qemux86-64" \
ghcr.io/home-assistant/amd64-hassio-supervisor:runner homeassistant/amd64-hassio-supervisor:runner
- name: Start the Supervisor - name: Start the Supervisor
run: docker start hassio_supervisor run: docker start hassio_supervisor
@@ -254,13 +246,13 @@ jobs:
run: | run: |
echo "Checking supervisor info" echo "Checking supervisor info"
test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r '.result') test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then if [ "$test" != "ok" ];then
exit 1 exit 1
fi fi
echo "Checking supervisor network info" echo "Checking supervisor network info"
test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r '.result') test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then if [ "$test" != "ok" ];then
exit 1 exit 1
fi fi
@@ -268,25 +260,13 @@ jobs:
run: | run: |
echo "Install Core SSH Add-on" echo "Install Core SSH Add-on"
test=$(docker exec hassio_cli ha addons install core_ssh --no-progress --raw-json | jq -r '.result') test=$(docker exec hassio_cli ha addons install core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then if [ "$test" != "ok" ];then
exit 1
fi
# Make sure it actually installed
test=$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.version')
if [[ "$test" == "null" ]]; then
exit 1 exit 1
fi fi
echo "Start Core SSH Add-on" echo "Start Core SSH Add-on"
test=$(docker exec hassio_cli ha addons start core_ssh --no-progress --raw-json | jq -r '.result') test=$(docker exec hassio_cli ha addons start core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then if [ "$test" != "ok" ];then
exit 1
fi
# Make sure its state is started
test="$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.state')"
if [ "$test" != "started" ]; then
exit 1 exit 1
fi fi
@@ -295,83 +275,19 @@ jobs:
run: | run: |
echo "Enable Content-Trust" echo "Enable Content-Trust"
test=$(docker exec hassio_cli ha security options --content-trust=true --no-progress --raw-json | jq -r '.result') test=$(docker exec hassio_cli ha security options --content-trust=true --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then if [ "$test" != "ok" ];then
exit 1 exit 1
fi fi
echo "Run supervisor health check" echo "Run supervisor health check"
test=$(docker exec hassio_cli ha resolution healthcheck --no-progress --raw-json | jq -r '.result') test=$(docker exec hassio_cli ha resolution healthcheck --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then if [ "$test" != "ok" ];then
exit 1 exit 1
fi fi
echo "Check supervisor unhealthy" echo "Check supervisor unhealthy"
test=$(docker exec hassio_cli ha resolution info --no-progress --raw-json | jq -r '.data.unhealthy[]') test=$(docker exec hassio_cli ha resolution info --no-progress --raw-json | jq -r '.data.unhealthy[]')
if [ "$test" != "" ]; then if [ "$test" != "" ];then
exit 1
fi
echo "Check supervisor supported"
test=$(docker exec hassio_cli ha resolution info --no-progress --raw-json | jq -r '.data.unsupported[]')
if [[ "$test" =~ source_mods ]]; then
exit 1
fi
- name: Create full backup
id: backup
run: |
test=$(docker exec hassio_cli ha backups new --no-progress --raw-json)
if [ "$(echo $test | jq -r '.result')" != "ok" ]; then
exit 1
fi
echo "::set-output name=slug::$(echo $test | jq -r '.data.slug')"
- name: Uninstall SSH add-on
run: |
test=$(docker exec hassio_cli ha addons uninstall core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
- name: Restart supervisor
run: |
test=$(docker exec hassio_cli ha supervisor restart --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
- name: Wait for Supervisor to come up
run: |
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
ping="error"
while [ "$ping" != "ok" ]; do
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
sleep 5
done
- name: Restore SSH add-on from backup
run: |
test=$(docker exec hassio_cli ha backups restore ${{ steps.backup.outputs.slug }} --addons core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
# Make sure it actually installed
test=$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.version')
if [[ "$test" == "null" ]]; then
exit 1
fi
# Make sure its state is started
test="$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.state')"
if [ "$test" != "started" ]; then
exit 1
fi
- name: Restore SSL directory from backup
run: |
test=$(docker exec hassio_cli ha backups restore ${{ steps.backup.outputs.slug }} --folders ssl --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1 exit 1
fi fi

View File

@@ -8,32 +8,30 @@ on:
pull_request: ~ pull_request: ~
env: env:
DEFAULT_PYTHON: "3.11" DEFAULT_PYTHON: 3.9
PRE_COMMIT_HOME: ~/.cache/pre-commit PRE_COMMIT_HOME: ~/.cache/pre-commit
DEFAULT_VCN: v0.9.8
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
jobs: jobs:
# Separate job to pre-populate the base dependency cache # Separate job to pre-populate the base dependency cache
# This prevent upcoming jobs to do the same individually # This prevent upcoming jobs to do the same individually
prepare: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: strategy:
python-version: ${{ steps.python.outputs.python-version }} matrix:
name: Prepare Python dependencies python-version: [3.9]
name: Prepare Python ${{ matrix.python-version }} dependencies
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ matrix.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
@@ -47,7 +45,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -66,19 +64,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -95,7 +93,7 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Register hadolint problem matcher - name: Register hadolint problem matcher
run: | run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json" echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -110,19 +108,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -130,7 +128,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -154,19 +152,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -186,19 +184,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -206,7 +204,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -227,19 +225,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -247,7 +245,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -271,19 +269,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -303,19 +301,19 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -323,7 +321,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -341,26 +339,29 @@ jobs:
pytest: pytest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: prepare needs: prepare
name: Run tests Python ${{ needs.prepare.outputs.python-version }} strategy:
matrix:
python-version: [3.9]
name: Run tests Python ${{ matrix.python-version }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Cosign - name: Install VCN tools
uses: sigstore/cosign-installer@v3.1.2 uses: home-assistant/actions/helpers/vcn@master
with: with:
cosign-release: "v2.0.2" vcn_version: ${{ env.DEFAULT_VCN }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -369,7 +370,7 @@ jobs:
- name: Install additional system dependencies - name: Install additional system dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends libpulse0 libudev1 dbus dbus-x11 sudo apt-get install -y --no-install-recommends libpulse0 libudev1
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
echo "::add-matcher::.github/workflows/matchers/python.json" echo "::add-matcher::.github/workflows/matchers/python.json"
@@ -391,7 +392,7 @@ jobs:
-o console_output_style=count \ -o console_output_style=count \
tests tests
- name: Upload coverage artifact - name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.3 uses: actions/upload-artifact@v2.3.1
with: with:
name: coverage-${{ matrix.python-version }} name: coverage-${{ matrix.python-version }}
path: .coverage path: .coverage
@@ -399,29 +400,29 @@ jobs:
coverage: coverage:
name: Process test coverage name: Process test coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ["pytest", "prepare"] needs: pytest
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v2.3.1
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.3.2 uses: actions/cache@v2.1.7
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }} ${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed - name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
echo "Failed to restore Python virtual environment from cache" echo "Failed to restore Python virtual environment from cache"
exit 1 exit 1
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v2
- name: Combine coverage results - name: Combine coverage results
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -429,4 +430,4 @@ jobs:
coverage report coverage report
coverage xml coverage xml
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.4 uses: codecov/codecov-action@v2.1.0

View File

@@ -9,7 +9,7 @@ jobs:
lock: lock:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v4.0.1 - uses: dessant/lock-threads@v3
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
issue-inactive-days: "30" issue-inactive-days: "30"

View File

@@ -11,7 +11,7 @@ jobs:
name: Release Drafter name: Release Drafter
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -36,7 +36,7 @@ jobs:
echo "::set-output name=version::$datepre.$newpost" echo "::set-output name=version::$datepre.$newpost"
- name: Run Release Drafter - name: Run Release Drafter
uses: release-drafter/release-drafter@v5.25.0 uses: release-drafter/release-drafter@v5
with: with:
tag: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }} name: ${{ steps.version.outputs.version }}

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v2.4.0
- name: Sentry Release - name: Sentry Release
uses: getsentry/action-release@v1.4.1 uses: getsentry/action-release@v1.1.6
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

View File

@@ -9,10 +9,10 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v8.0.0 - uses: actions/stale@v4
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30 days-before-stale: 60
days-before-close: 7 days-before-close: 7
stale-issue-label: "stale" stale-issue-label: "stale"
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security" exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"

View File

@@ -1,34 +1,34 @@
repos: repos:
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.1.0 rev: 21.12b0
hooks: hooks:
- id: black - id: black
args: args:
- --safe - --safe
- --quiet - --quiet
- --target-version - --target-version
- py310 - py39
files: ^((supervisor|tests)/.+)?[^/]+\.py$ files: ^((supervisor|tests)/.+)?[^/]+\.py$
- repo: https://github.com/PyCQA/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: 6.0.0 rev: 3.8.3
hooks: hooks:
- id: flake8 - id: flake8
additional_dependencies: additional_dependencies:
- flake8-docstrings==1.7.0 - flake8-docstrings==1.5.0
- pydocstyle==6.3.0 - pydocstyle==5.0.2
files: ^(supervisor|script|tests)/.+\.py$ files: ^(supervisor|script|tests)/.+\.py$
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v3.1.0
hooks: hooks:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
stages: [manual] stages: [manual]
- id: check-json - id: check-json
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
rev: 5.12.0 rev: 5.9.3
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.15.0 rev: v2.31.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py310-plus] args: [--py39-plus]

7
.vscode/launch.json vendored
View File

@@ -13,13 +13,6 @@
"remoteRoot": "/usr/src/supervisor" "remoteRoot": "/usr/src/supervisor"
} }
] ]
},
{
"name": "Debug Tests",
"type": "python",
"request": "test",
"console": "internalConsole",
"justMyCode": false
} }
] ]
} }

View File

@@ -3,15 +3,12 @@ FROM ${BUILD_FROM}
ENV \ ENV \
S6_SERVICES_GRACETIME=10000 \ S6_SERVICES_GRACETIME=10000 \
SUPERVISOR_API=http://localhost \ SUPERVISOR_API=http://localhost
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1
ARG \ ARG BUILD_ARCH
COSIGN_VERSION \ WORKDIR /usr/src
BUILD_ARCH
# Install base # Install base
WORKDIR /usr/src
RUN \ RUN \
set -x \ set -x \
&& apk add --no-cache \ && apk add --no-cache \
@@ -21,18 +18,14 @@ RUN \
libffi \ libffi \
libpulse \ libpulse \
musl \ musl \
openssl \ openssl
yaml \
\
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign
# Install requirements # Install requirements
COPY requirements.txt . COPY requirements.txt .
RUN \ RUN \
export MAKEFLAGS="-j$(nproc)" \ export MAKEFLAGS="-j$(nproc)" \
&& pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links \
"https://wheels.home-assistant.io/musllinux/" \ "https://wheels.home-assistant.io/alpine-$(cut -d '.' -f 1-2 < /etc/alpine-release)/${BUILD_ARCH}/" \
-r ./requirements.txt \ -r ./requirements.txt \
&& rm -f requirements.txt && rm -f requirements.txt

View File

@@ -1,18 +1,14 @@
image: ghcr.io/home-assistant/{arch}-hassio-supervisor image: homeassistant/{arch}-hassio-supervisor
shadow_repository: ghcr.io/home-assistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.11-alpine3.16 aarch64: ghcr.io/home-assistant/aarch64-base-python:3.9-alpine3.14
armhf: ghcr.io/home-assistant/armhf-base-python:3.11-alpine3.16 armhf: ghcr.io/home-assistant/armhf-base-python:3.9-alpine3.14
armv7: ghcr.io/home-assistant/armv7-base-python:3.11-alpine3.16 armv7: ghcr.io/home-assistant/armv7-base-python:3.9-alpine3.14
amd64: ghcr.io/home-assistant/amd64-base-python:3.11-alpine3.16 amd64: ghcr.io/home-assistant/amd64-base-python:3.9-alpine3.14
i386: ghcr.io/home-assistant/i386-base-python:3.11-alpine3.16 i386: ghcr.io/home-assistant/i386-base-python:3.9-alpine3.14
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io
cosign:
base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.*
args:
COSIGN_VERSION: 2.0.2
labels: labels:
io.hass.type: supervisor io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor org.opencontainers.image.title: Home Assistant Supervisor

View File

@@ -12,19 +12,24 @@ extension-pkg-whitelist=
# locally-disabled - it spams too much # locally-disabled - it spams too much
# duplicate-code - unavoidable # duplicate-code - unavoidable
# cyclic-import - doesn't test if both import on load # cyclic-import - doesn't test if both import on load
# abstract-class-little-used - prevents from setting right foundation
# abstract-class-not-used - is flaky, should not show up but does # abstract-class-not-used - is flaky, should not show up but does
# unused-argument - generic callbacks and setup methods create a lot of warnings # unused-argument - generic callbacks and setup methods create a lot of warnings
# redefined-variable-type - this is Python, we're duck typing!
# too-many-* - are not enforced for the sake of readability # too-many-* - are not enforced for the sake of readability
# too-few-* - same as too-many-* # too-few-* - same as too-many-*
# abstract-method - with intro of async there are always methods missing # abstract-method - with intro of async there are always methods missing
disable= disable=
format, format,
abstract-class-little-used,
abstract-method, abstract-method,
cyclic-import, cyclic-import,
duplicate-code, duplicate-code,
locally-disabled, locally-disabled,
no-else-return, no-else-return,
no-self-use,
not-context-manager, not-context-manager,
redefined-variable-type,
too-few-public-methods, too-few-public-methods,
too-many-arguments, too-many-arguments,
too-many-branches, too-many-branches,
@@ -38,7 +43,7 @@ disable=
consider-using-with consider-using-with
[EXCEPTIONS] [EXCEPTIONS]
overgeneral-exceptions=builtins.Exception overgeneral-exceptions=Exception
[TYPECHECK] [TYPECHECK]

View File

@@ -1,2 +0,0 @@
[pytest]
asyncio_mode = auto

View File

@@ -1,26 +1,22 @@
aiodns==3.1.1 aiohttp==3.8.1
aiohttp==3.8.6 async_timeout==4.0.2
async_timeout==4.0.3 atomicwrites==1.4.0
atomicwrites-homeassistant==1.4.1 attrs==21.2.0
attrs==23.1.0 awesomeversion==22.1.0
awesomeversion==23.8.0 brotli==1.0.9
brotli==1.1.0 cchardet==2.1.7
ciso8601==2.3.0 ciso8601==2.2.0
colorlog==6.7.0 colorlog==6.6.0
cpe==1.2.1 cpe==1.2.1
cryptography==41.0.4 cryptography==36.0.1
debugpy==1.8.0 debugpy==1.5.1
deepmerge==1.1.0 deepmerge==1.0.1
dirhash==0.2.1 docker==5.0.3
docker==6.1.3 gitpython==3.1.26
faust-cchardet==2.1.19 jinja2==3.0.3
gitpython==3.1.40 pulsectl==21.10.5
jinja2==3.1.2 pyudev==0.22.0
pulsectl==23.5.2 ruamel.yaml==0.17.17
pyudev==0.24.1 sentry-sdk==1.5.2
PyYAML==6.0.1 voluptuous==0.12.2
securetar==2023.3.0 dbus-next==0.2.3
sentry-sdk==1.32.0
voluptuous==0.13.1
dbus-fast==2.11.1
typing_extensions==4.8.0

View File

@@ -1,16 +1,14 @@
black==23.10.0 black==21.12b0
coverage==7.3.2 codecov==2.1.12
flake8-docstrings==1.7.0 coverage==6.2
flake8==6.1.0 flake8-docstrings==1.6.0
pre-commit==3.5.0 flake8==4.0.1
pydocstyle==6.3.0 pre-commit==2.17.0
pylint==3.0.2 pydocstyle==6.1.1
pytest-aiohttp==1.0.5 pylint==2.12.2
pytest-asyncio==0.18.3 pytest-aiohttp==0.3.0
pytest-cov==4.1.0 pytest-asyncio==0.12.0 # NB!: Versions over 0.12.0 breaks pytest-aiohttp (https://github.com/aio-libs/pytest-aiohttp/issues/16)
pytest-timeout==2.2.0 pytest-cov==3.0.0
pytest==7.4.2 pytest-timeout==2.0.2
pyupgrade==3.15.0 pytest==6.2.5
time-machine==2.13.0 pyupgrade==2.31.0
typing_extensions==4.8.0
urllib3==2.0.7

0
rootfs/etc/cont-init.d/udev.sh Executable file → Normal file
View File

11
rootfs/etc/services.d/supervisor/finish Executable file → Normal file
View File

@@ -1,11 +1,8 @@
#!/usr/bin/env bashio #!/usr/bin/execlineb -S1
# ============================================================================== # ==============================================================================
# Take down the S6 supervision tree when Supervisor fails # Take down the S6 supervision tree when Supervisor fails
# ============================================================================== # ==============================================================================
if { s6-test ${1} -ne 100 }
if { s6-test ${1} -ne 256 }
if [[ "$1" -ne 100 ]] && [[ "$1" -ne 256 ]]; then redirfd -w 2 /dev/null s6-svscanctl -t /var/run/s6/services
bashio::log.warning "Halt Supervisor"
/run/s6/basedir/bin/halt
fi
bashio::log.info "Supervisor restart after closing"

1
rootfs/etc/services.d/supervisor/run Executable file → Normal file
View File

@@ -3,6 +3,5 @@
# Start Supervisor service # Start Supervisor service
# ============================================================================== # ==============================================================================
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
export MALLOC_CONF="background_thread:true,metadata_thp:auto"
exec python3 -m supervisor exec python3 -m supervisor

11
rootfs/etc/services.d/watchdog/finish Executable file → Normal file
View File

@@ -1,11 +1,8 @@
#!/usr/bin/env bashio #!/usr/bin/execlineb -S1
# ============================================================================== # ==============================================================================
# Take down the S6 supervision tree when Watchdog fails # Take down the S6 supervision tree when Watchdog fails
# ============================================================================== # ==============================================================================
if { s6-test ${1} -ne 0 }
if { s6-test ${1} -ne 256 }
if [[ "$1" -ne 0 ]] && [[ "$1" -ne 256 ]]; then s6-svscanctl -t /var/run/s6/services
bashio::log.warning "Halt Supervisor (Wuff)"
/run/s6/basedir/bin/halt
fi
bashio::log.info "Watchdog restart after closing"

2
rootfs/etc/services.d/watchdog/run Executable file → Normal file
View File

@@ -31,4 +31,4 @@ do
done done
bashio::exit.nok "Watchdog detected issue with Supervisor - taking container down!" basio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"

View File

@@ -27,5 +27,3 @@ ignore =
E203, E203,
D202, D202,
W504 W504
per-file-ignores =
tests/dbus_service_mocks/*.py: F821,F722

View File

@@ -15,6 +15,18 @@ setup(
"A maintainless private cloud operator system that" "A maintainless private cloud operator system that"
"setup a Home-Assistant instance. Based on HassOS" "setup a Home-Assistant instance. Based on HassOS"
), ),
classifiers=[
"Intended Audience :: End Users/Desktop",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Topic :: Home Automation",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Scientific/Engineering :: Atmospheric Science",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.8",
],
keywords=["docker", "home-assistant", "api"], keywords=["docker", "home-assistant", "api"],
zip_safe=False, zip_safe=False,
platforms="any", platforms="any",
@@ -37,7 +49,6 @@ setup(
"supervisor.resolution.evaluations", "supervisor.resolution.evaluations",
"supervisor.resolution.fixups", "supervisor.resolution.fixups",
"supervisor.resolution", "supervisor.resolution",
"supervisor.security",
"supervisor.services.modules", "supervisor.services.modules",
"supervisor.services", "supervisor.services",
"supervisor.store", "supervisor.store",

View File

@@ -28,8 +28,7 @@ if __name__ == "__main__":
bootstrap.initialize_logging() bootstrap.initialize_logging()
# Init async event loop # Init async event loop
loop = asyncio.new_event_loop() loop = asyncio.get_event_loop()
asyncio.set_event_loop(loop)
# Check if all information are available to setup Supervisor # Check if all information are available to setup Supervisor
bootstrap.check_environment() bootstrap.check_environment()

View File

@@ -1,10 +1,9 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
import asyncio import asyncio
from collections.abc import Awaitable
from contextlib import suppress from contextlib import suppress
import logging import logging
import tarfile import tarfile
from typing import Union from typing import Optional, Union
from ..const import AddonBoot, AddonStartup, AddonState from ..const import AddonBoot, AddonStartup, AddonState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
@@ -24,9 +23,7 @@ from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType from ..resolution.const import ContextType, IssueType, SuggestionType
from ..store.addon import AddonStore from ..store.addon import AddonStore
from ..utils import check_exception_chain from ..utils import check_exception_chain
from ..utils.sentry import capture_exception
from .addon import Addon from .addon import Addon
from .const import ADDON_UPDATE_CONDITIONS
from .data import AddonsData from .data import AddonsData
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -55,7 +52,7 @@ class AddonManager(CoreSysAttributes):
"""Return a list of all installed add-ons.""" """Return a list of all installed add-ons."""
return list(self.local.values()) return list(self.local.values())
def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None: def get(self, addon_slug: str, local_only: bool = False) -> Optional[AnyAddon]:
"""Return an add-on from slug. """Return an add-on from slug.
Prio: Prio:
@@ -68,7 +65,7 @@ class AddonManager(CoreSysAttributes):
return self.store.get(addon_slug) return self.store.get(addon_slug)
return None return None
def from_token(self, token: str) -> Addon | None: def from_token(self, token: str) -> Optional[Addon]:
"""Return an add-on from Supervisor token.""" """Return an add-on from Supervisor token."""
for addon in self.installed: for addon in self.installed:
if token == addon.supervisor_token: if token == addon.supervisor_token:
@@ -80,7 +77,7 @@ class AddonManager(CoreSysAttributes):
tasks = [] tasks = []
for slug in self.data.system: for slug in self.data.system:
addon = self.local[slug] = Addon(self.coresys, slug) addon = self.local[slug] = Addon(self.coresys, slug)
tasks.append(self.sys_create_task(addon.load())) tasks.append(addon.load())
# Run initial tasks # Run initial tasks
_LOGGER.info("Found %d installed add-ons", len(tasks)) _LOGGER.info("Found %d installed add-ons", len(tasks))
@@ -105,13 +102,9 @@ class AddonManager(CoreSysAttributes):
# Start Add-ons sequential # Start Add-ons sequential
# avoid issue on slow IO # avoid issue on slow IO
# Config.wait_boot is deprecated. Until addons update with healthchecks,
# add a sleep task for it to keep the same minimum amount of wait time
wait_boot: list[Awaitable[None]] = [asyncio.sleep(self.sys_config.wait_boot)]
for addon in tasks: for addon in tasks:
try: try:
if start_task := await addon.start(): await addon.start()
wait_boot.append(start_task)
except AddonsError as err: except AddonsError as err:
# Check if there is an system/user issue # Check if there is an system/user issue
if check_exception_chain( if check_exception_chain(
@@ -120,14 +113,13 @@ class AddonManager(CoreSysAttributes):
addon.boot = AddonBoot.MANUAL addon.boot = AddonBoot.MANUAL
addon.save_persist() addon.save_persist()
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
capture_exception(err) self.sys_capture_exception(err)
else: else:
continue continue
_LOGGER.warning("Can't start Add-on %s", addon.slug) _LOGGER.warning("Can't start Add-on %s", addon.slug)
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere await asyncio.sleep(self.sys_config.wait_boot)
await asyncio.gather(*wait_boot, return_exceptions=True)
async def shutdown(self, stage: AddonStartup) -> None: async def shutdown(self, stage: AddonStartup) -> None:
"""Shutdown addons.""" """Shutdown addons."""
@@ -149,17 +141,18 @@ class AddonManager(CoreSysAttributes):
await addon.stop() await addon.stop()
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
capture_exception(err) self.sys_capture_exception(err)
@Job( @Job(
name="addon_manager_install", conditions=[
conditions=ADDON_UPDATE_CONDITIONS, JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError, on_condition=AddonsJobError,
) )
async def install(self, slug: str) -> None: async def install(self, slug: str) -> None:
"""Install an add-on.""" """Install an add-on."""
self.sys_jobs.current.reference = slug
if slug in self.local: if slug in self.local:
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning) raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
store = self.store.get(slug) store = self.store.get(slug)
@@ -167,11 +160,13 @@ class AddonManager(CoreSysAttributes):
if not store: if not store:
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error) raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error)
store.validate_availability() if not store.available:
raise AddonsNotSupportedError(
f"Add-on {slug} not supported on this platform", _LOGGER.error
)
self.data.install(store) self.data.install(store)
addon = Addon(self.coresys, slug) addon = Addon(self.coresys, slug)
await addon.load()
if not addon.path_data.is_dir(): if not addon.path_data.is_dir():
_LOGGER.info( _LOGGER.info(
@@ -183,12 +178,12 @@ class AddonManager(CoreSysAttributes):
await addon.install_apparmor() await addon.install_apparmor()
try: try:
await addon.instance.install(store.version, store.image, arch=addon.arch) await addon.instance.install(store.version, store.image)
except DockerError as err: except DockerError as err:
self.data.uninstall(addon) self.data.uninstall(addon)
raise AddonsError() from err raise AddonsError() from err
else:
self.local[slug] = addon self.local[slug] = addon
# Reload ingress tokens # Reload ingress tokens
if addon.with_ingress: if addon.with_ingress:
@@ -207,10 +202,10 @@ class AddonManager(CoreSysAttributes):
await addon.instance.remove() await addon.instance.remove()
except DockerError as err: except DockerError as err:
raise AddonsError() from err raise AddonsError() from err
else:
addon.state = AddonState.UNKNOWN
addon.state = AddonState.UNKNOWN await addon.remove_data()
await addon.unload()
# Cleanup audio settings # Cleanup audio settings
if addon.path_pulse.exists(): if addon.path_pulse.exists():
@@ -250,20 +245,15 @@ class AddonManager(CoreSysAttributes):
_LOGGER.info("Add-on '%s' successfully removed", slug) _LOGGER.info("Add-on '%s' successfully removed", slug)
@Job( @Job(
name="addon_manager_update", conditions=[
conditions=ADDON_UPDATE_CONDITIONS, JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError, on_condition=AddonsJobError,
) )
async def update( async def update(self, slug: str, backup: Optional[bool] = False) -> None:
self, slug: str, backup: bool | None = False """Update add-on."""
) -> Awaitable[None] | None:
"""Update add-on.
Returns a coroutine that completes when addon has state 'started' (see addon.start)
if addon is started after update. Else nothing is returned.
"""
self.sys_jobs.current.reference = slug
if slug not in self.local: if slug not in self.local:
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error) raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
addon = self.local[slug] addon = self.local[slug]
@@ -278,7 +268,10 @@ class AddonManager(CoreSysAttributes):
raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning) raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning)
# Check if available, Maybe something have changed # Check if available, Maybe something have changed
store.validate_availability() if not store.available:
raise AddonsNotSupportedError(
f"Add-on {slug} not supported on that platform", _LOGGER.error
)
if backup: if backup:
await self.sys_backups.do_backup_partial( await self.sys_backups.do_backup_partial(
@@ -288,43 +281,28 @@ class AddonManager(CoreSysAttributes):
) )
# Update instance # Update instance
last_state: AddonState = addon.state
old_image = addon.image old_image = addon.image
# Cache data to prevent races with other updates to global
store = store.clone()
try: try:
await addon.instance.update(store.version, store.image) await addon.instance.update(store.version, store.image)
except DockerError as err: except DockerError as err:
raise AddonsError() from err raise AddonsError() from err
# Stop the addon if running _LOGGER.info("Add-on '%s' successfully updated", slug)
if (last_state := addon.state) in {AddonState.STARTED, AddonState.STARTUP}: self.data.update(store)
await addon.stop()
try: # Cleanup
_LOGGER.info("Add-on '%s' successfully updated", slug) with suppress(DockerError):
self.data.update(store) await addon.instance.cleanup(old_image=old_image)
# Cleanup # Setup/Fix AppArmor profile
with suppress(DockerError): await addon.install_apparmor()
await addon.instance.cleanup(
old_image=old_image, image=store.image, version=store.version
)
# Setup/Fix AppArmor profile # restore state
await addon.install_apparmor() if last_state == AddonState.STARTED:
await addon.start()
finally:
# restore state. Return awaitable for caller if no exception
out = (
await addon.start()
if last_state in {AddonState.STARTED, AddonState.STARTUP}
else None
)
return out
@Job( @Job(
name="addon_manager_rebuild",
conditions=[ conditions=[
JobCondition.FREE_SPACE, JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST, JobCondition.INTERNET_HOST,
@@ -332,14 +310,8 @@ class AddonManager(CoreSysAttributes):
], ],
on_condition=AddonsJobError, on_condition=AddonsJobError,
) )
async def rebuild(self, slug: str) -> Awaitable[None] | None: async def rebuild(self, slug: str) -> None:
"""Perform a rebuild of local build add-on. """Perform a rebuild of local build add-on."""
Returns a coroutine that completes when addon has state 'started' (see addon.start)
if addon is started after rebuild. Else nothing is returned.
"""
self.sys_jobs.current.reference = slug
if slug not in self.local: if slug not in self.local:
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error) raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
addon = self.local[slug] addon = self.local[slug]
@@ -367,19 +339,15 @@ class AddonManager(CoreSysAttributes):
await addon.instance.install(addon.version) await addon.instance.install(addon.version)
except DockerError as err: except DockerError as err:
raise AddonsError() from err raise AddonsError() from err
else:
self.data.update(store) self.data.update(store)
_LOGGER.info("Add-on '%s' successfully rebuilt", slug) _LOGGER.info("Add-on '%s' successfully rebuilt", slug)
# restore state # restore state
return ( if last_state == AddonState.STARTED:
await addon.start() await addon.start()
if last_state in [AddonState.STARTED, AddonState.STARTUP]
else None
)
@Job( @Job(
name="addon_manager_restore",
conditions=[ conditions=[
JobCondition.FREE_SPACE, JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST, JobCondition.INTERNET_HOST,
@@ -387,26 +355,16 @@ class AddonManager(CoreSysAttributes):
], ],
on_condition=AddonsJobError, on_condition=AddonsJobError,
) )
async def restore( async def restore(self, slug: str, tar_file: tarfile.TarFile) -> None:
self, slug: str, tar_file: tarfile.TarFile """Restore state of an add-on."""
) -> Awaitable[None] | None:
"""Restore state of an add-on.
Returns a coroutine that completes when addon has state 'started' (see addon.start)
if addon is started after restore. Else nothing is returned.
"""
self.sys_jobs.current.reference = slug
if slug not in self.local: if slug not in self.local:
_LOGGER.debug("Add-on %s is not local available for restore", slug) _LOGGER.debug("Add-on %s is not local available for restore", slug)
addon = Addon(self.coresys, slug) addon = Addon(self.coresys, slug)
had_ingress = False
else: else:
_LOGGER.debug("Add-on %s is local available for restore", slug) _LOGGER.debug("Add-on %s is local available for restore", slug)
addon = self.local[slug] addon = self.local[slug]
had_ingress = addon.ingress_panel
wait_for_start = await addon.restore(tar_file) await addon.restore(tar_file)
# Check if new # Check if new
if slug not in self.local: if slug not in self.local:
@@ -414,17 +372,12 @@ class AddonManager(CoreSysAttributes):
self.local[slug] = addon self.local[slug] = addon
# Update ingress # Update ingress
if had_ingress != addon.ingress_panel: if addon.with_ingress:
await self.sys_ingress.reload() await self.sys_ingress.reload()
with suppress(HomeAssistantAPIError): with suppress(HomeAssistantAPIError):
await self.sys_ingress.update_hass_panel(addon) await self.sys_ingress.update_hass_panel(addon)
return wait_for_start @Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
@Job(
name="addon_manager_repair",
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST],
)
async def repair(self) -> None: async def repair(self) -> None:
"""Repair local add-ons.""" """Repair local add-ons."""
needs_repair: list[Addon] = [] needs_repair: list[Addon] = []
@@ -462,7 +415,6 @@ class AddonManager(CoreSysAttributes):
async def sync_dns(self) -> None: async def sync_dns(self) -> None:
"""Sync add-ons DNS names.""" """Sync add-ons DNS names."""
# Update hosts # Update hosts
add_host_coros: list[Awaitable[None]] = []
for addon in self.installed: for addon in self.installed:
try: try:
if not await addon.instance.is_running(): if not await addon.instance.is_running():
@@ -475,16 +427,12 @@ class AddonManager(CoreSysAttributes):
reference=addon.slug, reference=addon.slug,
suggestions=[SuggestionType.EXECUTE_REPAIR], suggestions=[SuggestionType.EXECUTE_REPAIR],
) )
capture_exception(err) self.sys_capture_exception(err)
else: else:
add_host_coros.append( self.sys_plugins.dns.add_host(
self.sys_plugins.dns.add_host( ipv4=addon.ip_address, names=[addon.hostname], write=False
ipv4=addon.ip_address, names=[addon.hostname], write=False
)
) )
await asyncio.gather(*add_host_coros)
# Write hosts files # Write hosts files
with suppress(CoreDNSError): with suppress(CoreDNSError):
await self.sys_plugins.dns.write_hosts() self.sys_plugins.dns.write_hosts()

View File

@@ -1,6 +1,5 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
import asyncio import asyncio
from collections.abc import Awaitable
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
from ipaddress import IPv4Address from ipaddress import IPv4Address
@@ -11,15 +10,13 @@ import secrets
import shutil import shutil
import tarfile import tarfile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, Final from typing import Any, Awaitable, Final, Optional
import aiohttp import aiohttp
from deepmerge import Merger from deepmerge import Merger
from securetar import atomic_contents_add, secure_path
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
from ..bus import EventListener
from ..const import ( from ..const import (
ATTR_ACCESS_TOKEN, ATTR_ACCESS_TOKEN,
ATTR_AUDIO_INPUT, ATTR_AUDIO_INPUT,
@@ -50,37 +47,26 @@ from ..const import (
AddonBoot, AddonBoot,
AddonStartup, AddonStartup,
AddonState, AddonState,
BusEvent,
) )
from ..coresys import CoreSys from ..coresys import CoreSys
from ..docker.addon import DockerAddon from ..docker.addon import DockerAddon
from ..docker.const import ContainerState
from ..docker.monitor import DockerContainerStateEvent
from ..docker.stats import DockerStats from ..docker.stats import DockerStats
from ..exceptions import ( from ..exceptions import (
AddonConfigurationError, AddonConfigurationError,
AddonsError, AddonsError,
AddonsJobError,
AddonsNotSupportedError, AddonsNotSupportedError,
ConfigurationFileError, ConfigurationFileError,
DockerError, DockerError,
DockerRequestError,
HostAppArmorError, HostAppArmorError,
) )
from ..hardware.data import Device from ..hardware.data import Device
from ..homeassistant.const import WSEvent, WSType from ..homeassistant.const import WSEvent, WSType
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..utils import check_port from ..utils import check_port
from ..utils.apparmor import adjust_profile from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file from ..utils.json import read_json_file, write_json_file
from ..utils.sentry import capture_exception from ..utils.tar import atomic_contents_add, secure_path
from .const import ( from .const import AddonBackupMode
WATCHDOG_MAX_ATTEMPTS,
WATCHDOG_RETRY_SECONDS,
WATCHDOG_THROTTLE_MAX_CALLS,
WATCHDOG_THROTTLE_PERIOD,
AddonBackupMode,
)
from .model import AddonModel, Data from .model import AddonModel, Data
from .options import AddonOptions from .options import AddonOptions
from .utils import remove_data from .utils import remove_data
@@ -98,8 +84,9 @@ RE_WATCHDOG = re.compile(
r":\/\/\[HOST\]:(?:\[PORT:)?(?P<t_port>\d+)\]?(?P<s_suffix>.*)$" r":\/\/\[HOST\]:(?:\[PORT:)?(?P<t_port>\d+)\]?(?P<s_suffix>.*)$"
) )
RE_OLD_AUDIO = re.compile(r"\d+,\d+")
WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10) WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10)
STARTUP_TIMEOUT = 120
_OPTIONS_MERGER: Final = Merger( _OPTIONS_MERGER: Final = Merger(
type_strategies=[(dict, ["merge"])], type_strategies=[(dict, ["merge"])],
@@ -107,14 +94,6 @@ _OPTIONS_MERGER: Final = Merger(
type_conflict_strategies=["override"], type_conflict_strategies=["override"],
) )
# Backups just need to know if an addon was running or not
# Map other addon states to those two
_MAP_ADDON_STATE = {
AddonState.STARTUP: AddonState.STARTED,
AddonState.ERROR: AddonState.STOPPED,
AddonState.UNKNOWN: AddonState.STOPPED,
}
class Addon(AddonModel): class Addon(AddonModel):
"""Hold data for add-on inside Supervisor.""" """Hold data for add-on inside Supervisor."""
@@ -124,12 +103,6 @@ class Addon(AddonModel):
super().__init__(coresys, slug) super().__init__(coresys, slug)
self.instance: DockerAddon = DockerAddon(coresys, self) self.instance: DockerAddon = DockerAddon(coresys, self)
self._state: AddonState = AddonState.UNKNOWN self._state: AddonState = AddonState.UNKNOWN
self._manual_stop: bool = (
self.sys_hardware.helper.last_boot != self.sys_config.last_boot
)
self._listeners: list[EventListener] = []
self._startup_event = asyncio.Event()
self._startup_task: asyncio.Task | None = None
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return internal representation.""" """Return internal representation."""
@@ -145,13 +118,7 @@ class Addon(AddonModel):
"""Set the add-on into new state.""" """Set the add-on into new state."""
if self._state == new_state: if self._state == new_state:
return return
old_state = self._state
self._state = new_state self._state = new_state
# Signal listeners about addon state change
if new_state == AddonState.STARTED or old_state == AddonState.STARTUP:
self._startup_event.set()
self.sys_homeassistant.websocket.send_message( self.sys_homeassistant.websocket.send_message(
{ {
ATTR_TYPE: WSType.SUPERVISOR_EVENT, ATTR_TYPE: WSType.SUPERVISOR_EVENT,
@@ -170,20 +137,15 @@ class Addon(AddonModel):
async def load(self) -> None: async def load(self) -> None:
"""Async initialize of object.""" """Async initialize of object."""
self._listeners.append(
self.sys_bus.register_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.container_state_changed
)
)
self._listeners.append(
self.sys_bus.register_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.watchdog_container
)
)
with suppress(DockerError): with suppress(DockerError):
await self.instance.attach(version=self.version) await self.instance.attach(version=self.version)
# Evaluate state
if await self.instance.is_running():
self.state = AddonState.STARTED
else:
self.state = AddonState.STOPPED
@property @property
def ip_address(self) -> IPv4Address: def ip_address(self) -> IPv4Address:
"""Return IP of add-on instance.""" """Return IP of add-on instance."""
@@ -220,7 +182,7 @@ class Addon(AddonModel):
return self._available(self.data_store) return self._available(self.data_store)
@property @property
def version(self) -> str | None: def version(self) -> Optional[str]:
"""Return installed version.""" """Return installed version."""
return self.persist[ATTR_VERSION] return self.persist[ATTR_VERSION]
@@ -244,7 +206,7 @@ class Addon(AddonModel):
) )
@options.setter @options.setter
def options(self, value: dict[str, Any] | None) -> None: def options(self, value: Optional[dict[str, Any]]) -> None:
"""Store user add-on options.""" """Store user add-on options."""
self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value) self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value)
@@ -289,17 +251,17 @@ class Addon(AddonModel):
return self.persist[ATTR_UUID] return self.persist[ATTR_UUID]
@property @property
def supervisor_token(self) -> str | None: def supervisor_token(self) -> Optional[str]:
"""Return access token for Supervisor API.""" """Return access token for Supervisor API."""
return self.persist.get(ATTR_ACCESS_TOKEN) return self.persist.get(ATTR_ACCESS_TOKEN)
@property @property
def ingress_token(self) -> str | None: def ingress_token(self) -> Optional[str]:
"""Return access token for Supervisor API.""" """Return access token for Supervisor API."""
return self.persist.get(ATTR_INGRESS_TOKEN) return self.persist.get(ATTR_INGRESS_TOKEN)
@property @property
def ingress_entry(self) -> str | None: def ingress_entry(self) -> Optional[str]:
"""Return ingress external URL.""" """Return ingress external URL."""
if self.with_ingress: if self.with_ingress:
return f"/api/hassio_ingress/{self.ingress_token}" return f"/api/hassio_ingress/{self.ingress_token}"
@@ -321,12 +283,12 @@ class Addon(AddonModel):
self.persist[ATTR_PROTECTED] = value self.persist[ATTR_PROTECTED] = value
@property @property
def ports(self) -> dict[str, int | None] | None: def ports(self) -> Optional[dict[str, Optional[int]]]:
"""Return ports of add-on.""" """Return ports of add-on."""
return self.persist.get(ATTR_NETWORK, super().ports) return self.persist.get(ATTR_NETWORK, super().ports)
@ports.setter @ports.setter
def ports(self, value: dict[str, int | None] | None) -> None: def ports(self, value: Optional[dict[str, Optional[int]]]) -> None:
"""Set custom ports of add-on.""" """Set custom ports of add-on."""
if value is None: if value is None:
self.persist.pop(ATTR_NETWORK, None) self.persist.pop(ATTR_NETWORK, None)
@@ -341,7 +303,7 @@ class Addon(AddonModel):
self.persist[ATTR_NETWORK] = new_ports self.persist[ATTR_NETWORK] = new_ports
@property @property
def ingress_url(self) -> str | None: def ingress_url(self) -> Optional[str]:
"""Return URL to ingress url.""" """Return URL to ingress url."""
if not self.with_ingress: if not self.with_ingress:
return None return None
@@ -352,7 +314,7 @@ class Addon(AddonModel):
return url return url
@property @property
def webui(self) -> str | None: def webui(self) -> Optional[str]:
"""Return URL to webui or None.""" """Return URL to webui or None."""
url = super().webui url = super().webui
if not url: if not url:
@@ -380,7 +342,7 @@ class Addon(AddonModel):
return f"{proto}://[HOST]:{port}{s_suffix}" return f"{proto}://[HOST]:{port}{s_suffix}"
@property @property
def ingress_port(self) -> int | None: def ingress_port(self) -> Optional[int]:
"""Return Ingress port.""" """Return Ingress port."""
if not self.with_ingress: if not self.with_ingress:
return None return None
@@ -391,11 +353,8 @@ class Addon(AddonModel):
return port return port
@property @property
def ingress_panel(self) -> bool | None: def ingress_panel(self) -> Optional[bool]:
"""Return True if the add-on access support ingress.""" """Return True if the add-on access support ingress."""
if not self.with_ingress:
return None
return self.persist[ATTR_INGRESS_PANEL] return self.persist[ATTR_INGRESS_PANEL]
@ingress_panel.setter @ingress_panel.setter
@@ -404,32 +363,43 @@ class Addon(AddonModel):
self.persist[ATTR_INGRESS_PANEL] = value self.persist[ATTR_INGRESS_PANEL] = value
@property @property
def audio_output(self) -> str | None: def audio_output(self) -> Optional[str]:
"""Return a pulse profile for output or None.""" """Return a pulse profile for output or None."""
if not self.with_audio: if not self.with_audio:
return None return None
return self.persist.get(ATTR_AUDIO_OUTPUT)
# Fallback with old audio settings
# Remove after 210
output_data = self.persist.get(ATTR_AUDIO_OUTPUT)
if output_data and RE_OLD_AUDIO.fullmatch(output_data):
return None
return output_data
@audio_output.setter @audio_output.setter
def audio_output(self, value: str | None): def audio_output(self, value: Optional[str]):
"""Set audio output profile settings.""" """Set audio output profile settings."""
self.persist[ATTR_AUDIO_OUTPUT] = value self.persist[ATTR_AUDIO_OUTPUT] = value
@property @property
def audio_input(self) -> str | None: def audio_input(self) -> Optional[str]:
"""Return pulse profile for input or None.""" """Return pulse profile for input or None."""
if not self.with_audio: if not self.with_audio:
return None return None
return self.persist.get(ATTR_AUDIO_INPUT) # Fallback with old audio settings
# Remove after 210
input_data = self.persist.get(ATTR_AUDIO_INPUT)
if input_data and RE_OLD_AUDIO.fullmatch(input_data):
return None
return input_data
@audio_input.setter @audio_input.setter
def audio_input(self, value: str | None) -> None: def audio_input(self, value: Optional[str]) -> None:
"""Set audio input settings.""" """Set audio input settings."""
self.persist[ATTR_AUDIO_INPUT] = value self.persist[ATTR_AUDIO_INPUT] = value
@property @property
def image(self) -> str | None: def image(self) -> Optional[str]:
"""Return image name of add-on.""" """Return image name of add-on."""
return self.persist.get(ATTR_IMAGE) return self.persist.get(ATTR_IMAGE)
@@ -438,11 +408,6 @@ class Addon(AddonModel):
"""Return True if this add-on need a local build.""" """Return True if this add-on need a local build."""
return ATTR_IMAGE not in self.data return ATTR_IMAGE not in self.data
@property
def latest_need_build(self) -> bool:
"""Return True if the latest version of the addon needs a local build."""
return ATTR_IMAGE not in self.data_store
@property @property
def path_data(self) -> Path: def path_data(self) -> Path:
"""Return add-on data path inside Supervisor.""" """Return add-on data path inside Supervisor."""
@@ -486,11 +451,6 @@ class Addon(AddonModel):
return options_schema.pwned return options_schema.pwned
@property
def loaded(self) -> bool:
"""Is add-on loaded."""
return bool(self._listeners)
def save_persist(self) -> None: def save_persist(self) -> None:
"""Save data of add-on.""" """Save data of add-on."""
self.sys_addons.data.save_data() self.sys_addons.data.save_data()
@@ -559,17 +519,8 @@ class Addon(AddonModel):
raise AddonConfigurationError() raise AddonConfigurationError()
async def unload(self) -> None: async def remove_data(self) -> None:
"""Unload add-on and remove data.""" """Remove add-on data."""
if self._startup_task:
# If we were waiting on startup, cancel that and let the task finish before proceeding
self._startup_task.cancel(f"Removing add-on {self.name} from system")
with suppress(asyncio.CancelledError):
await self._startup_task
for listener in self._listeners:
self.sys_bus.remove_listener(listener)
if not self.path_data.is_dir(): if not self.path_data.is_dir():
return return
@@ -655,32 +606,11 @@ class Addon(AddonModel):
return False return False
return True return True
async def _wait_for_startup(self) -> None: async def start(self) -> None:
"""Wait for startup event to be set with timeout.""" """Set options and start add-on."""
try:
self._startup_task = self.sys_create_task(self._startup_event.wait())
await asyncio.wait_for(self._startup_task, STARTUP_TIMEOUT)
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout while waiting for addon %s to start, took more than %s seconds",
self.name,
STARTUP_TIMEOUT,
)
except asyncio.CancelledError as err:
_LOGGER.info("Wait for addon startup task cancelled due to: %s", err)
finally:
self._startup_task = None
async def start(self) -> Awaitable[None]:
"""Set options and start add-on.
Returns a coroutine that completes when addon has state 'started'.
For addons with a healthcheck, that is when they become healthy or unhealthy.
Addons without a healthcheck have state 'started' immediately.
"""
if await self.instance.is_running(): if await self.instance.is_running():
_LOGGER.warning("%s is already running!", self.slug) _LOGGER.warning("%s is already running!", self.slug)
return self._wait_for_startup() return
# Access Token # Access Token
self.persist[ATTR_ACCESS_TOKEN] = secrets.token_hex(56) self.persist[ATTR_ACCESS_TOKEN] = secrets.token_hex(56)
@@ -694,32 +624,35 @@ class Addon(AddonModel):
self.write_pulse() self.write_pulse()
# Start Add-on # Start Add-on
self._startup_event.clear()
try: try:
await self.instance.run() await self.instance.run()
except DockerRequestError as err:
self.state = AddonState.ERROR
raise AddonsError() from err
except DockerError as err: except DockerError as err:
self.state = AddonState.ERROR self.state = AddonState.ERROR
raise AddonsError() from err raise AddonsError() from err
else:
return self._wait_for_startup() self.state = AddonState.STARTED
async def stop(self) -> None: async def stop(self) -> None:
"""Stop add-on.""" """Stop add-on."""
self._manual_stop = True
try: try:
await self.instance.stop() await self.instance.stop()
except DockerRequestError as err:
self.state = AddonState.ERROR
raise AddonsError() from err
except DockerError as err: except DockerError as err:
self.state = AddonState.ERROR self.state = AddonState.ERROR
raise AddonsError() from err raise AddonsError() from err
else:
self.state = AddonState.STOPPED
async def restart(self) -> Awaitable[None]: async def restart(self) -> None:
"""Restart add-on. """Restart add-on."""
Returns a coroutine that completes when addon has state 'started' (see start).
"""
with suppress(AddonsError): with suppress(AddonsError):
await self.stop() await self.stop()
return await self.start() await self.start()
def logs(self) -> Awaitable[bytes]: def logs(self) -> Awaitable[bytes]:
"""Return add-ons log output. """Return add-ons log output.
@@ -743,7 +676,10 @@ class Addon(AddonModel):
raise AddonsError() from err raise AddonsError() from err
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.
Return a coroutine.
"""
if not self.with_stdin: if not self.with_stdin:
raise AddonsNotSupportedError( raise AddonsNotSupportedError(
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
@@ -758,60 +694,20 @@ class Addon(AddonModel):
try: try:
command_return = await self.instance.run_inside(command) command_return = await self.instance.run_inside(command)
if command_return.exit_code != 0: if command_return.exit_code != 0:
_LOGGER.debug( _LOGGER.error(
"Pre-/Post backup command failed with: %s", command_return.output "Pre-/Post backup command returned error code: %s",
) command_return.exit_code,
raise AddonsError(
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
_LOGGER.error,
) )
raise AddonsError()
except DockerError as err: except DockerError as err:
raise AddonsError( _LOGGER.error(
f"Failed running pre-/post backup command {command}: {str(err)}", "Failed running pre-/post backup command %s: %s", command, err
_LOGGER.error, )
) from err raise AddonsError() from err
@Job(name="addon_begin_backup") async def backup(self, tar_file: tarfile.TarFile) -> None:
async def begin_backup(self) -> bool: """Backup state of an add-on."""
"""Execute pre commands or stop addon if necessary. is_running = await self.is_running()
Returns value of `is_running`. Caller should not call `end_backup` if return is false.
"""
if not await self.is_running():
return False
if self.backup_mode == AddonBackupMode.COLD:
_LOGGER.info("Shutdown add-on %s for cold backup", self.slug)
await self.stop()
elif self.backup_pre is not None:
await self._backup_command(self.backup_pre)
return True
@Job(name="addon_end_backup")
async def end_backup(self) -> Awaitable[None] | None:
"""Execute post commands or restart addon if necessary.
Returns a coroutine that completes when addon has state 'started' (see start)
for cold backup. Else nothing is returned.
"""
if self.backup_mode is AddonBackupMode.COLD:
_LOGGER.info("Starting add-on %s again", self.slug)
return await self.start()
if self.backup_post is not None:
await self._backup_command(self.backup_post)
return None
@Job(name="addon_backup")
async def backup(self, tar_file: tarfile.TarFile) -> Awaitable[None] | None:
"""Backup state of an add-on.
Returns a coroutine that completes when addon has state 'started' (see start)
for cold backup. Else nothing is returned.
"""
wait_for_start: Awaitable[None] | None = None
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp: with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
temp_path = Path(temp) temp_path = Path(temp)
@@ -827,7 +723,7 @@ class Addon(AddonModel):
ATTR_USER: self.persist, ATTR_USER: self.persist,
ATTR_SYSTEM: self.data, ATTR_SYSTEM: self.data,
ATTR_VERSION: self.version, ATTR_VERSION: self.version,
ATTR_STATE: _MAP_ADDON_STATE.get(self.state, self.state), ATTR_STATE: self.state,
} }
# Store local configs/state # Store local configs/state
@@ -852,7 +748,8 @@ class Addon(AddonModel):
def _write_tarfile(): def _write_tarfile():
"""Write tar inside loop.""" """Write tar inside loop."""
with tar_file as backup: with tar_file as backup:
# Backup metadata # Backup system
backup.add(temp, arcname=".") backup.add(temp, arcname=".")
# Backup data # Backup data
@@ -863,7 +760,16 @@ class Addon(AddonModel):
arcname="data", arcname="data",
) )
is_running = await self.begin_backup() if (
is_running
and self.backup_mode == AddonBackupMode.HOT
and self.backup_pre is not None
):
await self._backup_command(self.backup_pre)
elif is_running and self.backup_mode == AddonBackupMode.COLD:
_LOGGER.info("Shutdown add-on %s for cold backup", self.slug)
await self.instance.stop()
try: try:
_LOGGER.info("Building backup for add-on %s", self.slug) _LOGGER.info("Building backup for add-on %s", self.slug)
await self.sys_run_in_executor(_write_tarfile) await self.sys_run_in_executor(_write_tarfile)
@@ -872,19 +778,20 @@ class Addon(AddonModel):
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
) from err ) from err
finally: finally:
if is_running: if (
wait_for_start = await self.end_backup() is_running
and self.backup_mode == AddonBackupMode.HOT
and self.backup_post is not None
):
await self._backup_command(self.backup_post)
elif is_running and self.backup_mode is AddonBackupMode.COLD:
_LOGGER.info("Starting add-on %s again", self.slug)
await self.start()
_LOGGER.info("Finish backup for addon %s", self.slug) _LOGGER.info("Finish backup for addon %s", self.slug)
return wait_for_start
async def restore(self, tar_file: tarfile.TarFile) -> Awaitable[None] | None: async def restore(self, tar_file: tarfile.TarFile) -> None:
"""Restore state of an add-on. """Restore state of an add-on."""
Returns a coroutine that completes when addon has state 'started' (see start)
if addon is started after restore. Else nothing is returned.
"""
wait_for_start: Awaitable[None] | None = None
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp: with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
# extract backup # extract backup
def _extract_tarfile(): def _extract_tarfile():
@@ -909,10 +816,12 @@ class Addon(AddonModel):
try: try:
data = SCHEMA_ADDON_BACKUP(data) data = SCHEMA_ADDON_BACKUP(data)
except vol.Invalid as err: except vol.Invalid as err:
raise AddonsError( _LOGGER.error(
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}", "Can't validate %s, backup data: %s",
_LOGGER.error, self.slug,
) from err humanize_error(data, err),
)
raise AddonsError() from err
# If available # If available
if not self._available(data[ATTR_SYSTEM]): if not self._available(data[ATTR_SYSTEM]):
@@ -928,156 +837,59 @@ class Addon(AddonModel):
self.slug, data[ATTR_USER], data[ATTR_SYSTEM], restore_image self.slug, data[ATTR_USER], data[ATTR_SYSTEM], restore_image
) )
# Stop it first if its running # Check version / restore image
if await self.instance.is_running(): version = data[ATTR_VERSION]
await self.stop() if not await self.instance.exists():
_LOGGER.info("Restore/Install of image for addon %s", self.slug)
try: image_file = Path(temp, "image.tar")
# Check version / restore image if image_file.is_file():
version = data[ATTR_VERSION]
if not await self.instance.exists():
_LOGGER.info("Restore/Install of image for addon %s", self.slug)
image_file = Path(temp, "image.tar")
if image_file.is_file():
with suppress(DockerError):
await self.instance.import_image(image_file)
else:
with suppress(DockerError):
await self.instance.install(version, restore_image)
await self.instance.cleanup()
elif self.instance.version != version or self.legacy:
_LOGGER.info("Restore/Update of image for addon %s", self.slug)
with suppress(DockerError): with suppress(DockerError):
await self.instance.update(version, restore_image) await self.instance.import_image(image_file)
else:
with suppress(DockerError):
await self.instance.install(version, restore_image)
await self.instance.cleanup()
elif self.instance.version != version or self.legacy:
_LOGGER.info("Restore/Update of image for addon %s", self.slug)
with suppress(DockerError):
await self.instance.update(version, restore_image)
else:
with suppress(DockerError):
await self.instance.stop()
# Restore data # Restore data
def _restore_data(): def _restore_data():
"""Restore data.""" """Restore data."""
temp_data = Path(temp, "data") temp_data = Path(temp, "data")
if temp_data.is_dir(): if temp_data.is_dir():
shutil.copytree(temp_data, self.path_data, symlinks=True) shutil.copytree(temp_data, self.path_data, symlinks=True)
else: else:
self.path_data.mkdir() self.path_data.mkdir()
_LOGGER.info("Restoring data for addon %s", self.slug) _LOGGER.info("Restoring data for addon %s", self.slug)
if self.path_data.is_dir(): if self.path_data.is_dir():
await remove_data(self.path_data) await remove_data(self.path_data)
try:
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
raise AddonsError(
f"Can't restore origin data: {err}", _LOGGER.error
) from err
# Restore AppArmor
profile_file = Path(temp, "apparmor.txt")
if profile_file.exists():
try: try:
await self.sys_run_in_executor(_restore_data) await self.sys_host.apparmor.load_profile(self.slug, profile_file)
except shutil.Error as err: except HostAppArmorError as err:
raise AddonsError( _LOGGER.error(
f"Can't restore origin data: {err}", _LOGGER.error "Can't restore AppArmor profile for add-on %s", self.slug
) from err )
raise AddonsError() from err
# Restore AppArmor # Run add-on
profile_file = Path(temp, "apparmor.txt") if data[ATTR_STATE] == AddonState.STARTED:
if profile_file.exists(): return await self.start()
try:
await self.sys_host.apparmor.load_profile(
self.slug, profile_file
)
except HostAppArmorError as err:
_LOGGER.error(
"Can't restore AppArmor profile for add-on %s", self.slug
)
raise AddonsError() from err
# Is add-on loaded
if not self.loaded:
await self.load()
finally:
# Run add-on
if data[ATTR_STATE] == AddonState.STARTED:
wait_for_start = await self.start()
_LOGGER.info("Finished restore for add-on %s", self.slug) _LOGGER.info("Finished restore for add-on %s", self.slug)
return wait_for_start
def check_trust(self) -> Awaitable[None]:
"""Calculate Addon docker content trust.
Return Coroutine.
"""
return self.instance.check_trust()
@Job(
name="addon_restart_after_problem",
limit=JobExecutionLimit.GROUP_THROTTLE_RATE_LIMIT,
throttle_period=WATCHDOG_THROTTLE_PERIOD,
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
on_condition=AddonsJobError,
)
async def _restart_after_problem(self, state: ContainerState):
"""Restart unhealthy or failed addon."""
attempts = 0
while await self.instance.current_state() == state:
if not self.in_progress:
_LOGGER.warning(
"Watchdog found addon %s is %s, restarting...",
self.name,
state,
)
try:
if state == ContainerState.FAILED:
# Ensure failed container is removed before attempting reanimation
if attempts == 0:
with suppress(DockerError):
await self.instance.stop(remove_container=True)
await (await self.start())
else:
await (await self.restart())
except AddonsError as err:
attempts = attempts + 1
_LOGGER.error("Watchdog restart of addon %s failed!", self.name)
capture_exception(err)
else:
break
if attempts >= WATCHDOG_MAX_ATTEMPTS:
_LOGGER.critical(
"Watchdog cannot restart addon %s, failed all %s attempts",
self.name,
attempts,
)
break
await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
async def container_state_changed(self, event: DockerContainerStateEvent) -> None:
"""Set addon state from container state."""
if event.name != self.instance.name:
return
if event.state == ContainerState.RUNNING:
self._manual_stop = False
self.state = (
AddonState.STARTUP if self.instance.healthcheck else AddonState.STARTED
)
elif event.state in [
ContainerState.HEALTHY,
ContainerState.UNHEALTHY,
]:
self.state = AddonState.STARTED
elif event.state == ContainerState.STOPPED:
self.state = AddonState.STOPPED
elif event.state == ContainerState.FAILED:
self.state = AddonState.ERROR
async def watchdog_container(self, event: DockerContainerStateEvent) -> None:
"""Process state changes in addon container and restart if necessary."""
if event.name != self.instance.name:
return
# Skip watchdog if not enabled or manual stopped
if not self.watchdog or self._manual_stop:
return
if event.state in [
ContainerState.FAILED,
ContainerState.STOPPED,
ContainerState.UNHEALTHY,
]:
await self._restart_after_problem(event.state)

View File

@@ -1,7 +1,6 @@
"""Supervisor add-on build environment.""" """Supervisor add-on build environment."""
from __future__ import annotations from __future__ import annotations
from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -16,8 +15,7 @@ from ..const import (
META_ADDON, META_ADDON,
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..docker.interface import MAP_ARCH from ..exceptions import ConfigurationFileError
from ..exceptions import 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
@@ -46,33 +44,15 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Ignore save function.""" """Ignore save function."""
raise RuntimeError() raise RuntimeError()
@cached_property
def arch(self) -> str:
"""Return arch of the add-on."""
return self.sys_arch.match(self.addon.arch)
@property @property
def base_image(self) -> str: def base_image(self) -> str:
"""Return base image for this add-on.""" """Return base image for this add-on."""
if not self._data[ATTR_BUILD_FROM]: if not self._data[ATTR_BUILD_FROM]:
return f"ghcr.io/home-assistant/{self.sys_arch.default}-base:latest" return f"ghcr.io/home-assistant/{self.sys_arch.default}-base:latest"
if isinstance(self._data[ATTR_BUILD_FROM], str):
return self._data[ATTR_BUILD_FROM]
# Evaluate correct base image # Evaluate correct base image
if self.arch not in self._data[ATTR_BUILD_FROM]: arch = self.sys_arch.match(list(self._data[ATTR_BUILD_FROM].keys()))
raise HassioArchNotFound( return self._data[ATTR_BUILD_FROM][arch]
f"Add-on {self.addon.slug} is not supported on {self.arch}"
)
return self._data[ATTR_BUILD_FROM][self.arch]
@property
def dockerfile(self) -> Path:
"""Return Dockerfile path."""
if self.addon.path_location.joinpath(f"Dockerfile.{self.arch}").exists():
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
return self.addon.path_location.joinpath("Dockerfile")
@property @property
def squash(self) -> bool: def squash(self) -> bool:
@@ -92,29 +72,24 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:
"""Return true if the build env is valid.""" """Return true if the build env is valid."""
try: return all(
return all( [
[ self.addon.path_location.is_dir(),
self.addon.path_location.is_dir(), Path(self.addon.path_location, "Dockerfile").is_file(),
self.dockerfile.is_file(), ]
] )
)
except HassioArchNotFound:
return False
def get_docker_args(self, version: AwesomeVersion): def get_docker_args(self, version: AwesomeVersion):
"""Create a dict with Docker build arguments.""" """Create a dict with Docker build arguments."""
args = { args = {
"path": str(self.addon.path_location), "path": str(self.addon.path_location),
"tag": f"{self.addon.image}:{version!s}", "tag": f"{self.addon.image}:{version!s}",
"dockerfile": str(self.dockerfile),
"pull": True, "pull": True,
"forcerm": not self.sys_dev, "forcerm": not self.sys_dev,
"squash": self.squash, "squash": self.squash,
"platform": MAP_ARCH[self.arch],
"labels": { "labels": {
"io.hass.version": version, "io.hass.version": version,
"io.hass.arch": self.arch, "io.hass.arch": self.sys_arch.default,
"io.hass.type": META_ADDON, "io.hass.type": META_ADDON,
"io.hass.name": self._fix_label("name"), "io.hass.name": self._fix_label("name"),
"io.hass.description": self._fix_label("description"), "io.hass.description": self._fix_label("description"),

View File

@@ -1,11 +1,8 @@
"""Add-on static data.""" """Add-on static data."""
from datetime import timedelta from enum import Enum
from enum import StrEnum
from ..jobs.const import JobCondition
class AddonBackupMode(StrEnum): class AddonBackupMode(str, Enum):
"""Backup mode of an Add-on.""" """Backup mode of an Add-on."""
HOT = "hot" HOT = "hot"
@@ -13,18 +10,3 @@ class AddonBackupMode(StrEnum):
ATTR_BACKUP = "backup" ATTR_BACKUP = "backup"
ATTR_CODENOTARY = "codenotary"
WATCHDOG_RETRY_SECONDS = 10
WATCHDOG_MAX_ATTEMPTS = 5
WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30)
WATCHDOG_THROTTLE_MAX_CALLS = 10
ADDON_UPDATE_CONDITIONS = [
JobCondition.FREE_SPACE,
JobCondition.HEALTHY,
JobCondition.INTERNET_HOST,
JobCondition.PLUGINS_UPDATED,
JobCondition.SUPERVISOR_UPDATED,
]
RE_SLUG = r"[-_.A-Za-z0-9]+"

View File

@@ -1,14 +1,12 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Awaitable, Callable
from contextlib import suppress
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Awaitable, Optional
from awesomeversion import AwesomeVersion, AwesomeVersionException from awesomeversion import AwesomeVersion, AwesomeVersionException
from supervisor.addons.const import AddonBackupMode
from ..const import ( from ..const import (
ATTR_ADVANCED, ATTR_ADVANCED,
ATTR_APPARMOR, ATTR_APPARMOR,
@@ -35,7 +33,6 @@ from ..const import (
ATTR_HOST_IPC, ATTR_HOST_IPC,
ATTR_HOST_NETWORK, ATTR_HOST_NETWORK,
ATTR_HOST_PID, ATTR_HOST_PID,
ATTR_HOST_UTS,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_INGRESS, ATTR_INGRESS,
ATTR_INGRESS_STREAM, ATTR_INGRESS_STREAM,
@@ -80,28 +77,21 @@ from ..const import (
AddonStage, AddonStage,
AddonStartup, AddonStartup,
) )
from ..coresys import CoreSys from ..coresys import CoreSys, CoreSysAttributes
from ..docker.const import Capabilities from ..docker.const import Capabilities
from ..exceptions import AddonsNotSupportedError from .const import ATTR_BACKUP
from ..jobs.const import JOB_GROUP_ADDON
from ..jobs.job_group import JobGroup
from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode
from .options import AddonOptions, UiOptions from .options import AddonOptions, UiOptions
from .validate import RE_SERVICE, RE_VOLUME from .validate import RE_SERVICE, RE_VOLUME
_LOGGER: logging.Logger = logging.getLogger(__name__)
Data = dict[str, Any] Data = dict[str, Any]
class AddonModel(JobGroup, ABC): class AddonModel(CoreSysAttributes, ABC):
"""Add-on Data layout.""" """Add-on Data layout."""
def __init__(self, coresys: CoreSys, slug: str): def __init__(self, coresys: CoreSys, slug: str):
"""Initialize data holder.""" """Initialize data holder."""
super().__init__( self.coresys: CoreSys = coresys
coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug
)
self.slug: str = slug self.slug: str = slug
@property @property
@@ -135,7 +125,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_BOOT] return self.data[ATTR_BOOT]
@property @property
def auto_update(self) -> bool | None: def auto_update(self) -> Optional[bool]:
"""Return if auto update is enable.""" """Return if auto update is enable."""
return None return None
@@ -160,22 +150,22 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_TIMEOUT] return self.data[ATTR_TIMEOUT]
@property @property
def uuid(self) -> str | None: def uuid(self) -> Optional[str]:
"""Return an API token for this add-on.""" """Return an API token for this add-on."""
return None return None
@property @property
def supervisor_token(self) -> str | None: def supervisor_token(self) -> Optional[str]:
"""Return access token for Supervisor API.""" """Return access token for Supervisor API."""
return None return None
@property @property
def ingress_token(self) -> str | None: def ingress_token(self) -> Optional[str]:
"""Return access token for Supervisor API.""" """Return access token for Supervisor API."""
return None return None
@property @property
def ingress_entry(self) -> str | None: def ingress_entry(self) -> Optional[str]:
"""Return ingress external URL.""" """Return ingress external URL."""
return None return None
@@ -185,7 +175,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_DESCRIPTON] return self.data[ATTR_DESCRIPTON]
@property @property
def long_description(self) -> str | None: def long_description(self) -> Optional[str]:
"""Return README.md as long_description.""" """Return README.md as long_description."""
readme = Path(self.path_location, "README.md") readme = Path(self.path_location, "README.md")
@@ -255,32 +245,32 @@ class AddonModel(JobGroup, ABC):
return self.data.get(ATTR_DISCOVERY, []) return self.data.get(ATTR_DISCOVERY, [])
@property @property
def ports_description(self) -> dict[str, str] | None: def ports_description(self) -> Optional[dict[str, str]]:
"""Return descriptions of ports.""" """Return descriptions of ports."""
return self.data.get(ATTR_PORTS_DESCRIPTION) return self.data.get(ATTR_PORTS_DESCRIPTION)
@property @property
def ports(self) -> dict[str, int | None] | None: def ports(self) -> Optional[dict[str, Optional[int]]]:
"""Return ports of add-on.""" """Return ports of add-on."""
return self.data.get(ATTR_PORTS) return self.data.get(ATTR_PORTS)
@property @property
def ingress_url(self) -> str | None: def ingress_url(self) -> Optional[str]:
"""Return URL to ingress url.""" """Return URL to ingress url."""
return None return None
@property @property
def webui(self) -> str | None: def webui(self) -> Optional[str]:
"""Return URL to webui or None.""" """Return URL to webui or None."""
return self.data.get(ATTR_WEBUI) return self.data.get(ATTR_WEBUI)
@property @property
def watchdog(self) -> str | None: def watchdog(self) -> Optional[str]:
"""Return URL to for watchdog or None.""" """Return URL to for watchdog or None."""
return self.data.get(ATTR_WATCHDOG) return self.data.get(ATTR_WATCHDOG)
@property @property
def ingress_port(self) -> int | None: def ingress_port(self) -> Optional[int]:
"""Return Ingress port.""" """Return Ingress port."""
return None return None
@@ -314,11 +304,6 @@ class AddonModel(JobGroup, ABC):
"""Return True if add-on run on host IPC namespace.""" """Return True if add-on run on host IPC namespace."""
return self.data[ATTR_HOST_IPC] return self.data[ATTR_HOST_IPC]
@property
def host_uts(self) -> bool:
"""Return True if add-on run on host UTS namespace."""
return self.data[ATTR_HOST_UTS]
@property @property
def host_dbus(self) -> bool: def host_dbus(self) -> bool:
"""Return True if add-on run on host D-BUS.""" """Return True if add-on run on host D-BUS."""
@@ -330,7 +315,7 @@ class AddonModel(JobGroup, ABC):
return [Path(node) for node in self.data.get(ATTR_DEVICES, [])] return [Path(node) for node in self.data.get(ATTR_DEVICES, [])]
@property @property
def environment(self) -> dict[str, str] | None: def environment(self) -> Optional[dict[str, str]]:
"""Return environment of add-on.""" """Return environment of add-on."""
return self.data.get(ATTR_ENVIRONMENT) return self.data.get(ATTR_ENVIRONMENT)
@@ -379,12 +364,12 @@ class AddonModel(JobGroup, ABC):
return self.data.get(ATTR_BACKUP_EXCLUDE, []) return self.data.get(ATTR_BACKUP_EXCLUDE, [])
@property @property
def backup_pre(self) -> str | None: def backup_pre(self) -> Optional[str]:
"""Return pre-backup command.""" """Return pre-backup command."""
return self.data.get(ATTR_BACKUP_PRE) return self.data.get(ATTR_BACKUP_PRE)
@property @property
def backup_post(self) -> str | None: def backup_post(self) -> Optional[str]:
"""Return post-backup command.""" """Return post-backup command."""
return self.data.get(ATTR_BACKUP_POST) return self.data.get(ATTR_BACKUP_POST)
@@ -409,7 +394,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_INGRESS] return self.data[ATTR_INGRESS]
@property @property
def ingress_panel(self) -> bool | None: def ingress_panel(self) -> Optional[bool]:
"""Return True if the add-on access support ingress.""" """Return True if the add-on access support ingress."""
return None return None
@@ -459,7 +444,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_DEVICETREE] return self.data[ATTR_DEVICETREE]
@property @property
def with_tmpfs(self) -> str | None: def with_tmpfs(self) -> Optional[str]:
"""Return if tmp is in memory of add-on.""" """Return if tmp is in memory of add-on."""
return self.data[ATTR_TMPFS] return self.data[ATTR_TMPFS]
@@ -479,12 +464,12 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_VIDEO] return self.data[ATTR_VIDEO]
@property @property
def homeassistant_version(self) -> str | None: def homeassistant_version(self) -> Optional[str]:
"""Return min Home Assistant version they needed by Add-on.""" """Return min Home Assistant version they needed by Add-on."""
return self.data.get(ATTR_HOMEASSISTANT) return self.data.get(ATTR_HOMEASSISTANT)
@property @property
def url(self) -> str | None: def url(self) -> Optional[str]:
"""Return URL of add-on.""" """Return URL of add-on."""
return self.data.get(ATTR_URL) return self.data.get(ATTR_URL)
@@ -519,15 +504,7 @@ class AddonModel(JobGroup, ABC):
return self.data.get(ATTR_MACHINE, []) return self.data.get(ATTR_MACHINE, [])
@property @property
def arch(self) -> str: def image(self) -> Optional[str]:
"""Return architecture to use for the addon's image."""
if ATTR_IMAGE in self.data:
return self.sys_arch.match(self.data[ATTR_ARCH])
return self.sys_arch.default
@property
def image(self) -> str | None:
"""Generate image name from data.""" """Generate image name from data."""
return self._image(self.data) return self._image(self.data)
@@ -537,14 +514,14 @@ class AddonModel(JobGroup, ABC):
return ATTR_IMAGE not in self.data return ATTR_IMAGE not in self.data
@property @property
def map_volumes(self) -> dict[str, bool]: def map_volumes(self) -> dict[str, str]:
"""Return a dict of {volume: read-only} from add-on.""" """Return a dict of {volume: policy} from add-on."""
volumes = {} volumes = {}
for volume in self.data[ATTR_MAP]: for volume in self.data[ATTR_MAP]:
result = RE_VOLUME.match(volume) result = RE_VOLUME.match(volume)
if not result: if not result:
continue continue
volumes[result.group(1)] = result.group(2) != "rw" volumes[result.group(1)] = result.group(2) or "ro"
return volumes return volumes
@@ -588,7 +565,7 @@ class AddonModel(JobGroup, ABC):
return AddonOptions(self.coresys, raw_schema, self.name, self.slug) return AddonOptions(self.coresys, raw_schema, self.name, self.slug)
@property @property
def schema_ui(self) -> list[dict[any, any]] | None: def schema_ui(self) -> Optional[list[dict[any, any]]]:
"""Create a UI schema for add-on options.""" """Create a UI schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA] raw_schema = self.data[ATTR_SCHEMA]
@@ -601,64 +578,31 @@ class AddonModel(JobGroup, ABC):
"""Return True if the add-on accesses the system journal.""" """Return True if the add-on accesses the system journal."""
return self.data[ATTR_JOURNALD] return self.data[ATTR_JOURNALD]
@property
def signed(self) -> bool:
"""Return True if the image is signed."""
return ATTR_CODENOTARY in self.data
@property
def codenotary(self) -> str | None:
"""Return Signer email address for CAS."""
return self.data.get(ATTR_CODENOTARY)
def validate_availability(self) -> None:
"""Validate if addon is available for current system."""
return self._validate_availability(self.data, logger=_LOGGER.error)
def __eq__(self, other): def __eq__(self, other):
"""Compaired add-on objects.""" """Compaired add-on objects."""
if not isinstance(other, AddonModel): if not isinstance(other, AddonModel):
return False return False
return self.slug == other.slug return self.slug == other.slug
def _validate_availability( def _available(self, config) -> bool:
self, config, *, logger: Callable[..., None] | None = None """Return True if this add-on is available on this platform."""
) -> None:
"""Validate if addon is available for current system."""
# Architecture # Architecture
if not self.sys_arch.is_supported(config[ATTR_ARCH]): if not self.sys_arch.is_supported(config[ATTR_ARCH]):
raise AddonsNotSupportedError( return False
f"Add-on {self.slug} not supported on this platform, supported architectures: {', '.join(config[ATTR_ARCH])}",
logger,
)
# Machine / Hardware # Machine / Hardware
machine = config.get(ATTR_MACHINE) machine = config.get(ATTR_MACHINE)
if machine and ( if machine and f"!{self.sys_machine}" in machine:
f"!{self.sys_machine}" in machine or self.sys_machine not in machine return False
): elif machine and self.sys_machine not in machine:
raise AddonsNotSupportedError(
f"Add-on {self.slug} not supported on this machine, supported machine types: {', '.join(machine)}",
logger,
)
# Home Assistant
version: AwesomeVersion | None = config.get(ATTR_HOMEASSISTANT)
with suppress(AwesomeVersionException, TypeError):
if self.sys_homeassistant.version < version:
raise AddonsNotSupportedError(
f"Add-on {self.slug} not supported on this system, requires Home Assistant version {version} or greater",
logger,
)
def _available(self, config) -> bool:
"""Return True if this add-on is available on this platform."""
try:
self._validate_availability(config)
except AddonsNotSupportedError:
return False return False
return True # Home Assistant
version: Optional[AwesomeVersion] = config.get(ATTR_HOMEASSISTANT)
try:
return self.sys_homeassistant.version >= version
except (AwesomeVersionException, TypeError):
return True
def _image(self, config) -> str: def _image(self, config) -> str:
"""Generate image name from data.""" """Generate image name from data."""
@@ -678,10 +622,10 @@ class AddonModel(JobGroup, ABC):
"""Uninstall this add-on.""" """Uninstall this add-on."""
return self.sys_addons.uninstall(self.slug) return self.sys_addons.uninstall(self.slug)
def update(self, backup: bool | None = False) -> Awaitable[Awaitable[None] | None]: def update(self, backup: Optional[bool] = False) -> Awaitable[None]:
"""Update this add-on.""" """Update this add-on."""
return self.sys_addons.update(self.slug, backup=backup) return self.sys_addons.update(self.slug, backup=backup)
def rebuild(self) -> Awaitable[Awaitable[None] | None]: def rebuild(self) -> Awaitable[None]:
"""Rebuild this add-on.""" """Rebuild this add-on."""
return self.sys_addons.rebuild(self.slug) return self.sys_addons.rebuild(self.slug)

View File

@@ -3,7 +3,7 @@ import hashlib
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
from typing import Any from typing import Any, Union
import voluptuous as vol import voluptuous as vol
@@ -293,7 +293,7 @@ class UiOptions(CoreSysAttributes):
multiple: bool = False, multiple: bool = False,
) -> None: ) -> None:
"""Validate a single element.""" """Validate a single element."""
ui_node: dict[str, str | bool | float | list[str]] = {"name": key} ui_node: dict[str, Union[str, bool, float, list[str]]] = {"name": key}
# If multiple # If multiple
if multiple: if multiple:

View File

@@ -16,10 +16,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
def rating_security(addon: AddonModel) -> int: def rating_security(addon: AddonModel) -> int:
"""Return 1-8 for security rating. """Return 1-6 for security rating.
1 = not secure 1 = not secure
8 = high secure 6 = high secure
""" """
rating = 5 rating = 5
@@ -35,24 +35,17 @@ def rating_security(addon: AddonModel) -> int:
elif addon.access_auth_api: elif addon.access_auth_api:
rating += 1 rating += 1
# Signed
if addon.signed:
rating += 1
# Privileged options # Privileged options
if ( if (
any( any(
privilege in addon.privileged privilege in addon.privileged
for privilege in ( for privilege in (
Capabilities.BPF,
Capabilities.DAC_READ_SEARCH,
Capabilities.NET_ADMIN, Capabilities.NET_ADMIN,
Capabilities.NET_RAW,
Capabilities.PERFMON,
Capabilities.SYS_ADMIN, Capabilities.SYS_ADMIN,
Capabilities.SYS_MODULE,
Capabilities.SYS_PTRACE,
Capabilities.SYS_RAWIO, Capabilities.SYS_RAWIO,
Capabilities.SYS_PTRACE,
Capabilities.SYS_MODULE,
Capabilities.DAC_READ_SEARCH,
) )
) )
or addon.with_kernel_modules or addon.with_kernel_modules
@@ -73,15 +66,11 @@ def rating_security(addon: AddonModel) -> int:
if addon.host_pid: if addon.host_pid:
rating += -2 rating += -2
# UTS host namespace allows to set hostname only with SYS_ADMIN
if addon.host_uts and Capabilities.SYS_ADMIN in addon.privileged:
rating += -1
# Docker Access & full Access # Docker Access & full Access
if addon.access_docker_api or addon.with_full_access: if addon.access_docker_api or addon.with_full_access:
rating = 1 rating = 1
return max(min(8, rating), 1) return max(min(6, rating), 1)
async def remove_data(folder: Path) -> None: async def remove_data(folder: Path) -> None:

View File

@@ -7,6 +7,8 @@ import uuid
import voluptuous as vol import voluptuous as vol
from supervisor.addons.const import AddonBackupMode
from ..const import ( from ..const import (
ARCH_ALL, ARCH_ALL,
ATTR_ACCESS_TOKEN, ATTR_ACCESS_TOKEN,
@@ -41,7 +43,6 @@ from ..const import (
ATTR_HOST_IPC, ATTR_HOST_IPC,
ATTR_HOST_NETWORK, ATTR_HOST_NETWORK,
ATTR_HOST_PID, ATTR_HOST_PID,
ATTR_HOST_UTS,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_INGRESS, ATTR_INGRESS,
ATTR_INGRESS_ENTRY, ATTR_INGRESS_ENTRY,
@@ -109,7 +110,7 @@ from ..validate import (
uuid_match, uuid_match,
version_tag, version_tag,
) )
from .const import ATTR_BACKUP, ATTR_CODENOTARY, RE_SLUG, AddonBackupMode from .const import ATTR_BACKUP
from .options import RE_SCHEMA_ELEMENT from .options import RE_SCHEMA_ELEMENT
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -130,7 +131,6 @@ RE_MACHINE = re.compile(
r"|generic-x86-64" r"|generic-x86-64"
r"|odroid-c2" r"|odroid-c2"
r"|odroid-c4" r"|odroid-c4"
r"|odroid-m1"
r"|odroid-n2" r"|odroid-n2"
r"|odroid-xu" r"|odroid-xu"
r"|qemuarm-64" r"|qemuarm-64"
@@ -143,14 +143,10 @@ RE_MACHINE = re.compile(
r"|raspberrypi3" r"|raspberrypi3"
r"|raspberrypi4-64" r"|raspberrypi4-64"
r"|raspberrypi4" r"|raspberrypi4"
r"|yellow"
r"|green"
r"|tinker" r"|tinker"
r")$" r")$"
) )
RE_SLUG_FIELD = re.compile(r"^" + RE_SLUG + r"$")
def _warn_addon_config(config: dict[str, Any]): def _warn_addon_config(config: dict[str, Any]):
"""Warn about miss configs.""" """Warn about miss configs."""
@@ -177,20 +173,6 @@ def _warn_addon_config(config: dict[str, Any]):
name, name,
) )
invalid_services: list[str] = []
for service in config.get(ATTR_DISCOVERY, []):
try:
valid_discovery_service(service)
except vol.Invalid:
invalid_services.append(service)
if invalid_services:
_LOGGER.warning(
"Add-on lists the following unknown services for discovery: %s. Please report this to the maintainer of %s",
", ".join(invalid_services),
name,
)
return config return config
@@ -212,9 +194,9 @@ def _migrate_addon_config(protocol=False):
name, name,
) )
if value == "before": if value == "before":
config[ATTR_STARTUP] = AddonStartup.SERVICES config[ATTR_STARTUP] = AddonStartup.SERVICES.value
elif value == "after": elif value == "after":
config[ATTR_STARTUP] = AddonStartup.APPLICATION config[ATTR_STARTUP] = AddonStartup.APPLICATION.value
# UART 2021-01-20 # UART 2021-01-20
if "auto_uart" in config: if "auto_uart" in config:
@@ -270,7 +252,7 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
{ {
vol.Required(ATTR_NAME): str, vol.Required(ATTR_NAME): str,
vol.Required(ATTR_VERSION): version_tag, vol.Required(ATTR_VERSION): version_tag,
vol.Required(ATTR_SLUG): vol.Match(RE_SLUG_FIELD), vol.Required(ATTR_SLUG): str,
vol.Required(ATTR_DESCRIPTON): str, vol.Required(ATTR_DESCRIPTON): str,
vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)], vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)],
vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()), vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()),
@@ -303,7 +285,6 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_UTS, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(),
vol.Optional(ATTR_DEVICES): [str], vol.Optional(ATTR_DEVICES): [str],
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(), vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
@@ -329,14 +310,13 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(), vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(),
vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(), vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(),
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)], vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
vol.Optional(ATTR_DISCOVERY): [str], vol.Optional(ATTR_DISCOVERY): [valid_discovery_service],
vol.Optional(ATTR_BACKUP_EXCLUDE): [str], vol.Optional(ATTR_BACKUP_EXCLUDE): [str],
vol.Optional(ATTR_BACKUP_PRE): str, vol.Optional(ATTR_BACKUP_PRE): str,
vol.Optional(ATTR_BACKUP_POST): str, vol.Optional(ATTR_BACKUP_POST): str,
vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce( vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce(
AddonBackupMode AddonBackupMode
), ),
vol.Optional(ATTR_CODENOTARY): vol.Email(),
vol.Optional(ATTR_OPTIONS, default={}): dict, vol.Optional(ATTR_OPTIONS, default={}): dict,
vol.Optional(ATTR_SCHEMA, default={}): vol.Any( vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
vol.Schema( vol.Schema(
@@ -372,9 +352,8 @@ SCHEMA_ADDON_CONFIG = vol.All(
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_BUILD_CONFIG = vol.Schema( SCHEMA_BUILD_CONFIG = vol.Schema(
{ {
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Any( vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Schema(
vol.Match(RE_DOCKER_IMAGE_BUILD), {vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}
vol.Schema({vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}),
), ),
vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(), vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}), vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}),

View File

@@ -1,14 +1,11 @@
"""Init file for Supervisor RESTful API.""" """Init file for Supervisor RESTful API."""
from functools import partial
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Optional
from aiohttp import web from aiohttp import web
from ..const import AddonState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIAddonNotInstalled
from .addons import APIAddons from .addons import APIAddons
from .audio import APIAudio from .audio import APIAudio
from .auth import APIAuth from .auth import APIAuth
@@ -20,28 +17,25 @@ from .docker import APIDocker
from .hardware import APIHardware from .hardware import APIHardware
from .homeassistant import APIHomeAssistant from .homeassistant import APIHomeAssistant
from .host import APIHost from .host import APIHost
from .info import APIInfo
from .ingress import APIIngress from .ingress import APIIngress
from .jobs import APIJobs from .jobs import APIJobs
from .middleware.security import SecurityMiddleware from .middleware.security import SecurityMiddleware
from .mounts import APIMounts
from .multicast import APIMulticast from .multicast import APIMulticast
from .network import APINetwork from .network import APINetwork
from .observer import APIObserver from .observer import APIObserver
from .os import APIOS from .os import APIOS
from .proxy import APIProxy from .proxy import APIProxy
from .resolution import APIResoulution from .resolution import APIResoulution
from .root import APIRoot
from .security import APISecurity from .security import APISecurity
from .services import APIServices from .services import APIServices
from .store import APIStore from .store import APIStore
from .supervisor import APISupervisor from .supervisor import APISupervisor
from .utils import api_process
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
MAX_CLIENT_SIZE: int = 1024**2 * 16 MAX_CLIENT_SIZE: int = 1024 ** 2 * 16
MAX_LINE_SIZE: int = 24570
class RestAPI(CoreSysAttributes): class RestAPI(CoreSysAttributes):
@@ -54,20 +48,14 @@ class RestAPI(CoreSysAttributes):
self.webapp: web.Application = web.Application( self.webapp: web.Application = web.Application(
client_max_size=MAX_CLIENT_SIZE, client_max_size=MAX_CLIENT_SIZE,
middlewares=[ middlewares=[
self.security.block_bad_requests,
self.security.system_validation, self.security.system_validation,
self.security.token_validation, self.security.token_validation,
self.security.core_proxy,
], ],
handler_args={
"max_line_size": MAX_LINE_SIZE,
"max_field_size": MAX_LINE_SIZE,
},
) )
# service stuff # service stuff
self._runner: web.AppRunner = web.AppRunner(self.webapp) self._runner: web.AppRunner = web.AppRunner(self.webapp)
self._site: web.TCPSite | None = None self._site: Optional[web.TCPSite] = None
async def load(self) -> None: async def load(self) -> None:
"""Register REST API Calls.""" """Register REST API Calls."""
@@ -82,21 +70,20 @@ class RestAPI(CoreSysAttributes):
self._register_hardware() self._register_hardware()
self._register_homeassistant() self._register_homeassistant()
self._register_host() self._register_host()
self._register_jobs() self._register_info()
self._register_ingress() self._register_ingress()
self._register_mounts()
self._register_multicast() self._register_multicast()
self._register_network() self._register_network()
self._register_observer() self._register_observer()
self._register_os() self._register_os()
self._register_jobs()
self._register_panel() self._register_panel()
self._register_proxy() self._register_proxy()
self._register_resolution() self._register_resolution()
self._register_root()
self._register_security()
self._register_services() self._register_services()
self._register_store()
self._register_supervisor() self._register_supervisor()
self._register_store()
self._register_security()
await self.start() await self.start()
@@ -108,36 +95,16 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/host/info", api_host.info), web.get("/host/info", api_host.info),
web.get("/host/logs", api_host.advanced_logs), web.get("/host/logs", api_host.logs),
web.get(
"/host/logs/follow",
partial(api_host.advanced_logs, follow=True),
),
web.get("/host/logs/identifiers", api_host.list_identifiers),
web.get("/host/logs/identifiers/{identifier}", api_host.advanced_logs),
web.get(
"/host/logs/identifiers/{identifier}/follow",
partial(api_host.advanced_logs, follow=True),
),
web.get("/host/logs/boots", api_host.list_boots),
web.get("/host/logs/boots/{bootid}", api_host.advanced_logs),
web.get(
"/host/logs/boots/{bootid}/follow",
partial(api_host.advanced_logs, follow=True),
),
web.get(
"/host/logs/boots/{bootid}/identifiers/{identifier}",
api_host.advanced_logs,
),
web.get(
"/host/logs/boots/{bootid}/identifiers/{identifier}/follow",
partial(api_host.advanced_logs, follow=True),
),
web.post("/host/reboot", api_host.reboot), web.post("/host/reboot", api_host.reboot),
web.post("/host/shutdown", api_host.shutdown), web.post("/host/shutdown", api_host.shutdown),
web.post("/host/reload", api_host.reload), web.post("/host/reload", api_host.reload),
web.post("/host/options", api_host.options), web.post("/host/options", api_host.options),
web.get("/host/services", api_host.services), web.get("/host/services", api_host.services),
web.post("/host/services/{service}/stop", api_host.service_stop),
web.post("/host/services/{service}/start", api_host.service_start),
web.post("/host/services/{service}/restart", api_host.service_restart),
web.post("/host/services/{service}/reload", api_host.service_reload),
] ]
) )
@@ -183,17 +150,6 @@ class RestAPI(CoreSysAttributes):
] ]
) )
# Boards endpoints
self.webapp.add_routes(
[
web.get("/os/boards/green", api_os.boards_green_info),
web.post("/os/boards/green", api_os.boards_green_options),
web.get("/os/boards/yellow", api_os.boards_yellow_info),
web.post("/os/boards/yellow", api_os.boards_yellow_options),
web.get("/os/boards/{board}", api_os.boards_other_info),
]
)
def _register_security(self) -> None: def _register_security(self) -> None:
"""Register Security functions.""" """Register Security functions."""
api_security = APISecurity() api_security = APISecurity()
@@ -203,7 +159,6 @@ class RestAPI(CoreSysAttributes):
[ [
web.get("/security/info", api_security.info), web.get("/security/info", api_security.info),
web.post("/security/options", api_security.options), web.post("/security/options", api_security.options),
web.post("/security/integrity", api_security.integrity_check),
] ]
) )
@@ -273,21 +228,12 @@ class RestAPI(CoreSysAttributes):
] ]
) )
def _register_root(self) -> None: def _register_info(self) -> None:
"""Register root functions.""" """Register info functions."""
api_root = APIRoot() api_info = APIInfo()
api_root.coresys = self.coresys api_info.coresys = self.coresys
self.webapp.add_routes([web.get("/info", api_root.info)]) self.webapp.add_routes([web.get("/info", api_info.info)])
self.webapp.add_routes([web.post("/refresh_updates", api_root.refresh_updates)])
self.webapp.add_routes(
[web.get("/available_updates", api_root.available_updates)]
)
# Remove: 2023
self.webapp.add_routes(
[web.get("/supervisor/available_updates", api_root.available_updates)]
)
def _register_resolution(self) -> None: def _register_resolution(self) -> None:
"""Register info functions.""" """Register info functions."""
@@ -313,10 +259,6 @@ class RestAPI(CoreSysAttributes):
"/resolution/issue/{issue}", "/resolution/issue/{issue}",
api_resolution.dismiss_issue, api_resolution.dismiss_issue,
), ),
web.get(
"/resolution/issue/{issue}/suggestions",
api_resolution.suggestions_for_issue,
),
web.post("/resolution/healthcheck", api_resolution.healthcheck), web.post("/resolution/healthcheck", api_resolution.healthcheck),
] ]
) )
@@ -342,6 +284,10 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get(
"/supervisor/available_updates", api_supervisor.available_updates
),
web.post("/refresh_updates", api_supervisor.reload),
web.get("/supervisor/ping", api_supervisor.ping), web.get("/supervisor/ping", api_supervisor.ping),
web.get("/supervisor/info", api_supervisor.info), web.get("/supervisor/info", api_supervisor.info),
web.get("/supervisor/stats", api_supervisor.stats), web.get("/supervisor/stats", api_supervisor.stats),
@@ -371,22 +317,17 @@ class RestAPI(CoreSysAttributes):
web.post("/core/start", api_hass.start), web.post("/core/start", api_hass.start),
web.post("/core/check", api_hass.check), web.post("/core/check", api_hass.check),
web.post("/core/rebuild", api_hass.rebuild), web.post("/core/rebuild", api_hass.rebuild),
] # Remove with old Supervisor fallback
)
# Reroute from legacy
self.webapp.add_routes(
[
web.get("/homeassistant/info", api_hass.info), web.get("/homeassistant/info", api_hass.info),
web.get("/homeassistant/logs", api_hass.logs), web.get("/homeassistant/logs", api_hass.logs),
web.get("/homeassistant/stats", api_hass.stats), web.get("/homeassistant/stats", api_hass.stats),
web.post("/homeassistant/options", api_hass.options), web.post("/homeassistant/options", api_hass.options),
web.post("/homeassistant/update", api_hass.update),
web.post("/homeassistant/restart", api_hass.restart), web.post("/homeassistant/restart", api_hass.restart),
web.post("/homeassistant/stop", api_hass.stop), web.post("/homeassistant/stop", api_hass.stop),
web.post("/homeassistant/start", api_hass.start), web.post("/homeassistant/start", api_hass.start),
web.post("/homeassistant/update", api_hass.update),
web.post("/homeassistant/rebuild", api_hass.rebuild),
web.post("/homeassistant/check", api_hass.check), web.post("/homeassistant/check", api_hass.check),
web.post("/homeassistant/rebuild", api_hass.rebuild),
] ]
) )
@@ -403,12 +344,7 @@ class RestAPI(CoreSysAttributes):
web.post("/core/api/{path:.+}", api_proxy.api), web.post("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/{path:.+}", api_proxy.api), web.get("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/", api_proxy.api), web.get("/core/api/", api_proxy.api),
] # Remove with old Supervisor fallback
)
# Reroute from legacy
self.webapp.add_routes(
[
web.get("/homeassistant/api/websocket", api_proxy.websocket), web.get("/homeassistant/api/websocket", api_proxy.websocket),
web.get("/homeassistant/websocket", api_proxy.websocket), web.get("/homeassistant/websocket", api_proxy.websocket),
web.get("/homeassistant/api/stream", api_proxy.stream), web.get("/homeassistant/api/stream", api_proxy.stream),
@@ -426,6 +362,8 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/addons", api_addons.list), web.get("/addons", api_addons.list),
web.post("/addons/reload", api_addons.reload),
web.get("/addons/{addon}/info", api_addons.info),
web.post("/addons/{addon}/uninstall", api_addons.uninstall), web.post("/addons/{addon}/uninstall", api_addons.uninstall),
web.post("/addons/{addon}/start", api_addons.start), web.post("/addons/{addon}/start", api_addons.start),
web.post("/addons/{addon}/stop", api_addons.stop), web.post("/addons/{addon}/stop", api_addons.stop),
@@ -437,31 +375,16 @@ class RestAPI(CoreSysAttributes):
web.get("/addons/{addon}/options/config", api_addons.options_config), web.get("/addons/{addon}/options/config", api_addons.options_config),
web.post("/addons/{addon}/rebuild", api_addons.rebuild), web.post("/addons/{addon}/rebuild", api_addons.rebuild),
web.get("/addons/{addon}/logs", api_addons.logs), web.get("/addons/{addon}/logs", api_addons.logs),
web.get("/addons/{addon}/icon", api_addons.icon),
web.get("/addons/{addon}/logo", api_addons.logo),
web.get("/addons/{addon}/changelog", api_addons.changelog),
web.get("/addons/{addon}/documentation", api_addons.documentation),
web.post("/addons/{addon}/stdin", api_addons.stdin), web.post("/addons/{addon}/stdin", api_addons.stdin),
web.post("/addons/{addon}/security", api_addons.security), web.post("/addons/{addon}/security", api_addons.security),
web.get("/addons/{addon}/stats", api_addons.stats), web.get("/addons/{addon}/stats", api_addons.stats),
] ]
) )
# Legacy routing to support requests for not installed addons
api_store = APIStore()
api_store.coresys = self.coresys
@api_process
async def addons_addon_info(request: web.Request) -> dict[str, Any]:
"""Route to store if info requested for not installed addon."""
try:
return await api_addons.info(request)
except APIAddonNotInstalled:
# Route to store/{addon}/info but add missing fields
return dict(
await api_store.addons_addon_info_wrapped(request),
state=AddonState.UNKNOWN,
options=self.sys_addons.store[request.match_info["addon"]].options,
)
self.webapp.add_routes([web.get("/addons/{addon}/info", addons_addon_info)])
def _register_ingress(self) -> None: def _register_ingress(self) -> None:
"""Register Ingress functions.""" """Register Ingress functions."""
api_ingress = APIIngress() api_ingress = APIIngress()
@@ -483,16 +406,27 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/snapshots", api_backups.list),
web.post("/snapshots/reload", api_backups.reload),
web.post("/snapshots/new/full", api_backups.backup_full),
web.post("/snapshots/new/partial", api_backups.backup_partial),
web.post("/snapshots/new/upload", api_backups.upload),
web.get("/snapshots/{slug}/info", api_backups.info),
web.delete("/snapshots/{slug}", api_backups.remove),
web.post("/snapshots/{slug}/restore/full", api_backups.restore_full),
web.post(
"/snapshots/{slug}/restore/partial",
api_backups.restore_partial,
),
web.get("/snapshots/{slug}/download", api_backups.download),
web.post("/snapshots/{slug}/remove", api_backups.remove),
# June 2021: /snapshots was renamed to /backups
web.get("/backups", api_backups.list), web.get("/backups", api_backups.list),
web.get("/backups/info", api_backups.info),
web.post("/backups/options", api_backups.options),
web.post("/backups/reload", api_backups.reload), web.post("/backups/reload", api_backups.reload),
web.post("/backups/freeze", api_backups.freeze),
web.post("/backups/thaw", api_backups.thaw),
web.post("/backups/new/full", api_backups.backup_full), web.post("/backups/new/full", api_backups.backup_full),
web.post("/backups/new/partial", api_backups.backup_partial), web.post("/backups/new/partial", api_backups.backup_partial),
web.post("/backups/new/upload", api_backups.upload), web.post("/backups/new/upload", api_backups.upload),
web.get("/backups/{slug}/info", api_backups.backup_info), web.get("/backups/{slug}/info", api_backups.info),
web.delete("/backups/{slug}", api_backups.remove), web.delete("/backups/{slug}", api_backups.remove),
web.post("/backups/{slug}/restore/full", api_backups.restore_full), web.post("/backups/{slug}/restore/full", api_backups.restore_full),
web.post( web.post(
@@ -552,8 +486,6 @@ class RestAPI(CoreSysAttributes):
"""Register Audio functions.""" """Register Audio functions."""
api_audio = APIAudio() api_audio = APIAudio()
api_audio.coresys = self.coresys api_audio.coresys = self.coresys
api_host = APIHost()
api_host.coresys = self.coresys
self.webapp.add_routes( self.webapp.add_routes(
[ [
@@ -572,22 +504,6 @@ class RestAPI(CoreSysAttributes):
] ]
) )
def _register_mounts(self) -> None:
"""Register mounts endpoints."""
api_mounts = APIMounts()
api_mounts.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/mounts", api_mounts.info),
web.post("/mounts/options", api_mounts.options),
web.post("/mounts", api_mounts.create_mount),
web.put("/mounts/{mount}", api_mounts.update_mount),
web.delete("/mounts/{mount}", api_mounts.delete_mount),
web.post("/mounts/{mount}/reload", api_mounts.reload_mount),
]
)
def _register_store(self) -> None: def _register_store(self) -> None:
"""Register store endpoints.""" """Register store endpoints."""
api_store = APIStore() api_store = APIStore()
@@ -599,15 +515,6 @@ class RestAPI(CoreSysAttributes):
web.get("/store/addons", api_store.addons_list), web.get("/store/addons", api_store.addons_list),
web.get("/store/addons/{addon}", api_store.addons_addon_info), web.get("/store/addons/{addon}", api_store.addons_addon_info),
web.get("/store/addons/{addon}/{version}", api_store.addons_addon_info), web.get("/store/addons/{addon}/{version}", api_store.addons_addon_info),
web.get("/store/addons/{addon}/icon", api_store.addons_addon_icon),
web.get("/store/addons/{addon}/logo", api_store.addons_addon_logo),
web.get(
"/store/addons/{addon}/changelog", api_store.addons_addon_changelog
),
web.get(
"/store/addons/{addon}/documentation",
api_store.addons_addon_documentation,
),
web.post( web.post(
"/store/addons/{addon}/install", api_store.addons_addon_install "/store/addons/{addon}/install", api_store.addons_addon_install
), ),
@@ -626,26 +533,14 @@ class RestAPI(CoreSysAttributes):
"/store/repositories/{repository}", "/store/repositories/{repository}",
api_store.repositories_repository_info, api_store.repositories_repository_info,
), ),
web.post("/store/repositories", api_store.add_repository),
web.delete(
"/store/repositories/{repository}", api_store.remove_repository
),
] ]
) )
# Reroute from legacy # Reroute from legacy
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.post("/addons/reload", api_store.reload),
web.post("/addons/{addon}/install", api_store.addons_addon_install), web.post("/addons/{addon}/install", api_store.addons_addon_install),
web.post("/addons/{addon}/update", api_store.addons_addon_update), web.post("/addons/{addon}/update", api_store.addons_addon_update),
web.get("/addons/{addon}/icon", api_store.addons_addon_icon),
web.get("/addons/{addon}/logo", api_store.addons_addon_logo),
web.get("/addons/{addon}/changelog", api_store.addons_addon_changelog),
web.get(
"/addons/{addon}/documentation",
api_store.addons_addon_documentation,
),
] ]
) )

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor Home Assistant RESTful API.""" """Init file for Supervisor Home Assistant RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
import logging import logging
from typing import Any from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -46,7 +45,6 @@ from ..const import (
ATTR_HOST_IPC, ATTR_HOST_IPC,
ATTR_HOST_NETWORK, ATTR_HOST_NETWORK,
ATTR_HOST_PID, ATTR_HOST_PID,
ATTR_HOST_UTS,
ATTR_HOSTNAME, ATTR_HOSTNAME,
ATTR_ICON, ATTR_ICON,
ATTR_INGRESS, ATTR_INGRESS,
@@ -54,11 +52,13 @@ from ..const import (
ATTR_INGRESS_PANEL, ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT, ATTR_INGRESS_PORT,
ATTR_INGRESS_URL, ATTR_INGRESS_URL,
ATTR_INSTALLED,
ATTR_IP_ADDRESS, ATTR_IP_ADDRESS,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
ATTR_LOGO, ATTR_LOGO,
ATTR_LONG_DESCRIPTION, ATTR_LONG_DESCRIPTION,
ATTR_MACHINE, ATTR_MACHINE,
ATTR_MAINTAINER,
ATTR_MEMORY_LIMIT, ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT, ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE, ATTR_MEMORY_USAGE,
@@ -73,10 +73,12 @@ from ..const import (
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_PWNED, ATTR_PWNED,
ATTR_RATING, ATTR_RATING,
ATTR_REPOSITORIES,
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SCHEMA, ATTR_SCHEMA,
ATTR_SERVICES, ATTR_SERVICES,
ATTR_SLUG, ATTR_SLUG,
ATTR_SOURCE,
ATTR_STAGE, ATTR_STAGE,
ATTR_STARTUP, ATTR_STARTUP,
ATTR_STATE, ATTR_STATE,
@@ -93,20 +95,17 @@ from ..const import (
ATTR_VIDEO, ATTR_VIDEO,
ATTR_WATCHDOG, ATTR_WATCHDOG,
ATTR_WEBUI, ATTR_WEBUI,
CONTENT_TYPE_BINARY,
CONTENT_TYPE_PNG,
CONTENT_TYPE_TEXT,
REQUEST_FROM, REQUEST_FROM,
AddonBoot, AddonBoot,
AddonState,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats from ..docker.stats import DockerStats
from ..exceptions import ( from ..exceptions import APIError, APIForbidden, PwnedError, PwnedSecret
APIAddonNotInstalled,
APIError,
APIForbidden,
PwnedError,
PwnedSecret,
)
from ..validate import docker_ports from ..validate import docker_ports
from .const import ATTR_SIGNED, CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate, json_loads from .utils import api_process, api_process_raw, api_validate, json_loads
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -133,7 +132,7 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
class APIAddons(CoreSysAttributes): class APIAddons(CoreSysAttributes):
"""Handle RESTful API for add-on functions.""" """Handle RESTful API for add-on functions."""
def _extract_addon(self, request: web.Request) -> Addon: def _extract_addon(self, request: web.Request) -> AnyAddon:
"""Return addon, throw an exception it it doesn't exist.""" """Return addon, throw an exception it it doesn't exist."""
addon_slug: str = request.match_info.get("addon") addon_slug: str = request.match_info.get("addon")
@@ -147,11 +146,15 @@ class APIAddons(CoreSysAttributes):
addon = self.sys_addons.get(addon_slug) addon = self.sys_addons.get(addon_slug)
if not addon: if not addon:
raise APIError(f"Addon {addon_slug} does not exist") raise APIError(f"Addon {addon_slug} does not exist")
if not isinstance(addon, Addon) or not addon.is_installed:
raise APIAddonNotInstalled("Addon is not installed")
return addon return addon
def _extract_addon_installed(self, request: web.Request) -> Addon:
addon = self._extract_addon(request)
if not isinstance(addon, Addon) or not addon.is_installed:
raise APIError("Addon is not installed")
return addon
@api_process @api_process
async def list(self, request: web.Request) -> dict[str, Any]: async def list(self, request: web.Request) -> dict[str, Any]:
"""Return all add-ons or repositories.""" """Return all add-ons or repositories."""
@@ -162,29 +165,42 @@ class APIAddons(CoreSysAttributes):
ATTR_DESCRIPTON: addon.description, ATTR_DESCRIPTON: addon.description,
ATTR_ADVANCED: addon.advanced, ATTR_ADVANCED: addon.advanced,
ATTR_STAGE: addon.stage, ATTR_STAGE: addon.stage,
ATTR_VERSION: addon.version, ATTR_VERSION: addon.version if addon.is_installed else None,
ATTR_VERSION_LATEST: addon.latest_version, ATTR_VERSION_LATEST: addon.latest_version,
ATTR_UPDATE_AVAILABLE: addon.need_update, ATTR_UPDATE_AVAILABLE: addon.need_update
if addon.is_installed
else False,
ATTR_INSTALLED: addon.is_installed,
ATTR_AVAILABLE: addon.available, ATTR_AVAILABLE: addon.available,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_HOMEASSISTANT: addon.homeassistant_version, ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_STATE: addon.state,
ATTR_REPOSITORY: addon.repository, ATTR_REPOSITORY: addon.repository,
ATTR_BUILD: addon.need_build, ATTR_BUILD: addon.need_build,
ATTR_URL: addon.url, ATTR_URL: addon.url,
ATTR_ICON: addon.with_icon, ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo, ATTR_LOGO: addon.with_logo,
} }
for addon in self.sys_addons.installed for addon in self.sys_addons.all
] ]
return {ATTR_ADDONS: data_addons} data_repositories = [
{
ATTR_SLUG: repository.slug,
ATTR_NAME: repository.name,
ATTR_SOURCE: repository.source,
ATTR_URL: repository.url,
ATTR_MAINTAINER: repository.maintainer,
}
for repository in self.sys_store.all
]
return {ATTR_ADDONS: data_addons, ATTR_REPOSITORIES: data_repositories}
@api_process @api_process
async def reload(self, request: web.Request) -> None: async def reload(self, request: web.Request) -> None:
"""Reload all add-on data from store.""" """Reload all add-on data from store."""
await asyncio.shield(self.sys_store.reload()) await asyncio.shield(self.sys_store.reload())
@api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> dict[str, Any]:
"""Return add-on information.""" """Return add-on information."""
addon: AnyAddon = self._extract_addon(request) addon: AnyAddon = self._extract_addon(request)
@@ -198,8 +214,11 @@ class APIAddons(CoreSysAttributes):
ATTR_LONG_DESCRIPTION: addon.long_description, ATTR_LONG_DESCRIPTION: addon.long_description,
ATTR_ADVANCED: addon.advanced, ATTR_ADVANCED: addon.advanced,
ATTR_STAGE: addon.stage, ATTR_STAGE: addon.stage,
ATTR_AUTO_UPDATE: None,
ATTR_REPOSITORY: addon.repository, ATTR_REPOSITORY: addon.repository,
ATTR_VERSION: None,
ATTR_VERSION_LATEST: addon.latest_version, ATTR_VERSION_LATEST: addon.latest_version,
ATTR_UPDATE_AVAILABLE: False,
ATTR_PROTECTED: addon.protected, ATTR_PROTECTED: addon.protected,
ATTR_RATING: rating_security(addon), ATTR_RATING: rating_security(addon),
ATTR_BOOT: addon.boot, ATTR_BOOT: addon.boot,
@@ -209,6 +228,7 @@ class APIAddons(CoreSysAttributes):
ATTR_MACHINE: addon.supported_machine, ATTR_MACHINE: addon.supported_machine,
ATTR_HOMEASSISTANT: addon.homeassistant_version, ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_URL: addon.url, ATTR_URL: addon.url,
ATTR_STATE: AddonState.UNKNOWN,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_AVAILABLE: addon.available, ATTR_AVAILABLE: addon.available,
ATTR_BUILD: addon.need_build, ATTR_BUILD: addon.need_build,
@@ -217,16 +237,17 @@ class APIAddons(CoreSysAttributes):
ATTR_HOST_NETWORK: addon.host_network, ATTR_HOST_NETWORK: addon.host_network,
ATTR_HOST_PID: addon.host_pid, ATTR_HOST_PID: addon.host_pid,
ATTR_HOST_IPC: addon.host_ipc, ATTR_HOST_IPC: addon.host_ipc,
ATTR_HOST_UTS: addon.host_uts,
ATTR_HOST_DBUS: addon.host_dbus, ATTR_HOST_DBUS: addon.host_dbus,
ATTR_PRIVILEGED: addon.privileged, ATTR_PRIVILEGED: addon.privileged,
ATTR_FULL_ACCESS: addon.with_full_access, ATTR_FULL_ACCESS: addon.with_full_access,
ATTR_APPARMOR: addon.apparmor, ATTR_APPARMOR: addon.apparmor,
ATTR_DEVICES: addon.static_devices,
ATTR_ICON: addon.with_icon, ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo, ATTR_LOGO: addon.with_logo,
ATTR_CHANGELOG: addon.with_changelog, ATTR_CHANGELOG: addon.with_changelog,
ATTR_DOCUMENTATION: addon.with_documentation, ATTR_DOCUMENTATION: addon.with_documentation,
ATTR_STDIN: addon.with_stdin, ATTR_STDIN: addon.with_stdin,
ATTR_WEBUI: None,
ATTR_HASSIO_API: addon.access_hassio_api, ATTR_HASSIO_API: addon.access_hassio_api,
ATTR_HASSIO_ROLE: addon.hassio_role, ATTR_HASSIO_ROLE: addon.hassio_role,
ATTR_AUTH_API: addon.access_auth_api, ATTR_AUTH_API: addon.access_auth_api,
@@ -240,35 +261,48 @@ class APIAddons(CoreSysAttributes):
ATTR_DOCKER_API: addon.access_docker_api, ATTR_DOCKER_API: addon.access_docker_api,
ATTR_VIDEO: addon.with_video, ATTR_VIDEO: addon.with_video,
ATTR_AUDIO: addon.with_audio, ATTR_AUDIO: addon.with_audio,
ATTR_AUDIO_INPUT: None,
ATTR_AUDIO_OUTPUT: None,
ATTR_STARTUP: addon.startup, ATTR_STARTUP: addon.startup,
ATTR_SERVICES: _pretty_services(addon), ATTR_SERVICES: _pretty_services(addon),
ATTR_DISCOVERY: addon.discovery, ATTR_DISCOVERY: addon.discovery,
ATTR_IP_ADDRESS: None,
ATTR_TRANSLATIONS: addon.translations, ATTR_TRANSLATIONS: addon.translations,
ATTR_INGRESS: addon.with_ingress, ATTR_INGRESS: addon.with_ingress,
ATTR_SIGNED: addon.signed, ATTR_INGRESS_ENTRY: None,
ATTR_STATE: addon.state, ATTR_INGRESS_URL: None,
ATTR_WEBUI: addon.webui, ATTR_INGRESS_PORT: None,
ATTR_INGRESS_ENTRY: addon.ingress_entry, ATTR_INGRESS_PANEL: None,
ATTR_INGRESS_URL: addon.ingress_url, ATTR_WATCHDOG: None,
ATTR_INGRESS_PORT: addon.ingress_port,
ATTR_INGRESS_PANEL: addon.ingress_panel,
ATTR_AUDIO_INPUT: addon.audio_input,
ATTR_AUDIO_OUTPUT: addon.audio_output,
ATTR_AUTO_UPDATE: addon.auto_update,
ATTR_IP_ADDRESS: str(addon.ip_address),
ATTR_VERSION: addon.version,
ATTR_UPDATE_AVAILABLE: addon.need_update,
ATTR_WATCHDOG: addon.watchdog,
ATTR_DEVICES: addon.static_devices
+ [device.path for device in addon.devices],
} }
if isinstance(addon, Addon) and addon.is_installed:
data.update(
{
ATTR_STATE: addon.state,
ATTR_WEBUI: addon.webui,
ATTR_INGRESS_ENTRY: addon.ingress_entry,
ATTR_INGRESS_URL: addon.ingress_url,
ATTR_INGRESS_PORT: addon.ingress_port,
ATTR_INGRESS_PANEL: addon.ingress_panel,
ATTR_AUDIO_INPUT: addon.audio_input,
ATTR_AUDIO_OUTPUT: addon.audio_output,
ATTR_AUTO_UPDATE: addon.auto_update,
ATTR_IP_ADDRESS: str(addon.ip_address),
ATTR_VERSION: addon.version,
ATTR_UPDATE_AVAILABLE: addon.need_update,
ATTR_WATCHDOG: addon.watchdog,
ATTR_DEVICES: addon.static_devices
+ [device.path for device in addon.devices],
}
)
return data return data
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Store user options for add-on.""" """Store user options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
# Update secrets for validation # Update secrets for validation
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
@@ -303,7 +337,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def options_validate(self, request: web.Request) -> None: async def options_validate(self, request: web.Request) -> None:
"""Validate user options for add-on.""" """Validate user options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False} data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False}
options = await request.json(loads=json_loads) or addon.options options = await request.json(loads=json_loads) or addon.options
@@ -345,7 +379,7 @@ class APIAddons(CoreSysAttributes):
slug: str = request.match_info.get("addon") slug: str = request.match_info.get("addon")
if slug != "self": if slug != "self":
raise APIForbidden("This can be only read by the Add-on itself!") raise APIForbidden("This can be only read by the Add-on itself!")
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
# Lookup/reload secrets # Lookup/reload secrets
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
@@ -357,7 +391,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def security(self, request: web.Request) -> None: async def security(self, request: web.Request) -> None:
"""Store security options for add-on.""" """Store security options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request) body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
if ATTR_PROTECTED in body: if ATTR_PROTECTED in body:
@@ -369,7 +403,7 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> dict[str, Any]:
"""Return resource information.""" """Return resource information."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
stats: DockerStats = await addon.stats() stats: DockerStats = await addon.stats()
@@ -387,46 +421,83 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
def uninstall(self, request: web.Request) -> Awaitable[None]: def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on.""" """Uninstall add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return asyncio.shield(addon.uninstall()) return asyncio.shield(addon.uninstall())
@api_process @api_process
async def start(self, request: web.Request) -> None: def start(self, request: web.Request) -> Awaitable[None]:
"""Start add-on.""" """Start add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
if start_task := await asyncio.shield(addon.start()): return asyncio.shield(addon.start())
await start_task
@api_process @api_process
def stop(self, request: web.Request) -> Awaitable[None]: def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop add-on.""" """Stop add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return asyncio.shield(addon.stop()) return asyncio.shield(addon.stop())
@api_process @api_process
async def restart(self, request: web.Request) -> None: def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart add-on.""" """Restart add-on."""
addon: Addon = self._extract_addon(request) addon: Addon = self._extract_addon_installed(request)
if start_task := await asyncio.shield(addon.restart()): return asyncio.shield(addon.restart())
await start_task
@api_process @api_process
async def rebuild(self, request: web.Request) -> None: def rebuild(self, request: web.Request) -> Awaitable[None]:
"""Rebuild local build add-on.""" """Rebuild local build add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
if start_task := await asyncio.shield(addon.rebuild()): return asyncio.shield(addon.rebuild())
await start_task
@api_process_raw(CONTENT_TYPE_BINARY) @api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]: def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return logs from add-on.""" """Return logs from add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return addon.logs() return addon.logs()
@api_process_raw(CONTENT_TYPE_PNG)
async def icon(self, request: web.Request) -> bytes:
"""Return icon from add-on."""
addon = self._extract_addon(request)
if not addon.with_icon:
raise APIError(f"No icon found for add-on {addon.slug}!")
with addon.path_icon.open("rb") as png:
return png.read()
@api_process_raw(CONTENT_TYPE_PNG)
async def logo(self, request: web.Request) -> bytes:
"""Return logo from add-on."""
addon = self._extract_addon(request)
if not addon.with_logo:
raise APIError(f"No logo found for add-on {addon.slug}!")
with addon.path_logo.open("rb") as png:
return png.read()
@api_process_raw(CONTENT_TYPE_TEXT)
async def changelog(self, request: web.Request) -> str:
"""Return changelog from add-on."""
addon = self._extract_addon(request)
if not addon.with_changelog:
raise APIError(f"No changelog found for add-on {addon.slug}!")
with addon.path_changelog.open("r") as changelog:
return changelog.read()
@api_process_raw(CONTENT_TYPE_TEXT)
async def documentation(self, request: web.Request) -> str:
"""Return documentation from add-on."""
addon = self._extract_addon(request)
if not addon.with_documentation:
raise APIError(f"No documentation found for add-on {addon.slug}!")
with addon.path_documentation.open("r") as documentation:
return documentation.read()
@api_process @api_process
async def stdin(self, request: web.Request) -> None: async def stdin(self, request: web.Request) -> None:
"""Write to stdin of add-on.""" """Write to stdin of add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
if not addon.with_stdin: if not addon.with_stdin:
raise APIError(f"STDIN not supported the {addon.slug} add-on") raise APIError(f"STDIN not supported the {addon.slug} add-on")
@@ -434,6 +505,6 @@ class APIAddons(CoreSysAttributes):
await asyncio.shield(addon.write_stdin(data)) await asyncio.shield(addon.write_stdin(data))
def _pretty_services(addon: Addon) -> list[str]: def _pretty_services(addon: AnyAddon) -> list[str]:
"""Return a simplified services role list.""" """Return a simplified services role list."""
return [f"{name}:{access}" for name, access in addon.services_role.items()] return [f"{name}:{access}" for name, access in addon.services_role.items()]

View File

@@ -1,11 +1,10 @@
"""Init file for Supervisor Audio RESTful API.""" """Init file for Supervisor Audio RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
from dataclasses import asdict
import logging import logging
from typing import Any from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import attr
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
@@ -30,12 +29,12 @@ from ..const import (
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
ATTR_VOLUME, ATTR_VOLUME,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..host.sound import StreamType from ..host.sound import StreamType
from ..validate import version_tag from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -76,11 +75,15 @@ class APIAudio(CoreSysAttributes):
ATTR_UPDATE_AVAILABLE: self.sys_plugins.audio.need_update, ATTR_UPDATE_AVAILABLE: self.sys_plugins.audio.need_update,
ATTR_HOST: str(self.sys_docker.network.audio), ATTR_HOST: str(self.sys_docker.network.audio),
ATTR_AUDIO: { ATTR_AUDIO: {
ATTR_CARD: [asdict(card) for card in self.sys_host.sound.cards], ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards],
ATTR_INPUT: [asdict(stream) for stream in self.sys_host.sound.inputs], ATTR_INPUT: [
ATTR_OUTPUT: [asdict(stream) for stream in self.sys_host.sound.outputs], attr.asdict(stream) for stream in self.sys_host.sound.inputs
],
ATTR_OUTPUT: [
attr.asdict(stream) for stream in self.sys_host.sound.outputs
],
ATTR_APPLICATION: [ ATTR_APPLICATION: [
asdict(stream) for stream in self.sys_host.sound.applications attr.asdict(stream) for stream in self.sys_host.sound.applications
], ],
}, },
} }

View File

@@ -8,10 +8,15 @@ from aiohttp.web_exceptions import HTTPUnauthorized
import voluptuous as vol import voluptuous as vol
from ..addons.addon import Addon from ..addons.addon import Addon
from ..const import ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM from ..const import (
ATTR_PASSWORD,
ATTR_USERNAME,
CONTENT_TYPE_JSON,
CONTENT_TYPE_URL,
REQUEST_FROM,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIForbidden from ..exceptions import APIForbidden
from .const import CONTENT_TYPE_JSON, CONTENT_TYPE_URL
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@@ -4,56 +4,44 @@ import logging
from pathlib import Path from pathlib import Path
import re import re
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any
from aiohttp import web from aiohttp import web
from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.hdrs import CONTENT_DISPOSITION
import voluptuous as vol import voluptuous as vol
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale from ..backups.validate import ALL_FOLDERS
from ..const import ( from ..const import (
ATTR_ADDONS, ATTR_ADDONS,
ATTR_BACKUPS, ATTR_BACKUPS,
ATTR_COMPRESSED,
ATTR_CONTENT, ATTR_CONTENT,
ATTR_DATE, ATTR_DATE,
ATTR_DAYS_UNTIL_STALE,
ATTR_FOLDERS, ATTR_FOLDERS,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_LOCATON,
ATTR_NAME, ATTR_NAME,
ATTR_PASSWORD, ATTR_PASSWORD,
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_REPOSITORIES, ATTR_REPOSITORIES,
ATTR_SIZE, ATTR_SIZE,
ATTR_SLUG, ATTR_SLUG,
ATTR_SUPERVISOR_VERSION,
ATTR_TIMEOUT,
ATTR_TYPE, ATTR_TYPE,
ATTR_VERSION, ATTR_VERSION,
CONTENT_TYPE_TAR,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..mounts.const import MountUsage
from .const import CONTENT_TYPE_TAR
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+") RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
# Backwards compatible
# Remove: 2022.08
_ALL_FOLDERS = ALL_FOLDERS + [FOLDER_HOMEASSISTANT]
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_RESTORE_PARTIAL = vol.Schema( SCHEMA_RESTORE_PARTIAL = vol.Schema(
{ {
vol.Optional(ATTR_PASSWORD): vol.Maybe(str), vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(), vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()), vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()), vol.Optional(ATTR_FOLDERS): vol.All([vol.In(ALL_FOLDERS)], vol.Unique()),
} }
) )
@@ -63,32 +51,17 @@ SCHEMA_BACKUP_FULL = vol.Schema(
{ {
vol.Optional(ATTR_NAME): str, vol.Optional(ATTR_NAME): str,
vol.Optional(ATTR_PASSWORD): vol.Maybe(str), vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
} }
) )
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{ {
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()), vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()), vol.Optional(ATTR_FOLDERS): vol.All([vol.In(ALL_FOLDERS)], vol.Unique()),
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(), vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
} }
) )
SCHEMA_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_DAYS_UNTIL_STALE): days_until_stale,
}
)
SCHEMA_FREEZE = vol.Schema(
{
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
}
)
class APIBackups(CoreSysAttributes): class APIBackups(CoreSysAttributes):
"""Handle RESTful API for backups functions.""" """Handle RESTful API for backups functions."""
@@ -100,31 +73,26 @@ class APIBackups(CoreSysAttributes):
raise APIError("Backup does not exist") raise APIError("Backup does not exist")
return backup return backup
def _list_backups(self):
"""Return list of backups."""
return [
{
ATTR_SLUG: backup.slug,
ATTR_NAME: backup.name,
ATTR_DATE: backup.date,
ATTR_TYPE: backup.sys_type,
ATTR_SIZE: backup.size,
ATTR_LOCATON: backup.location,
ATTR_PROTECTED: backup.protected,
ATTR_COMPRESSED: backup.compressed,
ATTR_CONTENT: {
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
ATTR_ADDONS: backup.addon_list,
ATTR_FOLDERS: backup.folders,
},
}
for backup in self.sys_backups.list_backups
]
@api_process @api_process
async def list(self, request): async def list(self, request):
"""Return backup list.""" """Return backup list."""
data_backups = self._list_backups() data_backups = []
for backup in self.sys_backups.list_backups:
data_backups.append(
{
ATTR_SLUG: backup.slug,
ATTR_NAME: backup.name,
ATTR_DATE: backup.date,
ATTR_TYPE: backup.sys_type,
ATTR_SIZE: backup.size,
ATTR_PROTECTED: backup.protected,
ATTR_CONTENT: {
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
ATTR_ADDONS: backup.addon_list,
ATTR_FOLDERS: backup.folders,
},
}
)
if request.path == "/snapshots": if request.path == "/snapshots":
# Kept for backwards compability # Kept for backwards compability
@@ -133,31 +101,13 @@ class APIBackups(CoreSysAttributes):
return {ATTR_BACKUPS: data_backups} return {ATTR_BACKUPS: data_backups}
@api_process @api_process
async def info(self, request): async def reload(self, request):
"""Return backup list and manager info."""
return {
ATTR_BACKUPS: self._list_backups(),
ATTR_DAYS_UNTIL_STALE: self.sys_backups.days_until_stale,
}
@api_process
async def options(self, request):
"""Set backup manager options."""
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_DAYS_UNTIL_STALE in body:
self.sys_backups.days_until_stale = body[ATTR_DAYS_UNTIL_STALE]
self.sys_backups.save_data()
@api_process
async def reload(self, _):
"""Reload backup list.""" """Reload backup list."""
await asyncio.shield(self.sys_backups.reload()) await asyncio.shield(self.sys_backups.reload())
return True return True
@api_process @api_process
async def backup_info(self, request): async def info(self, request):
"""Return backup info.""" """Return backup info."""
backup = self._extract_slug(request) backup = self._extract_slug(request)
@@ -178,38 +128,18 @@ class APIBackups(CoreSysAttributes):
ATTR_NAME: backup.name, ATTR_NAME: backup.name,
ATTR_DATE: backup.date, ATTR_DATE: backup.date,
ATTR_SIZE: backup.size, ATTR_SIZE: backup.size,
ATTR_COMPRESSED: backup.compressed,
ATTR_PROTECTED: backup.protected, ATTR_PROTECTED: backup.protected,
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
ATTR_HOMEASSISTANT: backup.homeassistant_version, ATTR_HOMEASSISTANT: backup.homeassistant_version,
ATTR_LOCATON: backup.location,
ATTR_ADDONS: data_addons, ATTR_ADDONS: data_addons,
ATTR_REPOSITORIES: backup.repositories, ATTR_REPOSITORIES: backup.repositories,
ATTR_FOLDERS: backup.folders, ATTR_FOLDERS: backup.folders,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE: backup.homeassistant_exclude_database,
} }
def _location_to_mount(self, body: dict[str, Any]) -> dict[str, Any]:
"""Change location field to mount if necessary."""
if not body.get(ATTR_LOCATON):
return body
body[ATTR_LOCATON] = self.sys_mounts.get(body[ATTR_LOCATON])
if body[ATTR_LOCATON].usage != MountUsage.BACKUP:
raise APIError(
f"Mount {body[ATTR_LOCATON].name} is not used for backups, cannot backup to there"
)
return body
@api_process @api_process
async def backup_full(self, request): async def backup_full(self, request):
"""Create full backup.""" """Create full backup."""
body = await api_validate(SCHEMA_BACKUP_FULL, request) body = await api_validate(SCHEMA_BACKUP_FULL, request)
backup = await asyncio.shield(self.sys_backups.do_backup_full(**body))
backup = await asyncio.shield(
self.sys_backups.do_backup_full(**self._location_to_mount(body))
)
if backup: if backup:
return {ATTR_SLUG: backup.slug} return {ATTR_SLUG: backup.slug}
@@ -219,9 +149,7 @@ class APIBackups(CoreSysAttributes):
async def backup_partial(self, request): async def backup_partial(self, request):
"""Create a partial backup.""" """Create a partial backup."""
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request) body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
backup = await asyncio.shield( backup = await asyncio.shield(self.sys_backups.do_backup_partial(**body))
self.sys_backups.do_backup_partial(**self._location_to_mount(body))
)
if backup: if backup:
return {ATTR_SLUG: backup.slug} return {ATTR_SLUG: backup.slug}
@@ -243,17 +171,6 @@ class APIBackups(CoreSysAttributes):
return await asyncio.shield(self.sys_backups.do_restore_partial(backup, **body)) return await asyncio.shield(self.sys_backups.do_restore_partial(backup, **body))
@api_process
async def freeze(self, request):
"""Initiate manual freeze for external backup."""
body = await api_validate(SCHEMA_FREEZE, request)
await asyncio.shield(self.sys_backups.freeze_all(**body))
@api_process
async def thaw(self, request):
"""Begin thaw after manual freeze."""
await self.sys_backups.thaw_all()
@api_process @api_process
async def remove(self, request): async def remove(self, request):
"""Remove a backup.""" """Remove a backup."""

View File

@@ -1,54 +1,15 @@
"""Const for API.""" """Const for API."""
CONTENT_TYPE_BINARY = "application/octet-stream"
CONTENT_TYPE_JSON = "application/json"
CONTENT_TYPE_PNG = "image/png"
CONTENT_TYPE_TAR = "application/tar"
CONTENT_TYPE_TEXT = "text/plain"
CONTENT_TYPE_URL = "application/x-www-form-urlencoded"
COOKIE_INGRESS = "ingress_session"
ATTR_AGENT_VERSION = "agent_version" ATTR_AGENT_VERSION = "agent_version"
ATTR_APPARMOR_VERSION = "apparmor_version"
ATTR_ATTRIBUTES = "attributes"
ATTR_AVAILABLE_UPDATES = "available_updates"
ATTR_BOOT_TIMESTAMP = "boot_timestamp" ATTR_BOOT_TIMESTAMP = "boot_timestamp"
ATTR_BOOTS = "boots"
ATTR_BROADCAST_LLMNR = "broadcast_llmnr"
ATTR_BROADCAST_MDNS = "broadcast_mdns"
ATTR_BY_ID = "by_id"
ATTR_CHILDREN = "children"
ATTR_CONNECTION_BUS = "connection_bus"
ATTR_DATA_DISK = "data_disk" ATTR_DATA_DISK = "data_disk"
ATTR_DEVICE = "device" ATTR_DEVICE = "device"
ATTR_DEV_PATH = "dev_path"
ATTR_DISKS = "disks"
ATTR_DRIVES = "drives"
ATTR_DT_SYNCHRONIZED = "dt_synchronized" ATTR_DT_SYNCHRONIZED = "dt_synchronized"
ATTR_DT_UTC = "dt_utc" ATTR_DT_UTC = "dt_utc"
ATTR_EJECTABLE = "ejectable"
ATTR_FALLBACK = "fallback"
ATTR_FILESYSTEMS = "filesystems"
ATTR_IDENTIFIERS = "identifiers"
ATTR_JOBS = "jobs"
ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_MDNS = "mdns"
ATTR_MODEL = "model"
ATTR_MOUNTS = "mounts"
ATTR_MOUNT_POINTS = "mount_points"
ATTR_PANEL_PATH = "panel_path"
ATTR_REMOVABLE = "removable"
ATTR_REVISION = "revision"
ATTR_SEAT = "seat"
ATTR_SIGNED = "signed"
ATTR_STARTUP_TIME = "startup_time" ATTR_STARTUP_TIME = "startup_time"
ATTR_SUBSYSTEM = "subsystem"
ATTR_SYSFS = "sysfs"
ATTR_SYSTEM_HEALTH_LED = "system_health_led"
ATTR_TIME_DETECTED = "time_detected"
ATTR_UPDATE_TYPE = "update_type"
ATTR_USE_NTP = "use_ntp" ATTR_USE_NTP = "use_ntp"
ATTR_USAGE = "usage" ATTR_USE_RTC = "use_rtc"
ATTR_VENDOR = "vendor" ATTR_APPARMOR_VERSION = "apparmor_version"
ATTR_PANEL_PATH = "panel_path"
ATTR_UPDATE_TYPE = "update_type"
ATTR_AVAILABLE_UPDATES = "available_updates"

View File

@@ -1,9 +1,6 @@
"""Init file for Supervisor network RESTful API.""" """Init file for Supervisor network RESTful API."""
import logging
import voluptuous as vol import voluptuous as vol
from ..addons.addon import Addon
from ..const import ( from ..const import (
ATTR_ADDON, ATTR_ADDON,
ATTR_CONFIG, ATTR_CONFIG,
@@ -12,18 +9,15 @@ from ..const import (
ATTR_SERVICES, ATTR_SERVICES,
ATTR_UUID, ATTR_UUID,
REQUEST_FROM, REQUEST_FROM,
AddonState,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..discovery.validate import valid_discovery_service from ..discovery.validate import valid_discovery_service
from ..exceptions import APIError, APIForbidden from ..exceptions import APIError, APIForbidden
from .utils import api_process, api_validate, require_home_assistant from .utils import api_process, api_validate, require_home_assistant
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_DISCOVERY = vol.Schema( SCHEMA_DISCOVERY = vol.Schema(
{ {
vol.Required(ATTR_SERVICE): str, vol.Required(ATTR_SERVICE): valid_discovery_service,
vol.Optional(ATTR_CONFIG): vol.Maybe(dict), vol.Optional(ATTR_CONFIG): vol.Maybe(dict),
} }
) )
@@ -42,19 +36,19 @@ class APIDiscovery(CoreSysAttributes):
@api_process @api_process
@require_home_assistant @require_home_assistant
async def list(self, request): async def list(self, request):
"""Show registered and available services.""" """Show register services."""
# Get available discovery # Get available discovery
discovery = [ discovery = []
{ for message in self.sys_discovery.list_messages:
ATTR_ADDON: message.addon, discovery.append(
ATTR_SERVICE: message.service, {
ATTR_UUID: message.uuid, ATTR_ADDON: message.addon,
ATTR_CONFIG: message.config, ATTR_SERVICE: message.service,
} ATTR_UUID: message.uuid,
for message in self.sys_discovery.list_messages ATTR_CONFIG: message.config,
if (addon := self.sys_addons.get(message.addon, local_only=True)) }
and addon.state == AddonState.STARTED )
]
# Get available services/add-ons # Get available services/add-ons
services = {} services = {}
@@ -68,28 +62,11 @@ class APIDiscovery(CoreSysAttributes):
async def set_discovery(self, request): async def set_discovery(self, request):
"""Write data into a discovery pipeline.""" """Write data into a discovery pipeline."""
body = await api_validate(SCHEMA_DISCOVERY, request) body = await api_validate(SCHEMA_DISCOVERY, request)
addon: Addon = request[REQUEST_FROM] addon = request[REQUEST_FROM]
service = body[ATTR_SERVICE]
try:
valid_discovery_service(service)
except vol.Invalid:
_LOGGER.warning(
"Received discovery message for unknown service %s from addon %s. Please report this to the maintainer of the add-on",
service,
addon.name,
)
# Access? # Access?
if body[ATTR_SERVICE] not in addon.discovery: if body[ATTR_SERVICE] not in addon.discovery:
_LOGGER.error( raise APIForbidden("Can't use discovery!")
"Add-on %s attempted to send discovery for service %s which is not listed in its config. Please report this to the maintainer of the add-on",
addon.name,
service,
)
raise APIForbidden(
"Add-ons must list services they provide via discovery in their config!"
)
# Process discovery message # Process discovery message
message = self.sys_discovery.send(addon, **body) message = self.sys_discovery.send(addon, **body)

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor DNS RESTful API.""" """Init file for Supervisor DNS RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
import logging import logging
from typing import Any from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -22,22 +21,17 @@ from ..const import (
ATTR_UPDATE_AVAILABLE, ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import dns_server_list, version_tag from ..validate import dns_server_list, version_tag
from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS, CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema( SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): dns_server_list})
{
vol.Optional(ATTR_SERVERS): dns_server_list,
vol.Optional(ATTR_FALLBACK): vol.Boolean(),
}
)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
@@ -55,26 +49,15 @@ class APICoreDNS(CoreSysAttributes):
ATTR_HOST: str(self.sys_docker.network.dns), ATTR_HOST: str(self.sys_docker.network.dns),
ATTR_SERVERS: self.sys_plugins.dns.servers, ATTR_SERVERS: self.sys_plugins.dns.servers,
ATTR_LOCALS: self.sys_plugins.dns.locals, ATTR_LOCALS: self.sys_plugins.dns.locals,
ATTR_MDNS: self.sys_plugins.dns.mdns,
ATTR_LLMNR: self.sys_plugins.dns.llmnr,
ATTR_FALLBACK: self.sys_plugins.dns.fallback,
} }
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Set DNS options.""" """Set DNS options."""
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(SCHEMA_OPTIONS, request)
restart_required = False
if ATTR_SERVERS in body: if ATTR_SERVERS in body:
self.sys_plugins.dns.servers = body[ATTR_SERVERS] self.sys_plugins.dns.servers = body[ATTR_SERVERS]
restart_required = True
if ATTR_FALLBACK in body:
self.sys_plugins.dns.fallback = body[ATTR_FALLBACK]
restart_required = True
if restart_required:
self.sys_create_task(self.sys_plugins.dns.restart()) self.sys_create_task(self.sys_plugins.dns.restart())
self.sys_plugins.dns.save_data() self.sys_plugins.dns.save_data()

View File

@@ -4,49 +4,23 @@ from typing import Any
from aiohttp import web from aiohttp import web
from ..const import ( from ..const import ATTR_AUDIO, ATTR_DEVICES, ATTR_INPUT, ATTR_NAME, ATTR_OUTPUT
ATTR_AUDIO,
ATTR_DEVICES,
ATTR_ID,
ATTR_INPUT,
ATTR_NAME,
ATTR_OUTPUT,
ATTR_SERIAL,
ATTR_SIZE,
ATTR_SYSTEM,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..dbus.udisks2 import UDisks2 from ..hardware.const import (
from ..dbus.udisks2.block import UDisks2Block
from ..dbus.udisks2.drive import UDisks2Drive
from ..hardware.data import Device
from .const import (
ATTR_ATTRIBUTES, ATTR_ATTRIBUTES,
ATTR_BY_ID, ATTR_BY_ID,
ATTR_CHILDREN,
ATTR_CONNECTION_BUS,
ATTR_DEV_PATH, ATTR_DEV_PATH,
ATTR_DEVICE,
ATTR_DRIVES,
ATTR_EJECTABLE,
ATTR_FILESYSTEMS,
ATTR_MODEL,
ATTR_MOUNT_POINTS,
ATTR_REMOVABLE,
ATTR_REVISION,
ATTR_SEAT,
ATTR_SUBSYSTEM, ATTR_SUBSYSTEM,
ATTR_SYSFS, ATTR_SYSFS,
ATTR_TIME_DETECTED,
ATTR_VENDOR,
) )
from ..hardware.data import Device
from .utils import api_process from .utils import api_process
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
def device_struct(device: Device) -> dict[str, Any]: def device_struct(device: Device) -> dict[str, Any]:
"""Return a dict with information of a interface to be used in the API.""" """Return a dict with information of a interface to be used in th API."""
return { return {
ATTR_NAME: device.name, ATTR_NAME: device.name,
ATTR_SYSFS: device.sysfs, ATTR_SYSFS: device.sysfs,
@@ -54,43 +28,6 @@ def device_struct(device: Device) -> dict[str, Any]:
ATTR_SUBSYSTEM: device.subsystem, ATTR_SUBSYSTEM: device.subsystem,
ATTR_BY_ID: device.by_id, ATTR_BY_ID: device.by_id,
ATTR_ATTRIBUTES: device.attributes, ATTR_ATTRIBUTES: device.attributes,
ATTR_CHILDREN: device.children,
}
def filesystem_struct(fs_block: UDisks2Block) -> dict[str, Any]:
"""Return a dict with information of a filesystem block device to be used in the API."""
return {
ATTR_DEVICE: str(fs_block.device),
ATTR_ID: fs_block.id,
ATTR_SIZE: fs_block.size,
ATTR_NAME: fs_block.id_label,
ATTR_SYSTEM: fs_block.hint_system,
ATTR_MOUNT_POINTS: [
str(mount_point) for mount_point in fs_block.filesystem.mount_points
],
}
def drive_struct(udisks2: UDisks2, drive: UDisks2Drive) -> dict[str, Any]:
"""Return a dict with information of a disk to be used in the API."""
return {
ATTR_VENDOR: drive.vendor,
ATTR_MODEL: drive.model,
ATTR_REVISION: drive.revision,
ATTR_SERIAL: drive.serial,
ATTR_ID: drive.id,
ATTR_SIZE: drive.size,
ATTR_TIME_DETECTED: drive.time_detected.isoformat(),
ATTR_CONNECTION_BUS: drive.connection_bus,
ATTR_SEAT: drive.seat,
ATTR_REMOVABLE: drive.removable,
ATTR_EJECTABLE: drive.ejectable,
ATTR_FILESYSTEMS: [
filesystem_struct(block)
for block in udisks2.block_devices
if block.filesystem and block.drive == drive.object_path
],
} }
@@ -103,11 +40,7 @@ class APIHardware(CoreSysAttributes):
return { return {
ATTR_DEVICES: [ ATTR_DEVICES: [
device_struct(device) for device in self.sys_hardware.devices device_struct(device) for device in self.sys_hardware.devices
], ]
ATTR_DRIVES: [
drive_struct(self.sys_dbus.udisks2, drive)
for drive in self.sys_dbus.udisks2.drives
],
} }
@api_process @api_process

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor Home Assistant RESTful API.""" """Init file for Supervisor Home Assistant RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
import logging import logging
from typing import Any from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -12,7 +11,6 @@ from ..const import (
ATTR_AUDIO_INPUT, ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT, ATTR_AUDIO_OUTPUT,
ATTR_BACKUP, ATTR_BACKUP,
ATTR_BACKUPS_EXCLUDE_DATABASE,
ATTR_BLK_READ, ATTR_BLK_READ,
ATTR_BLK_WRITE, ATTR_BLK_WRITE,
ATTR_BOOT, ATTR_BOOT,
@@ -31,12 +29,13 @@ from ..const import (
ATTR_UPDATE_AVAILABLE, ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
ATTR_WAIT_BOOT,
ATTR_WATCHDOG, ATTR_WATCHDOG,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import docker_image, network_port, version_tag from ..validate import docker_image, network_port, version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -49,10 +48,10 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_PORT): network_port, vol.Optional(ATTR_PORT): network_port,
vol.Optional(ATTR_SSL): vol.Boolean(), vol.Optional(ATTR_SSL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(), vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(str), vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE): vol.Boolean(),
} }
) )
@@ -82,9 +81,11 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_PORT: self.sys_homeassistant.api_port, ATTR_PORT: self.sys_homeassistant.api_port,
ATTR_SSL: self.sys_homeassistant.api_ssl, ATTR_SSL: self.sys_homeassistant.api_ssl,
ATTR_WATCHDOG: self.sys_homeassistant.watchdog, ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
ATTR_WAIT_BOOT: self.sys_homeassistant.wait_boot,
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input, ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output, ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
ATTR_BACKUPS_EXCLUDE_DATABASE: self.sys_homeassistant.backups_exclude_database, # Remove end of Q3 2020
"last_version": self.sys_homeassistant.latest_version,
} }
@api_process @api_process
@@ -107,6 +108,9 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_WATCHDOG in body: if ATTR_WATCHDOG in body:
self.sys_homeassistant.watchdog = body[ATTR_WATCHDOG] self.sys_homeassistant.watchdog = body[ATTR_WATCHDOG]
if ATTR_WAIT_BOOT in body:
self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT]
if ATTR_REFRESH_TOKEN in body: if ATTR_REFRESH_TOKEN in body:
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN] self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
@@ -116,11 +120,6 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_AUDIO_OUTPUT in body: if ATTR_AUDIO_OUTPUT in body:
self.sys_homeassistant.audio_output = body[ATTR_AUDIO_OUTPUT] self.sys_homeassistant.audio_output = body[ATTR_AUDIO_OUTPUT]
if ATTR_BACKUPS_EXCLUDE_DATABASE in body:
self.sys_homeassistant.backups_exclude_database = body[
ATTR_BACKUPS_EXCLUDE_DATABASE
]
self.sys_homeassistant.save_data() self.sys_homeassistant.save_data()
@api_process @api_process

View File

@@ -1,12 +1,9 @@
"""Init file for Supervisor host RESTful API.""" """Init file for Supervisor host RESTful API."""
import asyncio import asyncio
from contextlib import suppress from typing import Awaitable
import logging
from aiohttp import web from aiohttp import web
from aiohttp.hdrs import ACCEPT, RANGE
import voluptuous as vol import voluptuous as vol
from voluptuous.error import CoerceInvalid
from ..const import ( from ..const import (
ATTR_CHASSIS, ATTR_CHASSIS,
@@ -25,32 +22,22 @@ from ..const import (
ATTR_SERVICES, ATTR_SERVICES,
ATTR_STATE, ATTR_STATE,
ATTR_TIMEZONE, ATTR_TIMEZONE,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HostLogError
from ..host.const import PARAM_BOOT_ID, PARAM_FOLLOW, PARAM_SYSLOG_IDENTIFIER
from .const import ( from .const import (
ATTR_AGENT_VERSION, ATTR_AGENT_VERSION,
ATTR_APPARMOR_VERSION, ATTR_APPARMOR_VERSION,
ATTR_BOOT_TIMESTAMP, ATTR_BOOT_TIMESTAMP,
ATTR_BOOTS,
ATTR_BROADCAST_LLMNR,
ATTR_BROADCAST_MDNS,
ATTR_DT_SYNCHRONIZED, ATTR_DT_SYNCHRONIZED,
ATTR_DT_UTC, ATTR_DT_UTC,
ATTR_IDENTIFIERS,
ATTR_LLMNR_HOSTNAME,
ATTR_STARTUP_TIME, ATTR_STARTUP_TIME,
ATTR_USE_NTP, ATTR_USE_NTP,
CONTENT_TYPE_TEXT, ATTR_USE_RTC,
) )
from .utils import api_process, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) SERVICE = "service"
IDENTIFIER = "identifier"
BOOTID = "bootid"
DEFAULT_RANGE = 100
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str}) SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str})
@@ -73,17 +60,15 @@ class APIHost(CoreSysAttributes):
ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time, ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time,
ATTR_FEATURES: self.sys_host.features, ATTR_FEATURES: self.sys_host.features,
ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,
ATTR_KERNEL: self.sys_host.info.kernel, ATTR_KERNEL: self.sys_host.info.kernel,
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
ATTR_TIMEZONE: self.sys_host.info.timezone, ATTR_TIMEZONE: self.sys_host.info.timezone,
ATTR_DT_UTC: self.sys_host.info.dt_utc, ATTR_DT_UTC: self.sys_host.info.dt_utc,
ATTR_DT_SYNCHRONIZED: self.sys_host.info.dt_synchronized, ATTR_DT_SYNCHRONIZED: self.sys_host.info.dt_synchronized,
ATTR_USE_NTP: self.sys_host.info.use_ntp, ATTR_USE_NTP: self.sys_host.info.use_ntp,
ATTR_USE_RTC: self.sys_host.info.use_rtc,
ATTR_STARTUP_TIME: self.sys_host.info.startup_time, ATTR_STARTUP_TIME: self.sys_host.info.startup_time,
ATTR_BOOT_TIMESTAMP: self.sys_host.info.boot_timestamp, ATTR_BOOT_TIMESTAMP: self.sys_host.info.boot_timestamp,
ATTR_BROADCAST_LLMNR: self.sys_host.info.broadcast_llmnr,
ATTR_BROADCAST_MDNS: self.sys_host.info.broadcast_mdns,
} }
@api_process @api_process
@@ -110,7 +95,11 @@ class APIHost(CoreSysAttributes):
@api_process @api_process
def reload(self, request): def reload(self, request):
"""Reload host data.""" """Reload host data."""
return asyncio.shield(self.sys_host.reload()) return asyncio.shield(
asyncio.wait(
[self.sys_host.reload(), self.sys_resolution.evaluate.evaluate_system()]
)
)
@api_process @api_process
async def services(self, request): async def services(self, request):
@@ -128,75 +117,30 @@ class APIHost(CoreSysAttributes):
return {ATTR_SERVICES: services} return {ATTR_SERVICES: services}
@api_process @api_process
async def list_boots(self, _: web.Request): def service_start(self, request):
"""Return a list of boot IDs.""" """Start a service."""
boot_ids = await self.sys_host.logs.get_boot_ids() unit = request.match_info.get(SERVICE)
return { return asyncio.shield(self.sys_host.services.start(unit))
ATTR_BOOTS: {
str(1 + i - len(boot_ids)): boot_id
for i, boot_id in enumerate(boot_ids)
}
}
@api_process @api_process
async def list_identifiers(self, _: web.Request): def service_stop(self, request):
"""Return a list of syslog identifiers.""" """Stop a service."""
return {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()} unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.stop(unit))
async def _get_boot_id(self, possible_offset: str) -> str:
"""Convert offset into boot ID if required."""
with suppress(CoerceInvalid):
offset = vol.Coerce(int)(possible_offset)
try:
return await self.sys_host.logs.get_boot_id(offset)
except (ValueError, HostLogError) as err:
raise APIError() from err
return possible_offset
@api_process @api_process
async def advanced_logs( def service_reload(self, request):
self, request: web.Request, identifier: str | None = None, follow: bool = False """Reload a service."""
) -> web.StreamResponse: unit = request.match_info.get(SERVICE)
"""Return systemd-journald logs.""" return asyncio.shield(self.sys_host.services.reload(unit))
params = {}
if identifier:
params[PARAM_SYSLOG_IDENTIFIER] = identifier
elif IDENTIFIER in request.match_info:
params[PARAM_SYSLOG_IDENTIFIER] = request.match_info.get(IDENTIFIER)
else:
params[PARAM_SYSLOG_IDENTIFIER] = self.sys_host.logs.default_identifiers
if BOOTID in request.match_info: @api_process
params[PARAM_BOOT_ID] = await self._get_boot_id( def service_restart(self, request):
request.match_info.get(BOOTID) """Restart a service."""
) unit = request.match_info.get(SERVICE)
if follow: return asyncio.shield(self.sys_host.services.restart(unit))
params[PARAM_FOLLOW] = ""
if ACCEPT in request.headers and request.headers[ACCEPT] not in [ @api_process_raw(CONTENT_TYPE_BINARY)
CONTENT_TYPE_TEXT, def logs(self, request: web.Request) -> Awaitable[bytes]:
"*/*", """Return host kernel logs."""
]: return self.sys_host.info.get_dmesg()
raise APIError(
"Invalid content type requested. Only text/plain supported for now."
)
if RANGE in request.headers:
range_header = request.headers.get(RANGE)
else:
range_header = f"entries=:-{DEFAULT_RANGE}:"
async with self.sys_host.logs.journald_logs(
params=params, range_header=range_header
) as resp:
try:
response = web.StreamResponse()
response.content_type = CONTENT_TYPE_TEXT
await response.prepare(request)
async for data in resp.content:
await response.write(data)
except ConnectionResetError as ex:
raise APIError(
"Connection reset when trying to fetch data from systemd-journald."
) from ex
return response

52
supervisor/api/info.py Normal file
View File

@@ -0,0 +1,52 @@
"""Init file for Supervisor info RESTful API."""
import logging
from typing import Any
from aiohttp import web
from ..const import (
ATTR_ARCH,
ATTR_CHANNEL,
ATTR_DOCKER,
ATTR_FEATURES,
ATTR_HASSOS,
ATTR_HOMEASSISTANT,
ATTR_HOSTNAME,
ATTR_LOGGING,
ATTR_MACHINE,
ATTR_OPERATING_SYSTEM,
ATTR_STATE,
ATTR_SUPERVISOR,
ATTR_SUPPORTED,
ATTR_SUPPORTED_ARCH,
ATTR_TIMEZONE,
)
from ..coresys import CoreSysAttributes
from .utils import api_process
_LOGGER: logging.Logger = logging.getLogger(__name__)
class APIInfo(CoreSysAttributes):
"""Handle RESTful API for info functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
"""Show system info."""
return {
ATTR_SUPERVISOR: self.sys_supervisor.version,
ATTR_HOMEASSISTANT: self.sys_homeassistant.version,
ATTR_HASSOS: self.sys_os.version,
ATTR_DOCKER: self.sys_docker.info.version,
ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
ATTR_FEATURES: self.sys_host.features,
ATTR_MACHINE: self.sys_machine,
ATTR_ARCH: self.sys_arch.default,
ATTR_STATE: self.sys_core.state,
ATTR_SUPPORTED_ARCH: self.sys_arch.supported,
ATTR_SUPPORTED: self.sys_core.supported,
ATTR_CHANNEL: self.sys_updater.channel,
ATTR_LOGGING: self.sys_config.logging,
ATTR_TIMEZONE: self.sys_timezone,
}

View File

@@ -2,7 +2,7 @@
import asyncio import asyncio
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
from typing import Any from typing import Any, Union
import aiohttp import aiohttp
from aiohttp import ClientTimeout, hdrs, web from aiohttp import ClientTimeout, hdrs, web
@@ -21,42 +21,22 @@ from ..const import (
ATTR_ICON, ATTR_ICON,
ATTR_PANELS, ATTR_PANELS,
ATTR_SESSION, ATTR_SESSION,
ATTR_SESSION_DATA_USER_ID,
ATTR_TITLE, ATTR_TITLE,
HEADER_REMOTE_USER_DISPLAY_NAME, COOKIE_INGRESS,
HEADER_REMOTE_USER_ID,
HEADER_REMOTE_USER_NAME,
HEADER_TOKEN, HEADER_TOKEN,
HEADER_TOKEN_OLD, HEADER_TOKEN_OLD,
IngressSessionData,
IngressSessionDataUser,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import HomeAssistantAPIError
from .const import COOKIE_INGRESS
from .utils import api_process, api_validate, require_home_assistant from .utils import api_process, api_validate, require_home_assistant
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str}) VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str})
"""Expected optional payload of create session request"""
SCHEMA_INGRESS_CREATE_SESSION_DATA = vol.Schema(
{
vol.Optional(ATTR_SESSION_DATA_USER_ID): str,
}
)
class APIIngress(CoreSysAttributes): class APIIngress(CoreSysAttributes):
"""Ingress view to handle add-on webui routing.""" """Ingress view to handle add-on webui routing."""
_list_of_users: list[IngressSessionDataUser]
def __init__(self) -> None:
"""Initialize APIIngress."""
self._list_of_users = []
def _extract_addon(self, request: web.Request) -> Addon: def _extract_addon(self, request: web.Request) -> Addon:
"""Return addon, throw an exception it it doesn't exist.""" """Return addon, throw an exception it it doesn't exist."""
token = request.match_info.get("token") token = request.match_info.get("token")
@@ -91,19 +71,7 @@ class APIIngress(CoreSysAttributes):
@require_home_assistant @require_home_assistant
async def create_session(self, request: web.Request) -> dict[str, Any]: async def create_session(self, request: web.Request) -> dict[str, Any]:
"""Create a new session.""" """Create a new session."""
schema_ingress_config_session_data = await api_validate( session = self.sys_ingress.create_session()
SCHEMA_INGRESS_CREATE_SESSION_DATA, request
)
data: IngressSessionData | None = None
if ATTR_SESSION_DATA_USER_ID in schema_ingress_config_session_data:
user = await self._find_user_by_id(
schema_ingress_config_session_data[ATTR_SESSION_DATA_USER_ID]
)
if user:
data = IngressSessionData(user)
session = self.sys_ingress.create_session(data)
return {ATTR_SESSION: session} return {ATTR_SESSION: session}
@api_process @api_process
@@ -117,9 +85,10 @@ class APIIngress(CoreSysAttributes):
_LOGGER.warning("No valid ingress session %s", data[ATTR_SESSION]) _LOGGER.warning("No valid ingress session %s", data[ATTR_SESSION])
raise HTTPUnauthorized() raise HTTPUnauthorized()
@require_home_assistant
async def handler( async def handler(
self, request: web.Request self, request: web.Request
) -> web.Response | web.StreamResponse | web.WebSocketResponse: ) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
"""Route data to Supervisor ingress service.""" """Route data to Supervisor ingress service."""
# Check Ingress Session # Check Ingress Session
@@ -131,14 +100,13 @@ class APIIngress(CoreSysAttributes):
# Process requests # Process requests
addon = self._extract_addon(request) addon = self._extract_addon(request)
path = request.match_info.get("path") path = request.match_info.get("path")
session_data = self.sys_ingress.get_session_data(session)
try: try:
# Websocket # Websocket
if _is_websocket(request): if _is_websocket(request):
return await self._handle_websocket(request, addon, path, session_data) return await self._handle_websocket(request, addon, path)
# Request # Request
return await self._handle_request(request, addon, path, session_data) return await self._handle_request(request, addon, path)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.error("Ingress error: %s", err) _LOGGER.error("Ingress error: %s", err)
@@ -146,11 +114,7 @@ class APIIngress(CoreSysAttributes):
raise HTTPBadGateway() raise HTTPBadGateway()
async def _handle_websocket( async def _handle_websocket(
self, self, request: web.Request, addon: Addon, path: str
request: web.Request,
addon: Addon,
path: str,
session_data: IngressSessionData | None,
) -> web.WebSocketResponse: ) -> web.WebSocketResponse:
"""Ingress route for websocket.""" """Ingress route for websocket."""
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers: if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
@@ -168,7 +132,7 @@ class APIIngress(CoreSysAttributes):
# Preparing # Preparing
url = self._create_url(addon, path) url = self._create_url(addon, path)
source_header = _init_header(request, addon, session_data) source_header = _init_header(request, addon)
# Support GET query # Support GET query
if request.query_string: if request.query_string:
@@ -185,8 +149,8 @@ class APIIngress(CoreSysAttributes):
# Proxy requests # Proxy requests
await asyncio.wait( await asyncio.wait(
[ [
self.sys_create_task(_websocket_forward(ws_server, ws_client)), _websocket_forward(ws_server, ws_client),
self.sys_create_task(_websocket_forward(ws_client, ws_server)), _websocket_forward(ws_client, ws_server),
], ],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
@@ -194,15 +158,11 @@ class APIIngress(CoreSysAttributes):
return ws_server return ws_server
async def _handle_request( async def _handle_request(
self, self, request: web.Request, addon: Addon, path: str
request: web.Request, ) -> Union[web.Response, web.StreamResponse]:
addon: Addon,
path: str,
session_data: IngressSessionData | None,
) -> web.Response | web.StreamResponse:
"""Ingress route for request.""" """Ingress route for request."""
url = self._create_url(addon, path) url = self._create_url(addon, path)
source_header = _init_header(request, addon, session_data) source_header = _init_header(request, addon)
# Passing the raw stream breaks requests for some webservers # Passing the raw stream breaks requests for some webservers
# since we just need it for POST requests really, for all other methods # since we just need it for POST requests really, for all other methods
@@ -222,7 +182,6 @@ class APIIngress(CoreSysAttributes):
allow_redirects=False, allow_redirects=False,
data=data, data=data,
timeout=ClientTimeout(total=None), timeout=ClientTimeout(total=None),
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result: ) as result:
headers = _response_header(result) headers = _response_header(result)
@@ -258,35 +217,13 @@ class APIIngress(CoreSysAttributes):
return response return response
async def _find_user_by_id(self, user_id: str) -> IngressSessionDataUser | None:
"""Find user object by the user's ID."""
try:
list_of_users = await self.sys_homeassistant.get_users()
except (HomeAssistantAPIError, TypeError) as err:
_LOGGER.error(
"%s error occurred while requesting list of users: %s", type(err), err
)
return None
if list_of_users is not None:
self._list_of_users = list_of_users
return next((user for user in self._list_of_users if user.id == user_id), None)
def _init_header( def _init_header(
request: web.Request, addon: Addon, session_data: IngressSessionData | None request: web.Request, addon: str
) -> CIMultiDict | dict[str, str]: ) -> Union[CIMultiDict, dict[str, str]]:
"""Create initial header.""" """Create initial header."""
headers = {} headers = {}
if session_data is not None:
headers[HEADER_REMOTE_USER_ID] = session_data.user.id
if session_data.user.username is not None:
headers[HEADER_REMOTE_USER_NAME] = session_data.user.username
if session_data.user.display_name is not None:
headers[HEADER_REMOTE_USER_DISPLAY_NAME] = session_data.user.display_name
# filter flags # filter flags
for name, value in request.headers.items(): for name, value in request.headers.items():
if name in ( if name in (
@@ -299,9 +236,6 @@ def _init_header(
hdrs.SEC_WEBSOCKET_KEY, hdrs.SEC_WEBSOCKET_KEY,
istr(HEADER_TOKEN), istr(HEADER_TOKEN),
istr(HEADER_TOKEN_OLD), istr(HEADER_TOKEN_OLD),
istr(HEADER_REMOTE_USER_ID),
istr(HEADER_REMOTE_USER_NAME),
istr(HEADER_REMOTE_USER_DISPLAY_NAME),
): ):
continue continue
headers[name] = value headers[name] = value

View File

@@ -6,9 +6,7 @@ from aiohttp import web
import voluptuous as vol import voluptuous as vol
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..jobs import SupervisorJob
from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
from .const import ATTR_JOBS
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -21,45 +19,11 @@ SCHEMA_OPTIONS = vol.Schema(
class APIJobs(CoreSysAttributes): class APIJobs(CoreSysAttributes):
"""Handle RESTful API for OS functions.""" """Handle RESTful API for OS functions."""
def _list_jobs(self) -> list[dict[str, Any]]:
"""Return current job tree."""
jobs_by_parent: dict[str | None, list[SupervisorJob]] = {}
for job in self.sys_jobs.jobs:
if job.internal:
continue
if job.parent_id not in jobs_by_parent:
jobs_by_parent[job.parent_id] = [job]
else:
jobs_by_parent[job.parent_id].append(job)
job_list: list[dict[str, Any]] = []
queue: list[tuple[list[dict[str, Any]], SupervisorJob]] = [
(job_list, job) for job in jobs_by_parent.get(None, [])
]
while queue:
(current_list, current_job) = queue.pop(0)
child_jobs: list[dict[str, Any]] = []
# We remove parent_id and instead use that info to represent jobs as a tree
job_dict = current_job.as_dict() | {"child_jobs": child_jobs}
job_dict.pop("parent_id")
current_list.append(job_dict)
if current_job.uuid in jobs_by_parent:
queue.extend(
[(child_jobs, job) for job in jobs_by_parent.get(current_job.uuid)]
)
return job_list
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> dict[str, Any]:
"""Return JobManager information.""" """Return JobManager information."""
return { return {
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions, ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,
ATTR_JOBS: self._list_jobs(),
} }
@api_process @api_process

View File

@@ -1,14 +1,10 @@
"""Handle security part of this API.""" """Handle security part of this API."""
import logging import logging
import re import re
from typing import Final
from urllib.parse import unquote
from aiohttp.web import Request, RequestHandler, Response, middleware from aiohttp.web import Request, RequestHandler, Response, middleware
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
from awesomeversion import AwesomeVersion
from ...addons.const import RE_SLUG
from ...const import ( from ...const import (
REQUEST_FROM, REQUEST_FROM,
ROLE_ADMIN, ROLE_ADMIN,
@@ -22,22 +18,11 @@ from ...coresys import CoreSys, CoreSysAttributes
from ..utils import api_return_error, excract_supervisor_token from ..utils import api_return_error, excract_supervisor_token
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
_CORE_VERSION: Final = AwesomeVersion("2023.3.4")
# fmt: off # fmt: off
_CORE_FRONTEND_PATHS: Final = (
r"|/app/.*\.(?:js|gz|json|map|woff2)"
r"|/(store/)?addons/" + RE_SLUG + r"/(logo|icon)"
)
CORE_FRONTEND: Final = re.compile(
r"^(?:" + _CORE_FRONTEND_PATHS + r")$"
)
# Block Anytime # Block Anytime
BLACKLIST: Final = re.compile( BLACKLIST = re.compile(
r"^(?:" r"^(?:"
r"|/homeassistant/api/hassio/.*" r"|/homeassistant/api/hassio/.*"
r"|/core/api/hassio/.*" r"|/core/api/hassio/.*"
@@ -45,27 +30,25 @@ BLACKLIST: Final = re.compile(
) )
# Free to call or have own security concepts # Free to call or have own security concepts
NO_SECURITY_CHECK: Final = re.compile( NO_SECURITY_CHECK = re.compile(
r"^(?:" r"^(?:"
r"|/homeassistant/api/.*" r"|/homeassistant/api/.*"
r"|/homeassistant/websocket" r"|/homeassistant/websocket"
r"|/core/api/.*" r"|/core/api/.*"
r"|/core/websocket" r"|/core/websocket"
r"|/supervisor/ping" r"|/supervisor/ping"
r"|/ingress/[-_A-Za-z0-9]+/.*" r")$"
+ _CORE_FRONTEND_PATHS
+ r")$"
) )
# Observer allow API calls # Observer allow API calls
OBSERVER_CHECK: Final = re.compile( OBSERVER_CHECK = re.compile(
r"^(?:" r"^(?:"
r"|/.+/info" r"|/.+/info"
r")$" r")$"
) )
# Can called by every add-on # Can called by every add-on
ADDONS_API_BYPASS: Final = re.compile( ADDONS_API_BYPASS = re.compile(
r"^(?:" r"^(?:"
r"|/addons/self/(?!security|update)[^/]+" r"|/addons/self/(?!security|update)[^/]+"
r"|/addons/self/options/config" r"|/addons/self/options/config"
@@ -77,7 +60,7 @@ ADDONS_API_BYPASS: Final = re.compile(
) )
# Policy role add-on API access # Policy role add-on API access
ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = { ADDONS_ROLE_ACCESS = {
ROLE_DEFAULT: re.compile( ROLE_DEFAULT: re.compile(
r"^(?:" r"^(?:"
r"|/.+/info" r"|/.+/info"
@@ -94,12 +77,13 @@ ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
r"^(?:" r"^(?:"
r"|/.+/info" r"|/.+/info"
r"|/backups.*" r"|/backups.*"
r"|/snapshots.*"
r")$" r")$"
), ),
ROLE_MANAGER: re.compile( ROLE_MANAGER: re.compile(
r"^(?:" r"^(?:"
r"|/.+/info" r"|/.+/info"
r"|/addons(?:/" + RE_SLUG + r"/(?!security).+|/reload)?" r"|/addons(?:/[^/]+/(?!security).+|/reload)?"
r"|/audio/.+" r"|/audio/.+"
r"|/auth/cache" r"|/auth/cache"
r"|/cli/.+" r"|/cli/.+"
@@ -128,26 +112,6 @@ ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
), ),
} }
FILTERS: Final = re.compile(
r"(?:"
# Common exploits
r"proc/self/environ"
r"|(<|%3C).*script.*(>|%3E)"
# File Injections
r"|(\.\.//?)+" # ../../anywhere
r"|[a-zA-Z0-9_]=/([a-z0-9_.]//?)+" # .html?v=/.//test
# SQL Injections
r"|union.*select.*\("
r"|union.*all.*select.*"
r"|concat.*\("
r")",
flags=re.IGNORECASE,
)
# fmt: on # fmt: on
@@ -158,32 +122,6 @@ class SecurityMiddleware(CoreSysAttributes):
"""Initialize security middleware.""" """Initialize security middleware."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
def _recursive_unquote(self, value: str) -> str:
"""Handle values that are encoded multiple times."""
if (unquoted := unquote(value)) != value:
unquoted = self._recursive_unquote(unquoted)
return unquoted
@middleware
async def block_bad_requests(
self, request: Request, handler: RequestHandler
) -> Response:
"""Process request and tblock commonly known exploit attempts."""
if FILTERS.search(self._recursive_unquote(request.path)):
_LOGGER.warning(
"Filtered a potential harmful request to: %s", request.raw_path
)
raise HTTPBadRequest
if FILTERS.search(self._recursive_unquote(request.query_string)):
_LOGGER.warning(
"Filtered a request with a potential harmful query string: %s",
request.raw_path,
)
raise HTTPBadRequest
return await handler(request)
@middleware @middleware
async def system_validation( async def system_validation(
self, request: Request, handler: RequestHandler self, request: Request, handler: RequestHandler
@@ -195,7 +133,7 @@ class SecurityMiddleware(CoreSysAttributes):
CoreState.FREEZE, CoreState.FREEZE,
): ):
return api_return_error( return api_return_error(
message=f"System is not ready with state: {self.sys_core.state}" message=f"System is not ready with state: {self.sys_core.state.value}"
) )
return await handler(request) return await handler(request)
@@ -216,7 +154,6 @@ class SecurityMiddleware(CoreSysAttributes):
# Ignore security check # Ignore security check
if NO_SECURITY_CHECK.match(request.path): if NO_SECURITY_CHECK.match(request.path):
_LOGGER.debug("Passthrough %s", request.path) _LOGGER.debug("Passthrough %s", request.path)
request[REQUEST_FROM] = None
return await handler(request) return await handler(request)
# Not token # Not token
@@ -269,45 +206,3 @@ class SecurityMiddleware(CoreSysAttributes):
_LOGGER.error("Invalid token for access %s", request.path) _LOGGER.error("Invalid token for access %s", request.path)
raise HTTPForbidden() raise HTTPForbidden()
@middleware
async def core_proxy(self, request: Request, handler: RequestHandler) -> Response:
"""Validate user from Core API proxy."""
if (
request[REQUEST_FROM] != self.sys_homeassistant
or self.sys_homeassistant.version >= _CORE_VERSION
):
return await handler(request)
authorization_index: int | None = None
content_type_index: int | None = None
user_request: bool = False
admin_request: bool = False
ingress_request: bool = False
for idx, (key, value) in enumerate(request.raw_headers):
if key in (b"Authorization", b"X-Hassio-Key"):
authorization_index = idx
elif key == b"Content-Type":
content_type_index = idx
elif key == b"X-Hass-User-ID":
user_request = True
elif key == b"X-Hass-Is-Admin":
admin_request = value == b"1"
elif key == b"X-Ingress-Path":
ingress_request = True
if (user_request or admin_request) and not ingress_request:
return await handler(request)
is_proxy_request = (
authorization_index is not None
and content_type_index is not None
and content_type_index - authorization_index == 1
)
if (
not CORE_FRONTEND.match(request.path) and is_proxy_request
) or ingress_request:
raise HTTPBadRequest()
return await handler(request)

View File

@@ -1,124 +0,0 @@
"""Inits file for supervisor mounts REST API."""
from typing import Any
from aiohttp import web
import voluptuous as vol
from ..const import ATTR_NAME, ATTR_STATE
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage
from ..mounts.mount import Mount
from ..mounts.validate import SCHEMA_MOUNT_CONFIG
from .const import ATTR_MOUNTS
from .utils import api_process, api_validate
SCHEMA_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_DEFAULT_BACKUP_MOUNT): vol.Maybe(str),
}
)
class APIMounts(CoreSysAttributes):
"""Handle REST API for mounting options."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
"""Return MountManager info."""
return {
ATTR_DEFAULT_BACKUP_MOUNT: self.sys_mounts.default_backup_mount.name
if self.sys_mounts.default_backup_mount
else None,
ATTR_MOUNTS: [
mount.to_dict() | {ATTR_STATE: mount.state}
for mount in self.sys_mounts.mounts
],
}
@api_process
async def options(self, request: web.Request) -> None:
"""Set Mount Manager options."""
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_DEFAULT_BACKUP_MOUNT in body:
name: str | None = body[ATTR_DEFAULT_BACKUP_MOUNT]
if name is None:
self.sys_mounts.default_backup_mount = None
elif (mount := self.sys_mounts.get(name)).usage != MountUsage.BACKUP:
raise APIError(
f"Mount {name} is not used for backups, cannot use it as default backup mount"
)
else:
self.sys_mounts.default_backup_mount = mount
self.sys_mounts.save_data()
@api_process
async def create_mount(self, request: web.Request) -> None:
"""Create a new mount in supervisor."""
body = await api_validate(SCHEMA_MOUNT_CONFIG, request)
if body[ATTR_NAME] in self.sys_mounts:
raise APIError(f"A mount already exists with name {body[ATTR_NAME]}")
mount = Mount.from_dict(self.coresys, body)
await self.sys_mounts.create_mount(mount)
# If it's a backup mount, reload backups
if mount.usage == MountUsage.BACKUP:
self.sys_create_task(self.sys_backups.reload())
# If there's no default backup mount, set it to the new mount
if not self.sys_mounts.default_backup_mount:
self.sys_mounts.default_backup_mount = mount
self.sys_mounts.save_data()
@api_process
async def update_mount(self, request: web.Request) -> None:
"""Update an existing mount in supervisor."""
name = request.match_info.get("mount")
name_schema = vol.Schema(
{vol.Optional(ATTR_NAME, default=name): name}, extra=vol.ALLOW_EXTRA
)
body = await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request)
if name not in self.sys_mounts:
raise APIError(f"No mount exists with name {name}")
mount = Mount.from_dict(self.coresys, body)
await self.sys_mounts.create_mount(mount)
# If it's a backup mount, reload backups
if mount.usage == MountUsage.BACKUP:
self.sys_create_task(self.sys_backups.reload())
# If this mount was the default backup mount and isn't for backups any more, remove it
elif self.sys_mounts.default_backup_mount == mount:
self.sys_mounts.default_backup_mount = None
self.sys_mounts.save_data()
@api_process
async def delete_mount(self, request: web.Request) -> None:
"""Delete an existing mount in supervisor."""
name = request.match_info.get("mount")
mount = await self.sys_mounts.remove_mount(name)
# If it was a backup mount, reload backups
if mount.usage == MountUsage.BACKUP:
self.sys_create_task(self.sys_backups.reload())
self.sys_mounts.save_data()
@api_process
async def reload_mount(self, request: web.Request) -> None:
"""Reload an existing mount in supervisor."""
name = request.match_info.get("mount")
await self.sys_mounts.reload_mount(name)
# If it's a backup mount, reload backups
if self.sys_mounts.get(name).usage == MountUsage.BACKUP:
self.sys_create_task(self.sys_backups.reload())

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor Multicast RESTful API.""" """Init file for Supervisor Multicast RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
import logging import logging
from typing import Any from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -19,11 +18,11 @@ from ..const import (
ATTR_UPDATE_AVAILABLE, ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import version_tag from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@@ -1,11 +1,10 @@
"""REST API for network.""" """REST API for network."""
import asyncio import asyncio
from collections.abc import Awaitable
from dataclasses import replace
from ipaddress import ip_address, ip_interface from ipaddress import ip_address, ip_interface
from typing import Any from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import attr
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
@@ -31,7 +30,6 @@ from ..const import (
ATTR_PARENT, ATTR_PARENT,
ATTR_PRIMARY, ATTR_PRIMARY,
ATTR_PSK, ATTR_PSK,
ATTR_READY,
ATTR_SIGNAL, ATTR_SIGNAL,
ATTR_SSID, ATTR_SSID,
ATTR_SUPERVISOR_INTERNET, ATTR_SUPERVISOR_INTERNET,
@@ -43,7 +41,8 @@ from ..const import (
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HostNetworkNotFound from ..exceptions import APIError, HostNetworkNotFound
from ..host.configuration import ( from ..host.const import AuthMethod, InterfaceType, WifiMode
from ..host.network import (
AccessPoint, AccessPoint,
Interface, Interface,
InterfaceMethod, InterfaceMethod,
@@ -51,7 +50,6 @@ from ..host.configuration import (
VlanConfig, VlanConfig,
WifiConfig, WifiConfig,
) )
from ..host.const import AuthMethod, InterfaceType, WifiMode
from .utils import api_process, api_validate from .utils import api_process, api_validate
_SCHEMA_IP_CONFIG = vol.Schema( _SCHEMA_IP_CONFIG = vol.Schema(
@@ -91,7 +89,6 @@ def ipconfig_struct(config: IpConfig) -> dict[str, Any]:
ATTR_ADDRESS: [address.with_prefixlen for address in config.address], ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
ATTR_NAMESERVERS: [str(address) for address in config.nameservers], ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
ATTR_GATEWAY: str(config.gateway) if config.gateway else None, ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
ATTR_READY: config.ready,
} }
@@ -121,7 +118,6 @@ def interface_struct(interface: Interface) -> dict[str, Any]:
ATTR_ENABLED: interface.enabled, ATTR_ENABLED: interface.enabled,
ATTR_CONNECTED: interface.connected, ATTR_CONNECTED: interface.connected,
ATTR_PRIMARY: interface.primary, ATTR_PRIMARY: interface.primary,
ATTR_MAC: interface.mac,
ATTR_IPV4: ipconfig_struct(interface.ipv4) if interface.ipv4 else None, ATTR_IPV4: ipconfig_struct(interface.ipv4) if interface.ipv4 else None,
ATTR_IPV6: ipconfig_struct(interface.ipv6) if interface.ipv6 else None, ATTR_IPV6: ipconfig_struct(interface.ipv6) if interface.ipv6 else None,
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None, ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
@@ -145,7 +141,9 @@ class APINetwork(CoreSysAttributes):
def _get_interface(self, name: str) -> Interface: def _get_interface(self, name: str) -> Interface:
"""Get Interface by name or default.""" """Get Interface by name or default."""
if name.lower() == "default": name = name.lower()
if name == "default":
for interface in self.sys_host.network.interfaces: for interface in self.sys_host.network.interfaces:
if not interface.primary: if not interface.primary:
continue continue
@@ -197,19 +195,17 @@ class APINetwork(CoreSysAttributes):
# Apply config # Apply config
for key, config in body.items(): for key, config in body.items():
if key == ATTR_IPV4: if key == ATTR_IPV4:
interface.ipv4 = replace( interface.ipv4 = attr.evolve(
interface.ipv4 interface.ipv4 or IpConfig(InterfaceMethod.STATIC, [], None, []),
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
**config, **config,
) )
elif key == ATTR_IPV6: elif key == ATTR_IPV6:
interface.ipv6 = replace( interface.ipv6 = attr.evolve(
interface.ipv6 interface.ipv6 or IpConfig(InterfaceMethod.STATIC, [], None, []),
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
**config, **config,
) )
elif key == ATTR_WIFI: elif key == ATTR_WIFI:
interface.wifi = replace( interface.wifi = attr.evolve(
interface.wifi interface.wifi
or WifiConfig( or WifiConfig(
WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None
@@ -224,9 +220,7 @@ class APINetwork(CoreSysAttributes):
@api_process @api_process
def reload(self, request: web.Request) -> Awaitable[None]: def reload(self, request: web.Request) -> Awaitable[None]:
"""Reload network data.""" """Reload network data."""
return asyncio.shield( return asyncio.shield(self.sys_host.network.update())
self.sys_host.network.update(force_connectivity_check=True)
)
@api_process @api_process
async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]: async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]:
@@ -263,7 +257,6 @@ class APINetwork(CoreSysAttributes):
body[ATTR_IPV4].get(ATTR_ADDRESS, []), body[ATTR_IPV4].get(ATTR_ADDRESS, []),
body[ATTR_IPV4].get(ATTR_GATEWAY, None), body[ATTR_IPV4].get(ATTR_GATEWAY, None),
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []), body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
None,
) )
ipv6_config = None ipv6_config = None
@@ -273,12 +266,9 @@ class APINetwork(CoreSysAttributes):
body[ATTR_IPV6].get(ATTR_ADDRESS, []), body[ATTR_IPV6].get(ATTR_ADDRESS, []),
body[ATTR_IPV6].get(ATTR_GATEWAY, None), body[ATTR_IPV6].get(ATTR_GATEWAY, None),
body[ATTR_IPV6].get(ATTR_NAMESERVERS, []), body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
None,
) )
vlan_interface = Interface( vlan_interface = Interface(
"",
"",
"", "",
True, True,
True, True,

View File

@@ -1,64 +1,29 @@
"""Init file for Supervisor HassOS RESTful API.""" """Init file for Supervisor HassOS RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
import logging import logging
from typing import Any from pathlib import Path
from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ATTR_ACTIVITY_LED,
ATTR_BOARD, ATTR_BOARD,
ATTR_BOOT, ATTR_BOOT,
ATTR_DEVICES, ATTR_DEVICES,
ATTR_DISK_LED,
ATTR_HEARTBEAT_LED,
ATTR_ID,
ATTR_NAME,
ATTR_POWER_LED,
ATTR_SERIAL,
ATTR_SIZE,
ATTR_UPDATE_AVAILABLE, ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import BoardInvalidError
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..validate import version_tag from ..validate import version_tag
from .const import ( from .const import ATTR_DATA_DISK, ATTR_DEVICE
ATTR_DATA_DISK,
ATTR_DEV_PATH,
ATTR_DEVICE,
ATTR_DISKS,
ATTR_MODEL,
ATTR_SYSTEM_HEALTH_LED,
ATTR_VENDOR,
)
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): str}) SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))})
SCHEMA_YELLOW_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_DISK_LED): vol.Boolean(),
vol.Optional(ATTR_HEARTBEAT_LED): vol.Boolean(),
vol.Optional(ATTR_POWER_LED): vol.Boolean(),
}
)
SCHEMA_GREEN_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_ACTIVITY_LED): vol.Boolean(),
vol.Optional(ATTR_POWER_LED): vol.Boolean(),
vol.Optional(ATTR_SYSTEM_HEALTH_LED): vol.Boolean(),
}
)
# pylint: enable=no-value-for-parameter
class APIOS(CoreSysAttributes): class APIOS(CoreSysAttributes):
@@ -73,7 +38,7 @@ class APIOS(CoreSysAttributes):
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update, ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
ATTR_BOARD: self.sys_os.board, ATTR_BOARD: self.sys_os.board,
ATTR_BOOT: self.sys_dbus.rauc.boot_slot, ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used_id, ATTR_DATA_DISK: self.sys_os.datadisk.disk_used,
} }
@api_process @api_process
@@ -100,82 +65,5 @@ class APIOS(CoreSysAttributes):
async def list_data(self, request: web.Request) -> dict[str, Any]: async def list_data(self, request: web.Request) -> dict[str, Any]:
"""Return possible data targets.""" """Return possible data targets."""
return { return {
ATTR_DEVICES: [disk.id for disk in self.sys_os.datadisk.available_disks], ATTR_DEVICES: self.sys_os.datadisk.available_disks,
ATTR_DISKS: [
{
ATTR_NAME: disk.name,
ATTR_VENDOR: disk.vendor,
ATTR_MODEL: disk.model,
ATTR_SERIAL: disk.serial,
ATTR_SIZE: disk.size,
ATTR_ID: disk.id,
ATTR_DEV_PATH: disk.device_path.as_posix(),
}
for disk in self.sys_os.datadisk.available_disks
],
} }
@api_process
async def boards_green_info(self, request: web.Request) -> dict[str, Any]:
"""Get green board settings."""
return {
ATTR_ACTIVITY_LED: self.sys_dbus.agent.board.green.activity_led,
ATTR_POWER_LED: self.sys_dbus.agent.board.green.power_led,
ATTR_SYSTEM_HEALTH_LED: self.sys_dbus.agent.board.green.user_led,
}
@api_process
async def boards_green_options(self, request: web.Request) -> None:
"""Update green board settings."""
body = await api_validate(SCHEMA_GREEN_OPTIONS, request)
if ATTR_ACTIVITY_LED in body:
self.sys_dbus.agent.board.green.activity_led = body[ATTR_ACTIVITY_LED]
if ATTR_POWER_LED in body:
self.sys_dbus.agent.board.green.power_led = body[ATTR_POWER_LED]
if ATTR_SYSTEM_HEALTH_LED in body:
self.sys_dbus.agent.board.green.user_led = body[ATTR_SYSTEM_HEALTH_LED]
self.sys_dbus.agent.board.green.save_data()
@api_process
async def boards_yellow_info(self, request: web.Request) -> dict[str, Any]:
"""Get yellow board settings."""
return {
ATTR_DISK_LED: self.sys_dbus.agent.board.yellow.disk_led,
ATTR_HEARTBEAT_LED: self.sys_dbus.agent.board.yellow.heartbeat_led,
ATTR_POWER_LED: self.sys_dbus.agent.board.yellow.power_led,
}
@api_process
async def boards_yellow_options(self, request: web.Request) -> None:
"""Update yellow board settings."""
body = await api_validate(SCHEMA_YELLOW_OPTIONS, request)
if ATTR_DISK_LED in body:
self.sys_dbus.agent.board.yellow.disk_led = body[ATTR_DISK_LED]
if ATTR_HEARTBEAT_LED in body:
self.sys_dbus.agent.board.yellow.heartbeat_led = body[ATTR_HEARTBEAT_LED]
if ATTR_POWER_LED in body:
self.sys_dbus.agent.board.yellow.power_led = body[ATTR_POWER_LED]
self.sys_dbus.agent.board.yellow.save_data()
self.sys_resolution.create_issue(
IssueType.REBOOT_REQUIRED,
ContextType.SYSTEM,
suggestions=[SuggestionType.EXECUTE_REBOOT],
)
@api_process
async def boards_other_info(self, request: web.Request) -> dict[str, Any]:
"""Empty success return if board is in use, error otherwise."""
if request.match_info["board"] != self.sys_os.board:
raise BoardInvalidError(
f"{request.match_info['board']} board is not in use", _LOGGER.error
)
return {}

View File

@@ -1 +1,16 @@
!function(){function n(n){var t=document.createElement("script");t.src=n,document.body.appendChild(t)}if(/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent))n("/api/hassio/app/frontend_es5/entrypoint-5yRSddAJzJ4.js");else try{new Function("import('/api/hassio/app/frontend_latest/entrypoint-qzB1D0O4L9U.js')")()}catch(t){n("/api/hassio/app/frontend_es5/entrypoint-5yRSddAJzJ4.js")}}()
function loadES5() {
var el = document.createElement('script');
el.src = '/api/hassio/app/frontend_es5/entrypoint.5d40ff8b.js';
document.body.appendChild(el);
}
if (/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent)) {
loadES5();
} else {
try {
new Function("import('/api/hassio/app/frontend_latest/entrypoint.f09e9f8e.js')")();
} catch (err) {
loadES5();
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
"use strict";(self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[]).push([[1047],{32594:function(e,t,r){r.d(t,{U:function(){return n}});var n=function(e){return e.stopPropagation()}},75054:function(e,t,r){r.r(t),r.d(t,{HaTimeDuration:function(){return f}});var n,a=r(88962),i=r(33368),o=r(71650),d=r(82390),u=r(69205),l=r(70906),s=r(91808),c=r(68144),v=r(79932),f=(r(47289),(0,s.Z)([(0,v.Mo)("ha-selector-duration")],(function(e,t){var r=function(t){(0,u.Z)(n,t);var r=(0,l.Z)(n);function n(){var t;(0,o.Z)(this,n);for(var a=arguments.length,i=new Array(a),u=0;u<a;u++)i[u]=arguments[u];return t=r.call.apply(r,[this].concat(i)),e((0,d.Z)(t)),t}return(0,i.Z)(n)}(t);return{F:r,d:[{kind:"field",decorators:[(0,v.Cb)({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[(0,v.Cb)({attribute:!1})],key:"selector",value:void 0},{kind:"field",decorators:[(0,v.Cb)({attribute:!1})],key:"value",value:void 0},{kind:"field",decorators:[(0,v.Cb)()],key:"label",value:void 0},{kind:"field",decorators:[(0,v.Cb)()],key:"helper",value:void 0},{kind:"field",decorators:[(0,v.Cb)({type:Boolean})],key:"disabled",value:function(){return!1}},{kind:"field",decorators:[(0,v.Cb)({type:Boolean})],key:"required",value:function(){return!0}},{kind:"method",key:"render",value:function(){var e;return(0,c.dy)(n||(n=(0,a.Z)([' <ha-duration-input .label="','" .helper="','" .data="','" .disabled="','" .required="','" ?enableDay="','"></ha-duration-input> '])),this.label,this.helper,this.value,this.disabled,this.required,null===(e=this.selector.duration)||void 0===e?void 0:e.enable_day)}}]}}),c.oi))}}]);
//# sourceMappingURL=1047-g7fFLS9eP4I.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"1047-g7fFLS9eP4I.js","mappings":"yKAAO,IAAMA,EAAkB,SAACC,GAAE,OAAKA,EAAGD,iBAAiB,C,qLCQ9CE,G,UAAcC,EAAAA,EAAAA,GAAA,EAD1BC,EAAAA,EAAAA,IAAc,0BAAuB,SAAAC,EAAAC,GAAA,IACzBJ,EAAc,SAAAK,IAAAC,EAAAA,EAAAA,GAAAN,EAAAK,GAAA,IAAAE,GAAAC,EAAAA,EAAAA,GAAAR,GAAA,SAAAA,IAAA,IAAAS,GAAAC,EAAAA,EAAAA,GAAA,KAAAV,GAAA,QAAAW,EAAAC,UAAAC,OAAAC,EAAA,IAAAC,MAAAJ,GAAAK,EAAA,EAAAA,EAAAL,EAAAK,IAAAF,EAAAE,GAAAJ,UAAAI,GAAA,OAAAP,EAAAF,EAAAU,KAAAC,MAAAX,EAAA,OAAAY,OAAAL,IAAAX,GAAAiB,EAAAA,EAAAA,GAAAX,IAAAA,CAAA,QAAAY,EAAAA,EAAAA,GAAArB,EAAA,EAAAI,GAAA,OAAAkB,EAAdtB,EAAcuB,EAAA,EAAAC,KAAA,QAAAC,WAAA,EACxBC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,OAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,WAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,SAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,MAAA,kBAAmB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAEnDC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,MAAA,kBAAmB,CAAI,IAAAL,KAAA,SAAAI,IAAA,SAAAC,MAEnD,WAAmB,IAAAG,EACjB,OAAOC,EAAAA,EAAAA,IAAIC,IAAAA,GAAAC,EAAAA,EAAAA,GAAA,wIAEEC,KAAKC,MACJD,KAAKE,OACPF,KAAKP,MACDO,KAAKG,SACLH,KAAKI,SACkB,QADVR,EACZI,KAAKK,SAASC,gBAAQ,IAAAV,OAAA,EAAtBA,EAAwBW,WAG3C,IAAC,GA1BiCC,EAAAA,I","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20230703.0/src/common/dom/stop_propagation.ts","https://raw.githubusercontent.com/home-assistant/frontend/20230703.0/src/components/ha-selector/ha-selector-duration.ts"],"names":["stopPropagation","ev","HaTimeDuration","_decorate","customElement","_initialize","_LitElement","_LitElement2","_inherits","_super","_createSuper","_this","_classCallCheck","_len","arguments","length","args","Array","_key","call","apply","concat","_assertThisInitialized","_createClass","F","d","kind","decorators","property","attribute","key","value","type","Boolean","_this$selector$durati","html","_templateObject","_taggedTemplateLiteral","this","label","helper","disabled","required","selector","duration","enable_day","LitElement"],"sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,2 +0,0 @@
!function(){"use strict";var n,t,e={14595:function(n,t,e){e(58556);var r,i,o=e(93217),u=e(422),a=e(62173),s=function(n,t,e){if("input"===n){if("type"===t&&"checkbox"===e||"checked"===t||"disabled"===t)return;return""}},c={renderMarkdown:function(n,t){var e,o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return r||(r=Object.assign(Object.assign({},(0,a.getDefaultWhiteList)()),{},{input:["type","disabled","checked"],"ha-icon":["icon"],"ha-svg-icon":["path"],"ha-alert":["alert-type","title"]})),o.allowSvg?(i||(i=Object.assign(Object.assign({},r),{},{svg:["xmlns","height","width"],path:["transform","stroke","d"],img:["src"]})),e=i):e=r,(0,a.filterXSS)((0,u.TU)(n,t),{whiteList:e,onTagAttr:s})}};(0,o.Jj)(c)}},r={};function i(n){var t=r[n];if(void 0!==t)return t.exports;var o=r[n]={exports:{}};return e[n](o,o.exports,i),o.exports}i.m=e,i.x=function(){var n=i.O(void 0,[9191,215],(function(){return i(14595)}));return n=i.O(n)},n=[],i.O=function(t,e,r,o){if(!e){var u=1/0;for(f=0;f<n.length;f++){e=n[f][0],r=n[f][1],o=n[f][2];for(var a=!0,s=0;s<e.length;s++)(!1&o||u>=o)&&Object.keys(i.O).every((function(n){return i.O[n](e[s])}))?e.splice(s--,1):(a=!1,o<u&&(u=o));if(a){n.splice(f--,1);var c=r();void 0!==c&&(t=c)}}return t}o=o||0;for(var f=n.length;f>0&&n[f-1][2]>o;f--)n[f]=n[f-1];n[f]=[e,r,o]},i.n=function(n){var t=n&&n.__esModule?function(){return n.default}:function(){return n};return i.d(t,{a:t}),t},i.d=function(n,t){for(var e in t)i.o(t,e)&&!i.o(n,e)&&Object.defineProperty(n,e,{enumerable:!0,get:t[e]})},i.f={},i.e=function(n){return Promise.all(Object.keys(i.f).reduce((function(t,e){return i.f[e](n,t),t}),[]))},i.u=function(n){return n+"-"+{215:"FPZmDYZTPdk",9191:"37260H-osZ4"}[n]+".js"},i.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)},i.p="/api/hassio/app/frontend_es5/",function(){var n={1402:1};i.f.i=function(t,e){n[t]||importScripts(i.p+i.u(t))};var t=self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[],e=t.push.bind(t);t.push=function(t){var r=t[0],o=t[1],u=t[2];for(var a in o)i.o(o,a)&&(i.m[a]=o[a]);for(u&&u(i);r.length;)n[r.pop()]=1;e(t)}}(),t=i.x,i.x=function(){return Promise.all([i.e(9191),i.e(215)]).then(t)};i.x()}();
//# sourceMappingURL=1402-6WKUruvoXtM.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"1402-6WKUruvoXtM.js","mappings":"6BAAIA,ECAAC,E,sCCMAC,EACAC,E,+BAMEC,EAAY,SAChBC,EACAC,EACAC,GAEA,GAAY,UAARF,EAAiB,CACnB,GACY,SAATC,GAA6B,aAAVC,GACX,YAATD,GACS,aAATA,EAEA,OAEF,MAAO,EACT,CAEF,EA0CME,EAAM,CACVC,eAzCqB,SACrBC,EACAC,GAKW,IAWPC,EAfJC,EAGCC,UAAAC,OAAA,QAAAC,IAAAF,UAAA,GAAAA,UAAA,GAAG,CAAC,EA4BL,OA1BKZ,IACHA,EAAee,OAAAC,OAAAD,OAAAC,OAAA,IACVC,EAAAA,EAAAA,wBAAqB,IACxBC,MAAO,CAAC,OAAQ,WAAY,WAC5B,UAAW,CAAC,QACZ,cAAe,CAAC,QAChB,WAAY,CAAC,aAAc,YAM3BP,EAAYQ,UACTlB,IACHA,EAAYc,OAAAC,OAAAD,OAAAC,OAAA,GACPhB,GAAe,IAClBoB,IAAK,CAAC,QAAS,SAAU,SACzBC,KAAM,CAAC,YAAa,SAAU,KAC9BC,IAAK,CAAC,UAGVZ,EAAYT,GAEZS,EAAYV,GAGPuB,EAAAA,EAAAA,YAAUC,EAAAA,EAAAA,IAAOhB,EAASC,GAAgB,CAC/CC,UAAAA,EACAR,UAAAA,GAEJ,IAQAuB,EAAAA,EAAAA,IAAOnB,E,GC5EHoB,EAA2B,CAAC,EAGhC,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBd,IAAjBe,EACH,OAAOA,EAAaC,QAGrB,IAAIC,EAASL,EAAyBE,GAAY,CAGjDE,QAAS,CAAC,GAOX,OAHAE,EAAoBJ,GAAUG,EAAQA,EAAOD,QAASH,GAG/CI,EAAOD,OACf,CAGAH,EAAoBM,EAAID,EAGxBL,EAAoBO,EAAI,WAGvB,IAAIC,EAAsBR,EAAoBS,OAAEtB,EAAW,CAAC,KAAK,MAAM,WAAa,OAAOa,EAAoB,MAAQ,IAEvH,OADAQ,EAAsBR,EAAoBS,EAAED,EAE7C,EHlCIrC,EAAW,GACf6B,EAAoBS,EAAI,SAASC,EAAQC,EAAUC,EAAIC,GACtD,IAAGF,EAAH,CAMA,IAAIG,EAAeC,IACnB,IAASC,EAAI,EAAGA,EAAI7C,EAASe,OAAQ8B,IAAK,CACrCL,EAAWxC,EAAS6C,GAAG,GACvBJ,EAAKzC,EAAS6C,GAAG,GACjBH,EAAW1C,EAAS6C,GAAG,GAE3B,IAJA,IAGIC,GAAY,EACPC,EAAI,EAAGA,EAAIP,EAASzB,OAAQgC,MACpB,EAAXL,GAAsBC,GAAgBD,IAAazB,OAAO+B,KAAKnB,EAAoBS,GAAGW,OAAM,SAASC,GAAO,OAAOrB,EAAoBS,EAAEY,GAAKV,EAASO,GAAK,IAChKP,EAASW,OAAOJ,IAAK,IAErBD,GAAY,EACTJ,EAAWC,IAAcA,EAAeD,IAG7C,GAAGI,EAAW,CACb9C,EAASmD,OAAON,IAAK,GACrB,IAAIO,EAAIX,SACEzB,IAANoC,IAAiBb,EAASa,EAC/B,CACD,CACA,OAAOb,CArBP,CAJCG,EAAWA,GAAY,EACvB,IAAI,IAAIG,EAAI7C,EAASe,OAAQ8B,EAAI,GAAK7C,EAAS6C,EAAI,GAAG,GAAKH,EAAUG,IAAK7C,EAAS6C,GAAK7C,EAAS6C,EAAI,GACrG7C,EAAS6C,GAAK,CAACL,EAAUC,EAAIC,EAwB/B,EI5BAb,EAAoBwB,EAAI,SAASpB,GAChC,IAAIqB,EAASrB,GAAUA,EAAOsB,WAC7B,WAAa,OAAOtB,EAAgB,OAAG,EACvC,WAAa,OAAOA,CAAQ,EAE7B,OADAJ,EAAoB2B,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CACR,ECNAzB,EAAoB2B,EAAI,SAASxB,EAAS0B,GACzC,IAAI,IAAIR,KAAOQ,EACX7B,EAAoB8B,EAAED,EAAYR,KAASrB,EAAoB8B,EAAE3B,EAASkB,IAC5EjC,OAAO2C,eAAe5B,EAASkB,EAAK,CAAEW,YAAY,EAAMC,IAAKJ,EAAWR,IAG3E,ECPArB,EAAoBkC,EAAI,CAAC,EAGzBlC,EAAoBmC,EAAI,SAASC,GAChC,OAAOC,QAAQC,IAAIlD,OAAO+B,KAAKnB,EAAoBkC,GAAGK,QAAO,SAASC,EAAUnB,GAE/E,OADArB,EAAoBkC,EAAEb,GAAKe,EAASI,GAC7BA,CACR,GAAG,IACJ,ECPAxC,EAAoByC,EAAI,SAASL,GAEhC,OAAYA,EAAU,IAAM,CAAC,IAAM,cAAc,KAAO,eAAeA,GAAW,KACnF,ECJApC,EAAoB8B,EAAI,SAASY,EAAKC,GAAQ,OAAOvD,OAAOwD,UAAUC,eAAeC,KAAKJ,EAAKC,EAAO,ECAtG3C,EAAoB+C,EAAI,gC,WCIxB,IAAIC,EAAkB,CACrB,KAAM,GAkBPhD,EAAoBkC,EAAElB,EAAI,SAASoB,EAASI,GAEvCQ,EAAgBZ,IAElBa,cAAcjD,EAAoB+C,EAAI/C,EAAoByC,EAAEL,GAG/D,EAEA,IAAIc,EAAqBC,KAA0C,oCAAIA,KAA0C,qCAAK,GAClHC,EAA6BF,EAAmBG,KAAKC,KAAKJ,GAC9DA,EAAmBG,KAzBA,SAASE,GAC3B,IAAI5C,EAAW4C,EAAK,GAChBC,EAAcD,EAAK,GACnBE,EAAUF,EAAK,GACnB,IAAI,IAAItD,KAAYuD,EAChBxD,EAAoB8B,EAAE0B,EAAavD,KACrCD,EAAoBM,EAAEL,GAAYuD,EAAYvD,IAIhD,IADGwD,GAASA,EAAQzD,GACdW,EAASzB,QACd8D,EAAgBrC,EAAS+C,OAAS,EACnCN,EAA2BG,EAC5B,C,ITtBInF,EAAO4B,EAAoBO,EAC/BP,EAAoBO,EAAI,WACvB,OAAO8B,QAAQC,IAAI,CAClBtC,EAAoBmC,EAAE,MACtBnC,EAAoBmC,EAAE,OACpBwB,KAAKvF,EACT,EUL0B4B,EAAoBO,G","sources":["no-source/webpack/runtime/chunk loaded","no-source/webpack/runtime/startup chunk dependencies","https://raw.githubusercontent.com/home-assistant/frontend/20230703.0/src/resources/markdown-worker.ts","no-source/webpack/bootstrap","no-source/webpack/runtime/compat get default export","no-source/webpack/runtime/define property getters","no-source/webpack/runtime/ensure chunk","no-source/webpack/runtime/get javascript chunk filename","no-source/webpack/runtime/hasOwnProperty shorthand","no-source/webpack/runtime/publicPath","no-source/webpack/runtime/importScripts chunk loading","no-source/webpack/startup"],"names":["deferred","next","whiteListNormal","whiteListSvg","onTagAttr","tag","name","value","api","renderMarkdown","content","markedOptions","whiteList","hassOptions","arguments","length","undefined","Object","assign","getDefaultWhiteList","input","allowSvg","svg","path","img","filterXSS","marked","expose","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","exports","module","__webpack_modules__","m","x","__webpack_exports__","O","result","chunkIds","fn","priority","notFulfilled","Infinity","i","fulfilled","j","keys","every","key","splice","r","n","getter","__esModule","d","a","definition","o","defineProperty","enumerable","get","f","e","chunkId","Promise","all","reduce","promises","u","obj","prop","prototype","hasOwnProperty","call","p","installedChunks","importScripts","chunkLoadingGlobal","self","parentChunkLoadingFunction","push","bind","data","moreModules","runtime","pop","then"],"sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
"use strict";(self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[]).push([[1639],{71639:function(s){s.exports=[]}}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More