Compare commits

..

8 Commits

Author SHA1 Message Date
Ludeeus
c51496ad2f add missing id 2021-04-13 13:06:32 +00:00
Ludeeus
fbe409337b Add parrent_id 2021-04-13 12:55:20 +00:00
Ludeeus
443a43cc5b hex is str 2021-04-13 12:48:38 +00:00
Ludeeus
0e25fad1c0 Merge branch 'main' of github.com:home-assistant/supervisor into context 2021-04-13 12:44:45 +00:00
Ludeeus
488a2327fb Add name setter 2020-12-05 12:58:49 +00:00
Ludeeus
b99ed631c5 Add data to job 2020-12-05 12:44:17 +00:00
Ludeeus
726dd3a8f9 Merge branch 'main' of github.com:home-assistant/supervisor into context 2020-12-05 12:29:11 +00:00
Ludeeus
b94810d044 init 2020-11-27 16:10:39 +00:00
1867 changed files with 18763 additions and 537350 deletions

60
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.8
ENV DEBIAN_FRONTEND=noninteractive
SHELL ["/bin/bash", "-c"]
WORKDIR /workspaces
# Set Docker daemon config
RUN \
mkdir -p /etc/docker \
&& echo '{"storage-driver": "vfs"}' > /etc/docker/daemon.json
# Install Node/Yarn for Frontent
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& apt-get update \
&& apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
apt-utils \
apt-transport-https \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \
&& apt-get update && apt-get install -y --no-install-recommends \
nodejs \
yarn \
&& curl -o - https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash \
&& rm -rf /var/lib/apt/lists/*
ENV NVM_DIR /root/.nvm
# Install docker
# https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/
RUN apt-get update && apt-get install -y --no-install-recommends \
apt-transport-https \
ca-certificates \
curl \
software-properties-common \
gpg-agent \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \
&& add-apt-repository "deb https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
&& apt-get update && apt-get install -y --no-install-recommends \
docker-ce \
docker-ce-cli \
containerd.io \
&& rm -rf /var/lib/apt/lists/*
# Install tools
RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
dbus \
network-manager \
libpulse0 \
&& bash <(curl https://getvcn.codenotary.com -L) \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies from requirements.txt if it exists
COPY requirements.txt requirements_tests.txt ./
RUN pip3 install -U setuptools pip \
&& pip3 install -r requirements.txt -r requirements_tests.txt \
&& pip3 install tox \
&& rm -f requirements.txt requirements_tests.txt

View File

@@ -1,26 +1,19 @@
{
"name": "Supervisor dev",
"image": "ghcr.io/home-assistant/devcontainer:supervisor",
"containerEnv": {
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
},
"appPort": ["9123:8123", "7357:4357"],
"postCreateCommand": "bash devcontainer_bootstrap",
"context": "..",
"dockerFile": "Dockerfile",
"appPort": "9123:8123",
"postCreateCommand": "pre-commit install",
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
"containerEnv": {"NVM_DIR":"/usr/local/share/nvm"},
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"visualstudioexptteam.vscodeintellicode",
"esbenp.prettier-vscode"
],
"mounts": ["type=volume,target=/var/lib/docker"],
"settings": {
"terminal.integrated.profiles.linux": {
"zsh": {
"path": "/usr/bin/zsh"
}
},
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.shell.linux": "/bin/bash",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
@@ -29,7 +22,7 @@
"python.linting.pylintEnabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "black",
"python.formatting.blackArgs": ["--target-version", "py310"],
"python.formatting.blackArgs": ["--target-version", "py38"],
"python.formatting.blackPath": "/usr/local/bin/black",
"python.linting.banditPath": "/usr/local/bin/bandit",
"python.linting.flake8Path": "/usr/local/bin/flake8",

View File

@@ -1,6 +1,8 @@
name: Bug Report Form
description: Report an issue related to the Home Assistant Supervisor.
about: Report an issue related to the Home Assistant Supervisor.
labels: bug
title: ""
issue_body: true
body:
- type: markdown
attributes:
@@ -20,14 +22,22 @@ body:
attributes:
value: |
## 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
validations:
required: true
attributes:
label: What type of installation are you running?
description: >
If you don't know, can be found in [Settings -> System -> Repairs -> System Information](https://my.home-assistant.io/redirect/system_health/).
It is listed as the `Installation Type` value.
If you don't know, you can find it in: Configuration panel -> Info.
options:
- Home Assistant OS
- Home Assistant Supervised
@@ -40,6 +50,22 @@ body:
- Home Assistant Operating System
- Debian
- 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
attributes:
value: |
@@ -63,34 +89,18 @@ body:
attributes:
label: Anything in the Supervisor logs that might be useful for us?
description: >
Supervisor Logs can be found in [Settings -> System -> Logs](https://my.home-assistant.io/redirect/logs/)
then choose `Supervisor` in the top right.
The Supervisor logs can be found in the Supervisor panel -> System tab.
value: |
```txt
# Put your logs below this line
[![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
- type: textarea
validations:
required: true
```
- type: markdown
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
value: |
## Additional information
- type: markdown
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
attributes:
label: Additional information
description: >
value: |
If you have any additional information for us, use the field below.
Please note, you can attach screenshots or screen recordings here, by
dragging and dropping files in the field below.
Please note, you can attach screenshots or screen recordings here.

View File

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

View File

@@ -27,19 +27,15 @@ on:
paths:
- "rootfs/**"
- "supervisor/**"
- build.yaml
- build.json
- Dockerfile
- requirements.txt
- setup.py
env:
DEFAULT_PYTHON: "3.11"
BUILD_NAME: supervisor
BUILD_TYPE: supervisor
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
WHEELS_TAG: 3.8-alpine3.13
jobs:
init:
@@ -53,7 +49,7 @@ jobs:
requirements: ${{ steps.requirements.outputs.changed }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v2
with:
fetch-depth: 0
@@ -75,7 +71,7 @@ jobs:
- name: Check if requirements files changed
id: requirements
run: |
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.json) ]]; then
if [[ "${{ steps.changed_files.outputs.all }}" =~ requirements.txt ]]; then
echo "::set-output name=changed::true"
fi
@@ -88,29 +84,21 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v2
with:
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
if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@2023.04.0
uses: home-assistant/wheels@master
with:
abi: cp311
tag: musllinux_1_2
tag: ${{ env.WHEELS_TAG }}
arch: ${{ matrix.arch }}
wheels-host: ${{ secrets.WHEELS_HOST }}
wheels-key: ${{ secrets.WHEELS_KEY }}
apk: "libffi-dev;openssl-dev"
wheels-user: wheels
apk: "build-base;libffi-dev;openssl-dev;cargo"
skip-binary: aiohttp
env-file: true
requirements: "requirements.txt"
- name: Set version
@@ -121,72 +109,60 @@ jobs:
- name: Login to DockerHub
if: needs.init.outputs.publish == 'true'
uses: docker/login-action@v2.2.0
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: needs.init.outputs.publish == 'true'
uses: docker/login-action@v2.2.0
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.GIT_USER }}
password: ${{ secrets.GIT_TOKEN }}
- name: Set build arguments
if: needs.init.outputs.publish == 'false'
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
- name: Build supervisor
uses: home-assistant/builder@2023.06.0
uses: home-assistant/builder@2021.04.0
with:
args: |
$BUILD_ARGS \
--${{ matrix.arch }} \
--target /data \
--with-codenotary "${{ secrets.VCN_USER }}" "${{ secrets.VCN_PASSWORD }}" "${{ secrets.VCN_ORG }}" \
--validate-from "${{ secrets.VCN_ORG }}" \
--validate-cache "${{ secrets.VCN_ORG }}" \
--generic ${{ needs.init.outputs.version }}
env:
CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
codenotary:
name: CAS signature
name: CodeNotary signature
needs: init
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
if: needs.init.outputs.publish == 'true'
uses: actions/checkout@v3.5.3
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@v4.6.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Set version
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version@master
with:
type: ${{ env.BUILD_TYPE }}
- name: Install dirhash and calc hash
if: needs.init.outputs.publish == 'true'
id: dirhash
run: |
pip3 install dirhash
dir_hash="$(dirhash "${{ github.workspace }}/supervisor" -a sha256 --match "*.py")"
echo "::set-output name=dirhash::${dir_hash}"
- name: Signing Source
- name: Signing image
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/codenotary@master
with:
source: hash://${{ steps.dirhash.outputs.dirhash }}
asset: supervisor-${{ needs.init.outputs.version }}
token: ${{ secrets.CAS_TOKEN }}
source: dir://${{ github.workspace }}
user: ${{ secrets.VCN_USER }}
password: ${{ secrets.VCN_PASSWORD }}
organisation: ${{ secrets.VCN_ORG }}
version:
name: Update version
@@ -195,7 +171,7 @@ jobs:
steps:
- name: Checkout the repository
if: needs.init.outputs.publish == 'true'
uses: actions/checkout@v3.5.3
uses: actions/checkout@v2
- name: Initialize git
if: needs.init.outputs.publish == 'true'
@@ -216,15 +192,13 @@ jobs:
run_supervisor:
runs-on: ubuntu-latest
name: Run the Supervisor
needs: ["build", "codenotary", "init"]
timeout-minutes: 60
needs: ["build", "codenotary"]
steps:
- name: Checkout the repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v2
- name: Build the Supervisor
if: needs.init.outputs.publish != 'true'
uses: home-assistant/builder@2023.06.0
uses: home-assistant/builder@2021.04.0
with:
args: |
--test \
@@ -232,19 +206,13 @@ jobs:
--target /data \
--generic runner
- name: Pull Supervisor
if: needs.init.outputs.publish == 'true'
run: |
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 }} homeassistant/amd64-hassio-supervisor:runner
- name: Create the Supervisor
run: |
mkdir -p /tmp/supervisor/data
docker create --name hassio_supervisor \
--privileged \
--security-opt seccomp=unconfined \
--security-opt apparmor=unconfined \
--security-opt apparmor:unconfined \
-v /run/docker.sock:/run/docker.sock \
-v /run/dbus:/run/dbus \
-v /tmp/supervisor/data:/data \
@@ -263,135 +231,22 @@ jobs:
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
ping="error"
while [ "$ping" != "ok" ]; do
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r .result)
sleep 5
done
- name: Check the Supervisor
run: |
echo "Checking supervisor info"
test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r .result)
if [ "$test" != "ok" ];then
docker logs hassio_supervisor
exit 1
fi
echo "Checking supervisor network info"
test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r .result)
if [ "$test" != "ok" ];then
docker logs hassio_supervisor
exit 1
fi
- name: Check the Store / Addon
run: |
echo "Install Core SSH Add-on"
test=$(docker exec hassio_cli ha addons install 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
echo "Start Core SSH Add-on"
test=$(docker exec hassio_cli ha addons start core_ssh --no-progress --raw-json | jq -r '.result')
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
fi
- name: Check the Supervisor code sign
if: needs.init.outputs.publish == 'true'
run: |
echo "Enable Content-Trust"
test=$(docker exec hassio_cli ha security options --content-trust=true --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
echo "Run supervisor health check"
test=$(docker exec hassio_cli ha resolution healthcheck --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
echo "Check supervisor unhealthy"
test=$(docker exec hassio_cli ha resolution info --no-progress --raw-json | jq -r '.data.unhealthy[]')
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
fi
- name: Get supervisor logs on failiure
if: ${{ cancelled() || failure() }}
run: docker logs hassio_supervisor

View File

@@ -8,37 +8,36 @@ on:
pull_request: ~
env:
DEFAULT_PYTHON: "3.11"
DEFAULT_PYTHON: 3.8
PRE_COMMIT_HOME: ~/.cache/pre-commit
DEFAULT_CAS: v1.0.2
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
jobs:
# Separate job to pre-populate the base dependency cache
# This prevent upcoming jobs to do the same individually
prepare:
runs-on: ubuntu-latest
outputs:
python-version: ${{ steps.python.outputs.python-version }}
name: Prepare Python dependencies
strategy:
matrix:
python-version: [3.8]
name: Prepare Python ${{ matrix.python-version }} dependencies
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.6.1
uses: actions/setup-python@v2.2.2
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version: ${{ matrix.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
key: |
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
restore-keys: |
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -48,7 +47,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: ${{ env.PRE_COMMIT_HOME }}
key: |
@@ -67,19 +66,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -96,7 +95,7 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
uses: actions/checkout@v2
- name: Register hadolint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -111,19 +110,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -131,7 +130,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: ${{ env.PRE_COMMIT_HOME }}
key: |
@@ -155,19 +154,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -187,19 +186,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -207,7 +206,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: ${{ env.PRE_COMMIT_HOME }}
key: |
@@ -228,19 +227,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -248,7 +247,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: ${{ env.PRE_COMMIT_HOME }}
key: |
@@ -272,19 +271,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -304,19 +303,19 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -324,7 +323,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: ${{ env.PRE_COMMIT_HOME }}
key: |
@@ -342,26 +341,29 @@ jobs:
pytest:
runs-on: ubuntu-latest
needs: prepare
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
strategy:
matrix:
python-version: [3.8]
name: Run tests Python ${{ matrix.python-version }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Install CAS tools
uses: home-assistant/actions/helpers/cas@master
with:
version: ${{ env.DEFAULT_CAS }}
python-version: ${{ matrix.python-version }}
- name: Install CodeNotary
shell: bash
run: |
bash <(curl https://getvcn.codenotary.com -L)
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -370,7 +372,7 @@ jobs:
- name: Install additional system dependencies
run: |
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
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
@@ -392,7 +394,7 @@ jobs:
-o console_output_style=count \
tests
- name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v2.2.3
with:
name: coverage-${{ matrix.python-version }}
path: .coverage
@@ -400,29 +402,29 @@ jobs:
coverage:
name: Process test coverage
runs-on: ubuntu-latest
needs: ["pytest", "prepare"]
needs: pytest
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.5.3
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v4.6.1
uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.2.2
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v3.3.1
uses: actions/cache@v2.1.5
with:
path: venv
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
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Download all coverage artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v2
- name: Combine coverage results
run: |
. venv/bin/activate
@@ -430,4 +432,4 @@ jobs:
coverage report
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.4
uses: codecov/codecov-action@v1.3.2

View File

@@ -9,12 +9,12 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4.0.1
- uses: dessant/lock-threads@v2.0.3
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
exclude-issue-created-before: "2020-10-01T00:00:00Z"
issue-lock-inactive-days: "30"
issue-exclude-created-before: "2020-10-01T00:00:00Z"
issue-lock-reason: ""
pr-inactive-days: "1"
exclude-pr-created-before: "2020-11-01T00:00:00Z"
pr-lock-inactive-days: "1"
pr-exclude-created-before: "2020-11-01T00:00:00Z"
pr-lock-reason: ""

View File

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

View File

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

View File

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

View File

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

7
.vscode/launch.json vendored
View File

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

25
.vscode/tasks.json vendored
View File

@@ -4,7 +4,7 @@
{
"label": "Run Supervisor",
"type": "shell",
"command": "supervisor_run",
"command": "./scripts/run-supervisor.sh",
"group": {
"kind": "test",
"isDefault": true
@@ -15,6 +15,20 @@
},
"problemMatcher": []
},
{
"label": "Build Supervisor",
"type": "shell",
"command": "./scripts/build-supervisor.sh",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Run Supervisor CLI",
"type": "shell",
@@ -32,7 +46,7 @@
{
"label": "Update Supervisor Panel",
"type": "shell",
"command": "LOKALISE_TOKEN='${input:localiseToken}' ./scripts/update-frontend.sh",
"command": "./scripts/update-frontend.sh",
"group": {
"kind": "build",
"isDefault": true
@@ -86,12 +100,5 @@
},
"problemMatcher": []
}
],
"inputs": [
{
"id": "localiseToken",
"type": "promptString",
"description": "Paste your lokalise token to download frontend translations"
}
]
}

View File

@@ -3,20 +3,20 @@ FROM ${BUILD_FROM}
ENV \
S6_SERVICES_GRACETIME=10000 \
SUPERVISOR_API=http://localhost \
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1
SUPERVISOR_API=http://localhost
ARG \
CAS_VERSION
ARG BUILD_ARCH
ARG VCN_VERSION
WORKDIR /usr/src
# Install base
WORKDIR /usr/src
RUN \
set -x \
&& apk add --no-cache \
eudev \
eudev-libs \
git \
glib \
libffi \
libpulse \
musl \
@@ -25,22 +25,39 @@ RUN \
build-base \
go \
\
&& git clone -b "v${CAS_VERSION}" --depth 1 \
https://github.com/codenotary/cas \
&& cd cas \
&& make cas \
&& mv cas /usr/bin/cas \
&& git clone -b v${VCN_VERSION} --depth 1 \
https://github.com/codenotary/vcn \
&& cd vcn \
\
# Fix: https://github.com/codenotary/vcn/issues/131
&& go get github.com/codenotary/immudb@4cf9e2ae06ac2e6ec98a60364c3de3eab5524757 \
\
&& if [ "${BUILD_ARCH}" = "armhf" ]; then \
GOARM=6 GOARCH=arm go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
elif [ "${BUILD_ARCH}" = "armv7" ]; then \
GOARM=7 GOARCH=arm go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
elif [ "${BUILD_ARCH}" = "aarch64" ]; then \
GOARCH=arm64 go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
elif [ "${BUILD_ARCH}" = "i386" ]; then \
GOARCH=386 go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
elif [ "${BUILD_ARCH}" = "amd64" ]; then \
GOARCH=amd64 go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
else \
exit 1; \
fi \
\
&& rm -rf /root/go /root/.cache \
&& mv vcn /usr/bin/vcn \
\
&& apk del .build-dependencies \
&& rm -rf /root/go /root/.cache \
&& rm -rf /usr/src/cas
&& rm -rf /usr/src/vcn
# Install requirements
COPY requirements.txt .
RUN \
export MAKEFLAGS="-j$(nproc)" \
&& 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 \
&& rm -f requirements.txt

18
build.json Normal file
View File

@@ -0,0 +1,18 @@
{
"image": "homeassistant/{arch}-hassio-supervisor",
"shadow_repository": "ghcr.io/home-assistant",
"build_from": {
"aarch64": "ghcr.io/home-assistant/aarch64-base-python:3.8-alpine3.13",
"armhf": "ghcr.io/home-assistant/armhf-base-python:3.8-alpine3.13",
"armv7": "ghcr.io/home-assistant/armv7-base-python:3.8-alpine3.13",
"amd64": "ghcr.io/home-assistant/amd64-base-python:3.8-alpine3.13",
"i386": "ghcr.io/home-assistant/i386-base-python:3.8-alpine3.13"
},
"args": {
"VCN_VERSION": "0.9.4"
},
"labels": {
"io.hass.type": "supervisor",
"org.opencontainers.image.source": "https://github.com/home-assistant/supervisor"
}
}

View File

@@ -1,22 +0,0 @@
image: homeassistant/{arch}-hassio-supervisor
shadow_repository: ghcr.io/home-assistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.11-alpine3.16
armhf: ghcr.io/home-assistant/armhf-base-python:3.11-alpine3.16
armv7: ghcr.io/home-assistant/armv7-base-python:3.11-alpine3.16
amd64: ghcr.io/home-assistant/amd64-base-python:3.11-alpine3.16
i386: ghcr.io/home-assistant/i386-base-python:3.11-alpine3.16
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
args:
CAS_VERSION: 1.0.2
labels:
io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor
org.opencontainers.image.description: Container-based system for managing Home Assistant Core installation
org.opencontainers.image.source: https://github.com/home-assistant/supervisor
org.opencontainers.image.authors: The Home Assistant Authors
org.opencontainers.image.url: https://www.home-assistant.io/
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
org.opencontainers.image.licenses: Apache License 2.0

View File

@@ -2,29 +2,31 @@
reports=no
jobs=2
good-names=id,i,j,k,ex,Run,_,fp,T,os
extension-pkg-whitelist=
ciso8601
good-names=id,i,j,k,ex,Run,_,fp,T
# Reasons disabled:
# format - handled by black
# locally-disabled - it spams too much
# duplicate-code - unavoidable
# 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
# 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-few-* - same as too-many-*
# abstract-method - with intro of async there are always methods missing
disable=
format,
abstract-class-little-used,
abstract-method,
cyclic-import,
duplicate-code,
locally-disabled,
no-else-return,
no-self-use,
not-context-manager,
redefined-variable-type,
too-few-public-methods,
too-many-arguments,
too-many-branches,
@@ -35,10 +37,9 @@ disable=
too-many-return-statements,
too-many-statements,
unused-argument,
consider-using-with
[EXCEPTIONS]
overgeneral-exceptions=builtins.Exception
overgeneral-exceptions=Exception
[TYPECHECK]

View File

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

View File

@@ -1,26 +1,20 @@
aiodns==3.0.0
aiohttp==3.8.4
async_timeout==4.0.2
atomicwrites-homeassistant==1.4.1
attrs==23.1.0
awesomeversion==23.5.0
aiohttp==3.7.4.post0
async_timeout==3.0.1
atomicwrites==1.4.0
attrs==20.3.0
awesomeversion==21.4.0
brotli==1.0.9
ciso8601==2.3.0
colorlog==6.7.0
cchardet==2.1.7
colorlog==4.8.0
cpe==1.2.1
cryptography==41.0.1
debugpy==1.6.7
deepmerge==1.1.0
dirhash==0.2.1
docker==6.1.3
faust-cchardet==2.1.18
gitpython==3.1.31
jinja2==3.1.2
pulsectl==23.5.2
pyudev==0.24.1
ruamel.yaml==0.17.21
securetar==2023.3.0
sentry-sdk==1.25.1
voluptuous==0.13.1
dbus-fast==1.86.0
typing_extensions==4.6.3
cryptography==3.4.6
debugpy==1.2.1
docker==5.0.0
gitpython==3.1.14
jinja2==2.11.3
pulsectl==21.3.4
pytz==2021.1
pyudev==0.22.0
ruamel.yaml==0.15.100
sentry-sdk==1.0.0
voluptuous==0.12.1

View File

@@ -1,16 +1,14 @@
black==23.3.0
coverage==7.2.7
flake8-docstrings==1.7.0
flake8==6.0.0
pre-commit==3.3.3
pydocstyle==6.3.0
pylint==2.17.4
pytest-aiohttp==1.0.4
pytest-asyncio==0.18.3
pytest-cov==4.1.0
pytest-timeout==2.1.0
pytest==7.3.2
pyupgrade==3.6.0
time-machine==2.9.0
typing_extensions==4.6.3
urllib3==2.0.3
black==20.8b1
codecov==2.1.11
coverage==5.5
flake8-docstrings==1.6.0
flake8==3.9.0
pre-commit==2.12.0
pydocstyle==6.0.0
pylint==2.7.4
pytest-aiohttp==0.3.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-cov==2.11.1
pytest-timeout==1.4.2
pytest==6.2.3
pyupgrade==2.12.0

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
# ==============================================================================
if { s6-test ${1} -ne 100 }
if { s6-test ${1} -ne 256 }
if [[ "$1" -ne 100 ]] && [[ "$1" -ne 256 ]]; then
bashio::log.warning "Halt Supervisor"
/run/s6/basedir/bin/halt
fi
bashio::log.info "Supervisor restart after closing"
redirfd -w 2 /dev/null s6-svscanctl -t /var/run/s6/services

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

@@ -3,6 +3,5 @@
# Start Supervisor service
# ==============================================================================
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
export MALLOC_CONF="background_thread:true,metadata_thp:auto"
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
# ==============================================================================
if { s6-test ${1} -ne 0 }
if { s6-test ${1} -ne 256 }
if [[ "$1" -ne 0 ]] && [[ "$1" -ne 256 ]]; then
bashio::log.warning "Halt Supervisor (Wuff)"
/run/s6/basedir/bin/halt
fi
bashio::log.info "Watchdog restart after closing"
s6-svscanctl -t /var/run/s6/services

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

@@ -31,4 +31,4 @@ do
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

@@ -1,4 +0,0 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE03LvYuz79GTJx4uKp3w6NrSe5JZI
iBtgzzYi0YQYtZO/r+xFpgDJEa0gLHkXtl94fpqrFiN89In83lzaszbZtA==
-----END PUBLIC KEY-----

View File

@@ -1,8 +0,0 @@
{
"currentcontext": {
"LcHost": "cas.codenotary.com",
"LcPort": "443"
},
"schemaversion": 3,
"users": null
}

28
scripts/build-supervisor.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
source "${BASH_SOURCE[0]%/*}/common.sh"
set -eE
DOCKER_TIMEOUT=30
DOCKER_PID=0
function build_supervisor() {
docker pull homeassistant/amd64-builder:dev
docker run --rm \
--privileged \
-v /run/docker.sock:/run/docker.sock \
-v "$(pwd):/data" \
homeassistant/amd64-builder:dev \
--generic latest \
--target /data \
--test \
--amd64 \
--no-cache
}
echo "Build Supervisor"
start_docker
trap "stop_docker" ERR
build_supervisor

58
scripts/common.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
function start_docker() {
local starttime
local endtime
echo "Starting docker."
dockerd 2> /dev/null &
DOCKER_PID=$!
echo "Waiting for docker to initialize..."
starttime="$(date +%s)"
endtime="$(date +%s)"
until docker info >/dev/null 2>&1; do
if [ $((endtime - starttime)) -le $DOCKER_TIMEOUT ]; then
sleep 1
endtime=$(date +%s)
else
echo "Timeout while waiting for docker to come up"
exit 1
fi
done
echo "Docker was initialized"
}
function stop_docker() {
local starttime
local endtime
echo "Stopping in container docker..."
if [ "$DOCKER_PID" -gt 0 ] && kill -0 "$DOCKER_PID" 2> /dev/null; then
starttime="$(date +%s)"
endtime="$(date +%s)"
# Now wait for it to die
kill "$DOCKER_PID"
while kill -0 "$DOCKER_PID" 2> /dev/null; do
if [ $((endtime - starttime)) -le $DOCKER_TIMEOUT ]; then
sleep 1
endtime=$(date +%s)
else
echo "Timeout while waiting for container docker to die"
exit 1
fi
done
else
echo "Your host might have been left with unreleased resources"
fi
}
function cleanup_lastboot() {
if [[ -f /workspaces/test_supervisor/config.json ]]; then
echo "Cleaning up last boot"
cp /workspaces/test_supervisor/config.json /tmp/config.json
jq -rM 'del(.last_boot)' /tmp/config.json > /workspaces/test_supervisor/config.json
rm /tmp/config.json
fi
}

102
scripts/run-supervisor.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
source "${BASH_SOURCE[0]%/*}/common.sh"
source "${BASH_SOURCE[0]%/*}/build-supervisor.sh"
set -eE
DOCKER_TIMEOUT=30
DOCKER_PID=0
function cleanup_docker() {
echo "Cleaning up stopped containers..."
docker rm $(docker ps -a -q) || true
}
function run_supervisor() {
mkdir -p /workspaces/test_supervisor
echo "Start Supervisor"
docker run --rm --privileged \
--name hassio_supervisor \
--privileged \
--security-opt seccomp=unconfined \
--security-opt apparmor:unconfined \
-v /run/docker.sock:/run/docker.sock:rw \
-v /run/dbus:/run/dbus:ro \
-v /run/udev:/run/udev:ro \
-v "/workspaces/test_supervisor":/data:rw \
-v /etc/machine-id:/etc/machine-id:ro \
-v /workspaces/supervisor:/usr/src/supervisor \
-e SUPERVISOR_SHARE="/workspaces/test_supervisor" \
-e SUPERVISOR_NAME=hassio_supervisor \
-e SUPERVISOR_DEV=1 \
-e SUPERVISOR_MACHINE="qemux86-64" \
homeassistant/amd64-hassio-supervisor:latest
}
function init_dbus() {
if pgrep dbus-daemon; then
echo "Dbus is running"
return 0
fi
echo "Startup dbus"
mkdir -p /var/lib/dbus
cp -f /etc/machine-id /var/lib/dbus/machine-id
# cleanups
mkdir -p /run/dbus
rm -f /run/dbus/pid
# run
dbus-daemon --system --print-address
}
function init_udev() {
if pgrep systemd-udevd; then
echo "udev is running"
return 0
fi
echo "Startup udev"
# cleanups
mkdir -p /run/udev
# run
/lib/systemd/systemd-udevd --daemon
sleep 3
udevadm trigger && udevadm settle
}
echo "Run Supervisor"
start_docker
trap "stop_docker" ERR
if [ "$( docker container inspect -f '{{.State.Status}}' hassio_supervisor )" == "running" ]; then
echo "Restarting Supervisor"
docker rm -f hassio_supervisor
init_dbus
init_udev
cleanup_lastboot
run_supervisor
stop_docker
else
echo "Starting Supervisor"
docker system prune -f
build_supervisor
cleanup_lastboot
cleanup_docker
init_dbus
init_udev
run_supervisor
stop_docker
fi

View File

@@ -1,6 +1,4 @@
#!/bin/bash
source "/etc/supervisor_scripts/common"
set -e
# Update frontend
@@ -11,10 +9,6 @@ cd home-assistant-polymer
nvm install
script/bootstrap
# Download translations
start_docker
./script/translations_download
# build frontend
cd hassio
./script/build_hassio
@@ -22,9 +16,3 @@ cd hassio
# Copy frontend
rm -rf ../../supervisor/api/panel/*
cp -rf build/* ../../supervisor/api/panel/
# Reset frontend git
cd ..
git reset --hard HEAD
stop_docker

View File

@@ -4,8 +4,9 @@ include_trailing_comma=True
force_grid_wrap=0
line_length=88
indent = " "
not_skip = __init__.py
force_sort_within_sections = true
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
default_section = THIRDPARTY
forced_separate = tests
combine_as_imports = true
@@ -27,5 +28,3 @@ ignore =
E203,
D202,
W504
per-file-ignores =
tests/dbus_service_mocks/*.py: F821,F722

View File

@@ -33,9 +33,8 @@ setup(
packages=[
"supervisor.addons",
"supervisor.api",
"supervisor.backups",
"supervisor.dbus.network",
"supervisor.dbus.network.setting",
"supervisor.dbus.payloads",
"supervisor.dbus",
"supervisor.discovery.services",
"supervisor.discovery",
@@ -49,9 +48,9 @@ setup(
"supervisor.resolution.evaluations",
"supervisor.resolution.fixups",
"supervisor.resolution",
"supervisor.security",
"supervisor.services.modules",
"supervisor.services",
"supervisor.snapshots",
"supervisor.store",
"supervisor.utils",
"supervisor",

View File

@@ -28,8 +28,7 @@ if __name__ == "__main__":
bootstrap.initialize_logging()
# Init async event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop = asyncio.get_event_loop()
# Check if all information are available to setup Supervisor
bootstrap.check_environment()
@@ -40,7 +39,6 @@ if __name__ == "__main__":
_LOGGER.info("Initializing Supervisor setup")
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
loop.set_debug(coresys.config.debug)
loop.run_until_complete(coresys.core.connect())
bootstrap.supervisor_debugger(coresys)

View File

@@ -3,7 +3,7 @@ import asyncio
from contextlib import suppress
import logging
import tarfile
from typing import Union
from typing import Dict, List, Optional, Union
from ..const import AddonBoot, AddonStartup, AddonState
from ..coresys import CoreSys, CoreSysAttributes
@@ -23,9 +23,7 @@ from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..store.addon import AddonStore
from ..utils import check_exception_chain
from ..utils.sentry import capture_exception
from .addon import Addon
from .const import ADDON_UPDATE_CONDITIONS
from .data import AddonsData
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -40,21 +38,21 @@ class AddonManager(CoreSysAttributes):
"""Initialize Docker base wrapper."""
self.coresys: CoreSys = coresys
self.data: AddonsData = AddonsData(coresys)
self.local: dict[str, Addon] = {}
self.store: dict[str, AddonStore] = {}
self.local: Dict[str, Addon] = {}
self.store: Dict[str, AddonStore] = {}
@property
def all(self) -> list[AnyAddon]:
def all(self) -> List[AnyAddon]:
"""Return a list of all add-ons."""
addons: dict[str, AnyAddon] = {**self.store, **self.local}
addons: Dict[str, AnyAddon] = {**self.store, **self.local}
return list(addons.values())
@property
def installed(self) -> list[Addon]:
def installed(self) -> List[Addon]:
"""Return a list of all installed add-ons."""
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.
Prio:
@@ -67,7 +65,7 @@ class AddonManager(CoreSysAttributes):
return self.store.get(addon_slug)
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."""
for addon in self.installed:
if token == addon.supervisor_token:
@@ -79,7 +77,7 @@ class AddonManager(CoreSysAttributes):
tasks = []
for slug in self.data.system:
addon = self.local[slug] = Addon(self.coresys, slug)
tasks.append(self.sys_create_task(addon.load()))
tasks.append(addon.load())
# Run initial tasks
_LOGGER.info("Found %d installed add-ons", len(tasks))
@@ -91,7 +89,7 @@ class AddonManager(CoreSysAttributes):
async def boot(self, stage: AddonStartup) -> None:
"""Boot add-ons with mode auto."""
tasks: list[Addon] = []
tasks: List[Addon] = []
for addon in self.installed:
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
continue
@@ -115,7 +113,7 @@ class AddonManager(CoreSysAttributes):
addon.boot = AddonBoot.MANUAL
addon.save_persist()
except Exception as err: # pylint: disable=broad-except
capture_exception(err)
self.sys_capture_exception(err)
else:
continue
@@ -125,7 +123,7 @@ class AddonManager(CoreSysAttributes):
async def shutdown(self, stage: AddonStartup) -> None:
"""Shutdown addons."""
tasks: list[Addon] = []
tasks: List[Addon] = []
for addon in self.installed:
if addon.state != AddonState.STARTED or addon.startup != stage:
continue
@@ -143,10 +141,14 @@ class AddonManager(CoreSysAttributes):
await addon.stop()
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
capture_exception(err)
self.sys_capture_exception(err)
@Job(
conditions=ADDON_UPDATE_CONDITIONS,
conditions=[
JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError,
)
async def install(self, slug: str) -> None:
@@ -156,13 +158,15 @@ class AddonManager(CoreSysAttributes):
store = self.store.get(slug)
if not store:
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error)
raise AddonsError(f"Add-on {slug} not exists", _LOGGER.error)
store.validate_availability()
if not store.available:
raise AddonsNotSupportedError(
f"Add-on {slug} not supported on that platform", _LOGGER.error
)
self.data.install(store)
addon = Addon(self.coresys, slug)
await addon.load()
if not addon.path_data.is_dir():
_LOGGER.info(
@@ -174,12 +178,12 @@ class AddonManager(CoreSysAttributes):
await addon.install_apparmor()
try:
await addon.instance.install(store.version, store.image, arch=addon.arch)
await addon.instance.install(store.version, store.image)
except DockerError as err:
self.data.uninstall(addon)
raise AddonsError() from err
self.local[slug] = addon
else:
self.local[slug] = addon
# Reload ingress tokens
if addon.with_ingress:
@@ -198,10 +202,10 @@ class AddonManager(CoreSysAttributes):
await addon.instance.remove()
except DockerError as err:
raise AddonsError() from err
else:
addon.state = AddonState.UNKNOWN
addon.state = AddonState.UNKNOWN
await addon.unload()
await addon.remove_data()
# Cleanup audio settings
if addon.path_pulse.exists():
@@ -241,10 +245,14 @@ class AddonManager(CoreSysAttributes):
_LOGGER.info("Add-on '%s' successfully removed", slug)
@Job(
conditions=ADDON_UPDATE_CONDITIONS,
conditions=[
JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError,
)
async def update(self, slug: str, backup: bool | None = False) -> None:
async def update(self, slug: str) -> None:
"""Update add-on."""
if slug not in self.local:
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
@@ -260,13 +268,9 @@ class AddonManager(CoreSysAttributes):
raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning)
# Check if available, Maybe something have changed
store.validate_availability()
if backup:
await self.sys_backups.do_backup_partial(
name=f"addon_{addon.slug}_{addon.version}",
homeassistant=False,
addons=[addon.slug],
if not store.available:
raise AddonsNotSupportedError(
f"Add-on {slug} not supported on that platform", _LOGGER.error
)
# Update instance
@@ -302,24 +306,22 @@ class AddonManager(CoreSysAttributes):
async def rebuild(self, slug: str) -> None:
"""Perform a rebuild of local build add-on."""
if slug not in self.local:
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
_LOGGER.error("Add-on %s is not installed", slug)
raise AddonsError()
addon = self.local[slug]
if addon.is_detached:
raise AddonsError(
f"Add-on {slug} is not available inside store", _LOGGER.error
)
_LOGGER.error("Add-on %s is not available inside store", slug)
raise AddonsError()
store = self.store[slug]
# Check if a rebuild is possible now
if addon.version != store.version:
raise AddonsError(
"Version changed, use Update instead Rebuild", _LOGGER.error
)
_LOGGER.error("Version changed, use Update instead Rebuild")
raise AddonsError()
if not addon.need_build:
raise AddonsNotSupportedError(
"Can't rebuild a image based add-on", _LOGGER.error
)
_LOGGER.error("Can't rebuild a image based add-on")
raise AddonsNotSupportedError()
# remove docker container but not addon config
last_state: AddonState = addon.state
@@ -328,9 +330,9 @@ class AddonManager(CoreSysAttributes):
await addon.instance.install(addon.version)
except DockerError as err:
raise AddonsError() from err
self.data.update(store)
_LOGGER.info("Add-on '%s' successfully rebuilt", slug)
else:
self.data.update(store)
_LOGGER.info("Add-on '%s' successfully rebuilt", slug)
# restore state
if last_state == AddonState.STARTED:
@@ -369,7 +371,7 @@ class AddonManager(CoreSysAttributes):
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
async def repair(self) -> None:
"""Repair local add-ons."""
needs_repair: list[Addon] = []
needs_repair: List[Addon] = []
# Evaluate Add-ons to repair
for addon in self.installed:
@@ -416,7 +418,7 @@ class AddonManager(CoreSysAttributes):
reference=addon.slug,
suggestions=[SuggestionType.EXECUTE_REPAIR],
)
capture_exception(err)
self.sys_capture_exception(err)
else:
self.sys_plugins.dns.add_host(
ipv4=addon.ip_address, names=[addon.hostname], write=False

View File

@@ -1,6 +1,5 @@
"""Init file for Supervisor add-ons."""
import asyncio
from collections.abc import Awaitable
from contextlib import suppress
from copy import deepcopy
from ipaddress import IPv4Address
@@ -11,15 +10,12 @@ import secrets
import shutil
import tarfile
from tempfile import TemporaryDirectory
from typing import Any, Final
from typing import Any, Awaitable, Dict, List, Optional, Set
import aiohttp
from deepmerge import Merger
from securetar import atomic_contents_add, secure_path
import voluptuous as vol
from voluptuous.humanize import humanize_error
from ..bus import EventListener
from ..const import (
ATTR_ACCESS_TOKEN,
ATTR_AUDIO_INPUT,
@@ -50,41 +46,29 @@ from ..const import (
AddonBoot,
AddonStartup,
AddonState,
BusEvent,
)
from ..coresys import CoreSys
from ..docker.addon import DockerAddon
from ..docker.const import ContainerState
from ..docker.monitor import DockerContainerStateEvent
from ..docker.stats import DockerStats
from ..exceptions import (
AddonConfigurationError,
AddonsError,
AddonsJobError,
AddonsNotSupportedError,
ConfigurationFileError,
DockerError,
DockerRequestError,
HostAppArmorError,
)
from ..hardware.data import Device
from ..homeassistant.const import WSEvent, WSType
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..utils import check_port
from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file
from ..utils.sentry import capture_exception
from .const import (
WATCHDOG_MAX_ATTEMPTS,
WATCHDOG_RETRY_SECONDS,
WATCHDOG_THROTTLE_MAX_CALLS,
WATCHDOG_THROTTLE_PERIOD,
AddonBackupMode,
)
from ..utils.tar import atomic_contents_add, secure_path
from .model import AddonModel, Data
from .options import AddonOptions
from .utils import remove_data
from .validate import SCHEMA_ADDON_BACKUP
from .validate import SCHEMA_ADDON_SNAPSHOT
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -98,13 +82,9 @@ RE_WATCHDOG = re.compile(
r":\/\/\[HOST\]:(?:\[PORT:)?(?P<t_port>\d+)\]?(?P<s_suffix>.*)$"
)
WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10)
RE_OLD_AUDIO = re.compile(r"\d+,\d+")
_OPTIONS_MERGER: Final = Merger(
type_strategies=[(dict, ["merge"])],
fallback_strategies=["override"],
type_conflict_strategies=["override"],
)
WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10)
class Addon(AddonModel):
@@ -115,58 +95,6 @@ class Addon(AddonModel):
super().__init__(coresys, slug)
self.instance: DockerAddon = DockerAddon(coresys, self)
self._state: AddonState = AddonState.UNKNOWN
self._manual_stop: bool = (
self.sys_hardware.helper.last_boot != self.sys_config.last_boot
)
self._listeners: list[EventListener] = []
@Job(
name=f"addon_{slug}_restart_after_problem",
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
throttle_period=WATCHDOG_THROTTLE_PERIOD,
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
on_condition=AddonsJobError,
)
async def restart_after_problem(addon: Addon, state: ContainerState):
"""Restart unhealthy or failed addon."""
attempts = 0
while await addon.instance.current_state() == state:
if not addon.in_progress:
_LOGGER.warning(
"Watchdog found addon %s is %s, restarting...",
addon.name,
state.value,
)
try:
if state == ContainerState.FAILED:
# Ensure failed container is removed before attempting reanimation
if attempts == 0:
with suppress(DockerError):
await addon.instance.stop(remove_container=True)
await addon.start()
else:
await addon.restart()
except AddonsError as err:
attempts = attempts + 1
_LOGGER.error(
"Watchdog restart of addon %s failed!", addon.name
)
capture_exception(err)
else:
break
if attempts >= WATCHDOG_MAX_ATTEMPTS:
_LOGGER.critical(
"Watchdog cannot restart addon %s, failed all %s attempts",
addon.name,
attempts,
)
break
await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
self._restart_after_problem = restart_after_problem
def __repr__(self) -> str:
"""Return internal representation."""
@@ -183,7 +111,7 @@ class Addon(AddonModel):
if self._state == new_state:
return
self._state = new_state
self.sys_homeassistant.websocket.send_message(
self.sys_homeassistant.websocket.send_command(
{
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
ATTR_DATA: {
@@ -201,20 +129,15 @@ class Addon(AddonModel):
async def load(self) -> None:
"""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):
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
def ip_address(self) -> IPv4Address:
"""Return IP of add-on instance."""
@@ -251,7 +174,7 @@ class Addon(AddonModel):
return self._available(self.data_store)
@property
def version(self) -> str | None:
def version(self) -> Optional[str]:
"""Return installed version."""
return self.persist[ATTR_VERSION]
@@ -263,19 +186,17 @@ class Addon(AddonModel):
return self.version != self.latest_version
@property
def dns(self) -> list[str]:
def dns(self) -> List[str]:
"""Return list of DNS name for that add-on."""
return [f"{self.hostname}.{DNS_SUFFIX}"]
@property
def options(self) -> dict[str, Any]:
def options(self) -> Dict[str, Any]:
"""Return options with local changes."""
return _OPTIONS_MERGER.merge(
deepcopy(self.data[ATTR_OPTIONS]), deepcopy(self.persist[ATTR_OPTIONS])
)
return {**self.data[ATTR_OPTIONS], **self.persist[ATTR_OPTIONS]}
@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."""
self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value)
@@ -320,17 +241,17 @@ class Addon(AddonModel):
return self.persist[ATTR_UUID]
@property
def supervisor_token(self) -> str | None:
def supervisor_token(self) -> Optional[str]:
"""Return access token for Supervisor API."""
return self.persist.get(ATTR_ACCESS_TOKEN)
@property
def ingress_token(self) -> str | None:
def ingress_token(self) -> Optional[str]:
"""Return access token for Supervisor API."""
return self.persist.get(ATTR_INGRESS_TOKEN)
@property
def ingress_entry(self) -> str | None:
def ingress_entry(self) -> Optional[str]:
"""Return ingress external URL."""
if self.with_ingress:
return f"/api/hassio_ingress/{self.ingress_token}"
@@ -352,12 +273,12 @@ class Addon(AddonModel):
self.persist[ATTR_PROTECTED] = value
@property
def ports(self) -> dict[str, int | None] | None:
def ports(self) -> Optional[Dict[str, Optional[int]]]:
"""Return ports of add-on."""
return self.persist.get(ATTR_NETWORK, super().ports)
@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."""
if value is None:
self.persist.pop(ATTR_NETWORK, None)
@@ -372,7 +293,7 @@ class Addon(AddonModel):
self.persist[ATTR_NETWORK] = new_ports
@property
def ingress_url(self) -> str | None:
def ingress_url(self) -> Optional[str]:
"""Return URL to ingress url."""
if not self.with_ingress:
return None
@@ -383,7 +304,7 @@ class Addon(AddonModel):
return url
@property
def webui(self) -> str | None:
def webui(self) -> Optional[str]:
"""Return URL to webui or None."""
url = super().webui
if not url:
@@ -411,7 +332,7 @@ class Addon(AddonModel):
return f"{proto}://[HOST]:{port}{s_suffix}"
@property
def ingress_port(self) -> int | None:
def ingress_port(self) -> Optional[int]:
"""Return Ingress port."""
if not self.with_ingress:
return None
@@ -422,11 +343,8 @@ class Addon(AddonModel):
return port
@property
def ingress_panel(self) -> bool | None:
def ingress_panel(self) -> Optional[bool]:
"""Return True if the add-on access support ingress."""
if not self.with_ingress:
return None
return self.persist[ATTR_INGRESS_PANEL]
@ingress_panel.setter
@@ -435,32 +353,43 @@ class Addon(AddonModel):
self.persist[ATTR_INGRESS_PANEL] = value
@property
def audio_output(self) -> str | None:
def audio_output(self) -> Optional[str]:
"""Return a pulse profile for output or None."""
if not self.with_audio:
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
def audio_output(self, value: str | None):
def audio_output(self, value: Optional[str]):
"""Set audio output profile settings."""
self.persist[ATTR_AUDIO_OUTPUT] = value
@property
def audio_input(self) -> str | None:
def audio_input(self) -> Optional[str]:
"""Return pulse profile for input or None."""
if not self.with_audio:
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
def audio_input(self, value: str | None) -> None:
def audio_input(self, value: Optional[str]) -> None:
"""Set audio input settings."""
self.persist[ATTR_AUDIO_INPUT] = value
@property
def image(self) -> str | None:
def image(self) -> Optional[str]:
"""Return image name of add-on."""
return self.persist.get(ATTR_IMAGE)
@@ -469,11 +398,6 @@ class Addon(AddonModel):
"""Return True if this add-on need a local build."""
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
def path_data(self) -> Path:
"""Return add-on data path inside Supervisor."""
@@ -500,27 +424,32 @@ class Addon(AddonModel):
return Path(self.sys_config.path_extern_tmp, f"{self.slug}_pulse")
@property
def devices(self) -> set[Device]:
def devices(self) -> Set[Device]:
"""Extract devices from add-on options."""
options_schema = self.schema
with suppress(vol.Invalid):
options_schema.validate(self.options)
raw_schema = self.data[ATTR_SCHEMA]
if isinstance(raw_schema, bool) or not raw_schema:
return set()
return options_schema.devices
# Validate devices
options_validator = AddonOptions(self.coresys, raw_schema, self.name, self.slug)
with suppress(vol.Invalid):
options_validator(self.options)
return options_validator.devices
@property
def pwned(self) -> set[str]:
def pwned(self) -> Set[str]:
"""Extract pwned data for add-on options."""
options_schema = self.schema
raw_schema = self.data[ATTR_SCHEMA]
if isinstance(raw_schema, bool) or not raw_schema:
return set()
# Validate devices
options_validator = AddonOptions(self.coresys, raw_schema, self.name, self.slug)
with suppress(vol.Invalid):
options_schema.validate(self.options)
options_validator(self.options)
return options_schema.pwned
@property
def loaded(self) -> bool:
"""Is add-on loaded."""
return bool(self._listeners)
return options_validator.pwned
def save_persist(self) -> None:
"""Save data of add-on."""
@@ -534,7 +463,7 @@ class Addon(AddonModel):
application = RE_WATCHDOG.match(url)
# extract arguments
t_port = int(application.group("t_port"))
t_port = application.group("t_port")
t_proto = application.group("t_proto")
s_prefix = application.group("s_prefix") or ""
s_suffix = application.group("s_suffix") or ""
@@ -558,8 +487,8 @@ class Addon(AddonModel):
# Make HTTP request
try:
url = f"{proto}://{self.ip_address}:{port}{s_suffix}"
async with self.sys_websession.get(
url, timeout=WATCHDOG_TIMEOUT, ssl=False
async with self.sys_websession_ssl.get(
url, timeout=WATCHDOG_TIMEOUT
) as req:
if req.status < 300:
return True
@@ -574,7 +503,7 @@ class Addon(AddonModel):
await self.sys_homeassistant.secrets.reload()
try:
options = self.schema.validate(self.options)
options = self.schema(self.options)
write_json_file(self.path_options, options)
except vol.Invalid as ex:
_LOGGER.error(
@@ -590,11 +519,8 @@ class Addon(AddonModel):
raise AddonConfigurationError()
async def unload(self) -> None:
"""Unload add-on and remove data."""
for listener in self._listeners:
self.sys_bus.remove_listener(listener)
async def remove_data(self) -> None:
"""Remove add-on data."""
if not self.path_data.is_dir():
return
@@ -613,7 +539,8 @@ class Addon(AddonModel):
# Write pulse config
try:
self.path_pulse.write_text(pulse_config, encoding="utf-8")
with self.path_pulse.open("w") as config_file:
config_file.write(pulse_config)
except OSError as err:
_LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err
@@ -661,9 +588,7 @@ class Addon(AddonModel):
return True
# merge options
options = _OPTIONS_MERGER.merge(
deepcopy(default_options), deepcopy(self.persist[ATTR_OPTIONS])
)
options = {**self.persist[ATTR_OPTIONS], **default_options}
# create voluptuous
new_schema = vol.Schema(
@@ -700,18 +625,27 @@ class Addon(AddonModel):
# Start Add-on
try:
await self.instance.run()
except DockerRequestError as err:
self.state = AddonState.ERROR
raise AddonsError() from err
except DockerError as err:
self.state = AddonState.ERROR
raise AddonsError() from err
else:
self.state = AddonState.STARTED
async def stop(self) -> None:
"""Stop add-on."""
self._manual_stop = True
try:
await self.instance.stop()
except DockerRequestError as err:
self.state = AddonState.ERROR
raise AddonsError() from err
except DockerError as err:
self.state = AddonState.ERROR
raise AddonsError() from err
else:
self.state = AddonState.STOPPED
async def restart(self) -> None:
"""Restart add-on."""
@@ -746,36 +680,16 @@ class Addon(AddonModel):
Return a coroutine.
"""
if not self.with_stdin:
raise AddonsNotSupportedError(
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
)
_LOGGER.error("Add-on %s does not support writing to stdin!", self.slug)
raise AddonsNotSupportedError()
try:
return await self.instance.write_stdin(data)
except DockerError as err:
raise AddonsError() from err
async def _backup_command(self, command: str) -> None:
try:
command_return = await self.instance.run_inside(command)
if command_return.exit_code != 0:
_LOGGER.debug(
"Pre-/Post backup command failed with: %s", command_return.output
)
raise AddonsError(
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
_LOGGER.error,
)
except DockerError as err:
raise AddonsError(
f"Failed running pre-/post backup command {command}: {str(err)}",
_LOGGER.error,
) from err
async def backup(self, tar_file: tarfile.TarFile) -> None:
"""Backup state of an add-on."""
is_running = await self.is_running()
async def snapshot(self, tar_file: tarfile.TarFile) -> None:
"""Snapshot state of an add-on."""
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
temp_path = Path(temp)
@@ -797,82 +711,59 @@ class Addon(AddonModel):
try:
write_json_file(temp_path.joinpath("addon.json"), data)
except ConfigurationFileError as err:
raise AddonsError(
f"Can't save meta for {self.slug}", _LOGGER.error
) from err
_LOGGER.error("Can't save meta for %s", self.slug)
raise AddonsError() from err
# Store AppArmor Profile
if self.sys_host.apparmor.exists(self.slug):
profile = temp_path.joinpath("apparmor.txt")
try:
await self.sys_host.apparmor.backup_profile(self.slug, profile)
self.sys_host.apparmor.backup_profile(self.slug, profile)
except HostAppArmorError as err:
raise AddonsError(
"Can't backup AppArmor profile", _LOGGER.error
) from err
_LOGGER.error("Can't backup AppArmor profile")
raise AddonsError() from err
# write into tarfile
def _write_tarfile():
"""Write tar inside loop."""
with tar_file as backup:
# Backup metadata
backup.add(temp, arcname=".")
with tar_file as snapshot:
# Snapshot system
# Backup data
snapshot.add(temp, arcname=".")
# Snapshot data
atomic_contents_add(
backup,
snapshot,
self.path_data,
excludes=self.backup_exclude,
excludes=self.snapshot_exclude,
arcname="data",
)
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:
_LOGGER.info("Building backup for add-on %s", self.slug)
_LOGGER.info("Building snapshot for add-on %s", self.slug)
await self.sys_run_in_executor(_write_tarfile)
except (tarfile.TarError, OSError) as err:
raise AddonsError(
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
) from err
finally:
if (
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.error("Can't write tarfile %s: %s", tar_file, err)
raise AddonsError() from err
_LOGGER.info("Finish backup for addon %s", self.slug)
_LOGGER.info("Finish snapshot for addon %s", self.slug)
async def restore(self, tar_file: tarfile.TarFile) -> None:
"""Restore state of an add-on."""
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
# extract backup
# extract snapshot
def _extract_tarfile():
"""Extract tar backup."""
with tar_file as backup:
backup.extractall(path=Path(temp), members=secure_path(backup))
"""Extract tar snapshot."""
with tar_file as snapshot:
snapshot.extractall(path=Path(temp), members=secure_path(snapshot))
try:
await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err:
raise AddonsError(
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
) from err
_LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
raise AddonsError() from err
# Read backup data
# Read snapshot data
try:
data = read_json_file(Path(temp, "addon.json"))
except ConfigurationFileError as err:
@@ -880,19 +771,19 @@ class Addon(AddonModel):
# Validate
try:
data = SCHEMA_ADDON_BACKUP(data)
data = SCHEMA_ADDON_SNAPSHOT(data)
except vol.Invalid as err:
raise AddonsError(
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
_LOGGER.error,
) from err
_LOGGER.error(
"Can't validate %s, snapshot data: %s",
self.slug,
humanize_error(data, err),
)
raise AddonsError() from err
# If available
if not self._available(data[ATTR_SYSTEM]):
raise AddonsNotSupportedError(
f"Add-on {self.slug} is not available for this platform",
_LOGGER.error,
)
_LOGGER.error("Add-on %s is not available for this platform", self.slug)
raise AddonsNotSupportedError()
# Restore local add-on information
_LOGGER.info("Restore config for addon %s", self.slug)
@@ -925,11 +816,7 @@ class Addon(AddonModel):
# Restore data
def _restore_data():
"""Restore data."""
temp_data = Path(temp, "data")
if temp_data.is_dir():
shutil.copytree(temp_data, self.path_data, symlinks=True)
else:
self.path_data.mkdir()
shutil.copytree(Path(temp, "data"), self.path_data, symlinks=True)
_LOGGER.info("Restoring data for addon %s", self.slug)
if self.path_data.is_dir():
@@ -937,9 +824,8 @@ class Addon(AddonModel):
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
_LOGGER.error("Can't restore origin data: %s", err)
raise AddonsError() from err
# Restore AppArmor
profile_file = Path(temp, "apparmor.txt")
@@ -952,52 +838,8 @@ class Addon(AddonModel):
)
raise AddonsError() from err
# Is add-on loaded
if not self.loaded:
await self.load()
# Run add-on
if data[ATTR_STATE] == AddonState.STARTED:
return await self.start()
_LOGGER.info("Finished restore for add-on %s", self.slug)
def check_trust(self) -> Awaitable[None]:
"""Calculate Addon docker content trust.
Return Coroutine.
"""
return self.instance.check_trust()
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 in [
ContainerState.RUNNING,
ContainerState.HEALTHY,
ContainerState.UNHEALTHY,
]:
self._manual_stop = False
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(self, event.state)

View File

@@ -1,23 +1,20 @@
"""Supervisor add-on build environment."""
from __future__ import annotations
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict
from awesomeversion import AwesomeVersion
from ..const import (
ATTR_ARGS,
ATTR_BUILD_FROM,
ATTR_LABELS,
ATTR_SQUASH,
FILE_SUFFIX_CONFIGURATION,
META_ADDON,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.interface import MAP_ARCH
from ..exceptions import ConfigurationFileError, HassioArchNotFound
from ..exceptions import ConfigurationFileError
from ..utils.common import FileConfiguration, find_one_filetype
from .validate import SCHEMA_BUILD_CONFIG
@@ -46,33 +43,12 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Ignore save function."""
raise RuntimeError()
@cached_property
def arch(self) -> str:
"""Return arch of the add-on."""
return self.sys_arch.match(self.addon.arch)
@property
def base_image(self) -> str:
"""Return base image for this add-on."""
if not self._data[ATTR_BUILD_FROM]:
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
if self.arch not in self._data[ATTR_BUILD_FROM]:
raise HassioArchNotFound(
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")
return self._data[ATTR_BUILD_FROM].get(
self.sys_arch.default, f"homeassistant/{self.sys_arch.default}-base:latest"
)
@property
def squash(self) -> bool:
@@ -80,45 +56,34 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
return self._data[ATTR_SQUASH]
@property
def additional_args(self) -> dict[str, str]:
def additional_args(self) -> Dict[str, str]:
"""Return additional Docker build arguments."""
return self._data[ATTR_ARGS]
@property
def additional_labels(self) -> dict[str, str]:
"""Return additional Docker labels."""
return self._data[ATTR_LABELS]
@property
def is_valid(self) -> bool:
"""Return true if the build env is valid."""
try:
return all(
[
self.addon.path_location.is_dir(),
self.dockerfile.is_file(),
]
)
except HassioArchNotFound:
return False
return all(
[
self.addon.path_location.is_dir(),
Path(self.addon.path_location, "Dockerfile").is_file(),
]
)
def get_docker_args(self, version: AwesomeVersion):
"""Create a dict with Docker build arguments."""
args = {
"path": str(self.addon.path_location),
"tag": f"{self.addon.image}:{version!s}",
"dockerfile": str(self.dockerfile),
"pull": True,
"forcerm": not self.sys_dev,
"forcerm": True,
"squash": self.squash,
"platform": MAP_ARCH[self.arch],
"labels": {
"io.hass.version": version,
"io.hass.arch": self.arch,
"io.hass.arch": self.sys_arch.default,
"io.hass.type": META_ADDON,
"io.hass.name": self._fix_label("name"),
"io.hass.description": self._fix_label("description"),
**self.additional_labels,
},
"buildargs": {
"BUILD_FROM": self.base_image,

View File

@@ -1,30 +0,0 @@
"""Add-on static data."""
from datetime import timedelta
from enum import Enum
from ..jobs.const import JobCondition
class AddonBackupMode(str, Enum):
"""Backup mode of an Add-on."""
HOT = "hot"
COLD = "cold"
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,6 +1,6 @@
"""Init file for Supervisor add-on data."""
from copy import deepcopy
from typing import Any
from typing import Any, Dict
from ..const import (
ATTR_IMAGE,
@@ -16,7 +16,7 @@ from ..utils.common import FileConfiguration
from .addon import Addon
from .validate import SCHEMA_ADDONS_FILE
Config = dict[str, Any]
Config = Dict[str, Any]
class AddonsData(FileConfiguration, CoreSysAttributes):

View File

@@ -1,12 +1,10 @@
"""Init file for Supervisor add-ons."""
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from contextlib import suppress
import logging
from pathlib import Path
from typing import Any
from typing import Any, Awaitable, Dict, List, Optional
from awesomeversion import AwesomeVersion, AwesomeVersionException
import voluptuous as vol
from ..const import (
ATTR_ADVANCED,
@@ -14,9 +12,6 @@ from ..const import (
ATTR_ARCH,
ATTR_AUDIO,
ATTR_AUTH_API,
ATTR_BACKUP_EXCLUDE,
ATTR_BACKUP_POST,
ATTR_BACKUP_PRE,
ATTR_BOOT,
ATTR_DESCRIPTON,
ATTR_DEVICES,
@@ -34,10 +29,8 @@ from ..const import (
ATTR_HOST_IPC,
ATTR_HOST_NETWORK,
ATTR_HOST_PID,
ATTR_HOST_UTS,
ATTR_IMAGE,
ATTR_INGRESS,
ATTR_INGRESS_STREAM,
ATTR_INIT,
ATTR_JOURNALD,
ATTR_KERNEL_MODULES,
@@ -58,6 +51,7 @@ from ..const import (
ATTR_SCHEMA,
ATTR_SERVICES,
ATTR_SLUG,
ATTR_SNAPSHOT_EXCLUDE,
ATTR_STAGE,
ATTR_STARTUP,
ATTR_STDIN,
@@ -81,14 +75,10 @@ from ..const import (
)
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.const import Capabilities
from ..exceptions import AddonsNotSupportedError
from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode
from .options import AddonOptions, UiOptions
from .validate import RE_SERVICE, RE_VOLUME
_LOGGER: logging.Logger = logging.getLogger(__name__)
Data = dict[str, Any]
Data = Dict[str, Any]
class AddonModel(CoreSysAttributes, ABC):
@@ -120,7 +110,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self._available(self.data)
@property
def options(self) -> dict[str, Any]:
def options(self) -> Dict[str, Any]:
"""Return options with local changes."""
return self.data[ATTR_OPTIONS]
@@ -130,7 +120,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_BOOT]
@property
def auto_update(self) -> bool | None:
def auto_update(self) -> Optional[bool]:
"""Return if auto update is enable."""
return None
@@ -145,7 +135,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.slug.replace("_", "-")
@property
def dns(self) -> list[str]:
def dns(self) -> List[str]:
"""Return list of DNS name for that add-on."""
return []
@@ -155,22 +145,22 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_TIMEOUT]
@property
def uuid(self) -> str | None:
def uuid(self) -> Optional[str]:
"""Return an API token for this add-on."""
return None
@property
def supervisor_token(self) -> str | None:
def supervisor_token(self) -> Optional[str]:
"""Return access token for Supervisor API."""
return None
@property
def ingress_token(self) -> str | None:
def ingress_token(self) -> Optional[str]:
"""Return access token for Supervisor API."""
return None
@property
def ingress_entry(self) -> str | None:
def ingress_entry(self) -> Optional[str]:
"""Return ingress external URL."""
return None
@@ -180,7 +170,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_DESCRIPTON]
@property
def long_description(self) -> str | None:
def long_description(self) -> Optional[str]:
"""Return README.md as long_description."""
readme = Path(self.path_location, "README.md")
@@ -189,7 +179,8 @@ class AddonModel(CoreSysAttributes, ABC):
return None
# Return data
return readme.read_text(encoding="utf-8")
with readme.open("r") as readme_file:
return readme_file.read()
@property
def repository(self) -> str:
@@ -232,7 +223,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_STAGE]
@property
def services_role(self) -> dict[str, str]:
def services_role(self) -> Dict[str, str]:
"""Return dict of services with rights."""
services_list = self.data.get(ATTR_SERVICES, [])
@@ -245,37 +236,37 @@ class AddonModel(CoreSysAttributes, ABC):
return services
@property
def discovery(self) -> list[str]:
def discovery(self) -> List[str]:
"""Return list of discoverable components/platforms."""
return self.data.get(ATTR_DISCOVERY, [])
@property
def ports_description(self) -> dict[str, str] | None:
def ports_description(self) -> Optional[Dict[str, str]]:
"""Return descriptions of ports."""
return self.data.get(ATTR_PORTS_DESCRIPTION)
@property
def ports(self) -> dict[str, int | None] | None:
def ports(self) -> Optional[Dict[str, Optional[int]]]:
"""Return ports of add-on."""
return self.data.get(ATTR_PORTS)
@property
def ingress_url(self) -> str | None:
def ingress_url(self) -> Optional[str]:
"""Return URL to ingress url."""
return None
@property
def webui(self) -> str | None:
def webui(self) -> Optional[str]:
"""Return URL to webui or None."""
return self.data.get(ATTR_WEBUI)
@property
def watchdog(self) -> str | None:
def watchdog(self) -> Optional[str]:
"""Return URL to for watchdog or None."""
return self.data.get(ATTR_WATCHDOG)
@property
def ingress_port(self) -> int | None:
def ingress_port(self) -> Optional[int]:
"""Return Ingress port."""
return None
@@ -309,28 +300,23 @@ class AddonModel(CoreSysAttributes, ABC):
"""Return True if add-on run on host IPC namespace."""
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
def host_dbus(self) -> bool:
"""Return True if add-on run on host D-BUS."""
return self.data[ATTR_HOST_DBUS]
@property
def static_devices(self) -> list[Path]:
def static_devices(self) -> List[Path]:
"""Return static devices of add-on."""
return [Path(node) for node in self.data.get(ATTR_DEVICES, [])]
@property
def environment(self) -> dict[str, str] | None:
def environment(self) -> Optional[Dict[str, str]]:
"""Return environment of add-on."""
return self.data.get(ATTR_ENVIRONMENT)
@property
def privileged(self) -> list[Capabilities]:
def privileged(self) -> List[Capabilities]:
"""Return list of privilege."""
return self.data.get(ATTR_PRIVILEGED, [])
@@ -369,24 +355,9 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_HASSIO_ROLE]
@property
def backup_exclude(self) -> list[str]:
"""Return Exclude list for backup."""
return self.data.get(ATTR_BACKUP_EXCLUDE, [])
@property
def backup_pre(self) -> str | None:
"""Return pre-backup command."""
return self.data.get(ATTR_BACKUP_PRE)
@property
def backup_post(self) -> str | None:
"""Return post-backup command."""
return self.data.get(ATTR_BACKUP_POST)
@property
def backup_mode(self) -> AddonBackupMode:
"""Return if backup is hot/cold."""
return self.data[ATTR_BACKUP]
def snapshot_exclude(self) -> List[str]:
"""Return Exclude list for snapshot."""
return self.data.get(ATTR_SNAPSHOT_EXCLUDE, [])
@property
def default_init(self) -> bool:
@@ -404,15 +375,10 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_INGRESS]
@property
def ingress_panel(self) -> bool | None:
def ingress_panel(self) -> Optional[bool]:
"""Return True if the add-on access support ingress."""
return None
@property
def ingress_stream(self) -> bool:
"""Return True if post requests to ingress should be streamed."""
return self.data[ATTR_INGRESS_STREAM]
@property
def with_gpio(self) -> bool:
"""Return True if the add-on access to GPIO interface."""
@@ -454,7 +420,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_DEVICETREE]
@property
def with_tmpfs(self) -> str | None:
def with_tmpfs(self) -> Optional[str]:
"""Return if tmp is in memory of add-on."""
return self.data[ATTR_TMPFS]
@@ -474,12 +440,12 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_VIDEO]
@property
def homeassistant_version(self) -> str | None:
def homeassistant_version(self) -> Optional[str]:
"""Return min Home Assistant version they needed by Add-on."""
return self.data.get(ATTR_HOMEASSISTANT)
@property
def url(self) -> str | None:
def url(self) -> Optional[str]:
"""Return URL of add-on."""
return self.data.get(ATTR_URL)
@@ -504,25 +470,17 @@ class AddonModel(CoreSysAttributes, ABC):
return self.path_documentation.exists()
@property
def supported_arch(self) -> list[str]:
def supported_arch(self) -> List[str]:
"""Return list of supported arch."""
return self.data[ATTR_ARCH]
@property
def supported_machine(self) -> list[str]:
def supported_machine(self) -> List[str]:
"""Return list of supported machine."""
return self.data.get(ATTR_MACHINE, [])
@property
def arch(self) -> 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:
def image(self) -> Optional[str]:
"""Generate image name from data."""
return self._image(self.data)
@@ -532,14 +490,14 @@ class AddonModel(CoreSysAttributes, ABC):
return ATTR_IMAGE not in self.data
@property
def map_volumes(self) -> dict[str, bool]:
"""Return a dict of {volume: read-only} from add-on."""
def map_volumes(self) -> Dict[str, str]:
"""Return a dict of {volume: policy} from add-on."""
volumes = {}
for volume in self.data[ATTR_MAP]:
result = RE_VOLUME.match(volume)
if not result:
continue
volumes[result.group(1)] = result.group(2) != "rw"
volumes[result.group(1)] = result.group(2) or "ro"
return volumes
@@ -574,16 +532,18 @@ class AddonModel(CoreSysAttributes, ABC):
return Path(self.path_location, "apparmor.txt")
@property
def schema(self) -> AddonOptions:
"""Return Addon options validation object."""
def schema(self) -> vol.Schema:
"""Create a schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA]
if isinstance(raw_schema, bool):
raw_schema = {}
return AddonOptions(self.coresys, raw_schema, self.name, self.slug)
return vol.Schema(
vol.All(dict, AddonOptions(self.coresys, raw_schema, self.name, self.slug))
)
@property
def schema_ui(self) -> list[dict[any, any]] | None:
def schema_ui(self) -> Optional[List[Dict[str, Any]]]:
"""Create a UI schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA]
@@ -596,64 +556,31 @@ class AddonModel(CoreSysAttributes, ABC):
"""Return True if the add-on accesses the system journal."""
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):
"""Compaired add-on objects."""
if not isinstance(other, AddonModel):
return False
return self.slug == other.slug
def _validate_availability(
self, config, *, logger: Callable[..., None] | None = None
) -> None:
"""Validate if addon is available for current system."""
def _available(self, config) -> bool:
"""Return True if this add-on is available on this platform."""
# Architecture
if not self.sys_arch.is_supported(config[ATTR_ARCH]):
raise AddonsNotSupportedError(
f"Add-on {self.slug} not supported on this platform, supported architectures: {', '.join(config[ATTR_ARCH])}",
logger,
)
return False
# Machine / Hardware
machine = config.get(ATTR_MACHINE)
if machine and (
f"!{self.sys_machine}" in machine or 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:
if machine and f"!{self.sys_machine}" in machine:
return False
elif machine and self.sys_machine not in machine:
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:
"""Generate image name from data."""
@@ -673,9 +600,9 @@ class AddonModel(CoreSysAttributes, ABC):
"""Uninstall this add-on."""
return self.sys_addons.uninstall(self.slug)
def update(self, backup: bool | None = False) -> Awaitable[None]:
def update(self) -> Awaitable[None]:
"""Update this add-on."""
return self.sys_addons.update(self.slug, backup=backup)
return self.sys_addons.update(self.slug)
def rebuild(self) -> Awaitable[None]:
"""Rebuild this add-on."""

View File

@@ -3,7 +3,7 @@ import hashlib
import logging
from pathlib import Path
import re
from typing import Any
from typing import Any, Dict, List, Set, Union
import voluptuous as vol
@@ -59,21 +59,16 @@ class AddonOptions(CoreSysAttributes):
"""Validate Add-ons Options."""
def __init__(
self, coresys: CoreSys, raw_schema: dict[str, Any], name: str, slug: str
self, coresys: CoreSys, raw_schema: Dict[str, Any], name: str, slug: str
):
"""Validate schema."""
self.coresys: CoreSys = coresys
self.raw_schema: dict[str, Any] = raw_schema
self.devices: set[Device] = set()
self.pwned: set[str] = set()
self.raw_schema: Dict[str, Any] = raw_schema
self.devices: Set[Device] = set()
self.pwned: Set[str] = set()
self._name = name
self._slug = slug
@property
def validate(self) -> vol.Schema:
"""Create a schema for add-on options."""
return vol.Schema(vol.All(dict, self))
def __call__(self, struct):
"""Create schema validator for add-ons options."""
options = {}
@@ -167,7 +162,7 @@ class AddonOptions(CoreSysAttributes):
device = self.sys_hardware.get_by_path(Path(value))
except HardwareNotFound:
raise vol.Invalid(
f"Device '{value}' does not exist in {self._name} ({self._slug})"
f"Device '{value}' does not exists! in {self._name} ({self._slug})"
) from None
# Have filter
@@ -187,7 +182,7 @@ class AddonOptions(CoreSysAttributes):
f"Fatal error for option '{key}' with type '{typ}' in {self._name} ({self._slug})"
) from None
def _nested_validate_list(self, typ: Any, data_list: list[Any], key: str):
def _nested_validate_list(self, typ: Any, data_list: List[Any], key: str):
"""Validate nested items."""
options = []
@@ -209,7 +204,7 @@ class AddonOptions(CoreSysAttributes):
return options
def _nested_validate_dict(
self, typ: dict[Any, Any], data_dict: dict[Any, Any], key: str
self, typ: Dict[Any, Any], data_dict: Dict[Any, Any], key: str
):
"""Validate nested items."""
options = {}
@@ -241,7 +236,7 @@ class AddonOptions(CoreSysAttributes):
return options
def _check_missing_options(
self, origin: dict[Any, Any], exists: dict[Any, Any], root: str
self, origin: Dict[Any, Any], exists: Dict[Any, Any], root: str
) -> None:
"""Check if all options are exists."""
missing = set(origin) - set(exists)
@@ -267,9 +262,9 @@ class UiOptions(CoreSysAttributes):
"""Initialize UI option render."""
self.coresys = coresys
def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
def __call__(self, raw_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Generate UI schema."""
ui_schema: list[dict[str, Any]] = []
ui_schema: List[Dict[str, Any]] = []
# read options
for key, value in raw_schema.items():
@@ -287,13 +282,13 @@ class UiOptions(CoreSysAttributes):
def _single_ui_option(
self,
ui_schema: list[dict[str, Any]],
ui_schema: List[Dict[str, Any]],
value: str,
key: str,
multiple: bool = False,
) -> None:
"""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:
@@ -365,8 +360,8 @@ class UiOptions(CoreSysAttributes):
def _nested_ui_list(
self,
ui_schema: list[dict[str, Any]],
option_list: list[Any],
ui_schema: List[Dict[str, Any]],
option_list: List[Any],
key: str,
) -> None:
"""UI nested list items."""
@@ -383,8 +378,8 @@ class UiOptions(CoreSysAttributes):
def _nested_ui_dict(
self,
ui_schema: list[dict[str, Any]],
option_dict: dict[str, Any],
ui_schema: List[Dict[str, Any]],
option_dict: Dict[str, Any],
key: str,
multiple: bool = False,
) -> None:
@@ -408,7 +403,7 @@ class UiOptions(CoreSysAttributes):
ui_schema.append(ui_node)
def _create_device_filter(str_filter: str) -> dict[str, Any]:
def _create_device_filter(str_filter: str) -> Dict[str, Any]:
"""Generate device Filter."""
raw_filter = dict(value.split("=") for value in str_filter.split(";"))

View File

@@ -16,10 +16,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
def rating_security(addon: AddonModel) -> int:
"""Return 1-8 for security rating.
"""Return 1-6 for security rating.
1 = not secure
8 = high secure
6 = high secure
"""
rating = 5
@@ -35,24 +35,17 @@ def rating_security(addon: AddonModel) -> int:
elif addon.access_auth_api:
rating += 1
# Signed
if addon.signed:
rating += 1
# Privileged options
if (
any(
privilege in addon.privileged
for privilege in (
Capabilities.BPF,
Capabilities.DAC_READ_SEARCH,
Capabilities.NET_ADMIN,
Capabilities.NET_RAW,
Capabilities.PERFMON,
Capabilities.SYS_ADMIN,
Capabilities.SYS_MODULE,
Capabilities.SYS_PTRACE,
Capabilities.SYS_RAWIO,
Capabilities.SYS_PTRACE,
Capabilities.SYS_MODULE,
Capabilities.DAC_READ_SEARCH,
)
)
or addon.with_kernel_modules
@@ -73,15 +66,11 @@ def rating_security(addon: AddonModel) -> int:
if addon.host_pid:
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
if addon.access_docker_api or addon.with_full_access:
rating = 1
return max(min(8, rating), 1)
return max(min(6, rating), 1)
async def remove_data(folder: Path) -> None:

View File

@@ -2,7 +2,7 @@
import logging
import re
import secrets
from typing import Any
from typing import Any, Dict
import uuid
import voluptuous as vol
@@ -19,9 +19,6 @@ from ..const import (
ATTR_AUDIO_OUTPUT,
ATTR_AUTH_API,
ATTR_AUTO_UPDATE,
ATTR_BACKUP_EXCLUDE,
ATTR_BACKUP_POST,
ATTR_BACKUP_PRE,
ATTR_BOOT,
ATTR_BUILD_FROM,
ATTR_CONFIGURATION,
@@ -41,18 +38,15 @@ from ..const import (
ATTR_HOST_IPC,
ATTR_HOST_NETWORK,
ATTR_HOST_PID,
ATTR_HOST_UTS,
ATTR_IMAGE,
ATTR_INGRESS,
ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT,
ATTR_INGRESS_STREAM,
ATTR_INGRESS_TOKEN,
ATTR_INIT,
ATTR_JOURNALD,
ATTR_KERNEL_MODULES,
ATTR_LABELS,
ATTR_LEGACY,
ATTR_LOCATON,
ATTR_MACHINE,
@@ -72,6 +66,7 @@ from ..const import (
ATTR_SCHEMA,
ATTR_SERVICES,
ATTR_SLUG,
ATTR_SNAPSHOT_EXCLUDE,
ATTR_SQUASH,
ATTR_STAGE,
ATTR_STARTUP,
@@ -109,7 +104,6 @@ from ..validate import (
uuid_match,
version_tag,
)
from .const import ATTR_BACKUP, ATTR_CODENOTARY, RE_SLUG, AddonBackupMode
from .options import RE_SCHEMA_ELEMENT
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -127,10 +121,8 @@ SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
RE_MACHINE = re.compile(
r"^!?(?:"
r"|intel-nuc"
r"|generic-x86-64"
r"|odroid-c2"
r"|odroid-c4"
r"|odroid-m1"
r"|odroid-n2"
r"|odroid-xu"
r"|qemuarm-64"
@@ -147,10 +139,8 @@ RE_MACHINE = re.compile(
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."""
name = config.get(ATTR_NAME)
if not name:
@@ -167,21 +157,13 @@ def _warn_addon_config(config: dict[str, Any]):
name,
)
if config.get(ATTR_BACKUP, AddonBackupMode.HOT) == AddonBackupMode.COLD and (
config.get(ATTR_BACKUP_POST) or config.get(ATTR_BACKUP_PRE)
):
_LOGGER.warning(
"Add-on which only support COLD backups trying to use post/pre commands. Please report this to the maintainer of %s",
name,
)
return config
def _migrate_addon_config(protocol=False):
"""Migrate addon config."""
def _migrate(config: dict[str, Any]):
def _migrate(config: Dict[str, Any]):
name = config.get(ATTR_NAME)
if not name:
raise vol.Invalid("Invalid Add-on config!")
@@ -227,23 +209,6 @@ def _migrate_addon_config(protocol=False):
)
config[ATTR_TMPFS] = True
# 2021-06 "snapshot" renamed to "backup"
for entry in (
"snapshot_exclude",
"snapshot_post",
"snapshot_pre",
"snapshot",
):
if entry in config:
new_entry = entry.replace("snapshot", "backup")
config[new_entry] = config.pop(entry)
_LOGGER.warning(
"Add-on config '%s' is deprecated, '%s' should be used instead. Please report this to the maintainer of %s",
entry,
new_entry,
name,
)
return config
return _migrate
@@ -254,7 +219,7 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
{
vol.Required(ATTR_NAME): str,
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_ARCH): [vol.In(ARCH_ALL)],
vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()),
@@ -279,15 +244,13 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
network_port, vol.Equal(0)
),
vol.Optional(ATTR_INGRESS_ENTRY): str,
vol.Optional(ATTR_INGRESS_STREAM, default=False): vol.Boolean(),
vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): str,
vol.Optional(ATTR_PANEL_TITLE): str,
vol.Optional(ATTR_PANEL_ADMIN, default=True): vol.Boolean(),
vol.Optional(ATTR_HOMEASSISTANT): version_tag,
vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(version_tag),
vol.Optional(ATTR_HOST_NETWORK, 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_UTS, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(),
vol.Optional(ATTR_DEVICES): [str],
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
@@ -314,13 +277,7 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(),
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
vol.Optional(ATTR_DISCOVERY): [valid_discovery_service],
vol.Optional(ATTR_BACKUP_EXCLUDE): [str],
vol.Optional(ATTR_BACKUP_PRE): str,
vol.Optional(ATTR_BACKUP_POST): str,
vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce(
AddonBackupMode
),
vol.Optional(ATTR_CODENOTARY): vol.Email(),
vol.Optional(ATTR_SNAPSHOT_EXCLUDE): [str],
vol.Optional(ATTR_OPTIONS, default={}): dict,
vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
vol.Schema(
@@ -356,13 +313,13 @@ SCHEMA_ADDON_CONFIG = vol.All(
# pylint: disable=no-value-for-parameter
SCHEMA_BUILD_CONFIG = vol.Schema(
{
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Any(
vol.Match(RE_DOCKER_IMAGE_BUILD),
vol.Schema({vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}),
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Schema(
{vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}
),
vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}),
vol.Optional(ATTR_LABELS, default=dict): vol.Schema({str: str}),
vol.Optional(ATTR_ARGS, default=dict): vol.Schema(
{vol.Coerce(str): vol.Coerce(str)}
),
},
extra=vol.REMOVE_EXTRA,
)
@@ -429,7 +386,7 @@ SCHEMA_ADDONS_FILE = vol.Schema(
)
SCHEMA_ADDON_BACKUP = vol.Schema(
SCHEMA_ADDON_SNAPSHOT = vol.Schema(
{
vol.Required(ATTR_USER): SCHEMA_ADDON_USER,
vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM,

View File

@@ -1,18 +1,14 @@
"""Init file for Supervisor RESTful API."""
from functools import partial
import logging
from pathlib import Path
from typing import Any
from typing import Optional
from aiohttp import web
from ..const import AddonState
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIAddonNotInstalled
from .addons import APIAddons
from .audio import APIAudio
from .auth import APIAuth
from .backups import APIBackups
from .cli import APICli
from .discovery import APIDiscovery
from .dns import APICoreDNS
@@ -20,28 +16,25 @@ from .docker import APIDocker
from .hardware import APIHardware
from .homeassistant import APIHomeAssistant
from .host import APIHost
from .info import APIInfo
from .ingress import APIIngress
from .jobs import APIJobs
from .middleware.security import SecurityMiddleware
from .mounts import APIMounts
from .multicast import APIMulticast
from .network import APINetwork
from .observer import APIObserver
from .os import APIOS
from .proxy import APIProxy
from .resolution import APIResoulution
from .root import APIRoot
from .security import APISecurity
from .security import SecurityMiddleware
from .services import APIServices
from .snapshots import APISnapshots
from .store import APIStore
from .supervisor import APISupervisor
from .utils import api_process
_LOGGER: logging.Logger = logging.getLogger(__name__)
MAX_CLIENT_SIZE: int = 1024**2 * 16
MAX_LINE_SIZE: int = 24570
MAX_CLIENT_SIZE: int = 1024 ** 2 * 16
class RestAPI(CoreSysAttributes):
@@ -54,27 +47,20 @@ class RestAPI(CoreSysAttributes):
self.webapp: web.Application = web.Application(
client_max_size=MAX_CLIENT_SIZE,
middlewares=[
self.security.block_bad_requests,
self.security.system_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
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:
"""Register REST API Calls."""
self._register_addons()
self._register_audio()
self._register_auth()
self._register_backups()
self._register_cli()
self._register_discovery()
self._register_dns()
@@ -82,21 +68,20 @@ class RestAPI(CoreSysAttributes):
self._register_hardware()
self._register_homeassistant()
self._register_host()
self._register_jobs()
self._register_info()
self._register_ingress()
self._register_mounts()
self._register_multicast()
self._register_network()
self._register_observer()
self._register_os()
self._register_jobs()
self._register_panel()
self._register_proxy()
self._register_resolution()
self._register_root()
self._register_security()
self._register_services()
self._register_store()
self._register_snapshots()
self._register_supervisor()
self._register_store()
await self.start()
@@ -108,36 +93,16 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
web.get("/host/info", api_host.info),
web.get("/host/logs", api_host.advanced_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.get("/host/logs", api_host.logs),
web.post("/host/reboot", api_host.reboot),
web.post("/host/shutdown", api_host.shutdown),
web.post("/host/reload", api_host.reload),
web.post("/host/options", api_host.options),
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),
]
)
@@ -178,30 +143,6 @@ class RestAPI(CoreSysAttributes):
web.get("/os/info", api_os.info),
web.post("/os/update", api_os.update),
web.post("/os/config/sync", api_os.config_sync),
web.post("/os/datadisk/move", api_os.migrate_data),
web.get("/os/datadisk/list", api_os.list_data),
]
)
# Boards endpoints
self.webapp.add_routes(
[
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:
"""Register Security functions."""
api_security = APISecurity()
api_security.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/security/info", api_security.info),
web.post("/security/options", api_security.options),
web.post("/security/integrity", api_security.integrity_check),
]
)
@@ -268,24 +209,16 @@ class RestAPI(CoreSysAttributes):
[
web.get("/hardware/info", api_hardware.info),
web.get("/hardware/audio", api_hardware.audio),
web.post("/hardware/trigger", api_hardware.trigger),
]
)
def _register_root(self) -> None:
"""Register root functions."""
api_root = APIRoot()
api_root.coresys = self.coresys
def _register_info(self) -> None:
"""Register info functions."""
api_info = APIInfo()
api_info.coresys = self.coresys
self.webapp.add_routes([web.get("/info", api_root.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)]
)
self.webapp.add_routes([web.get("/info", api_info.info)])
def _register_resolution(self) -> None:
"""Register info functions."""
@@ -311,10 +244,6 @@ class RestAPI(CoreSysAttributes):
"/resolution/issue/{issue}",
api_resolution.dismiss_issue,
),
web.get(
"/resolution/issue/{issue}/suggestions",
api_resolution.suggestions_for_issue,
),
web.post("/resolution/healthcheck", api_resolution.healthcheck),
]
)
@@ -369,22 +298,17 @@ class RestAPI(CoreSysAttributes):
web.post("/core/start", api_hass.start),
web.post("/core/check", api_hass.check),
web.post("/core/rebuild", api_hass.rebuild),
]
)
# Reroute from legacy
self.webapp.add_routes(
[
# Remove with old Supervisor fallback
web.get("/homeassistant/info", api_hass.info),
web.get("/homeassistant/logs", api_hass.logs),
web.get("/homeassistant/stats", api_hass.stats),
web.post("/homeassistant/options", api_hass.options),
web.post("/homeassistant/update", api_hass.update),
web.post("/homeassistant/restart", api_hass.restart),
web.post("/homeassistant/stop", api_hass.stop),
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/rebuild", api_hass.rebuild),
]
)
@@ -401,12 +325,7 @@ class RestAPI(CoreSysAttributes):
web.post("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/", api_proxy.api),
]
)
# Reroute from legacy
self.webapp.add_routes(
[
# Remove with old Supervisor fallback
web.get("/homeassistant/api/websocket", api_proxy.websocket),
web.get("/homeassistant/websocket", api_proxy.websocket),
web.get("/homeassistant/api/stream", api_proxy.stream),
@@ -424,6 +343,8 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
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}/start", api_addons.start),
web.post("/addons/{addon}/stop", api_addons.stop),
@@ -435,31 +356,16 @@ class RestAPI(CoreSysAttributes):
web.get("/addons/{addon}/options/config", api_addons.options_config),
web.post("/addons/{addon}/rebuild", api_addons.rebuild),
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}/security", api_addons.security),
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:
"""Register Ingress functions."""
api_ingress = APIIngress()
@@ -474,28 +380,30 @@ class RestAPI(CoreSysAttributes):
]
)
def _register_backups(self) -> None:
"""Register backups functions."""
api_backups = APIBackups()
api_backups.coresys = self.coresys
def _register_snapshots(self) -> None:
"""Register snapshots functions."""
api_snapshots = APISnapshots()
api_snapshots.coresys = self.coresys
self.webapp.add_routes(
[
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/new/full", api_backups.backup_full),
web.post("/backups/new/partial", api_backups.backup_partial),
web.post("/backups/new/upload", api_backups.upload),
web.get("/backups/{slug}/info", api_backups.backup_info),
web.delete("/backups/{slug}", api_backups.remove),
web.post("/backups/{slug}/restore/full", api_backups.restore_full),
web.get("/snapshots", api_snapshots.list),
web.post("/snapshots/reload", api_snapshots.reload),
web.post("/snapshots/new/full", api_snapshots.snapshot_full),
web.post("/snapshots/new/partial", api_snapshots.snapshot_partial),
web.post("/snapshots/new/upload", api_snapshots.upload),
web.get("/snapshots/{snapshot}/info", api_snapshots.info),
web.delete("/snapshots/{snapshot}", api_snapshots.remove),
web.post(
"/backups/{slug}/restore/partial",
api_backups.restore_partial,
"/snapshots/{snapshot}/restore/full", api_snapshots.restore_full
),
web.get("/backups/{slug}/download", api_backups.download),
web.post(
"/snapshots/{snapshot}/restore/partial",
api_snapshots.restore_partial,
),
web.get("/snapshots/{snapshot}/download", api_snapshots.download),
# Old, remove at end of 2020
web.post("/snapshots/{snapshot}/remove", api_snapshots.remove),
]
)
@@ -548,8 +456,6 @@ class RestAPI(CoreSysAttributes):
"""Register Audio functions."""
api_audio = APIAudio()
api_audio.coresys = self.coresys
api_host = APIHost()
api_host.coresys = self.coresys
self.webapp.add_routes(
[
@@ -568,22 +474,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:
"""Register store endpoints."""
api_store = APIStore()
@@ -595,15 +485,6 @@ class RestAPI(CoreSysAttributes):
web.get("/store/addons", api_store.addons_list),
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}/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(
"/store/addons/{addon}/install", api_store.addons_addon_install
),
@@ -622,26 +503,14 @@ class RestAPI(CoreSysAttributes):
"/store/repositories/{repository}",
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
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}/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."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any
from typing import Any, Awaitable, Dict, List
from aiohttp import web
import voluptuous as vol
@@ -46,7 +45,6 @@ from ..const import (
ATTR_HOST_IPC,
ATTR_HOST_NETWORK,
ATTR_HOST_PID,
ATTR_HOST_UTS,
ATTR_HOSTNAME,
ATTR_ICON,
ATTR_INGRESS,
@@ -54,11 +52,13 @@ from ..const import (
ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT,
ATTR_INGRESS_URL,
ATTR_INSTALLED,
ATTR_IP_ADDRESS,
ATTR_KERNEL_MODULES,
ATTR_LOGO,
ATTR_LONG_DESCRIPTION,
ATTR_MACHINE,
ATTR_MAINTAINER,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
@@ -71,12 +71,13 @@ from ..const import (
ATTR_OPTIONS,
ATTR_PRIVILEGED,
ATTR_PROTECTED,
ATTR_PWNED,
ATTR_RATING,
ATTR_REPOSITORIES,
ATTR_REPOSITORY,
ATTR_SCHEMA,
ATTR_SERVICES,
ATTR_SLUG,
ATTR_SOURCE,
ATTR_STAGE,
ATTR_STARTUP,
ATTR_STATE,
@@ -93,25 +94,23 @@ from ..const import (
ATTR_VIDEO,
ATTR_WATCHDOG,
ATTR_WEBUI,
CONTENT_TYPE_BINARY,
CONTENT_TYPE_PNG,
CONTENT_TYPE_TEXT,
REQUEST_FROM,
AddonBoot,
AddonState,
)
from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats
from ..exceptions import (
APIAddonNotInstalled,
APIError,
APIForbidden,
PwnedError,
PwnedSecret,
)
from ..exceptions import APIError, APIForbidden, PwnedError, PwnedSecret
from ..utils.pwned import check_pwned_password
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
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): str})
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
# pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema(
@@ -119,8 +118,8 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports),
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
}
@@ -133,7 +132,7 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
class APIAddons(CoreSysAttributes):
"""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."""
addon_slug: str = request.match_info.get("addon")
@@ -147,13 +146,17 @@ class APIAddons(CoreSysAttributes):
addon = self.sys_addons.get(addon_slug)
if not addon:
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
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
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."""
data_addons = [
{
@@ -162,30 +165,43 @@ class APIAddons(CoreSysAttributes):
ATTR_DESCRIPTON: addon.description,
ATTR_ADVANCED: addon.advanced,
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_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_DETACHED: addon.is_detached,
ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_STATE: addon.state,
ATTR_REPOSITORY: addon.repository,
ATTR_BUILD: addon.need_build,
ATTR_URL: addon.url,
ATTR_ICON: addon.with_icon,
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
async def reload(self, request: web.Request) -> None:
"""Reload all add-on data from store."""
await asyncio.shield(self.sys_store.reload())
async def info(self, request: web.Request) -> dict[str, Any]:
@api_process
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return add-on information."""
addon: AnyAddon = self._extract_addon(request)
@@ -198,8 +214,11 @@ class APIAddons(CoreSysAttributes):
ATTR_LONG_DESCRIPTION: addon.long_description,
ATTR_ADVANCED: addon.advanced,
ATTR_STAGE: addon.stage,
ATTR_AUTO_UPDATE: None,
ATTR_REPOSITORY: addon.repository,
ATTR_VERSION: None,
ATTR_VERSION_LATEST: addon.latest_version,
ATTR_UPDATE_AVAILABLE: False,
ATTR_PROTECTED: addon.protected,
ATTR_RATING: rating_security(addon),
ATTR_BOOT: addon.boot,
@@ -209,6 +228,7 @@ class APIAddons(CoreSysAttributes):
ATTR_MACHINE: addon.supported_machine,
ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_URL: addon.url,
ATTR_STATE: AddonState.UNKNOWN,
ATTR_DETACHED: addon.is_detached,
ATTR_AVAILABLE: addon.available,
ATTR_BUILD: addon.need_build,
@@ -217,16 +237,17 @@ class APIAddons(CoreSysAttributes):
ATTR_HOST_NETWORK: addon.host_network,
ATTR_HOST_PID: addon.host_pid,
ATTR_HOST_IPC: addon.host_ipc,
ATTR_HOST_UTS: addon.host_uts,
ATTR_HOST_DBUS: addon.host_dbus,
ATTR_PRIVILEGED: addon.privileged,
ATTR_FULL_ACCESS: addon.with_full_access,
ATTR_APPARMOR: addon.apparmor,
ATTR_DEVICES: addon.static_devices,
ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo,
ATTR_CHANGELOG: addon.with_changelog,
ATTR_DOCUMENTATION: addon.with_documentation,
ATTR_STDIN: addon.with_stdin,
ATTR_WEBUI: None,
ATTR_HASSIO_API: addon.access_hassio_api,
ATTR_HASSIO_ROLE: addon.hassio_role,
ATTR_AUTH_API: addon.access_auth_api,
@@ -240,42 +261,55 @@ class APIAddons(CoreSysAttributes):
ATTR_DOCKER_API: addon.access_docker_api,
ATTR_VIDEO: addon.with_video,
ATTR_AUDIO: addon.with_audio,
ATTR_AUDIO_INPUT: None,
ATTR_AUDIO_OUTPUT: None,
ATTR_STARTUP: addon.startup,
ATTR_SERVICES: _pretty_services(addon),
ATTR_DISCOVERY: addon.discovery,
ATTR_IP_ADDRESS: None,
ATTR_TRANSLATIONS: addon.translations,
ATTR_INGRESS: addon.with_ingress,
ATTR_SIGNED: addon.signed,
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],
ATTR_INGRESS_ENTRY: None,
ATTR_INGRESS_URL: None,
ATTR_INGRESS_PORT: None,
ATTR_INGRESS_PANEL: None,
ATTR_WATCHDOG: None,
}
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
@api_process
async def options(self, request: web.Request) -> None:
"""Store user options for add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
# Update secrets for validation
await self.sys_homeassistant.secrets.reload()
# Extend schema with add-on specific validation
addon_schema = SCHEMA_OPTIONS.extend(
{vol.Optional(ATTR_OPTIONS): vol.Maybe(addon.schema)}
{vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema)}
)
# Validate/Process Body
@@ -303,39 +337,31 @@ class APIAddons(CoreSysAttributes):
@api_process
async def options_validate(self, request: web.Request) -> None:
"""Validate user options for add-on."""
addon = self._extract_addon(request)
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False}
options = await request.json(loads=json_loads) or addon.options
addon = self._extract_addon_installed(request)
data = {ATTR_MESSAGE: "", ATTR_VALID: True}
# Validate config
options_schema = addon.schema
try:
options_schema.validate(options)
addon.schema(addon.options)
except vol.Invalid as ex:
data[ATTR_MESSAGE] = humanize_error(options, ex)
data[ATTR_MESSAGE] = humanize_error(addon.options, ex)
data[ATTR_VALID] = False
if not self.sys_security.pwned:
return data
# Validate security
if self.sys_config.force_security:
for secret in addon.pwned:
try:
await check_pwned_password(self.sys_websession, secret)
continue
except PwnedSecret:
data[ATTR_MESSAGE] = "Add-on use pwned secrets!"
except PwnedError as err:
data[
ATTR_MESSAGE
] = f"Error happening on pwned secrets check: {err!s}!"
# Pwned check
for secret in options_schema.pwned:
try:
await self.sys_security.verify_secret(secret)
continue
except PwnedSecret:
data[ATTR_PWNED] = True
except PwnedError:
data[ATTR_PWNED] = None
break
if self.sys_security.force and data[ATTR_PWNED] in (None, True):
data[ATTR_VALID] = False
if data[ATTR_PWNED] is None:
data[ATTR_MESSAGE] = "Error happening on pwned secrets check!"
else:
data[ATTR_MESSAGE] = "Add-on uses pwned secrets!"
data[ATTR_VALID] = False
break
return data
@@ -345,20 +371,18 @@ class APIAddons(CoreSysAttributes):
slug: str = request.match_info.get("addon")
if slug != "self":
raise APIForbidden("This can be only read by the Add-on itself!")
addon = self._extract_addon(request)
# Lookup/reload secrets
await self.sys_homeassistant.secrets.reload()
addon = self._extract_addon_installed(request)
try:
return addon.schema.validate(addon.options)
return addon.schema(addon.options)
except vol.Invalid:
raise APIError("Invalid configuration data for the add-on") from None
@api_process
async def security(self, request: web.Request) -> None:
"""Store security options for add-on."""
addon = self._extract_addon(request)
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
addon = self._extract_addon_installed(request)
body: Dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
if ATTR_PROTECTED in body:
_LOGGER.warning("Changing protected flag for %s!", addon.slug)
@@ -367,9 +391,9 @@ class APIAddons(CoreSysAttributes):
addon.save_persist()
@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."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
stats: DockerStats = await addon.stats()
@@ -387,43 +411,83 @@ class APIAddons(CoreSysAttributes):
@api_process
def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
return asyncio.shield(addon.uninstall())
@api_process
def start(self, request: web.Request) -> Awaitable[None]:
"""Start add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
return asyncio.shield(addon.start())
@api_process
def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
return asyncio.shield(addon.stop())
@api_process
def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart add-on."""
addon: Addon = self._extract_addon(request)
addon: Addon = self._extract_addon_installed(request)
return asyncio.shield(addon.restart())
@api_process
def rebuild(self, request: web.Request) -> Awaitable[None]:
"""Rebuild local build add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
return asyncio.shield(addon.rebuild())
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return logs from add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
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
async def stdin(self, request: web.Request) -> None:
"""Write to stdin of add-on."""
addon = self._extract_addon(request)
addon = self._extract_addon_installed(request)
if not addon.with_stdin:
raise APIError(f"STDIN not supported the {addon.slug} add-on")
@@ -431,6 +495,6 @@ class APIAddons(CoreSysAttributes):
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 [f"{name}:{access}" for name, access in addon.services_role.items()]

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor Audio RESTful API."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
import attr
@@ -30,12 +29,12 @@ from ..const import (
ATTR_VERSION,
ATTR_VERSION_LATEST,
ATTR_VOLUME,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..host.sound import StreamType
from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -57,10 +56,10 @@ SCHEMA_MUTE = vol.Schema(
}
)
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): str})
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
SCHEMA_PROFILE = vol.Schema(
{vol.Required(ATTR_CARD): str, vol.Required(ATTR_NAME): str}
{vol.Required(ATTR_CARD): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str)}
)
@@ -68,7 +67,7 @@ class APIAudio(CoreSysAttributes):
"""Handle RESTful API for Audio functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Audio information."""
return {
ATTR_VERSION: self.sys_plugins.audio.version,
@@ -90,7 +89,7 @@ class APIAudio(CoreSysAttributes):
}
@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."""
stats = await self.sys_plugins.audio.stats()

View File

@@ -1,6 +1,7 @@
"""Init file for Supervisor auth/SSO RESTful API."""
import asyncio
import logging
from typing import Dict
from aiohttp import BasicAuth, web
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
@@ -8,22 +9,27 @@ from aiohttp.web_exceptions import HTTPUnauthorized
import voluptuous as vol
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 ..exceptions import APIForbidden
from .const import CONTENT_TYPE_JSON, CONTENT_TYPE_URL
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_PASSWORD_RESET = vol.Schema(
{
vol.Required(ATTR_USERNAME): str,
vol.Required(ATTR_PASSWORD): str,
vol.Required(ATTR_USERNAME): vol.Coerce(str),
vol.Required(ATTR_PASSWORD): vol.Coerce(str),
}
)
REALM_HEADER: dict[str, str] = {
REALM_HEADER: Dict[str, str] = {
WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'
}
@@ -40,7 +46,7 @@ class APIAuth(CoreSysAttributes):
return self.sys_auth.check_login(addon, auth.login, auth.password)
def _process_dict(
self, request: web.Request, addon: Addon, data: dict[str, str]
self, request: web.Request, addon: Addon, data: Dict[str, str]
) -> bool:
"""Process login with dict data.
@@ -80,7 +86,7 @@ class APIAuth(CoreSysAttributes):
@api_process
async def reset(self, request: web.Request) -> None:
"""Process reset password request."""
body: dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request)
body: Dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request)
await asyncio.shield(
self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD])
)

View File

@@ -1,280 +0,0 @@
"""Backups RESTful API."""
import asyncio
import logging
from pathlib import Path
import re
from tempfile import TemporaryDirectory
from typing import Any
from aiohttp import web
from aiohttp.hdrs import CONTENT_DISPOSITION
import voluptuous as vol
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
from ..const import (
ATTR_ADDONS,
ATTR_BACKUPS,
ATTR_COMPRESSED,
ATTR_CONTENT,
ATTR_DATE,
ATTR_DAYS_UNTIL_STALE,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_LOCATON,
ATTR_NAME,
ATTR_PASSWORD,
ATTR_PROTECTED,
ATTR_REPOSITORIES,
ATTR_SIZE,
ATTR_SLUG,
ATTR_SUPERVISOR_VERSION,
ATTR_TYPE,
ATTR_VERSION,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..mounts.const import MountUsage
from .const import CONTENT_TYPE_TAR
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
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
SCHEMA_RESTORE_PARTIAL = vol.Schema(
{
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
}
)
SCHEMA_RESTORE_FULL = vol.Schema({vol.Optional(ATTR_PASSWORD): vol.Maybe(str)})
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(ATTR_NAME): str,
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
}
)
SCHEMA_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_DAYS_UNTIL_STALE): days_until_stale,
}
)
class APIBackups(CoreSysAttributes):
"""Handle RESTful API for backups functions."""
def _extract_slug(self, request):
"""Return backup, throw an exception if it doesn't exist."""
backup = self.sys_backups.get(request.match_info.get("slug"))
if not backup:
raise APIError("Backup does not exist")
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
async def list(self, request):
"""Return backup list."""
data_backups = self._list_backups()
if request.path == "/snapshots":
# Kept for backwards compability
return {"snapshots": data_backups}
return {ATTR_BACKUPS: data_backups}
@api_process
async def info(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, request):
"""Reload backup list."""
await asyncio.shield(self.sys_backups.reload())
return True
@api_process
async def backup_info(self, request):
"""Return backup info."""
backup = self._extract_slug(request)
data_addons = []
for addon_data in backup.addons:
data_addons.append(
{
ATTR_SLUG: addon_data[ATTR_SLUG],
ATTR_NAME: addon_data[ATTR_NAME],
ATTR_VERSION: addon_data[ATTR_VERSION],
ATTR_SIZE: addon_data[ATTR_SIZE],
}
)
return {
ATTR_SLUG: backup.slug,
ATTR_TYPE: backup.sys_type,
ATTR_NAME: backup.name,
ATTR_DATE: backup.date,
ATTR_SIZE: backup.size,
ATTR_COMPRESSED: backup.compressed,
ATTR_PROTECTED: backup.protected,
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
ATTR_HOMEASSISTANT: backup.homeassistant_version,
ATTR_LOCATON: backup.location,
ATTR_ADDONS: data_addons,
ATTR_REPOSITORIES: backup.repositories,
ATTR_FOLDERS: backup.folders,
}
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
async def backup_full(self, request):
"""Create full backup."""
body = await api_validate(SCHEMA_BACKUP_FULL, request)
backup = await asyncio.shield(
self.sys_backups.do_backup_full(**self._location_to_mount(body))
)
if backup:
return {ATTR_SLUG: backup.slug}
return False
@api_process
async def backup_partial(self, request):
"""Create a partial backup."""
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
backup = await asyncio.shield(
self.sys_backups.do_backup_partial(**self._location_to_mount(body))
)
if backup:
return {ATTR_SLUG: backup.slug}
return False
@api_process
async def restore_full(self, request):
"""Full restore of a backup."""
backup = self._extract_slug(request)
body = await api_validate(SCHEMA_RESTORE_FULL, request)
return await asyncio.shield(self.sys_backups.do_restore_full(backup, **body))
@api_process
async def restore_partial(self, request):
"""Partial restore a backup."""
backup = self._extract_slug(request)
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
return await asyncio.shield(self.sys_backups.do_restore_partial(backup, **body))
@api_process
async def remove(self, request):
"""Remove a backup."""
backup = self._extract_slug(request)
return self.sys_backups.remove(backup)
async def download(self, request):
"""Download a backup file."""
backup = self._extract_slug(request)
_LOGGER.info("Downloading backup %s", backup.slug)
response = web.FileResponse(backup.tarfile)
response.content_type = CONTENT_TYPE_TAR
response.headers[
CONTENT_DISPOSITION
] = f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"
return response
@api_process
async def upload(self, request):
"""Upload a backup file."""
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp_dir:
tar_file = Path(temp_dir, "backup.tar")
reader = await request.multipart()
contents = await reader.next()
try:
with tar_file.open("wb") as backup:
while True:
chunk = await contents.read_chunk()
if not chunk:
break
backup.write(chunk)
except OSError as err:
_LOGGER.error("Can't write new backup file: %s", err)
return False
except asyncio.CancelledError:
return False
backup = await asyncio.shield(self.sys_backups.import_backup(tar_file))
if backup:
return {ATTR_SLUG: backup.slug}
return False

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor HA cli RESTful API."""
import asyncio
import logging
from typing import Any
from typing import Any, Dict
from aiohttp import web
import voluptuous as vol
@@ -32,7 +32,7 @@ class APICli(CoreSysAttributes):
"""Handle RESTful API for HA Cli functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HA cli information."""
return {
ATTR_VERSION: self.sys_plugins.cli.version,
@@ -41,7 +41,7 @@ class APICli(CoreSysAttributes):
}
@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."""
stats = await self.sys_plugins.cli.stats()

View File

@@ -1,55 +0,0 @@
"""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_APPARMOR_VERSION = "apparmor_version"
ATTR_ATTRIBUTES = "attributes"
ATTR_AVAILABLE_UPDATES = "available_updates"
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_DEVICE = "device"
ATTR_DEV_PATH = "dev_path"
ATTR_DISK_LED = "disk_led"
ATTR_DISKS = "disks"
ATTR_DRIVES = "drives"
ATTR_DT_SYNCHRONIZED = "dt_synchronized"
ATTR_DT_UTC = "dt_utc"
ATTR_EJECTABLE = "ejectable"
ATTR_FALLBACK = "fallback"
ATTR_FILESYSTEMS = "filesystems"
ATTR_HEARTBEAT_LED = "heartbeat_led"
ATTR_IDENTIFIERS = "identifiers"
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_POWER_LED = "power_led"
ATTR_REMOVABLE = "removable"
ATTR_REVISION = "revision"
ATTR_SEAT = "seat"
ATTR_SIGNED = "signed"
ATTR_STARTUP_TIME = "startup_time"
ATTR_SUBSYSTEM = "subsystem"
ATTR_SYSFS = "sysfs"
ATTR_TIME_DETECTED = "time_detected"
ATTR_UPDATE_TYPE = "update_type"
ATTR_USE_NTP = "use_ntp"
ATTR_USAGE = "usage"
ATTR_VENDOR = "vendor"

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor DNS RESTful API."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol
@@ -22,22 +21,17 @@ from ..const import (
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
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
_LOGGER: logging.Logger = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_SERVERS): dns_server_list,
vol.Optional(ATTR_FALLBACK): vol.Boolean(),
}
)
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): dns_server_list})
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
@@ -46,7 +40,7 @@ class APICoreDNS(CoreSysAttributes):
"""Handle RESTful API for DNS functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return DNS information."""
return {
ATTR_VERSION: self.sys_plugins.dns.version,
@@ -55,32 +49,21 @@ class APICoreDNS(CoreSysAttributes):
ATTR_HOST: str(self.sys_docker.network.dns),
ATTR_SERVERS: self.sys_plugins.dns.servers,
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
async def options(self, request: web.Request) -> None:
"""Set DNS options."""
body = await api_validate(SCHEMA_OPTIONS, request)
restart_required = False
if ATTR_SERVERS in body:
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_plugins.dns.save_data()
@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."""
stats = await self.sys_plugins.dns.stats()

View File

@@ -1,6 +1,6 @@
"""Init file for Supervisor Home Assistant RESTful API."""
import logging
from typing import Any
from typing import Any, Dict
from aiohttp import web
import voluptuous as vol
@@ -21,7 +21,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_DOCKER_REGISTRY = vol.Schema(
{
str: {
vol.Coerce(str): {
vol.Required(ATTR_USERNAME): str,
vol.Required(ATTR_PASSWORD): str,
}
@@ -33,7 +33,7 @@ class APIDocker(CoreSysAttributes):
"""Handle RESTful API for Docker configuration."""
@api_process
async def registries(self, request) -> dict[str, Any]:
async def registries(self, request) -> Dict[str, Any]:
"""Return the list of registries."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():

View File

@@ -1,52 +1,26 @@
"""Init file for Supervisor hardware RESTful API."""
import logging
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
from ..const import (
ATTR_AUDIO,
ATTR_DEVICES,
ATTR_ID,
ATTR_INPUT,
ATTR_NAME,
ATTR_OUTPUT,
ATTR_SERIAL,
ATTR_SIZE,
ATTR_SYSTEM,
)
from ..const import ATTR_AUDIO, ATTR_DEVICES, ATTR_INPUT, ATTR_NAME, ATTR_OUTPUT
from ..coresys import CoreSysAttributes
from ..dbus.udisks2 import UDisks2
from ..dbus.udisks2.block import UDisks2Block
from ..dbus.udisks2.drive import UDisks2Drive
from ..hardware.data import Device
from .const import (
from ..hardware.const import (
ATTR_ATTRIBUTES,
ATTR_BY_ID,
ATTR_CHILDREN,
ATTR_CONNECTION_BUS,
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_SYSFS,
ATTR_TIME_DETECTED,
ATTR_VENDOR,
)
from ..hardware.data import Device
from .utils import api_process
_LOGGER: logging.Logger = logging.getLogger(__name__)
def device_struct(device: Device) -> dict[str, Any]:
"""Return a dict with information of a interface to be used in the API."""
def device_struct(device: Device) -> Dict[str, Any]:
"""Return a dict with information of a interface to be used in th API."""
return {
ATTR_NAME: device.name,
ATTR_SYSFS: device.sysfs,
@@ -54,43 +28,6 @@ def device_struct(device: Device) -> dict[str, Any]:
ATTR_SUBSYSTEM: device.subsystem,
ATTR_BY_ID: device.by_id,
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
],
}
@@ -98,20 +35,16 @@ class APIHardware(CoreSysAttributes):
"""Handle RESTful API for hardware functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Show hardware info."""
return {
ATTR_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
async def audio(self, request: web.Request) -> dict[str, Any]:
async def audio(self, request: web.Request) -> Dict[str, Any]:
"""Show pulse audio profiles."""
return {
ATTR_AUDIO: {
@@ -125,3 +58,8 @@ class APIHardware(CoreSysAttributes):
},
}
}
@api_process
async def trigger(self, request: web.Request) -> Awaitable[None]:
"""Trigger a udev device reload."""
_LOGGER.debug("Ignoring DEPRECATED hardware trigger function call.")

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor Home Assistant RESTful API."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol
@@ -11,7 +10,6 @@ from ..const import (
ATTR_ARCH,
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_BACKUP,
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_BOOT,
@@ -30,12 +28,13 @@ from ..const import (
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
ATTR_WAIT_BOOT,
ATTR_WATCHDOG,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
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
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -44,29 +43,25 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_BOOT): vol.Boolean(),
vol.Optional(ATTR_IMAGE): vol.Maybe(docker_image),
vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_PORT): network_port,
vol.Optional(ATTR_SSL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
}
)
SCHEMA_UPDATE = vol.Schema(
{
vol.Optional(ATTR_VERSION): version_tag,
vol.Optional(ATTR_BACKUP): bool,
}
)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
class APIHomeAssistant(CoreSysAttributes):
"""Handle RESTful API for Home Assistant functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return host information."""
return {
ATTR_VERSION: self.sys_homeassistant.version,
@@ -80,8 +75,11 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_PORT: self.sys_homeassistant.api_port,
ATTR_SSL: self.sys_homeassistant.api_ssl,
ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
ATTR_WAIT_BOOT: self.sys_homeassistant.wait_boot,
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
# Remove end of Q3 2020
"last_version": self.sys_homeassistant.latest_version,
}
@api_process
@@ -104,6 +102,9 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_WATCHDOG in body:
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:
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
@@ -116,7 +117,7 @@ class APIHomeAssistant(CoreSysAttributes):
self.sys_homeassistant.save_data()
@api_process
async def stats(self, request: web.Request) -> dict[Any, str]:
async def stats(self, request: web.Request) -> Dict[Any, str]:
"""Return resource information."""
stats = await self.sys_homeassistant.core.stats()
if not stats:
@@ -136,14 +137,10 @@ class APIHomeAssistant(CoreSysAttributes):
@api_process
async def update(self, request: web.Request) -> None:
"""Update Home Assistant."""
body = await api_validate(SCHEMA_UPDATE, request)
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_homeassistant.latest_version)
await asyncio.shield(
self.sys_homeassistant.core.update(
version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version),
backup=body.get(ATTR_BACKUP),
)
)
await asyncio.shield(self.sys_homeassistant.core.update(version))
@api_process
def stop(self, request: web.Request) -> Awaitable[None]:

View File

@@ -1,12 +1,9 @@
"""Init file for Supervisor host RESTful API."""
import asyncio
from contextlib import suppress
import logging
from typing import Awaitable
from aiohttp import web
from aiohttp.hdrs import ACCEPT, RANGE
import voluptuous as vol
from voluptuous.error import CoerceInvalid
from ..const import (
ATTR_CHASSIS,
@@ -24,35 +21,14 @@ from ..const import (
ATTR_OPERATING_SYSTEM,
ATTR_SERVICES,
ATTR_STATE,
ATTR_TIMEZONE,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HostLogError
from ..host.const import PARAM_BOOT_ID, PARAM_FOLLOW, PARAM_SYSLOG_IDENTIFIER
from .const import (
ATTR_AGENT_VERSION,
ATTR_APPARMOR_VERSION,
ATTR_BOOT_TIMESTAMP,
ATTR_BOOTS,
ATTR_BROADCAST_LLMNR,
ATTR_BROADCAST_MDNS,
ATTR_DT_SYNCHRONIZED,
ATTR_DT_UTC,
ATTR_IDENTIFIERS,
ATTR_LLMNR_HOSTNAME,
ATTR_STARTUP_TIME,
ATTR_USE_NTP,
CONTENT_TYPE_TEXT,
)
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): vol.Coerce(str)})
class APIHost(CoreSysAttributes):
@@ -62,8 +38,6 @@ class APIHost(CoreSysAttributes):
async def info(self, request):
"""Return host information."""
return {
ATTR_AGENT_VERSION: self.sys_dbus.agent.version,
ATTR_APPARMOR_VERSION: self.sys_host.apparmor.version,
ATTR_CHASSIS: self.sys_host.info.chassis,
ATTR_CPE: self.sys_host.info.cpe,
ATTR_DEPLOYMENT: self.sys_host.info.deployment,
@@ -73,17 +47,8 @@ class APIHost(CoreSysAttributes):
ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time,
ATTR_FEATURES: self.sys_host.features,
ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,
ATTR_KERNEL: self.sys_host.info.kernel,
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
ATTR_TIMEZONE: self.sys_host.info.timezone,
ATTR_DT_UTC: self.sys_host.info.dt_utc,
ATTR_DT_SYNCHRONIZED: self.sys_host.info.dt_synchronized,
ATTR_USE_NTP: self.sys_host.info.use_ntp,
ATTR_STARTUP_TIME: self.sys_host.info.startup_time,
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
@@ -110,7 +75,11 @@ class APIHost(CoreSysAttributes):
@api_process
def reload(self, request):
"""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
async def services(self, request):
@@ -128,75 +97,30 @@ class APIHost(CoreSysAttributes):
return {ATTR_SERVICES: services}
@api_process
async def list_boots(self, _: web.Request):
"""Return a list of boot IDs."""
boot_ids = await self.sys_host.logs.get_boot_ids()
return {
ATTR_BOOTS: {
str(1 + i - len(boot_ids)): boot_id
for i, boot_id in enumerate(boot_ids)
}
}
def service_start(self, request):
"""Start a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.start(unit))
@api_process
async def list_identifiers(self, _: web.Request):
"""Return a list of syslog identifiers."""
return {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()}
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
def service_stop(self, request):
"""Stop a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.stop(unit))
@api_process
async def advanced_logs(
self, request: web.Request, identifier: str | None = None, follow: bool = False
) -> web.StreamResponse:
"""Return systemd-journald logs."""
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
def service_reload(self, request):
"""Reload a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.reload(unit))
if BOOTID in request.match_info:
params[PARAM_BOOT_ID] = await self._get_boot_id(
request.match_info.get(BOOTID)
)
if follow:
params[PARAM_FOLLOW] = ""
@api_process
def service_restart(self, request):
"""Restart a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.restart(unit))
if ACCEPT in request.headers and request.headers[ACCEPT] not in [
CONTENT_TYPE_TEXT,
"*/*",
]:
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
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return host kernel logs."""
return self.sys_host.info.get_dmesg()

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, Dict
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_hassos.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_config.timezone,
}

View File

@@ -2,10 +2,10 @@
import asyncio
from ipaddress import ip_address
import logging
from typing import Any
from typing import Any, Dict, Union
import aiohttp
from aiohttp import ClientTimeout, hdrs, web
from aiohttp import hdrs, web
from aiohttp.web_exceptions import (
HTTPBadGateway,
HTTPServiceUnavailable,
@@ -22,11 +22,11 @@ from ..const import (
ATTR_PANELS,
ATTR_SESSION,
ATTR_TITLE,
COOKIE_INGRESS,
HEADER_TOKEN,
HEADER_TOKEN_OLD,
)
from ..coresys import CoreSysAttributes
from .const import COOKIE_INGRESS
from .utils import api_process, api_validate, require_home_assistant
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -54,7 +54,7 @@ class APIIngress(CoreSysAttributes):
return f"http://{addon.ip_address}:{addon.ingress_port}/{path}"
@api_process
async def panels(self, request: web.Request) -> dict[str, Any]:
async def panels(self, request: web.Request) -> Dict[str, Any]:
"""Create a list of panel data."""
addons = {}
for addon in self.sys_ingress.addons:
@@ -69,14 +69,14 @@ class APIIngress(CoreSysAttributes):
@api_process
@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."""
session = self.sys_ingress.create_session()
return {ATTR_SESSION: session}
@api_process
@require_home_assistant
async def validate_session(self, request: web.Request) -> dict[str, Any]:
async def validate_session(self, request: web.Request) -> Dict[str, Any]:
"""Validate session and extending how long it's valid for."""
data = await api_validate(VALIDATE_SESSION_DATA, request)
@@ -85,9 +85,10 @@ class APIIngress(CoreSysAttributes):
_LOGGER.warning("No valid ingress session %s", data[ATTR_SESSION])
raise HTTPUnauthorized()
@require_home_assistant
async def handler(
self, request: web.Request
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
"""Route data to Supervisor ingress service."""
# Check Ingress Session
@@ -148,8 +149,8 @@ class APIIngress(CoreSysAttributes):
# Proxy requests
await asyncio.wait(
[
self.sys_create_task(_websocket_forward(ws_server, ws_client)),
self.sys_create_task(_websocket_forward(ws_client, ws_server)),
_websocket_forward(ws_server, ws_client),
_websocket_forward(ws_client, ws_server),
],
return_when=asyncio.FIRST_COMPLETED,
)
@@ -158,21 +159,12 @@ class APIIngress(CoreSysAttributes):
async def _handle_request(
self, request: web.Request, addon: Addon, path: str
) -> web.Response | web.StreamResponse:
) -> Union[web.Response, web.StreamResponse]:
"""Ingress route for request."""
url = self._create_url(addon, path)
data = await request.read()
source_header = _init_header(request, addon)
# Passing the raw stream breaks requests for some webservers
# since we just need it for POST requests really, for all other methods
# we read the bytes and pass that to the request to the add-on
# add-ons needs to add support with that in the configuration
data = (
request.content
if request.method == "POST" and addon.ingress_stream
else await request.read()
)
async with self.sys_websession.request(
request.method,
url,
@@ -180,8 +172,6 @@ class APIIngress(CoreSysAttributes):
params=request.query,
allow_redirects=False,
data=data,
timeout=ClientTimeout(total=None),
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result:
headers = _response_header(result)
@@ -218,7 +208,9 @@ class APIIngress(CoreSysAttributes):
return response
def _init_header(request: web.Request, addon: str) -> CIMultiDict | dict[str, str]:
def _init_header(
request: web.Request, addon: str
) -> Union[CIMultiDict, Dict[str, str]]:
"""Create initial header."""
headers = {}
@@ -227,7 +219,6 @@ def _init_header(request: web.Request, addon: str) -> CIMultiDict | dict[str, st
if name in (
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_ENCODING,
hdrs.TRANSFER_ENCODING,
hdrs.SEC_WEBSOCKET_EXTENSIONS,
hdrs.SEC_WEBSOCKET_PROTOCOL,
hdrs.SEC_WEBSOCKET_VERSION,
@@ -246,7 +237,7 @@ def _init_header(request: web.Request, addon: str) -> CIMultiDict | dict[str, st
return headers
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
"""Create response header."""
headers = {}

View File

@@ -1,6 +1,6 @@
"""Init file for Supervisor Jobs RESTful API."""
import logging
from typing import Any
from typing import Any, Dict
from aiohttp import web
import voluptuous as vol
@@ -20,7 +20,7 @@ class APIJobs(CoreSysAttributes):
"""Handle RESTful API for OS functions."""
@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 {
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,

View File

@@ -1 +0,0 @@
"""API middleware for aiohttp."""

View File

@@ -1,313 +0,0 @@
"""Handle security part of this API."""
import logging
import re
from typing import Final
from urllib.parse import unquote
from aiohttp.web import Request, RequestHandler, Response, middleware
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
from awesomeversion import AwesomeVersion
from ...addons.const import RE_SLUG
from ...const import (
REQUEST_FROM,
ROLE_ADMIN,
ROLE_BACKUP,
ROLE_DEFAULT,
ROLE_HOMEASSISTANT,
ROLE_MANAGER,
CoreState,
)
from ...coresys import CoreSys, CoreSysAttributes
from ..utils import api_return_error, excract_supervisor_token
_LOGGER: logging.Logger = logging.getLogger(__name__)
_CORE_VERSION: Final = AwesomeVersion("2023.3.4")
# 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
BLACKLIST: Final = re.compile(
r"^(?:"
r"|/homeassistant/api/hassio/.*"
r"|/core/api/hassio/.*"
r")$"
)
# Free to call or have own security concepts
NO_SECURITY_CHECK: Final = re.compile(
r"^(?:"
r"|/homeassistant/api/.*"
r"|/homeassistant/websocket"
r"|/core/api/.*"
r"|/core/websocket"
r"|/supervisor/ping"
r"|/ingress/[-_A-Za-z0-9]+/.*"
+ _CORE_FRONTEND_PATHS
+ r")$"
)
# Observer allow API calls
OBSERVER_CHECK: Final = re.compile(
r"^(?:"
r"|/.+/info"
r")$"
)
# Can called by every add-on
ADDONS_API_BYPASS: Final = re.compile(
r"^(?:"
r"|/addons/self/(?!security|update)[^/]+"
r"|/addons/self/options/config"
r"|/info"
r"|/services.*"
r"|/discovery.*"
r"|/auth"
r")$"
)
# Policy role add-on API access
ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
ROLE_DEFAULT: re.compile(
r"^(?:"
r"|/.+/info"
r")$"
),
ROLE_HOMEASSISTANT: re.compile(
r"^(?:"
r"|/.+/info"
r"|/core/.+"
r"|/homeassistant/.+"
r")$"
),
ROLE_BACKUP: re.compile(
r"^(?:"
r"|/.+/info"
r"|/backups.*"
r")$"
),
ROLE_MANAGER: re.compile(
r"^(?:"
r"|/.+/info"
r"|/addons(?:/" + RE_SLUG + r"/(?!security).+|/reload)?"
r"|/audio/.+"
r"|/auth/cache"
r"|/cli/.+"
r"|/core/.+"
r"|/dns/.+"
r"|/docker/.+"
r"|/jobs/.+"
r"|/hardware/.+"
r"|/hassos/.+"
r"|/homeassistant/.+"
r"|/host/.+"
r"|/multicast/.+"
r"|/network/.+"
r"|/observer/.+"
r"|/os/.+"
r"|/resolution/.+"
r"|/backups.*"
r"|/snapshots.*"
r"|/store.*"
r"|/supervisor/.+"
r"|/security/.+"
r")$"
),
ROLE_ADMIN: re.compile(
r".*"
),
}
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
class SecurityMiddleware(CoreSysAttributes):
"""Security middleware functions."""
def __init__(self, coresys: CoreSys):
"""Initialize security middleware."""
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
async def system_validation(
self, request: Request, handler: RequestHandler
) -> Response:
"""Check if core is ready to response."""
if self.sys_core.state not in (
CoreState.STARTUP,
CoreState.RUNNING,
CoreState.FREEZE,
):
return api_return_error(
message=f"System is not ready with state: {self.sys_core.state.value}"
)
return await handler(request)
@middleware
async def token_validation(
self, request: Request, handler: RequestHandler
) -> Response:
"""Check security access of this layer."""
request_from = None
supervisor_token = excract_supervisor_token(request)
# Blacklist
if BLACKLIST.match(request.path):
_LOGGER.error("%s is blacklisted!", request.path)
raise HTTPForbidden()
# Ignore security check
if NO_SECURITY_CHECK.match(request.path):
_LOGGER.debug("Passthrough %s", request.path)
request[REQUEST_FROM] = None
return await handler(request)
# Not token
if not supervisor_token:
_LOGGER.warning("No API token provided for %s", request.path)
raise HTTPUnauthorized()
# Home-Assistant
if supervisor_token == self.sys_homeassistant.supervisor_token:
_LOGGER.debug("%s access from Home Assistant", request.path)
request_from = self.sys_homeassistant
# Host
if supervisor_token == self.sys_plugins.cli.supervisor_token:
_LOGGER.debug("%s access from Host", request.path)
request_from = self.sys_host
# Observer
if supervisor_token == self.sys_plugins.observer.supervisor_token:
if not OBSERVER_CHECK.match(request.path):
_LOGGER.warning("%s invalid Observer access", request.path)
raise HTTPForbidden()
_LOGGER.debug("%s access from Observer", request.path)
request_from = self.sys_plugins.observer
# Add-on
addon = None
if supervisor_token and not request_from:
addon = self.sys_addons.from_token(supervisor_token)
# Check Add-on API access
if addon and ADDONS_API_BYPASS.match(request.path):
_LOGGER.debug("Passthrough %s from %s", request.path, addon.slug)
request_from = addon
elif addon and addon.access_hassio_api:
# Check Role
if ADDONS_ROLE_ACCESS[addon.hassio_role].match(request.path):
_LOGGER.info("%s access from %s", request.path, addon.slug)
request_from = addon
else:
_LOGGER.warning("%s no role for %s", request.path, addon.slug)
elif addon:
_LOGGER.warning(
"%s missing API permission for %s", addon.slug, request.path
)
if request_from:
request[REQUEST_FROM] = request_from
return await handler(request)
_LOGGER.error("Invalid token for access %s", request.path)
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."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol
@@ -19,11 +18,11 @@ from ..const import (
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -35,7 +34,7 @@ class APIMulticast(CoreSysAttributes):
"""Handle RESTful API for Multicast functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Multicast information."""
return {
ATTR_VERSION: self.sys_plugins.multicast.version,
@@ -44,7 +43,7 @@ class APIMulticast(CoreSysAttributes):
}
@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."""
stats = await self.sys_plugins.multicast.stats()

View File

@@ -1,8 +1,7 @@
"""REST API for network."""
import asyncio
from collections.abc import Awaitable
from ipaddress import ip_address, ip_interface
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
import attr
@@ -31,7 +30,6 @@ from ..const import (
ATTR_PARENT,
ATTR_PRIMARY,
ATTR_PSK,
ATTR_READY,
ATTR_SIGNAL,
ATTR_SSID,
ATTR_SUPERVISOR_INTERNET,
@@ -84,18 +82,17 @@ SCHEMA_UPDATE = vol.Schema(
)
def ipconfig_struct(config: IpConfig) -> dict[str, Any]:
def ipconfig_struct(config: IpConfig) -> Dict[str, Any]:
"""Return a dict with information about ip configuration."""
return {
ATTR_METHOD: config.method,
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
ATTR_READY: config.ready,
}
def wifi_struct(config: WifiConfig) -> dict[str, Any]:
def wifi_struct(config: WifiConfig) -> Dict[str, Any]:
"""Return a dict with information about wifi configuration."""
return {
ATTR_MODE: config.mode,
@@ -105,7 +102,7 @@ def wifi_struct(config: WifiConfig) -> dict[str, Any]:
}
def vlan_struct(config: VlanConfig) -> dict[str, Any]:
def vlan_struct(config: VlanConfig) -> Dict[str, Any]:
"""Return a dict with information about VLAN configuration."""
return {
ATTR_ID: config.id,
@@ -113,7 +110,7 @@ def vlan_struct(config: VlanConfig) -> dict[str, Any]:
}
def interface_struct(interface: Interface) -> dict[str, Any]:
def interface_struct(interface: Interface) -> Dict[str, Any]:
"""Return a dict with information of a interface to be used in th API."""
return {
ATTR_INTERFACE: interface.name,
@@ -128,7 +125,7 @@ def interface_struct(interface: Interface) -> dict[str, Any]:
}
def accesspoint_struct(accesspoint: AccessPoint) -> dict[str, Any]:
def accesspoint_struct(accesspoint: AccessPoint) -> Dict[str, Any]:
"""Return a dict for AccessPoint."""
return {
ATTR_MODE: accesspoint.mode,
@@ -144,7 +141,9 @@ class APINetwork(CoreSysAttributes):
def _get_interface(self, name: str) -> Interface:
"""Get Interface by name or default."""
if name.lower() == "default":
name = name.lower()
if name == "default":
for interface in self.sys_host.network.interfaces:
if not interface.primary:
continue
@@ -159,7 +158,7 @@ class APINetwork(CoreSysAttributes):
raise APIError(f"Interface {name} does not exist") from None
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return network information."""
return {
ATTR_INTERFACES: [
@@ -177,7 +176,7 @@ class APINetwork(CoreSysAttributes):
}
@api_process
async def interface_info(self, request: web.Request) -> dict[str, Any]:
async def interface_info(self, request: web.Request) -> Dict[str, Any]:
"""Return network information for a interface."""
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
@@ -197,14 +196,12 @@ class APINetwork(CoreSysAttributes):
for key, config in body.items():
if key == ATTR_IPV4:
interface.ipv4 = attr.evolve(
interface.ipv4
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
interface.ipv4 or IpConfig(InterfaceMethod.STATIC, [], None, []),
**config,
)
elif key == ATTR_IPV6:
interface.ipv6 = attr.evolve(
interface.ipv6
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
interface.ipv6 or IpConfig(InterfaceMethod.STATIC, [], None, []),
**config,
)
elif key == ATTR_WIFI:
@@ -223,12 +220,10 @@ class APINetwork(CoreSysAttributes):
@api_process
def reload(self, request: web.Request) -> Awaitable[None]:
"""Reload network data."""
return asyncio.shield(
self.sys_host.network.update(force_connectivity_check=True)
)
return asyncio.shield(self.sys_host.network.update())
@api_process
async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]:
async def scan_accesspoints(self, request: web.Request) -> Dict[str, Any]:
"""Scan and return a list of available networks."""
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
@@ -262,7 +257,6 @@ class APINetwork(CoreSysAttributes):
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
None,
)
ipv6_config = None
@@ -272,7 +266,6 @@ class APINetwork(CoreSysAttributes):
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
None,
)
vlan_interface = Interface(

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor Observer RESTful API."""
import asyncio
import logging
from typing import Any
from typing import Any, Dict
from aiohttp import web
import voluptuous as vol
@@ -33,7 +33,7 @@ class APIObserver(CoreSysAttributes):
"""Handle RESTful API for Observer functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HA Observer information."""
return {
ATTR_HOST: str(self.sys_docker.network.observer),
@@ -43,7 +43,7 @@ class APIObserver(CoreSysAttributes):
}
@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."""
stats = await self.sys_plugins.observer.stats()

View File

@@ -1,8 +1,7 @@
"""Init file for Supervisor HassOS RESTful API."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol
@@ -10,136 +9,42 @@ import voluptuous as vol
from ..const import (
ATTR_BOARD,
ATTR_BOOT,
ATTR_DEVICES,
ATTR_ID,
ATTR_NAME,
ATTR_SERIAL,
ATTR_SIZE,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
)
from ..coresys import CoreSysAttributes
from ..exceptions import BoardInvalidError
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..validate import version_tag
from .const import (
ATTR_DATA_DISK,
ATTR_DEV_PATH,
ATTR_DEVICE,
ATTR_DISK_LED,
ATTR_DISKS,
ATTR_HEARTBEAT_LED,
ATTR_MODEL,
ATTR_POWER_LED,
ATTR_VENDOR,
)
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): str})
# pylint: disable=no-value-for-parameter
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(),
}
)
class APIOS(CoreSysAttributes):
"""Handle RESTful API for OS functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return OS information."""
return {
ATTR_VERSION: self.sys_os.version,
ATTR_VERSION_LATEST: self.sys_os.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
ATTR_BOARD: self.sys_os.board,
ATTR_VERSION: self.sys_hassos.version,
ATTR_VERSION_LATEST: self.sys_hassos.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_hassos.need_update,
ATTR_BOARD: self.sys_hassos.board,
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used_id,
}
@api_process
async def update(self, request: web.Request) -> None:
"""Update OS."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_os.latest_version)
version = body.get(ATTR_VERSION, self.sys_hassos.latest_version)
await asyncio.shield(self.sys_os.update(version))
await asyncio.shield(self.sys_hassos.update(version))
@api_process
def config_sync(self, request: web.Request) -> Awaitable[None]:
"""Trigger config reload on OS."""
return asyncio.shield(self.sys_os.config_sync())
@api_process
async def migrate_data(self, request: web.Request) -> None:
"""Trigger data disk migration on Host."""
body = await api_validate(SCHEMA_DISK, request)
await asyncio.shield(self.sys_os.datadisk.migrate_disk(body[ATTR_DEVICE]))
@api_process
async def list_data(self, request: web.Request) -> dict[str, Any]:
"""Return possible data targets."""
return {
ATTR_DEVICES: [disk.id for disk in 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_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_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 {}
return asyncio.shield(self.sys_hassos.config_sync())

View File

@@ -1 +1,9 @@
!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-NoHhvMA3Ku8.js");else try{new Function("import('/api/hassio/app/frontend_latest/entrypoint-G81gb268sps.js')")()}catch(t){n("/api/hassio/app/frontend_es5/entrypoint-NoHhvMA3Ku8.js")}}()
try {
new Function("import('/api/hassio/app/frontend_latest/entrypoint.4050b348.js')")();
} catch (err) {
var el = document.createElement('script');
el.src = '/api/hassio/app/frontend_es5/entrypoint.bcf8e8ff.js';
document.body.appendChild(el);
}

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/20230601.0/src/common/dom/stop_propagation.ts","https://raw.githubusercontent.com/home-assistant/frontend/20230601.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

View File

@@ -1,5 +0,0 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

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

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

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