Compare commits

..

2 Commits

Author SHA1 Message Date
Pascal Vizeli
20bb890a27 Merge pull request #2149 from home-assistant/dev
Release 249
2020-10-19 16:44:30 +02:00
Pascal Vizeli
ebf0fe8397 Merge pull request #2141 from home-assistant/dev
Release 248
2020-10-18 16:16:58 +02:00
1001 changed files with 14737 additions and 41736 deletions

49
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.8
WORKDIR /workspaces
# Install Node/Yarn for Frontent
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
apt-utils \
apt-transport-https \
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& 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 \
&& 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,8 +1,9 @@
{ {
"name": "Supervisor dev", "name": "Supervisor dev",
"image": "ghcr.io/home-assistant/devcontainer:supervisor", "context": "..",
"appPort": ["9123:8123", "7357:4357"], "dockerFile": "Dockerfile",
"postCreateCommand": "bash devcontainer_bootstrap", "appPort": "9123:8123",
"postCreateCommand": "pre-commit install",
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
"extensions": [ "extensions": [
"ms-python.python", "ms-python.python",
@@ -10,14 +11,8 @@
"visualstudioexptteam.vscodeintellicode", "visualstudioexptteam.vscodeintellicode",
"esbenp.prettier-vscode" "esbenp.prettier-vscode"
], ],
"mounts": [ "type=volume,target=/var/lib/docker" ],
"settings": { "settings": {
"terminal.integrated.profiles.linux": { "terminal.integrated.shell.linux": "/bin/bash",
"zsh": {
"path": "/usr/bin/zsh"
}
},
"terminal.integrated.defaultProfile.linux": "zsh",
"editor.formatOnPaste": false, "editor.formatOnPaste": false,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnType": true, "editor.formatOnType": true,
@@ -26,7 +21,7 @@
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": true,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.formatting.provider": "black", "python.formatting.provider": "black",
"python.formatting.blackArgs": ["--target-version", "py39"], "python.formatting.blackArgs": ["--target-version", "py38"],
"python.formatting.blackPath": "/usr/local/bin/black", "python.formatting.blackPath": "/usr/local/bin/black",
"python.linting.banditPath": "/usr/local/bin/bandit", "python.linting.banditPath": "/usr/local/bin/bandit",
"python.linting.flake8Path": "/usr/local/bin/flake8", "python.linting.flake8Path": "/usr/local/bin/flake8",

View File

@@ -1,69 +1,28 @@
---
name: Report a bug with the Supervisor on a supported System
about: Report an issue related to the Home Assistant Supervisor.
labels: bug
---
<!-- READ THIS FIRST: <!-- READ THIS FIRST:
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/ - If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/core/releases
- Do not report issues for integrations here, please refer to https://github.com/home-assistant/core/issues
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests - This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template! - Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
- If you have a problem with an add-on, make an issue in it's repository. - If you have a problem with an add-on, make an issue in its repository.
--> -->
**Home Assistant release with the issue:**
<!-- <!--
Important: You can only fill a bug repport for an supported system! If you run an unsupported installation. This report would be closed without comment. - Frontend -> Configuration -> Info
- Or use this command: hass --version
--> -->
### Describe the issue **Operating environment (HassOS/Generic):**
<!--
Please provide details about your environment.
-->
<!-- Provide as many details as possible. --> **Supervisor logs:**
### Steps to reproduce
<!-- What do you do to encounter the issue. -->
1. ...
2. ...
3. ...
### Enviroment details
<!-- You can find these details in the system tab of the supervisor panel, or by using the `ha` CLI. -->
- **Operating System:**: xxx
- **Supervisor version:**: xxx
- **Home Assistant version**: xxx
### Supervisor logs
<details>
<summary>Supervisor logs</summary>
<!-- <!--
- Frontend -> Supervisor -> System - Frontend -> Supervisor -> System
- Or use this command: ha supervisor logs - Or use this command: ha supervisor logs
- Logs are more than just errors, even if you don't think it's important, it is.
--> -->
```
Paste supervisor logs here
```
</details>
### System Information
<details>
<summary>System Information</summary>
<!--
- Use this command: ha info
-->
```
Paste system info here
```
</details>
**Description of problem:**

View File

@@ -1,98 +0,0 @@
name: Bug Report Form
description: Report an issue related to the Home Assistant Supervisor.
labels: bug
body:
- type: markdown
attributes:
value: |
This issue form is for reporting bugs with **supported** setups only!
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
[fr]: https://community.home-assistant.io/c/feature-requests
- type: textarea
validations:
required: true
attributes:
label: Describe the issue you are experiencing
description: Provide a clear and concise description of what the bug is.
- type: markdown
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, you can find it in: Configuration panel -> Info.
options:
- Home Assistant OS
- Home Assistant Supervised
- type: dropdown
validations:
required: true
attributes:
label: Which operating system are you running on?
options:
- 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: |
# Details
- type: textarea
validations:
required: true
attributes:
label: Steps to reproduce the issue
description: |
Please tell us exactly how to reproduce your issue.
Provide clear and concise step by step instructions and add code snippets if needed.
value: |
1.
2.
3.
...
- type: textarea
validations:
required: true
attributes:
label: Anything in the Supervisor logs that might be useful for us?
description: >
The Supervisor logs can be found in the Supervisor panel -> System tab.
render: txt
- type: textarea
attributes:
label: Additional information
description: >
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.

View File

@@ -1,25 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Report a bug/issues with an unsupported Supervisor
url: https://community.home-assistant.io
about: The Community guide can help or was updated to solve your issue
- name: Report a bug for the Supervisor panel
url: https://github.com/home-assistant/frontend/issues
about: The Supervisor panel is a part of the Home Assistant frontend
- name: Report incorrect or missing information on our developer documentation
url: https://github.com/home-assistant/developers.home-assistant.io/issues
about: Our documentation has its own issue tracker. Please report issues with the website there.
- name: Request a feature for the Supervisor
url: https://community.home-assistant.io/c/feature-requests
about: Request an new feature for the Supervisor.
- name: I have a question or need support
url: https://www.home-assistant.io/help
about: We use GitHub for tracking bugs, check our website for resources on getting help.
- name: I'm unsure where to go?
url: https://www.home-assistant.io/join-chat
about: If you are unsure where to go, then joining our chat is recommended; Just ask!

View File

@@ -37,7 +37,6 @@
- This PR fixes or closes issue: fixes # - This PR fixes or closes issue: fixes #
- This PR is related to issue: - This PR is related to issue:
- Link to documentation pull request: - Link to documentation pull request:
- Link to cli pull request:
## Checklist ## Checklist

27
.github/lock.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# Configuration for Lock Threads - https://github.com/dessant/lock-threads
# Number of days of inactivity before a closed issue or pull request is locked
daysUntilLock: 1
# Skip issues and pull requests created before a given timestamp. Timestamp must
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
skipCreatedBefore: 2020-01-01
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
exemptLabels: []
# Label to add before locking, such as `outdated`. Set to `false` to disable
lockLabel: false
# Comment to post before locking. Set to `false` to disable
lockComment: false
# Assign `resolved` as the reason for locking. Set to `false` to disable
setLockReason: false
# Limit to only `issues` or `pulls`
only: pulls
# Optionally, specify configuration settings just for `issues` or `pulls`
issues:
daysUntilLock: 30

View File

@@ -1,50 +1,4 @@
change-template: "- #$NUMBER $TITLE @$AUTHOR"
sort-direction: ascending
categories:
- title: ":boom: Breaking Changes"
label: "breaking-change"
- title: ":wrench: Build"
label: "build"
- title: ":boar: Chore"
label: "chore"
- title: ":sparkles: New Features"
label: "new-feature"
- title: ":zap: Performance"
label: "performance"
- title: ":recycle: Refactor"
label: "refactor"
- title: ":green_heart: CI"
label: "ci"
- title: ":bug: Bug Fixes"
label: "bugfix"
- title: ":white_check_mark: Test"
label: "test"
- title: ":arrow_up: Dependency Updates"
label: "dependencies"
collapse-after: 1
include-labels:
- "breaking-change"
- "build"
- "chore"
- "performance"
- "refactor"
- "new-feature"
- "bugfix"
- "dependencies"
- "test"
- "ci"
template: | template: |
## What's Changed
$CHANGES $CHANGES

18
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- rfc
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@@ -1,374 +0,0 @@
name: Build supervisor
on:
workflow_dispatch:
inputs:
channel:
description: "Channel"
required: true
default: "dev"
version:
description: "Version"
required: true
publish:
description: "Publish"
required: true
default: "false"
stable:
description: "Stable"
required: true
default: "false"
pull_request:
branches: ["main"]
release:
types: ["published"]
push:
branches: ["main"]
paths:
- "rootfs/**"
- "supervisor/**"
- build.yaml
- Dockerfile
- requirements.txt
- setup.py
env:
DEFAULT_PYTHON: 3.9
BUILD_NAME: supervisor
BUILD_TYPE: supervisor
WHEELS_TAG: 3.9-alpine3.14
jobs:
init:
name: Initialize build
runs-on: ubuntu-latest
outputs:
architectures: ${{ steps.info.outputs.architectures }}
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
requirements: ${{ steps.requirements.outputs.changed }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.0.2
with:
fetch-depth: 0
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master
- name: Get version
id: version
uses: home-assistant/actions/helpers/version@master
with:
type: ${{ env.BUILD_TYPE }}
- name: Get changed files
id: changed_files
if: steps.version.outputs.publish == 'false'
uses: jitterbit/get-changed-files@v1
- name: Check if requirements files changed
id: requirements
run: |
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.json) ]]; then
echo "::set-output name=changed::true"
fi
build:
name: Build ${{ matrix.arch }} supervisor
needs: init
runs-on: ubuntu-latest
strategy:
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.0.2
with:
fetch-depth: 0
- name: Build wheels
if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@master
with:
tag: ${{ env.WHEELS_TAG }}
arch: ${{ matrix.arch }}
wheels-host: wheels.hass.io
wheels-key: ${{ secrets.WHEELS_KEY }}
wheels-user: wheels
apk: "build-base;libffi-dev;openssl-dev;cargo"
skip-binary: aiohttp
requirements: "requirements.txt"
- name: Set version
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version@master
with:
type: ${{ env.BUILD_TYPE }}
- name: Login to DockerHub
if: needs.init.outputs.publish == 'true'
uses: docker/login-action@v2.0.0
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.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_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@2022.06.2
with:
args: |
$BUILD_ARGS \
--${{ matrix.arch }} \
--target /data \
--generic ${{ needs.init.outputs.version }}
env:
CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
codenotary:
name: CAS signature
needs: init
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
if: needs.init.outputs.publish == 'true'
uses: actions/checkout@v3.0.2
with:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@v4.0.0
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
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 }}
version:
name: Update version
needs: ["init", "run_supervisor"]
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
if: needs.init.outputs.publish == 'true'
uses: actions/checkout@v3.0.2
- name: Initialize git
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/git-init@master
with:
name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }}
- name: Update version file
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version-push@master
with:
key: ${{ env.BUILD_NAME }}
version: ${{ needs.init.outputs.version }}
channel: ${{ needs.init.outputs.channel }}
run_supervisor:
runs-on: ubuntu-latest
name: Run the Supervisor
needs: ["build", "codenotary", "init"]
timeout-minutes: 60
steps:
- name: Checkout the repository
uses: actions/checkout@v3.0.2
- name: Build the Supervisor
if: needs.init.outputs.publish != 'true'
uses: home-assistant/builder@2022.06.2
with:
args: |
--test \
--amd64 \
--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 \
-v /run/docker.sock:/run/docker.sock \
-v /run/dbus:/run/dbus \
-v /tmp/supervisor/data:/data \
-v /etc/machine-id:/etc/machine-id:ro \
-e SUPERVISOR_SHARE="/tmp/supervisor/data" \
-e SUPERVISOR_NAME=hassio_supervisor \
-e SUPERVISOR_DEV=1 \
-e SUPERVISOR_MACHINE="qemux86-64" \
homeassistant/amd64-hassio-supervisor:runner
- name: Start the Supervisor
run: docker start hassio_supervisor
- 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: 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
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
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
- 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
- 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

@@ -1,19 +0,0 @@
name: Check PR
on:
pull_request:
branches: ["main"]
types: [labeled, unlabeled, synchronize]
jobs:
init:
name: Check labels
runs-on: ubuntu-latest
steps:
- name: Check labels
run: |
labels=$(jq -r '.pull_request.labels[] | .name' ${{github.event_path }})
echo "$labels"
if [ "$labels" == "cla-signed" ]; then
exit 1
fi

View File

@@ -4,13 +4,13 @@ name: CI
on: on:
push: push:
branches: branches:
- main - dev
- master
pull_request: ~ pull_request: ~
env: env:
DEFAULT_PYTHON: 3.9 DEFAULT_PYTHON: 3.8
PRE_COMMIT_HOME: ~/.cache/pre-commit PRE_COMMIT_HOME: ~/.cache/pre-commit
DEFAULT_CAS: v1.0.2
jobs: jobs:
# Separate job to pre-populate the base dependency cache # Separate job to pre-populate the base dependency cache
@@ -19,23 +19,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: [3.8]
name: Prepare Python ${{ matrix.python-version }} dependencies name: Prepare Python ${{ matrix.python-version }} dependencies
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
${{ runner.os }}-venv-${{ steps.python.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') }}
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 - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -45,7 +48,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -64,15 +67,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -93,7 +96,7 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Register hadolint problem matcher - name: Register hadolint problem matcher
run: | run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json" echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -108,15 +111,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -128,7 +131,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -152,15 +155,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -184,15 +187,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -204,7 +207,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -225,15 +228,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -245,7 +248,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -269,15 +272,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -301,15 +304,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -321,7 +324,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: ${{ env.PRE_COMMIT_HOME }} path: ${{ env.PRE_COMMIT_HOME }}
key: | key: |
@@ -341,23 +344,19 @@ jobs:
needs: prepare needs: prepare
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: [3.8]
name: Run tests Python ${{ matrix.python-version }} name: Run tests Python ${{ matrix.python-version }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install CAS tools
uses: home-assistant/actions/helpers/cas@master
with:
version: ${{ env.DEFAULT_CAS }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -392,7 +391,7 @@ jobs:
-o console_output_style=count \ -o console_output_style=count \
tests tests
- name: Upload coverage artifact - name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.0 uses: actions/upload-artifact@v2.2.0
with: with:
name: coverage-${{ matrix.python-version }} name: coverage-${{ matrix.python-version }}
path: .coverage path: .coverage
@@ -403,15 +402,15 @@ jobs:
needs: pytest needs: pytest
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.0.2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.0.0 uses: actions/setup-python@v2.1.4
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v3.0.4 uses: actions/cache@v2
with: with:
path: venv path: venv
key: | key: |
@@ -422,7 +421,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache" echo "Failed to restore Python virtual environment from cache"
exit 1 exit 1
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v2
- name: Combine coverage results - name: Combine coverage results
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -430,4 +429,4 @@ jobs:
coverage report coverage report
coverage xml coverage xml
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.0 uses: codecov/codecov-action@v1.0.14

View File

@@ -1,20 +0,0 @@
name: Lock
# yamllint disable-line rule:truthy
on:
schedule:
- cron: "0 0 * * *"
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v3.0.0
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
exclude-issue-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-reason: ""

View File

@@ -2,43 +2,14 @@ name: Release Drafter
on: on:
push: push:
# branches to consider in the event; optional, defaults to all
branches: branches:
- main - dev
jobs: jobs:
update_release_draft: update_release_draft:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Release Drafter
steps: steps:
- name: Checkout the repository - uses: release-drafter/release-drafter@v5
uses: actions/checkout@v3.0.2
with:
fetch-depth: 0
- name: Find Next Version
id: version
run: |
declare -i newpost
latest=$(git describe --tags $(git rev-list --tags --max-count=1))
latestpre=$(echo "$latest" | awk '{split($0,a,"."); print a[1] "." a[2]}')
datepre=$(date --utc '+%Y.%m')
if [[ "$latestpre" == "$datepre" ]]; then
latestpost=$(echo "$latest" | awk '{split($0,a,"."); print a[3]}')
newpost=$latestpost+1
else
newpost=0
fi
echo Current version: $latest
echo New target version: $datepre.$newpost
echo "::set-output name=version::$datepre.$newpost"
- name: Run Release Drafter
uses: release-drafter/release-drafter@v5.20.0
with:
tag: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

@@ -1,39 +0,0 @@
name: Stale
# yamllint disable-line rule:truthy
on:
schedule:
- cron: "0 * * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
days-before-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest version and check if that
solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
stale-pr-label: "stale"
exempt-pr-labels: "no-stale,pinned,rfc,security"
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
Thank you for your contributions.

View File

@@ -1,6 +1,5 @@
ignored: ignored:
- DL3003 - DL3018
- DL3006 - DL3006
- DL3013 - DL3013
- DL3018
- SC2155 - SC2155

View File

@@ -1,13 +1,13 @@
repos: repos:
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.3.0 rev: 20.8b1
hooks: hooks:
- id: black - id: black
args: args:
- --safe - --safe
- --quiet - --quiet
- --target-version - --target-version
- py39 - py38
files: ^((supervisor|tests)/.+)?[^/]+\.py$ files: ^((supervisor|tests)/.+)?[^/]+\.py$
- repo: https://gitlab.com/pycqa/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: 3.8.3 rev: 3.8.3
@@ -23,12 +23,12 @@ repos:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
stages: [manual] stages: [manual]
- id: check-json - id: check-json
- repo: https://github.com/PyCQA/isort - repo: https://github.com/pre-commit/mirrors-isort
rev: 5.9.3 rev: v4.3.21
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.32.1 rev: v2.6.2
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py39-plus] args: [--py37-plus]

View File

@@ -1,21 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
*.egg-info/
# General files
.git
.github
.devcontainer
.vscode
.tox
# Data
home-assistant-polymer/
script/
tests/
data/
venv/

17
.vscode/tasks.json vendored
View File

@@ -2,9 +2,9 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "Run Supervisor", "label": "Run Testenv",
"type": "shell", "type": "shell",
"command": "supervisor_run", "command": "./scripts/test_env.sh",
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
@@ -16,7 +16,7 @@
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Run Supervisor CLI", "label": "Run Testenv CLI",
"type": "shell", "type": "shell",
"command": "docker exec -ti hassio_cli /usr/bin/cli.sh", "command": "docker exec -ti hassio_cli /usr/bin/cli.sh",
"group": { "group": {
@@ -30,9 +30,9 @@
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Update Supervisor Panel", "label": "Update UI",
"type": "shell", "type": "shell",
"command": "LOKALISE_TOKEN='${input:localiseToken}' ./scripts/update-frontend.sh", "command": "./scripts/update-frontend.sh",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
@@ -86,12 +86,5 @@
}, },
"problemMatcher": [] "problemMatcher": []
} }
],
"inputs": [
{
"id": "localiseToken",
"type": "promptString",
"description": "Paste your lokalise token to download frontend translations"
}
] ]
} }

View File

@@ -1,39 +1,24 @@
ARG BUILD_FROM ARG BUILD_FROM
FROM ${BUILD_FROM} FROM $BUILD_FROM
ENV \ ENV \
S6_SERVICES_GRACETIME=10000 \ S6_SERVICES_GRACETIME=10000 \
SUPERVISOR_API=http://localhost SUPERVISOR_API=http://localhost
ARG \
BUILD_ARCH \
CAS_VERSION
# Install base # Install base
WORKDIR /usr/src
RUN \ RUN \
set -x \ apk add --no-cache \
&& apk add --no-cache \
eudev \ eudev \
eudev-libs \ eudev-libs \
git \ git \
glib \
libffi \ libffi \
libpulse \ libpulse \
musl \ musl \
openssl \ openssl
&& apk add --no-cache --virtual .build-dependencies \
build-base \ ARG BUILD_ARCH
go \ WORKDIR /usr/src
\
&& git clone -b "v${CAS_VERSION}" --depth 1 \
https://github.com/codenotary/cas \
&& cd cas \
&& make cas \
&& mv cas /usr/bin/cas \
\
&& apk del .build-dependencies \
&& rm -rf /root/go /root/.cache \
&& rm -rf /usr/src/cas
# Install requirements # Install requirements
COPY requirements.txt . COPY requirements.txt .

View File

@@ -10,23 +10,17 @@ network settings or installing and updating software.
## Installation ## Installation
Installation instructions can be found at https://home-assistant.io/getting-started. Installation instructions can be found at https://home-assistant.io/hassio.
## Development ## Development
For small changes and bugfixes you can just follow this, but for significant changes open a RFC first. The development of the Supervisor is not difficult but tricky.
Development instructions can be found [here][development].
## Release - You can use the builder to create your Supervisor: https://github.com/home-assistant/hassio-builder
- Access a HassOS device or VM and pull your Supervisor.
- Set the developer modus with the CLI tool: `ha supervisor options --channel=dev`
- Tag it as `homeassistant/xy-hassio-supervisor:latest`
- Restart the service with `systemctl restart hassos-supervisor | journalctl -fu hassos-supervisor`
- Test your changes
Releases are done in 3 stages (channels) with this structure: For small bugfixes or improvements, make a PR. For significant changes open a RFC first, please. Thanks.
1. Pull requests are merged to the `main` branch.
2. A new build is pushed to the `dev` stage.
3. Releases are published.
4. A new build is pushed to the `beta` stage.
5. The [`stable.json`][stable] file is updated.
6. The build that was pushed to `beta` will now be pushed to `stable`.
[development]: https://developers.home-assistant.io/docs/supervisor/development
[stable]: https://github.com/home-assistant/version/blob/master/stable.json

View File

@@ -0,0 +1,53 @@
# https://dev.azure.com/home-assistant
trigger:
batch: true
branches:
include:
- dev
tags:
include:
- "*"
pr: none
variables:
- name: versionBuilder
value: "7.0"
- group: docker
jobs:
- job: "VersionValidate"
pool:
vmImage: "ubuntu-latest"
steps:
- task: UsePythonVersion@0
displayName: "Use Python 3.8"
inputs:
versionSpec: "3.8"
- script: |
setup_version="$(python setup.py -V)"
branch_version="$(Build.SourceBranchName)"
if [ "${branch_version}" == "dev" ]; then
exit 0
elif [ "${setup_version}" != "${branch_version}" ]; then
echo "Version of tag ${branch_version} don't match with ${setup_version}!"
exit 1
fi
displayName: "Check version of branch/tag"
- job: "Release"
dependsOn:
- "VersionValidate"
pool:
vmImage: "ubuntu-latest"
steps:
- script: sudo docker login -u $(dockerUser) -p $(dockerPassword)
displayName: "Docker hub login"
- script: sudo docker pull homeassistant/amd64-builder:$(versionBuilder)
displayName: "Install Builder"
- script: |
sudo docker run --rm --privileged \
-v ~/.docker:/root/.docker \
-v /run/docker.sock:/run/docker.sock:rw -v $(pwd):/data:ro \
homeassistant/amd64-builder:$(versionBuilder) \
--generic $(Build.SourceBranchName) --all -t /data
displayName: "Build Release"

View File

@@ -0,0 +1,27 @@
# https://dev.azure.com/home-assistant
trigger:
batch: true
branches:
include:
- dev
pr: none
variables:
- name: versionWheels
value: '1.13.0-3.8-alpine3.12'
resources:
repositories:
- repository: azure
type: github
name: 'home-assistant/ci-azure'
endpoint: 'home-assistant'
jobs:
- template: templates/azp-job-wheels.yaml@azure
parameters:
builderVersion: '$(versionWheels)'
builderApk: 'build-base;libffi-dev;openssl-dev'
builderPip: 'Cython'
skipBinary: 'aiohttp'
wheelsRequirement: 'requirements.txt'

13
build.json Normal file
View File

@@ -0,0 +1,13 @@
{
"image": "homeassistant/{arch}-hassio-supervisor",
"build_from": {
"aarch64": "homeassistant/aarch64-base-python:3.8-alpine3.12",
"armhf": "homeassistant/armhf-base-python:3.8-alpine3.12",
"armv7": "homeassistant/armv7-base-python:3.8-alpine3.12",
"amd64": "homeassistant/amd64-base-python:3.8-alpine3.12",
"i386": "homeassistant/i386-base-python:3.8-alpine3.12"
},
"labels": {
"io.hass.type": "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.9-alpine3.14
armhf: ghcr.io/home-assistant/armhf-base-python:3.9-alpine3.14
armv7: ghcr.io/home-assistant/armv7-base-python:3.9-alpine3.14
amd64: ghcr.io/home-assistant/amd64-base-python:3.9-alpine3.14
i386: ghcr.io/home-assistant/i386-base-python:3.9-alpine3.14
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 reports=no
jobs=2 jobs=2
good-names=id,i,j,k,ex,Run,_,fp,T,os good-names=id,i,j,k,ex,Run,_,fp,T
extension-pkg-whitelist=
ciso8601
# Reasons disabled: # Reasons disabled:
# format - handled by black # format - handled by black
# locally-disabled - it spams too much # locally-disabled - it spams too much
# duplicate-code - unavoidable # duplicate-code - unavoidable
# cyclic-import - doesn't test if both import on load # cyclic-import - doesn't test if both import on load
# abstract-class-little-used - prevents from setting right foundation
# abstract-class-not-used - is flaky, should not show up but does # abstract-class-not-used - is flaky, should not show up but does
# unused-argument - generic callbacks and setup methods create a lot of warnings # unused-argument - generic callbacks and setup methods create a lot of warnings
# redefined-variable-type - this is Python, we're duck typing!
# too-many-* - are not enforced for the sake of readability # too-many-* - are not enforced for the sake of readability
# too-few-* - same as too-many-* # too-few-* - same as too-many-*
# abstract-method - with intro of async there are always methods missing # abstract-method - with intro of async there are always methods missing
disable= disable=
format, format,
abstract-class-little-used,
abstract-method, abstract-method,
cyclic-import, cyclic-import,
duplicate-code, duplicate-code,
locally-disabled, locally-disabled,
no-else-return, no-else-return,
no-self-use,
not-context-manager, not-context-manager,
redefined-variable-type,
too-few-public-methods, too-few-public-methods,
too-many-arguments, too-many-arguments,
too-many-branches, too-many-branches,
@@ -35,7 +37,6 @@ disable=
too-many-return-statements, too-many-return-statements,
too-many-statements, too-many-statements,
unused-argument, unused-argument,
consider-using-with
[EXCEPTIONS] [EXCEPTIONS]
overgeneral-exceptions=Exception overgeneral-exceptions=Exception

View File

@@ -1,25 +1,21 @@
aiodns==3.0.0 aiohttp==3.6.3
aiohttp==3.8.1 async_timeout==3.0.1
async_timeout==4.0.2 attrs==20.2.0
atomicwrites==1.4.0 brotlipy==0.7.0
attrs==21.4.0 cchardet==2.1.6
awesomeversion==22.5.2 colorlog==4.4.0
brotli==1.0.9
cchardet==2.1.7
ciso8601==2.2.0
colorlog==6.6.0
cpe==1.2.1 cpe==1.2.1
cryptography==36.0.2 cryptography==3.1
debugpy==1.6.0 debugpy==1.0.0
deepmerge==1.0.1 docker==4.3.1
dirhash==0.2.1 gitpython==3.1.9
docker==5.0.3 jinja2==2.11.2
gitpython==3.1.27 packaging==20.4
jinja2==3.1.2 pulsectl==20.5.1
pulsectl==22.3.2 pytz==2020.1
pyudev==0.23.2 pyudev==0.22.0
ruamel.yaml==0.17.17 ruamel.yaml==0.15.100
securetar==2022.2.0 sentry-sdk==0.18.0
sentry-sdk==1.5.12 uvloop==0.14.0
voluptuous==0.13.1 voluptuous==0.12.0
dbus-next==0.2.3 yarl==1.5.1

View File

@@ -1,15 +1,14 @@
black==22.3.0 black==20.8b1
codecov==2.1.12 codecov==2.1.10
coverage==6.4.1 coverage==5.3
flake8-docstrings==1.6.0 flake8-docstrings==1.5.0
flake8==4.0.1 flake8==3.8.4
pre-commit==2.19.0 pre-commit==2.7.1
pydocstyle==6.1.1 pydocstyle==5.1.1
pylint==2.14.3 pylint==2.6.0
pytest-aiohttp==0.3.0 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-asyncio==0.12.0 # NB!: Versions over 0.12.0 breaks pytest-aiohttp (https://github.com/aio-libs/pytest-aiohttp/issues/16)
pytest-cov==3.0.0 pytest-cov==2.10.1
pytest-timeout==2.1.0 pytest-timeout==1.4.2
pytest==7.1.2 pytest==6.1.1
pyupgrade==2.34.0 pyupgrade==2.7.2
time-machine==2.7.0

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

@@ -2,17 +2,9 @@
# ============================================================================== # ==============================================================================
# Start udev service # Start udev service
# ============================================================================== # ==============================================================================
if bashio::fs.directory_exists /run/udev && ! bashio::fs.file_exists /run/.old_udev; then
bashio::log.info "Using udev information from host"
bashio::exit.ok
fi
bashio::log.info "Setup udev backend inside container"
udevd --daemon udevd --daemon
bashio::log.info "Update udev information" bashio::log.info "Update udev information"
touch /run/.old_udev
if udevadm trigger; then if udevadm trigger; then
udevadm settle || true udevadm settle || true
else else

View File

@@ -26,7 +26,7 @@ autospawn = no
; daemon-binary = /usr/bin/pulseaudio ; daemon-binary = /usr/bin/pulseaudio
; extra-arguments = --log-target=syslog ; extra-arguments = --log-target=syslog
cookie-file = /run/pulse-cookie ; cookie-file =
; enable-shm = yes ; enable-shm = yes
; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB ; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB

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

@@ -1,11 +1,5 @@
#!/usr/bin/env bashio #!/usr/bin/execlineb -S0
# ============================================================================== # ==============================================================================
# Take down the S6 supervision tree when Supervisor fails # Take down the S6 supervision tree when Supervisor fails
# ============================================================================== # ==============================================================================
redirfd -w 2 /dev/null s6-svscanctl -t /var/run/s6/services
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"

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

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

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

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

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

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

View File

@@ -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
}

133
scripts/test_env.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/bin/bash
set -eE
DOCKER_TIMEOUT=30
DOCKER_PID=0
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 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 dev -t /data --test --amd64 --no-cache
}
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
}
function cleanup_docker() {
echo "Cleaning up stopped containers..."
docker rm $(docker ps -a -q) || true
}
function setup_test_env() {
mkdir -p /workspaces/test_supervisor
echo "Start Supervisor"
docker run --rm --privileged \
--name hassio_supervisor \
--security-opt seccomp=unconfined \
--security-opt apparmor:unconfined \
-v /run/docker.sock:/run/docker.sock \
-v /run/dbus:/run/dbus \
-v "/workspaces/test_supervisor":/data \
-v /etc/machine-id:/etc/machine-id:ro \
-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
}
echo "Start Test-Env"
start_docker
trap "stop_docker" ERR
build_supervisor
cleanup_lastboot
cleanup_docker
init_dbus
setup_test_env
stop_docker

View File

@@ -1,6 +1,4 @@
#!/bin/bash #!/bin/bash
source "/etc/supervisor_scripts/common"
set -e set -e
# Update frontend # Update frontend
@@ -11,10 +9,6 @@ cd home-assistant-polymer
nvm install nvm install
script/bootstrap script/bootstrap
# Download translations
start_docker
./script/translations_download
# build frontend # build frontend
cd hassio cd hassio
./script/build_hassio ./script/build_hassio
@@ -22,9 +16,3 @@ cd hassio
# Copy frontend # Copy frontend
rm -rf ../../supervisor/api/panel/* rm -rf ../../supervisor/api/panel/*
cp -rf build/* ../../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 force_grid_wrap=0
line_length=88 line_length=88
indent = " " indent = " "
not_skip = __init__.py
force_sort_within_sections = true force_sort_within_sections = true
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
default_section = THIRDPARTY default_section = THIRDPARTY
forced_separate = tests forced_separate = tests
combine_as_imports = true combine_as_imports = true

View File

@@ -31,30 +31,24 @@ setup(
zip_safe=False, zip_safe=False,
platforms="any", platforms="any",
packages=[ packages=[
"supervisor",
"supervisor.docker",
"supervisor.addons", "supervisor.addons",
"supervisor.api", "supervisor.api",
"supervisor.backups",
"supervisor.dbus.network",
"supervisor.dbus.network.setting",
"supervisor.dbus", "supervisor.dbus",
"supervisor.discovery.services", "supervisor.dbus.payloads",
"supervisor.dbus.network",
"supervisor.discovery", "supervisor.discovery",
"supervisor.docker", "supervisor.discovery.services",
"supervisor.services",
"supervisor.services.modules",
"supervisor.homeassistant", "supervisor.homeassistant",
"supervisor.host", "supervisor.host",
"supervisor.jobs",
"supervisor.misc", "supervisor.misc",
"supervisor.plugins",
"supervisor.resolution.checks",
"supervisor.resolution.evaluations",
"supervisor.resolution.fixups",
"supervisor.resolution",
"supervisor.security",
"supervisor.services.modules",
"supervisor.services",
"supervisor.store",
"supervisor.utils", "supervisor.utils",
"supervisor", "supervisor.plugins",
"supervisor.snapshots",
"supervisor.store",
], ],
include_package_data=True, include_package_data=True,
) )

View File

@@ -2,25 +2,24 @@
import asyncio import asyncio
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import logging import logging
from pathlib import Path
import sys import sys
from supervisor import bootstrap from supervisor import bootstrap
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
CONTAINER_OS_STARTUP_CHECK = Path("/run/os/startup-marker")
def run_os_startup_check_cleanup() -> None:
"""Cleanup OS startup check."""
if not CONTAINER_OS_STARTUP_CHECK.exists():
return
def initialize_event_loop():
"""Attempt to use uvloop."""
try: try:
CONTAINER_OS_STARTUP_CHECK.unlink() # pylint: disable=import-outside-toplevel
except OSError as err: import uvloop
_LOGGER.warning("Not able to remove the startup health file: %s", err)
uvloop.install()
except ImportError:
pass
return asyncio.get_event_loop()
# pylint: disable=invalid-name # pylint: disable=invalid-name
@@ -28,7 +27,7 @@ if __name__ == "__main__":
bootstrap.initialize_logging() bootstrap.initialize_logging()
# Init async event loop # Init async event loop
loop = asyncio.get_event_loop() loop = initialize_event_loop()
# Check if all information are available to setup Supervisor # Check if all information are available to setup Supervisor
bootstrap.check_environment() bootstrap.check_environment()
@@ -39,15 +38,11 @@ if __name__ == "__main__":
_LOGGER.info("Initializing Supervisor setup") _LOGGER.info("Initializing Supervisor setup")
coresys = loop.run_until_complete(bootstrap.initialize_coresys()) coresys = loop.run_until_complete(bootstrap.initialize_coresys())
loop.set_debug(coresys.config.debug)
loop.run_until_complete(coresys.core.connect()) loop.run_until_complete(coresys.core.connect())
bootstrap.supervisor_debugger(coresys) bootstrap.supervisor_debugger(coresys)
bootstrap.migrate_system_env(coresys) bootstrap.migrate_system_env(coresys)
# Signal health startup for container
run_os_startup_check_cleanup()
_LOGGER.info("Setting up Supervisor") _LOGGER.info("Setting up Supervisor")
loop.run_until_complete(coresys.core.setup()) loop.run_until_complete(coresys.core.setup())
@@ -61,4 +56,4 @@ if __name__ == "__main__":
loop.close() loop.close()
_LOGGER.info("Closing Supervisor") _LOGGER.info("Closing Supervisor")
sys.exit(coresys.core.exit_code) sys.exit(0)

View File

@@ -3,14 +3,13 @@ import asyncio
from contextlib import suppress from contextlib import suppress
import logging import logging
import tarfile import tarfile
from typing import Optional, Union from typing import Dict, List, Optional, Union
from ..const import AddonBoot, AddonStartup, AddonState from ..const import AddonBoot, AddonStartup, AddonState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ( from ..exceptions import (
AddonConfigurationError, AddonConfigurationError,
AddonsError, AddonsError,
AddonsJobError,
AddonsNotSupportedError, AddonsNotSupportedError,
CoreDNSError, CoreDNSError,
DockerAPIError, DockerAPIError,
@@ -19,8 +18,6 @@ from ..exceptions import (
HomeAssistantAPIError, HomeAssistantAPIError,
HostAppArmorError, HostAppArmorError,
) )
from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..store.addon import AddonStore from ..store.addon import AddonStore
from ..utils import check_exception_chain from ..utils import check_exception_chain
from .addon import Addon from .addon import Addon
@@ -38,17 +35,17 @@ class AddonManager(CoreSysAttributes):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self.data: AddonsData = AddonsData(coresys) self.data: AddonsData = AddonsData(coresys)
self.local: dict[str, Addon] = {} self.local: Dict[str, Addon] = {}
self.store: dict[str, AddonStore] = {} self.store: Dict[str, AddonStore] = {}
@property @property
def all(self) -> list[AnyAddon]: def all(self) -> List[AnyAddon]:
"""Return a list of all add-ons.""" """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()) return list(addons.values())
@property @property
def installed(self) -> list[Addon]: def installed(self) -> List[Addon]:
"""Return a list of all installed add-ons.""" """Return a list of all installed add-ons."""
return list(self.local.values()) return list(self.local.values())
@@ -89,7 +86,7 @@ class AddonManager(CoreSysAttributes):
async def boot(self, stage: AddonStartup) -> None: async def boot(self, stage: AddonStartup) -> None:
"""Boot add-ons with mode auto.""" """Boot add-ons with mode auto."""
tasks: list[Addon] = [] tasks: List[Addon] = []
for addon in self.installed: for addon in self.installed:
if addon.boot != AddonBoot.AUTO or addon.startup != stage: if addon.boot != AddonBoot.AUTO or addon.startup != stage:
continue continue
@@ -123,7 +120,7 @@ class AddonManager(CoreSysAttributes):
async def shutdown(self, stage: AddonStartup) -> None: async def shutdown(self, stage: AddonStartup) -> None:
"""Shutdown addons.""" """Shutdown addons."""
tasks: list[Addon] = [] tasks: List[Addon] = []
for addon in self.installed: for addon in self.installed:
if addon.state != AddonState.STARTED or addon.startup != stage: if addon.state != AddonState.STARTED or addon.startup != stage:
continue continue
@@ -143,27 +140,20 @@ class AddonManager(CoreSysAttributes):
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
self.sys_capture_exception(err) self.sys_capture_exception(err)
@Job(
conditions=[
JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError,
)
async def install(self, slug: str) -> None: async def install(self, slug: str) -> None:
"""Install an add-on.""" """Install an add-on."""
if slug in self.local: if slug in self.local:
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning) _LOGGER.warning("Add-on %s is already installed", slug)
return
store = self.store.get(slug) store = self.store.get(slug)
if not store: if not store:
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error) _LOGGER.error("Add-on %s not exists", slug)
raise AddonsError()
if not store.available: if not store.available:
raise AddonsNotSupportedError( _LOGGER.error("Add-on %s not supported on that platform", slug)
f"Add-on {slug} not supported on this platform", _LOGGER.error raise AddonsNotSupportedError()
)
self.data.install(store) self.data.install(store)
addon = Addon(self.coresys, slug) addon = Addon(self.coresys, slug)
@@ -178,7 +168,7 @@ class AddonManager(CoreSysAttributes):
await addon.install_apparmor() await addon.install_apparmor()
try: try:
await addon.instance.install(store.version, store.image, arch=addon.arch) await addon.instance.install(store.version, store.image)
except DockerError as err: except DockerError as err:
self.data.uninstall(addon) self.data.uninstall(addon)
raise AddonsError() from err raise AddonsError() from err
@@ -244,56 +234,40 @@ class AddonManager(CoreSysAttributes):
_LOGGER.info("Add-on '%s' successfully removed", slug) _LOGGER.info("Add-on '%s' successfully removed", slug)
@Job( async def update(self, slug: str) -> None:
conditions=[
JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError,
)
async def update(self, slug: str, backup: Optional[bool] = False) -> None:
"""Update add-on.""" """Update add-on."""
if slug not in self.local: 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] addon = self.local[slug]
if addon.is_detached: if addon.is_detached:
raise AddonsError( _LOGGER.error("Add-on %s is not available inside store", slug)
f"Add-on {slug} is not available inside store", _LOGGER.error raise AddonsError()
)
store = self.store[slug] store = self.store[slug]
if addon.version == store.version: if addon.version == store.version:
raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning) _LOGGER.warning("No update available for add-on %s", slug)
return
# Check if available, Maybe something have changed # Check if available, Maybe something have changed
if not store.available: if not store.available:
raise AddonsNotSupportedError( _LOGGER.error("Add-on %s not supported on that platform", slug)
f"Add-on {slug} not supported on that platform", _LOGGER.error raise AddonsNotSupportedError()
)
if backup:
await self.sys_backups.do_backup_partial(
name=f"addon_{addon.slug}_{addon.version}",
homeassistant=False,
addons=[addon.slug],
)
# Update instance # Update instance
last_state: AddonState = addon.state last_state: AddonState = addon.state
old_image = addon.image
try: try:
await addon.instance.update(store.version, store.image) await addon.instance.update(store.version, store.image)
# Cleanup
with suppress(DockerError):
await addon.instance.cleanup()
except DockerError as err: except DockerError as err:
raise AddonsError() from err raise AddonsError() from err
else:
_LOGGER.info("Add-on '%s' successfully updated", slug) self.data.update(store)
self.data.update(store) _LOGGER.info("Add-on '%s' successfully updated", slug)
# Cleanup
with suppress(DockerError):
await addon.instance.cleanup(old_image=old_image)
# Setup/Fix AppArmor profile # Setup/Fix AppArmor profile
await addon.install_apparmor() await addon.install_apparmor()
@@ -302,35 +276,25 @@ class AddonManager(CoreSysAttributes):
if last_state == AddonState.STARTED: if last_state == AddonState.STARTED:
await addon.start() await addon.start()
@Job(
conditions=[
JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError,
)
async def rebuild(self, slug: str) -> None: async def rebuild(self, slug: str) -> None:
"""Perform a rebuild of local build add-on.""" """Perform a rebuild of local build add-on."""
if slug not in self.local: 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] addon = self.local[slug]
if addon.is_detached: if addon.is_detached:
raise AddonsError( _LOGGER.error("Add-on %s is not available inside store", slug)
f"Add-on {slug} is not available inside store", _LOGGER.error raise AddonsError()
)
store = self.store[slug] store = self.store[slug]
# Check if a rebuild is possible now # Check if a rebuild is possible now
if addon.version != store.version: if addon.version != store.version:
raise AddonsError( _LOGGER.error("Version changed, use Update instead Rebuild")
"Version changed, use Update instead Rebuild", _LOGGER.error raise AddonsError()
)
if not addon.need_build: if not addon.need_build:
raise AddonsNotSupportedError( _LOGGER.error("Can't rebuild a image based add-on")
"Can't rebuild a image based add-on", _LOGGER.error raise AddonsNotSupportedError()
)
# remove docker container but not addon config # remove docker container but not addon config
last_state: AddonState = addon.state last_state: AddonState = addon.state
@@ -347,14 +311,6 @@ class AddonManager(CoreSysAttributes):
if last_state == AddonState.STARTED: if last_state == AddonState.STARTED:
await addon.start() await addon.start()
@Job(
conditions=[
JobCondition.FREE_SPACE,
JobCondition.INTERNET_HOST,
JobCondition.HEALTHY,
],
on_condition=AddonsJobError,
)
async def restore(self, slug: str, tar_file: tarfile.TarFile) -> None: async def restore(self, slug: str, tar_file: tarfile.TarFile) -> None:
"""Restore state of an add-on.""" """Restore state of an add-on."""
if slug not in self.local: if slug not in self.local:
@@ -377,10 +333,9 @@ class AddonManager(CoreSysAttributes):
with suppress(HomeAssistantAPIError): with suppress(HomeAssistantAPIError):
await self.sys_ingress.update_hass_panel(addon) await self.sys_ingress.update_hass_panel(addon)
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
async def repair(self) -> None: async def repair(self) -> None:
"""Repair local add-ons.""" """Repair local add-ons."""
needs_repair: list[Addon] = [] needs_repair: List[Addon] = []
# Evaluate Add-ons to repair # Evaluate Add-ons to repair
for addon in self.installed: for addon in self.installed:
@@ -394,6 +349,10 @@ class AddonManager(CoreSysAttributes):
for addon in needs_repair: for addon in needs_repair:
_LOGGER.info("Repairing for add-on: %s", addon.slug) _LOGGER.info("Repairing for add-on: %s", addon.slug)
await self.sys_run_in_executor(
self.sys_docker.network.stale_cleanup, addon.instance.name
)
with suppress(DockerError, KeyError): with suppress(DockerError, KeyError):
# Need pull a image again # Need pull a image again
if not addon.need_build: if not addon.need_build:
@@ -421,12 +380,7 @@ class AddonManager(CoreSysAttributes):
continue continue
except DockerError as err: except DockerError as err:
_LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err) _LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err)
self.sys_resolution.create_issue( self.sys_core.healthy = False
IssueType.CORRUPT_DOCKER,
ContextType.ADDON,
reference=addon.slug,
suggestions=[SuggestionType.EXECUTE_REPAIR],
)
self.sys_capture_exception(err) self.sys_capture_exception(err)
else: else:
self.sys_plugins.dns.add_host( self.sys_plugins.dns.add_host(

View File

@@ -10,11 +10,9 @@ import secrets
import shutil import shutil
import tarfile import tarfile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, Awaitable, Final, Optional from typing import Any, Awaitable, Dict, List, Optional
import aiohttp import aiohttp
from deepmerge import Merger
from securetar import atomic_contents_add, secure_path
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
@@ -24,8 +22,6 @@ from ..const import (
ATTR_AUDIO_OUTPUT, ATTR_AUDIO_OUTPUT,
ATTR_AUTO_UPDATE, ATTR_AUTO_UPDATE,
ATTR_BOOT, ATTR_BOOT,
ATTR_DATA,
ATTR_EVENT,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_INGRESS_ENTRY, ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PANEL, ATTR_INGRESS_PANEL,
@@ -36,10 +32,8 @@ from ..const import (
ATTR_PORTS, ATTR_PORTS,
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_SCHEMA, ATTR_SCHEMA,
ATTR_SLUG,
ATTR_STATE, ATTR_STATE,
ATTR_SYSTEM, ATTR_SYSTEM,
ATTR_TYPE,
ATTR_USER, ATTR_USER,
ATTR_UUID, ATTR_UUID,
ATTR_VERSION, ATTR_VERSION,
@@ -56,21 +50,18 @@ from ..exceptions import (
AddonConfigurationError, AddonConfigurationError,
AddonsError, AddonsError,
AddonsNotSupportedError, AddonsNotSupportedError,
ConfigurationFileError,
DockerError, DockerError,
DockerRequestError, DockerRequestError,
HostAppArmorError, HostAppArmorError,
JsonFileError,
) )
from ..hardware.data import Device
from ..homeassistant.const import WSEvent, WSType
from ..utils import check_port from ..utils import check_port
from ..utils.apparmor import adjust_profile from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file from ..utils.json import read_json_file, write_json_file
from .const import AddonBackupMode from ..utils.tar import atomic_contents_add, secure_path
from .model import AddonModel, Data from .model import AddonModel, Data
from .options import AddonOptions
from .utils import remove_data from .utils import remove_data
from .validate import SCHEMA_ADDON_BACKUP from .validate import SCHEMA_ADDON_SNAPSHOT, validate_options
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -81,17 +72,13 @@ RE_WEBUI = re.compile(
RE_WATCHDOG = re.compile( RE_WATCHDOG = re.compile(
r"^(?:(?P<s_prefix>https?|tcp)|\[PROTO:(?P<t_proto>\w+)\])" r"^(?:(?P<s_prefix>https?|tcp)|\[PROTO:(?P<t_proto>\w+)\])"
r":\/\/\[HOST\]:(?:\[PORT:)?(?P<t_port>\d+)\]?(?P<s_suffix>.*)$" r":\/\/\[HOST\]:\[PORT:(?P<t_port>\d+)\](?P<s_suffix>.*)$"
) )
RE_OLD_AUDIO = re.compile(r"\d+,\d+")
WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10) WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10)
_OPTIONS_MERGER: Final = Merger(
type_strategies=[(dict, ["merge"])],
fallback_strategies=["override"],
type_conflict_strategies=["override"],
)
class Addon(AddonModel): class Addon(AddonModel):
"""Hold data for add-on inside Supervisor.""" """Hold data for add-on inside Supervisor."""
@@ -100,34 +87,12 @@ class Addon(AddonModel):
"""Initialize data holder.""" """Initialize data holder."""
super().__init__(coresys, slug) super().__init__(coresys, slug)
self.instance: DockerAddon = DockerAddon(coresys, self) self.instance: DockerAddon = DockerAddon(coresys, self)
self._state: AddonState = AddonState.UNKNOWN self.state: AddonState = AddonState.UNKNOWN
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return internal representation.""" """Return internal representation."""
return f"<Addon: {self.slug}>" return f"<Addon: {self.slug}>"
@property
def state(self) -> AddonState:
"""Return state of the add-on."""
return self._state
@state.setter
def state(self, new_state: AddonState) -> None:
"""Set the add-on into new state."""
if self._state == new_state:
return
self._state = new_state
self.sys_homeassistant.websocket.send_message(
{
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
ATTR_DATA: {
ATTR_EVENT: WSEvent.ADDON,
ATTR_SLUG: self.slug,
ATTR_STATE: new_state,
},
}
)
@property @property
def in_progress(self) -> bool: def in_progress(self) -> bool:
"""Return True if a task is in progress.""" """Return True if a task is in progress."""
@@ -136,7 +101,7 @@ class Addon(AddonModel):
async def load(self) -> None: async def load(self) -> None:
"""Async initialize of object.""" """Async initialize of object."""
with suppress(DockerError): with suppress(DockerError):
await self.instance.attach(version=self.version) await self.instance.attach(tag=self.version)
# Evaluate state # Evaluate state
if await self.instance.is_running(): if await self.instance.is_running():
@@ -185,26 +150,17 @@ class Addon(AddonModel):
return self.persist[ATTR_VERSION] return self.persist[ATTR_VERSION]
@property @property
def need_update(self) -> bool: def dns(self) -> List[str]:
"""Return True if an update is available."""
if self.is_detached:
return False
return self.version != self.latest_version
@property
def dns(self) -> list[str]:
"""Return list of DNS name for that add-on.""" """Return list of DNS name for that add-on."""
return [f"{self.hostname}.{DNS_SUFFIX}"] return [f"{self.hostname}.{DNS_SUFFIX}"]
@property @property
def options(self) -> dict[str, Any]: def options(self) -> Dict[str, Any]:
"""Return options with local changes.""" """Return options with local changes."""
return _OPTIONS_MERGER.merge( return {**self.data[ATTR_OPTIONS], **self.persist[ATTR_OPTIONS]}
deepcopy(self.data[ATTR_OPTIONS]), deepcopy(self.persist[ATTR_OPTIONS])
)
@options.setter @options.setter
def options(self, value: Optional[dict[str, Any]]) -> None: def options(self, value: Optional[Dict[str, Any]]) -> None:
"""Store user add-on options.""" """Store user add-on options."""
self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value) self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value)
@@ -281,12 +237,12 @@ class Addon(AddonModel):
self.persist[ATTR_PROTECTED] = value self.persist[ATTR_PROTECTED] = value
@property @property
def ports(self) -> Optional[dict[str, Optional[int]]]: def ports(self) -> Optional[Dict[str, Optional[int]]]:
"""Return ports of add-on.""" """Return ports of add-on."""
return self.persist.get(ATTR_NETWORK, super().ports) return self.persist.get(ATTR_NETWORK, super().ports)
@ports.setter @ports.setter
def ports(self, value: Optional[dict[str, Optional[int]]]) -> None: def ports(self, value: Optional[Dict[str, Optional[int]]]) -> None:
"""Set custom ports of add-on.""" """Set custom ports of add-on."""
if value is None: if value is None:
self.persist.pop(ATTR_NETWORK, None) self.persist.pop(ATTR_NETWORK, None)
@@ -365,7 +321,13 @@ class Addon(AddonModel):
"""Return a pulse profile for output or None.""" """Return a pulse profile for output or None."""
if not self.with_audio: if not self.with_audio:
return None return None
return self.persist.get(ATTR_AUDIO_OUTPUT)
# Fallback with old audio settings
# Remove after 210
output_data = self.persist.get(ATTR_AUDIO_OUTPUT)
if output_data and RE_OLD_AUDIO.fullmatch(output_data):
return None
return output_data
@audio_output.setter @audio_output.setter
def audio_output(self, value: Optional[str]): def audio_output(self, value: Optional[str]):
@@ -378,7 +340,12 @@ class Addon(AddonModel):
if not self.with_audio: if not self.with_audio:
return None return None
return self.persist.get(ATTR_AUDIO_INPUT) # Fallback with old audio settings
# Remove after 210
input_data = self.persist.get(ATTR_AUDIO_INPUT)
if input_data and RE_OLD_AUDIO.fullmatch(input_data):
return None
return input_data
@audio_input.setter @audio_input.setter
def audio_input(self, value: Optional[str]) -> None: def audio_input(self, value: Optional[str]) -> None:
@@ -420,24 +387,6 @@ class Addon(AddonModel):
"""Return path to asound config for Docker.""" """Return path to asound config for Docker."""
return Path(self.sys_config.path_extern_tmp, f"{self.slug}_pulse") return Path(self.sys_config.path_extern_tmp, f"{self.slug}_pulse")
@property
def devices(self) -> set[Device]:
"""Extract devices from add-on options."""
options_schema = self.schema
with suppress(vol.Invalid):
options_schema.validate(self.options)
return options_schema.devices
@property
def pwned(self) -> set[str]:
"""Extract pwned data for add-on options."""
options_schema = self.schema
with suppress(vol.Invalid):
options_schema.validate(self.options)
return options_schema.pwned
def save_persist(self) -> None: def save_persist(self) -> None:
"""Save data of add-on.""" """Save data of add-on."""
self.sys_addons.data.save_data() self.sys_addons.data.save_data()
@@ -450,7 +399,7 @@ class Addon(AddonModel):
application = RE_WATCHDOG.match(url) application = RE_WATCHDOG.match(url)
# extract arguments # extract arguments
t_port = int(application.group("t_port")) t_port = application.group("t_port")
t_proto = application.group("t_proto") t_proto = application.group("t_proto")
s_prefix = application.group("s_prefix") or "" s_prefix = application.group("s_prefix") or ""
s_suffix = application.group("s_suffix") or "" s_suffix = application.group("s_suffix") or ""
@@ -474,8 +423,8 @@ class Addon(AddonModel):
# Make HTTP request # Make HTTP request
try: try:
url = f"{proto}://{self.ip_address}:{port}{s_suffix}" url = f"{proto}://{self.ip_address}:{port}{s_suffix}"
async with self.sys_websession.get( async with self.sys_websession_ssl.get(
url, timeout=WATCHDOG_TIMEOUT, ssl=False url, timeout=WATCHDOG_TIMEOUT
) as req: ) as req:
if req.status < 300: if req.status < 300:
return True return True
@@ -486,19 +435,22 @@ class Addon(AddonModel):
async def write_options(self) -> None: async def write_options(self) -> None:
"""Return True if add-on options is written to data.""" """Return True if add-on options is written to data."""
schema = self.schema
options = self.options
# Update secrets for validation # Update secrets for validation
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
try: try:
options = self.schema.validate(self.options) options = schema(options)
write_json_file(self.path_options, options) write_json_file(self.path_options, options)
except vol.Invalid as ex: except vol.Invalid as ex:
_LOGGER.error( _LOGGER.error(
"Add-on %s has invalid options: %s", "Add-on %s has invalid options: %s",
self.slug, self.slug,
humanize_error(self.options, ex), humanize_error(options, ex),
) )
except ConfigurationFileError: except JsonFileError:
_LOGGER.error("Add-on %s can't write options", self.slug) _LOGGER.error("Add-on %s can't write options", self.slug)
else: else:
_LOGGER.debug("Add-on %s write options: %s", self.slug, options) _LOGGER.debug("Add-on %s write options: %s", self.slug, options)
@@ -526,7 +478,8 @@ class Addon(AddonModel):
# Write pulse config # Write pulse config
try: 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: except OSError as err:
_LOGGER.error( _LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err "Add-on %s can't write pulse/client.config: %s", self.slug, err
@@ -574,15 +527,11 @@ class Addon(AddonModel):
return True return True
# merge options # merge options
options = _OPTIONS_MERGER.merge( options = {**self.persist[ATTR_OPTIONS], **default_options}
deepcopy(default_options), deepcopy(self.persist[ATTR_OPTIONS])
)
# create voluptuous # create voluptuous
new_schema = vol.Schema( new_schema = vol.Schema(
vol.All( vol.All(dict, validate_options(self.coresys, new_raw_schema))
dict, AddonOptions(self.coresys, new_raw_schema, self.name, self.slug)
)
) )
# validate # validate
@@ -614,7 +563,7 @@ class Addon(AddonModel):
try: try:
await self.instance.run() await self.instance.run()
except DockerRequestError as err: except DockerRequestError as err:
self.state = AddonState.ERROR self.state = AddonState.STOPPED
raise AddonsError() from err raise AddonsError() from err
except DockerError as err: except DockerError as err:
self.state = AddonState.ERROR self.state = AddonState.ERROR
@@ -625,9 +574,8 @@ class Addon(AddonModel):
async def stop(self) -> None: async def stop(self) -> None:
"""Stop add-on.""" """Stop add-on."""
try: try:
await self.instance.stop() return await self.instance.stop()
except DockerRequestError as err: except DockerRequestError as err:
self.state = AddonState.ERROR
raise AddonsError() from err raise AddonsError() from err
except DockerError as err: except DockerError as err:
self.state = AddonState.ERROR self.state = AddonState.ERROR
@@ -668,34 +616,16 @@ class Addon(AddonModel):
Return a coroutine. Return a coroutine.
""" """
if not self.with_stdin: if not self.with_stdin:
raise AddonsNotSupportedError( _LOGGER.error("Add-on %s does not support writing to stdin!", self.slug)
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error raise AddonsNotSupportedError()
)
try: try:
return await self.instance.write_stdin(data) return await self.instance.write_stdin(data)
except DockerError as err: except DockerError as err:
raise AddonsError() from err raise AddonsError() from err
async def _backup_command(self, command: str) -> None: async def snapshot(self, tar_file: tarfile.TarFile) -> None:
try: """Snapshot state of an add-on."""
command_return = await self.instance.run_inside(command)
if command_return.exit_code != 0:
_LOGGER.error(
"Pre-/Post backup command returned error code: %s",
command_return.exit_code,
)
raise AddonsError()
except DockerError as err:
_LOGGER.error(
"Failed running pre-/post backup command %s: %s", command, err
)
raise AddonsError() from err
async def backup(self, tar_file: tarfile.TarFile) -> None:
"""Backup state of an add-on."""
is_running = await self.is_running()
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp: with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
temp_path = Path(temp) temp_path = Path(temp)
@@ -716,103 +646,80 @@ class Addon(AddonModel):
# Store local configs/state # Store local configs/state
try: try:
write_json_file(temp_path.joinpath("addon.json"), data) write_json_file(temp_path.joinpath("addon.json"), data)
except ConfigurationFileError as err: except JsonFileError as err:
raise AddonsError( _LOGGER.error("Can't save meta for %s", self.slug)
f"Can't save meta for {self.slug}", _LOGGER.error raise AddonsError() from err
) from err
# Store AppArmor Profile # Store AppArmor Profile
if self.sys_host.apparmor.exists(self.slug): if self.sys_host.apparmor.exists(self.slug):
profile = temp_path.joinpath("apparmor.txt") profile = temp_path.joinpath("apparmor.txt")
try: try:
await self.sys_host.apparmor.backup_profile(self.slug, profile) self.sys_host.apparmor.backup_profile(self.slug, profile)
except HostAppArmorError as err: except HostAppArmorError as err:
raise AddonsError( _LOGGER.error("Can't backup AppArmor profile")
"Can't backup AppArmor profile", _LOGGER.error raise AddonsError() from err
) from err
# write into tarfile # write into tarfile
def _write_tarfile(): def _write_tarfile():
"""Write tar inside loop.""" """Write tar inside loop."""
with tar_file as backup: with tar_file as snapshot:
# Backup metadata # Snapshot system
backup.add(temp, arcname=".")
# Backup data snapshot.add(temp, arcname=".")
# Snapshot data
atomic_contents_add( atomic_contents_add(
backup, snapshot,
self.path_data, self.path_data,
excludes=self.backup_exclude, excludes=self.snapshot_exclude,
arcname="data", 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: 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) await self.sys_run_in_executor(_write_tarfile)
except (tarfile.TarError, OSError) as err: except (tarfile.TarError, OSError) as err:
raise AddonsError( _LOGGER.error("Can't write tarfile %s: %s", tar_file, err)
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error raise AddonsError() from err
) 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.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: async def restore(self, tar_file: tarfile.TarFile) -> None:
"""Restore state of an add-on.""" """Restore state of an add-on."""
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp: with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
# extract backup # extract snapshot
def _extract_tarfile(): def _extract_tarfile():
"""Extract tar backup.""" """Extract tar snapshot."""
with tar_file as backup: with tar_file as snapshot:
backup.extractall(path=Path(temp), members=secure_path(backup)) snapshot.extractall(path=Path(temp), members=secure_path(snapshot))
try: try:
await self.sys_run_in_executor(_extract_tarfile) await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err: except tarfile.TarError as err:
raise AddonsError( _LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error raise AddonsError() from err
) from err
# Read backup data # Read snapshot data
try: try:
data = read_json_file(Path(temp, "addon.json")) data = read_json_file(Path(temp, "addon.json"))
except ConfigurationFileError as err: except JsonFileError as err:
raise AddonsError() from err raise AddonsError() from err
# Validate # Validate
try: try:
data = SCHEMA_ADDON_BACKUP(data) data = SCHEMA_ADDON_SNAPSHOT(data)
except vol.Invalid as err: except vol.Invalid as err:
raise AddonsError( _LOGGER.error(
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}", "Can't validate %s, snapshot data: %s",
_LOGGER.error, self.slug,
) from err humanize_error(data, err),
)
raise AddonsError() from err
# If available # If available
if not self._available(data[ATTR_SYSTEM]): if not self._available(data[ATTR_SYSTEM]):
raise AddonsNotSupportedError( _LOGGER.error("Add-on %s is not available for this platform", self.slug)
f"Add-on {self.slug} is not available for this platform", raise AddonsNotSupportedError()
_LOGGER.error,
)
# Restore local add-on information # Restore local add-on information
_LOGGER.info("Restore config for addon %s", self.slug) _LOGGER.info("Restore config for addon %s", self.slug)
@@ -845,11 +752,7 @@ class Addon(AddonModel):
# Restore data # Restore data
def _restore_data(): def _restore_data():
"""Restore data.""" """Restore data."""
temp_data = Path(temp, "data") shutil.copytree(Path(temp, "data"), self.path_data, symlinks=True)
if temp_data.is_dir():
shutil.copytree(temp_data, self.path_data, symlinks=True)
else:
self.path_data.mkdir()
_LOGGER.info("Restoring data for addon %s", self.slug) _LOGGER.info("Restoring data for addon %s", self.slug)
if self.path_data.is_dir(): if self.path_data.is_dir():
@@ -857,9 +760,8 @@ class Addon(AddonModel):
try: try:
await self.sys_run_in_executor(_restore_data) await self.sys_run_in_executor(_restore_data)
except shutil.Error as err: except shutil.Error as err:
raise AddonsError( _LOGGER.error("Can't restore origin data: %s", err)
f"Can't restore origin data: {err}", _LOGGER.error raise AddonsError() from err
) from err
# Restore AppArmor # Restore AppArmor
profile_file = Path(temp, "apparmor.txt") profile_file = Path(temp, "apparmor.txt")
@@ -877,10 +779,3 @@ class Addon(AddonModel):
return await self.start() return await self.start()
_LOGGER.info("Finished restore for add-on %s", self.slug) _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()

View File

@@ -2,28 +2,18 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path 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_SQUASH, META_ADDON
from ..const import (
ATTR_ARGS,
ATTR_BUILD_FROM,
ATTR_LABELS,
ATTR_SQUASH,
FILE_SUFFIX_CONFIGURATION,
META_ADDON,
)
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError from ..utils.json import JsonConfig
from ..utils.common import FileConfiguration, find_one_filetype
from .validate import SCHEMA_BUILD_CONFIG from .validate import SCHEMA_BUILD_CONFIG
if TYPE_CHECKING: if TYPE_CHECKING:
from . import AnyAddon from . import AnyAddon
class AddonBuild(FileConfiguration, CoreSysAttributes): class AddonBuild(JsonConfig, CoreSysAttributes):
"""Handle build options for add-ons.""" """Handle build options for add-ons."""
def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None: def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None:
@@ -31,14 +21,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self.addon = addon self.addon = addon
try: super().__init__(
build_file = find_one_filetype( Path(self.addon.path_location, "build.json"), SCHEMA_BUILD_CONFIG
self.addon.path_location, "build", FILE_SUFFIX_CONFIGURATION )
)
except ConfigurationFileError:
build_file = self.addon.path_location / "build.json"
super().__init__(build_file, SCHEMA_BUILD_CONFIG)
def save_data(self): def save_data(self):
"""Ignore save function.""" """Ignore save function."""
@@ -47,12 +32,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
@property @property
def base_image(self) -> str: def base_image(self) -> str:
"""Return base image for this add-on.""" """Return base image for this add-on."""
if not self._data[ATTR_BUILD_FROM]: return self._data[ATTR_BUILD_FROM].get(
return f"ghcr.io/home-assistant/{self.sys_arch.default}-base:latest" self.sys_arch.default, f"homeassistant/{self.sys_arch.default}-base:latest"
)
# Evaluate correct base image
arch = self.sys_arch.match(list(self._data[ATTR_BUILD_FROM].keys()))
return self._data[ATTR_BUILD_FROM][arch]
@property @property
def squash(self) -> bool: def squash(self) -> bool:
@@ -60,32 +42,17 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
return self._data[ATTR_SQUASH] return self._data[ATTR_SQUASH]
@property @property
def additional_args(self) -> dict[str, str]: def additional_args(self) -> Dict[str, str]:
"""Return additional Docker build arguments.""" """Return additional Docker build arguments."""
return self._data[ATTR_ARGS] return self._data[ATTR_ARGS]
@property def get_docker_args(self, version):
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."""
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.""" """Create a dict with Docker build arguments."""
args = { args = {
"path": str(self.addon.path_location), "path": str(self.addon.path_location),
"tag": f"{self.addon.image}:{version!s}", "tag": f"{self.addon.image}:{version}",
"pull": True, "pull": True,
"forcerm": not self.sys_dev, "forcerm": True,
"squash": self.squash, "squash": self.squash,
"labels": { "labels": {
"io.hass.version": version, "io.hass.version": version,
@@ -93,7 +60,6 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
"io.hass.type": META_ADDON, "io.hass.type": META_ADDON,
"io.hass.name": self._fix_label("name"), "io.hass.name": self._fix_label("name"),
"io.hass.description": self._fix_label("description"), "io.hass.description": self._fix_label("description"),
**self.additional_labels,
}, },
"buildargs": { "buildargs": {
"BUILD_FROM": self.base_image, "BUILD_FROM": self.base_image,

View File

@@ -1,13 +0,0 @@
"""Add-on static data."""
from enum import Enum
class AddonBackupMode(str, Enum):
"""Backup mode of an Add-on."""
HOT = "hot"
COLD = "cold"
ATTR_BACKUP = "backup"
ATTR_CODENOTARY = "codenotary"

View File

@@ -1,6 +1,7 @@
"""Init file for Supervisor add-on data.""" """Init file for Supervisor add-on data."""
from copy import deepcopy from copy import deepcopy
from typing import Any import logging
from typing import Any, Dict
from ..const import ( from ..const import (
ATTR_IMAGE, ATTR_IMAGE,
@@ -12,14 +13,16 @@ from ..const import (
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..store.addon import AddonStore from ..store.addon import AddonStore
from ..utils.common import FileConfiguration from ..utils.json import JsonConfig
from .addon import Addon from .addon import Addon
from .validate import SCHEMA_ADDONS_FILE from .validate import SCHEMA_ADDONS_FILE
Config = dict[str, Any] _LOGGER: logging.Logger = logging.getLogger(__name__)
Config = Dict[str, Any]
class AddonsData(FileConfiguration, CoreSysAttributes): class AddonsData(JsonConfig, CoreSysAttributes):
"""Hold data for installed Add-ons inside Supervisor.""" """Hold data for installed Add-ons inside Supervisor."""
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):

View File

@@ -1,11 +1,10 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Optional from typing import Any, Awaitable, Dict, List, Optional
from awesomeversion import AwesomeVersion, AwesomeVersionException from packaging import version as pkg_version
import voluptuous as vol
from supervisor.addons.const import AddonBackupMode
from ..const import ( from ..const import (
ATTR_ADVANCED, ATTR_ADVANCED,
@@ -13,9 +12,7 @@ from ..const import (
ATTR_ARCH, ATTR_ARCH,
ATTR_AUDIO, ATTR_AUDIO,
ATTR_AUTH_API, ATTR_AUTH_API,
ATTR_BACKUP_EXCLUDE, ATTR_AUTO_UART,
ATTR_BACKUP_POST,
ATTR_BACKUP_PRE,
ATTR_BOOT, ATTR_BOOT,
ATTR_DESCRIPTON, ATTR_DESCRIPTON,
ATTR_DEVICES, ATTR_DEVICES,
@@ -35,9 +32,7 @@ from ..const import (
ATTR_HOST_PID, ATTR_HOST_PID,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_INGRESS, ATTR_INGRESS,
ATTR_INGRESS_STREAM,
ATTR_INIT, ATTR_INIT,
ATTR_JOURNALD,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
ATTR_LEGACY, ATTR_LEGACY,
ATTR_LOCATON, ATTR_LOCATON,
@@ -51,18 +46,16 @@ from ..const import (
ATTR_PORTS, ATTR_PORTS,
ATTR_PORTS_DESCRIPTION, ATTR_PORTS_DESCRIPTION,
ATTR_PRIVILEGED, ATTR_PRIVILEGED,
ATTR_REALTIME,
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SCHEMA, ATTR_SCHEMA,
ATTR_SERVICES, ATTR_SERVICES,
ATTR_SLUG, ATTR_SLUG,
ATTR_SNAPSHOT_EXCLUDE,
ATTR_STAGE, ATTR_STAGE,
ATTR_STARTUP, ATTR_STARTUP,
ATTR_STDIN, ATTR_STDIN,
ATTR_TIMEOUT, ATTR_TIMEOUT,
ATTR_TMPFS, ATTR_TMPFS,
ATTR_TRANSLATIONS,
ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_URL, ATTR_URL,
ATTR_USB, ATTR_USB,
@@ -78,12 +71,9 @@ from ..const import (
AddonStartup, AddonStartup,
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..docker.const import Capabilities from .validate import RE_SERVICE, RE_VOLUME, schema_ui_options, validate_options
from .const import ATTR_BACKUP, ATTR_CODENOTARY
from .options import AddonOptions, UiOptions
from .validate import RE_SERVICE, RE_VOLUME
Data = dict[str, Any] Data = Dict[str, Any]
class AddonModel(CoreSysAttributes, ABC): class AddonModel(CoreSysAttributes, ABC):
@@ -115,7 +105,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self._available(self.data) return self._available(self.data)
@property @property
def options(self) -> dict[str, Any]: def options(self) -> Dict[str, Any]:
"""Return options with local changes.""" """Return options with local changes."""
return self.data[ATTR_OPTIONS] return self.data[ATTR_OPTIONS]
@@ -140,7 +130,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.slug.replace("_", "-") return self.slug.replace("_", "-")
@property @property
def dns(self) -> list[str]: def dns(self) -> List[str]:
"""Return list of DNS name for that add-on.""" """Return list of DNS name for that add-on."""
return [] return []
@@ -184,7 +174,8 @@ class AddonModel(CoreSysAttributes, ABC):
return None return None
# Return data # Return data
return readme.read_text(encoding="utf-8") with readme.open("r") as readme_file:
return readme_file.read()
@property @property
def repository(self) -> str: def repository(self) -> str:
@@ -192,17 +183,12 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_REPOSITORY] return self.data[ATTR_REPOSITORY]
@property @property
def translations(self) -> dict: def latest_version(self) -> str:
"""Return add-on translations."""
return self.data[ATTR_TRANSLATIONS]
@property
def latest_version(self) -> AwesomeVersion:
"""Return latest version of add-on.""" """Return latest version of add-on."""
return self.data[ATTR_VERSION] return self.data[ATTR_VERSION]
@property @property
def version(self) -> AwesomeVersion: def version(self) -> Optional[str]:
"""Return version of add-on.""" """Return version of add-on."""
return self.data[ATTR_VERSION] return self.data[ATTR_VERSION]
@@ -227,7 +213,7 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_STAGE] return self.data[ATTR_STAGE]
@property @property
def services_role(self) -> dict[str, str]: def services_role(self) -> Dict[str, str]:
"""Return dict of services with rights.""" """Return dict of services with rights."""
services_list = self.data.get(ATTR_SERVICES, []) services_list = self.data.get(ATTR_SERVICES, [])
@@ -240,17 +226,17 @@ class AddonModel(CoreSysAttributes, ABC):
return services return services
@property @property
def discovery(self) -> list[str]: def discovery(self) -> List[str]:
"""Return list of discoverable components/platforms.""" """Return list of discoverable components/platforms."""
return self.data.get(ATTR_DISCOVERY, []) return self.data.get(ATTR_DISCOVERY, [])
@property @property
def ports_description(self) -> Optional[dict[str, str]]: def ports_description(self) -> Optional[Dict[str, str]]:
"""Return descriptions of ports.""" """Return descriptions of ports."""
return self.data.get(ATTR_PORTS_DESCRIPTION) return self.data.get(ATTR_PORTS_DESCRIPTION)
@property @property
def ports(self) -> Optional[dict[str, Optional[int]]]: def ports(self) -> Optional[Dict[str, Optional[int]]]:
"""Return ports of add-on.""" """Return ports of add-on."""
return self.data.get(ATTR_PORTS) return self.data.get(ATTR_PORTS)
@@ -310,17 +296,22 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_HOST_DBUS] return self.data[ATTR_HOST_DBUS]
@property @property
def static_devices(self) -> list[Path]: def devices(self) -> List[str]:
"""Return static devices of add-on.""" """Return devices of add-on."""
return [Path(node) for node in self.data.get(ATTR_DEVICES, [])] return self.data.get(ATTR_DEVICES, [])
@property @property
def environment(self) -> Optional[dict[str, str]]: def tmpfs(self) -> Optional[str]:
"""Return tmpfs of add-on."""
return self.data.get(ATTR_TMPFS)
@property
def environment(self) -> Optional[Dict[str, str]]:
"""Return environment of add-on.""" """Return environment of add-on."""
return self.data.get(ATTR_ENVIRONMENT) return self.data.get(ATTR_ENVIRONMENT)
@property @property
def privileged(self) -> list[Capabilities]: def privileged(self) -> List[str]:
"""Return list of privilege.""" """Return list of privilege."""
return self.data.get(ATTR_PRIVILEGED, []) return self.data.get(ATTR_PRIVILEGED, [])
@@ -359,24 +350,9 @@ class AddonModel(CoreSysAttributes, ABC):
return self.data[ATTR_HASSIO_ROLE] return self.data[ATTR_HASSIO_ROLE]
@property @property
def backup_exclude(self) -> list[str]: def snapshot_exclude(self) -> List[str]:
"""Return Exclude list for backup.""" """Return Exclude list for snapshot."""
return self.data.get(ATTR_BACKUP_EXCLUDE, []) return self.data.get(ATTR_SNAPSHOT_EXCLUDE, [])
@property
def backup_pre(self) -> Optional[str]:
"""Return pre-backup command."""
return self.data.get(ATTR_BACKUP_PRE)
@property
def backup_post(self) -> Optional[str]:
"""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]
@property @property
def default_init(self) -> bool: def default_init(self) -> bool:
@@ -398,11 +374,6 @@ class AddonModel(CoreSysAttributes, ABC):
"""Return True if the add-on access support ingress.""" """Return True if the add-on access support ingress."""
return None 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 @property
def with_gpio(self) -> bool: def with_gpio(self) -> bool:
"""Return True if the add-on access to GPIO interface.""" """Return True if the add-on access to GPIO interface."""
@@ -416,7 +387,7 @@ class AddonModel(CoreSysAttributes, ABC):
@property @property
def with_uart(self) -> bool: def with_uart(self) -> bool:
"""Return True if we should map all UART device.""" """Return True if we should map all UART device."""
return self.data[ATTR_UART] return self.data[ATTR_AUTO_UART]
@property @property
def with_udev(self) -> bool: def with_udev(self) -> bool:
@@ -428,11 +399,6 @@ class AddonModel(CoreSysAttributes, ABC):
"""Return True if the add-on access to kernel modules.""" """Return True if the add-on access to kernel modules."""
return self.data[ATTR_KERNEL_MODULES] return self.data[ATTR_KERNEL_MODULES]
@property
def with_realtime(self) -> bool:
"""Return True if the add-on need realtime schedule functions."""
return self.data[ATTR_REALTIME]
@property @property
def with_full_access(self) -> bool: def with_full_access(self) -> bool:
"""Return True if the add-on want full access to hardware.""" """Return True if the add-on want full access to hardware."""
@@ -443,11 +409,6 @@ class AddonModel(CoreSysAttributes, ABC):
"""Return True if the add-on read access to devicetree.""" """Return True if the add-on read access to devicetree."""
return self.data[ATTR_DEVICETREE] return self.data[ATTR_DEVICETREE]
@property
def with_tmpfs(self) -> Optional[str]:
"""Return if tmp is in memory of add-on."""
return self.data[ATTR_TMPFS]
@property @property
def access_auth_api(self) -> bool: def access_auth_api(self) -> bool:
"""Return True if the add-on access to login/auth backend.""" """Return True if the add-on access to login/auth backend."""
@@ -494,23 +455,15 @@ class AddonModel(CoreSysAttributes, ABC):
return self.path_documentation.exists() return self.path_documentation.exists()
@property @property
def supported_arch(self) -> list[str]: def supported_arch(self) -> List[str]:
"""Return list of supported arch.""" """Return list of supported arch."""
return self.data[ATTR_ARCH] return self.data[ATTR_ARCH]
@property @property
def supported_machine(self) -> list[str]: def supported_machine(self) -> List[str]:
"""Return list of supported machine.""" """Return list of supported machine."""
return self.data.get(ATTR_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 @property
def image(self) -> Optional[str]: def image(self) -> Optional[str]:
"""Generate image name from data.""" """Generate image name from data."""
@@ -522,7 +475,7 @@ class AddonModel(CoreSysAttributes, ABC):
return ATTR_IMAGE not in self.data return ATTR_IMAGE not in self.data
@property @property
def map_volumes(self) -> dict[str, str]: def map_volumes(self) -> Dict[str, str]:
"""Return a dict of {volume: policy} from add-on.""" """Return a dict of {volume: policy} from add-on."""
volumes = {} volumes = {}
for volume in self.data[ATTR_MAP]: for volume in self.data[ATTR_MAP]:
@@ -564,37 +517,22 @@ class AddonModel(CoreSysAttributes, ABC):
return Path(self.path_location, "apparmor.txt") return Path(self.path_location, "apparmor.txt")
@property @property
def schema(self) -> AddonOptions: def schema(self) -> vol.Schema:
"""Return Addon options validation object.""" """Create a schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA] raw_schema = self.data[ATTR_SCHEMA]
if isinstance(raw_schema, bool):
raw_schema = {}
return AddonOptions(self.coresys, raw_schema, self.name, self.slug) if isinstance(raw_schema, bool):
return vol.Schema(dict)
return vol.Schema(vol.All(dict, validate_options(self.coresys, raw_schema)))
@property @property
def schema_ui(self) -> Optional[list[dict[any, any]]]: def schema_ui(self) -> Optional[List[Dict[str, Any]]]:
"""Create a UI schema for add-on options.""" """Create a UI schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA] raw_schema = self.data[ATTR_SCHEMA]
if isinstance(raw_schema, bool): if isinstance(raw_schema, bool):
return None return None
return UiOptions(self.coresys)(raw_schema) return schema_ui_options(raw_schema)
@property
def with_journald(self) -> bool:
"""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) -> Optional[str]:
"""Return Signer email address for CAS."""
return self.data.get(ATTR_CODENOTARY)
def __eq__(self, other): def __eq__(self, other):
"""Compaired add-on objects.""" """Compaired add-on objects."""
@@ -616,10 +554,15 @@ class AddonModel(CoreSysAttributes, ABC):
return False return False
# Home Assistant # Home Assistant
version: Optional[AwesomeVersion] = config.get(ATTR_HOMEASSISTANT) version = config.get(ATTR_HOMEASSISTANT)
if version is None or self.sys_homeassistant.version is None:
return True
try: try:
return self.sys_homeassistant.version >= version return pkg_version.parse(
except (AwesomeVersionException, TypeError): self.sys_homeassistant.version
) >= pkg_version.parse(version)
except pkg_version.InvalidVersion:
return True return True
def _image(self, config) -> str: def _image(self, config) -> str:
@@ -640,9 +583,9 @@ class AddonModel(CoreSysAttributes, ABC):
"""Uninstall this add-on.""" """Uninstall this add-on."""
return self.sys_addons.uninstall(self.slug) return self.sys_addons.uninstall(self.slug)
def update(self, backup: Optional[bool] = False) -> Awaitable[None]: def update(self) -> Awaitable[None]:
"""Update this add-on.""" """Update this add-on."""
return self.sys_addons.update(self.slug, backup=backup) return self.sys_addons.update(self.slug)
def rebuild(self) -> Awaitable[None]: def rebuild(self) -> Awaitable[None]:
"""Rebuild this add-on.""" """Rebuild this add-on."""

View File

@@ -1,422 +0,0 @@
"""Add-on Options / UI rendering."""
import hashlib
import logging
from pathlib import Path
import re
from typing import Any, Union
import voluptuous as vol
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HardwareNotFound
from ..hardware.const import UdevSubsystem
from ..hardware.data import Device
from ..validate import network_port
_LOGGER: logging.Logger = logging.getLogger(__name__)
_STR = "str"
_INT = "int"
_FLOAT = "float"
_BOOL = "bool"
_PASSWORD = "password"
_EMAIL = "email"
_URL = "url"
_PORT = "port"
_MATCH = "match"
_LIST = "list"
_DEVICE = "device"
RE_SCHEMA_ELEMENT = re.compile(
r"^(?:"
r"|bool"
r"|email"
r"|url"
r"|port"
r"|device(?:\((?P<filter>subsystem=[a-z]+)\))?"
r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?"
r"|password(?:\((?P<p_min>\d+)?,(?P<p_max>\d+)?\))?"
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
r"|match\((?P<match>.*)\)"
r"|list\((?P<list>.+)\)"
r")\??$"
)
_SCHEMA_LENGTH_PARTS = (
"i_min",
"i_max",
"f_min",
"f_max",
"s_min",
"s_max",
"p_min",
"p_max",
)
class AddonOptions(CoreSysAttributes):
"""Validate Add-ons Options."""
def __init__(
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._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 = {}
# read options
for key, value in struct.items():
# Ignore unknown options / remove from list
if key not in self.raw_schema:
_LOGGER.warning(
"Option '%s' does not exist in the schema for %s (%s)",
key,
self._name,
self._slug,
)
continue
typ = self.raw_schema[key]
try:
if isinstance(typ, list):
# nested value list
options[key] = self._nested_validate_list(typ[0], value, key)
elif isinstance(typ, dict):
# nested value dict
options[key] = self._nested_validate_dict(typ, value, key)
else:
# normal value
options[key] = self._single_validate(typ, value, key)
except (IndexError, KeyError):
raise vol.Invalid(
f"Type error for option '{key}' in {self._name} ({self._slug})"
) from None
self._check_missing_options(self.raw_schema, options, "root")
return options
# pylint: disable=no-value-for-parameter
def _single_validate(self, typ: str, value: Any, key: str):
"""Validate a single element."""
# if required argument
if value is None:
raise vol.Invalid(
f"Missing required option '{key}' in {self._name} ({self._slug})"
) from None
# Lookup secret
if str(value).startswith("!secret "):
secret: str = value.partition(" ")[2]
value = self.sys_homeassistant.secrets.get(secret)
if value is None:
raise vol.Invalid(
f"Unknown secret '{secret}' in {self._name} ({self._slug})"
) from None
# parse extend data from type
match = RE_SCHEMA_ELEMENT.match(typ)
if not match:
raise vol.Invalid(
f"Unknown type '{typ}' in {self._name} ({self._slug})"
) from None
# prepare range
range_args = {}
for group_name in _SCHEMA_LENGTH_PARTS:
group_value = match.group(group_name)
if group_value:
range_args[group_name[2:]] = float(group_value)
if typ.startswith(_STR) or typ.startswith(_PASSWORD):
if typ.startswith(_PASSWORD) and value:
self.pwned.add(hashlib.sha1(str(value).encode()).hexdigest())
return vol.All(str(value), vol.Range(**range_args))(value)
elif typ.startswith(_INT):
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
elif typ.startswith(_FLOAT):
return vol.All(vol.Coerce(float), vol.Range(**range_args))(value)
elif typ.startswith(_BOOL):
return vol.Boolean()(value)
elif typ.startswith(_EMAIL):
return vol.Email()(value)
elif typ.startswith(_URL):
return vol.Url()(value)
elif typ.startswith(_PORT):
return network_port(value)
elif typ.startswith(_MATCH):
return vol.Match(match.group("match"))(str(value))
elif typ.startswith(_LIST):
return vol.In(match.group("list").split("|"))(str(value))
elif typ.startswith(_DEVICE):
try:
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})"
) from None
# Have filter
if match.group("filter"):
str_filter = match.group("filter")
device_filter = _create_device_filter(str_filter)
if device not in self.sys_hardware.filter_devices(**device_filter):
raise vol.Invalid(
f"Device '{value}' don't match the filter {str_filter}! in {self._name} ({self._slug})"
)
# Device valid
self.devices.add(device)
return str(device.path)
raise vol.Invalid(
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):
"""Validate nested items."""
options = []
# Make sure it is a list
if not isinstance(data_list, list):
raise vol.Invalid(
f"Invalid list for option '{key}' in {self._name} ({self._slug})"
) from None
# Process list
for element in data_list:
# Nested?
if isinstance(typ, dict):
c_options = self._nested_validate_dict(typ, element, key)
options.append(c_options)
else:
options.append(self._single_validate(typ, element, key))
return options
def _nested_validate_dict(
self, typ: dict[Any, Any], data_dict: dict[Any, Any], key: str
):
"""Validate nested items."""
options = {}
# Make sure it is a dict
if not isinstance(data_dict, dict):
raise vol.Invalid(
f"Invalid dict for option '{key}' in {self._name} ({self._slug})"
) from None
# Process dict
for c_key, c_value in data_dict.items():
# Ignore unknown options / remove from list
if c_key not in typ:
_LOGGER.warning(
"Unknown option '%s' for %s (%s)", c_key, self._name, self._slug
)
continue
# Nested?
if isinstance(typ[c_key], list):
options[c_key] = self._nested_validate_list(
typ[c_key][0], c_value, c_key
)
else:
options[c_key] = self._single_validate(typ[c_key], c_value, c_key)
self._check_missing_options(typ, options, key)
return options
def _check_missing_options(
self, origin: dict[Any, Any], exists: dict[Any, Any], root: str
) -> None:
"""Check if all options are exists."""
missing = set(origin) - set(exists)
for miss_opt in missing:
miss_schema = origin[miss_opt]
# If its a list then value in list decides if its optional like ["str?"]
if isinstance(miss_schema, list) and len(miss_schema) > 0:
miss_schema = miss_schema[0]
if isinstance(miss_schema, str) and miss_schema.endswith("?"):
continue
raise vol.Invalid(
f"Missing option '{miss_opt}' in {root} in {self._name} ({self._slug})"
) from None
class UiOptions(CoreSysAttributes):
"""Render UI Add-ons Options."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize UI option render."""
self.coresys = coresys
def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Generate UI schema."""
ui_schema: list[dict[str, Any]] = []
# read options
for key, value in raw_schema.items():
if isinstance(value, list):
# nested value list
self._nested_ui_list(ui_schema, value, key)
elif isinstance(value, dict):
# nested value dict
self._nested_ui_dict(ui_schema, value, key)
else:
# normal value
self._single_ui_option(ui_schema, value, key)
return ui_schema
def _single_ui_option(
self,
ui_schema: list[dict[str, Any]],
value: str,
key: str,
multiple: bool = False,
) -> None:
"""Validate a single element."""
ui_node: dict[str, Union[str, bool, float, list[str]]] = {"name": key}
# If multiple
if multiple:
ui_node["multiple"] = True
# Parse extend data from type
match = RE_SCHEMA_ELEMENT.match(value)
if not match:
return
# Prepare range
for group_name in _SCHEMA_LENGTH_PARTS:
group_value = match.group(group_name)
if not group_value:
continue
if group_name[2:] == "min":
ui_node["lengthMin"] = float(group_value)
elif group_name[2:] == "max":
ui_node["lengthMax"] = float(group_value)
# If required
if value.endswith("?"):
ui_node["optional"] = True
else:
ui_node["required"] = True
# Data types
if value.startswith(_STR):
ui_node["type"] = "string"
elif value.startswith(_PASSWORD):
ui_node["type"] = "string"
ui_node["format"] = "password"
elif value.startswith(_INT):
ui_node["type"] = "integer"
elif value.startswith(_FLOAT):
ui_node["type"] = "float"
elif value.startswith(_BOOL):
ui_node["type"] = "boolean"
elif value.startswith(_EMAIL):
ui_node["type"] = "string"
ui_node["format"] = "email"
elif value.startswith(_URL):
ui_node["type"] = "string"
ui_node["format"] = "url"
elif value.startswith(_PORT):
ui_node["type"] = "integer"
elif value.startswith(_MATCH):
ui_node["type"] = "string"
elif value.startswith(_LIST):
ui_node["type"] = "select"
ui_node["options"] = match.group("list").split("|")
elif value.startswith(_DEVICE):
ui_node["type"] = "select"
# Have filter
if match.group("filter"):
device_filter = _create_device_filter(match.group("filter"))
ui_node["options"] = [
(device.by_id or device.path).as_posix()
for device in self.sys_hardware.filter_devices(**device_filter)
]
else:
ui_node["options"] = [
(device.by_id or device.path).as_posix()
for device in self.sys_hardware.devices
]
ui_schema.append(ui_node)
def _nested_ui_list(
self,
ui_schema: list[dict[str, Any]],
option_list: list[Any],
key: str,
) -> None:
"""UI nested list items."""
try:
element = option_list[0]
except IndexError:
_LOGGER.error("Invalid schema %s", key)
return
if isinstance(element, dict):
self._nested_ui_dict(ui_schema, element, key, multiple=True)
else:
self._single_ui_option(ui_schema, element, key, multiple=True)
def _nested_ui_dict(
self,
ui_schema: list[dict[str, Any]],
option_dict: dict[str, Any],
key: str,
multiple: bool = False,
) -> None:
"""UI nested dict items."""
ui_node = {
"name": key,
"type": "schema",
"optional": True,
"multiple": multiple,
}
nested_schema = []
for c_key, c_value in option_dict.items():
# Nested?
if isinstance(c_value, list):
self._nested_ui_list(nested_schema, c_value, c_key)
else:
self._single_ui_option(nested_schema, c_value, c_key)
ui_node["schema"] = nested_schema
ui_schema.append(ui_node)
def _create_device_filter(str_filter: str) -> dict[str, Any]:
"""Generate device Filter."""
raw_filter = dict(value.split("=") for value in str_filter.split(";"))
clean_filter = {}
for key, value in raw_filter.items():
if key == "subsystem":
clean_filter[key] = UdevSubsystem(value)
else:
clean_filter[key] = value
return clean_filter

View File

@@ -6,8 +6,18 @@ import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..const import ROLE_ADMIN, ROLE_MANAGER, SECURITY_DISABLE, SECURITY_PROFILE from ..const import (
from ..docker.const import Capabilities PRIVILEGED_DAC_READ_SEARCH,
PRIVILEGED_NET_ADMIN,
PRIVILEGED_SYS_ADMIN,
PRIVILEGED_SYS_MODULE,
PRIVILEGED_SYS_PTRACE,
PRIVILEGED_SYS_RAWIO,
ROLE_ADMIN,
ROLE_MANAGER,
SECURITY_DISABLE,
SECURITY_PROFILE,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .model import AddonModel from .model import AddonModel
@@ -16,10 +26,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
def rating_security(addon: AddonModel) -> int: def rating_security(addon: AddonModel) -> int:
"""Return 1-8 for security rating. """Return 1-6 for security rating.
1 = not secure 1 = not secure
8 = high secure 6 = high secure
""" """
rating = 5 rating = 5
@@ -35,24 +45,17 @@ def rating_security(addon: AddonModel) -> int:
elif addon.access_auth_api: elif addon.access_auth_api:
rating += 1 rating += 1
# Signed
if addon.signed:
rating += 1
# Privileged options # Privileged options
if ( if any(
any( privilege in addon.privileged
privilege in addon.privileged for privilege in (
for privilege in ( PRIVILEGED_NET_ADMIN,
Capabilities.NET_ADMIN, PRIVILEGED_SYS_ADMIN,
Capabilities.SYS_ADMIN, PRIVILEGED_SYS_RAWIO,
Capabilities.SYS_RAWIO, PRIVILEGED_SYS_PTRACE,
Capabilities.SYS_PTRACE, PRIVILEGED_SYS_MODULE,
Capabilities.SYS_MODULE, PRIVILEGED_DAC_READ_SEARCH,
Capabilities.DAC_READ_SEARCH,
)
) )
or addon.with_kernel_modules
): ):
rating += -1 rating += -1
@@ -70,11 +73,15 @@ def rating_security(addon: AddonModel) -> int:
if addon.host_pid: if addon.host_pid:
rating += -2 rating += -2
# Docker Access & full Access # Full Access
if addon.access_docker_api or addon.with_full_access: if addon.with_full_access:
rating += -2
# Docker Access
if addon.access_docker_api:
rating = 1 rating = 1
return max(min(8, rating), 1) return max(min(6, rating), 1)
async def remove_data(folder: Path) -> None: async def remove_data(folder: Path) -> None:

View File

@@ -2,13 +2,11 @@
import logging import logging
import re import re
import secrets import secrets
from typing import Any from typing import Any, Dict, List, Union
import uuid import uuid
import voluptuous as vol import voluptuous as vol
from supervisor.addons.const import AddonBackupMode
from ..const import ( from ..const import (
ARCH_ALL, ARCH_ALL,
ATTR_ACCESS_TOKEN, ATTR_ACCESS_TOKEN,
@@ -20,13 +18,10 @@ from ..const import (
ATTR_AUDIO_INPUT, ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT, ATTR_AUDIO_OUTPUT,
ATTR_AUTH_API, ATTR_AUTH_API,
ATTR_AUTO_UART,
ATTR_AUTO_UPDATE, ATTR_AUTO_UPDATE,
ATTR_BACKUP_EXCLUDE,
ATTR_BACKUP_POST,
ATTR_BACKUP_PRE,
ATTR_BOOT, ATTR_BOOT,
ATTR_BUILD_FROM, ATTR_BUILD_FROM,
ATTR_CONFIGURATION,
ATTR_DESCRIPTON, ATTR_DESCRIPTON,
ATTR_DEVICES, ATTR_DEVICES,
ATTR_DEVICETREE, ATTR_DEVICETREE,
@@ -48,12 +43,9 @@ from ..const import (
ATTR_INGRESS_ENTRY, ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PANEL, ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT, ATTR_INGRESS_PORT,
ATTR_INGRESS_STREAM,
ATTR_INGRESS_TOKEN, ATTR_INGRESS_TOKEN,
ATTR_INIT, ATTR_INIT,
ATTR_JOURNALD,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
ATTR_LABELS,
ATTR_LEGACY, ATTR_LEGACY,
ATTR_LOCATON, ATTR_LOCATON,
ATTR_MACHINE, ATTR_MACHINE,
@@ -68,11 +60,11 @@ from ..const import (
ATTR_PORTS_DESCRIPTION, ATTR_PORTS_DESCRIPTION,
ATTR_PRIVILEGED, ATTR_PRIVILEGED,
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_REALTIME,
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SCHEMA, ATTR_SCHEMA,
ATTR_SERVICES, ATTR_SERVICES,
ATTR_SLUG, ATTR_SLUG,
ATTR_SNAPSHOT_EXCLUDE,
ATTR_SQUASH, ATTR_SQUASH,
ATTR_STAGE, ATTR_STAGE,
ATTR_STARTUP, ATTR_STARTUP,
@@ -81,8 +73,6 @@ from ..const import (
ATTR_SYSTEM, ATTR_SYSTEM,
ATTR_TIMEOUT, ATTR_TIMEOUT,
ATTR_TMPFS, ATTR_TMPFS,
ATTR_TRANSLATIONS,
ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_URL, ATTR_URL,
ATTR_USB, ATTR_USB,
@@ -92,6 +82,7 @@ from ..const import (
ATTR_VIDEO, ATTR_VIDEO,
ATTR_WATCHDOG, ATTR_WATCHDOG,
ATTR_WEBUI, ATTR_WEBUI,
PRIVILEGED_ALL,
ROLE_ALL, ROLE_ALL,
ROLE_DEFAULT, ROLE_DEFAULT,
AddonBoot, AddonBoot,
@@ -99,10 +90,9 @@ from ..const import (
AddonStartup, AddonStartup,
AddonState, AddonState,
) )
from ..coresys import CoreSys
from ..discovery.validate import valid_discovery_service from ..discovery.validate import valid_discovery_service
from ..docker.const import Capabilities
from ..validate import ( from ..validate import (
docker_image,
docker_ports, docker_ports,
docker_ports_description, docker_ports_description,
network_port, network_port,
@@ -110,15 +100,51 @@ from ..validate import (
uuid_match, uuid_match,
version_tag, version_tag,
) )
from .const import ATTR_BACKUP, ATTR_CODENOTARY
from .options import RE_SCHEMA_ELEMENT
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
RE_VOLUME = re.compile(r"^(config|ssl|addons|backup|share|media)(?::(rw|ro))?$") RE_VOLUME = re.compile(r"^(config|ssl|addons|backup|share|media)(?::(rw|ro))?$")
RE_SERVICE = re.compile(r"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$") RE_SERVICE = re.compile(r"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")
V_STR = "str"
V_INT = "int"
V_FLOAT = "float"
V_BOOL = "bool"
V_PASSWORD = "password"
V_EMAIL = "email"
V_URL = "url"
V_PORT = "port"
V_MATCH = "match"
V_LIST = "list"
RE_SCHEMA_ELEMENT = re.compile(
r"^(?:"
r"|bool"
r"|email"
r"|url"
r"|port"
r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?"
r"|password(?:\((?P<p_min>\d+)?,(?P<p_max>\d+)?\))?"
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
r"|match\((?P<match>.*)\)"
r"|list\((?P<list>.+)\)"
r")\??$"
)
_SCHEMA_LENGTH_PARTS = (
"i_min",
"i_max",
"f_min",
"f_max",
"s_min",
"s_max",
"p_min",
"p_max",
)
RE_DOCKER_IMAGE = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$")
RE_DOCKER_IMAGE_BUILD = re.compile( RE_DOCKER_IMAGE_BUILD = re.compile(
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$" r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$"
) )
@@ -128,9 +154,7 @@ SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
RE_MACHINE = re.compile( RE_MACHINE = re.compile(
r"^!?(?:" r"^!?(?:"
r"|intel-nuc" r"|intel-nuc"
r"|generic-x86-64"
r"|odroid-c2" r"|odroid-c2"
r"|odroid-c4"
r"|odroid-n2" r"|odroid-n2"
r"|odroid-xu" r"|odroid-xu"
r"|qemuarm-64" r"|qemuarm-64"
@@ -148,126 +172,34 @@ RE_MACHINE = re.compile(
) )
def _warn_addon_config(config: dict[str, Any]): def _simple_startup(value) -> str:
"""Warn about miss configs.""" """Define startup schema."""
name = config.get(ATTR_NAME) if value == "before":
if not name: return AddonStartup.SERVICES.value
raise vol.Invalid("Invalid Add-on config!") if value == "after":
return AddonStartup.APPLICATION.value
if config.get(ATTR_FULL_ACCESS, False) and ( return value
config.get(ATTR_DEVICES)
or config.get(ATTR_UART)
or config.get(ATTR_USB)
or config.get(ATTR_GPIO)
):
_LOGGER.warning(
"Add-on have full device access, and selective device access in the configuration. Please report this to the maintainer of %s",
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]):
name = config.get(ATTR_NAME)
if not name:
raise vol.Invalid("Invalid Add-on config!")
# Startup 2018-03-30
if config.get(ATTR_STARTUP) in ("before", "after"):
value = config[ATTR_STARTUP]
if protocol:
_LOGGER.warning(
"Add-on config 'startup' with '%s' is deprecated. Please report this to the maintainer of %s",
value,
name,
)
if value == "before":
config[ATTR_STARTUP] = AddonStartup.SERVICES.value
elif value == "after":
config[ATTR_STARTUP] = AddonStartup.APPLICATION.value
# UART 2021-01-20
if "auto_uart" in config:
if protocol:
_LOGGER.warning(
"Add-on config 'auto_uart' is deprecated, use 'uart'. Please report this to the maintainer of %s",
name,
)
config[ATTR_UART] = config.pop("auto_uart")
# Device 2021-01-20
if ATTR_DEVICES in config and any(":" in line for line in config[ATTR_DEVICES]):
if protocol:
_LOGGER.warning(
"Add-on config 'devices' use a deprecated format, the new format uses a list of paths only. Please report this to the maintainer of %s",
name,
)
config[ATTR_DEVICES] = [line.split(":")[0] for line in config[ATTR_DEVICES]]
# TMPFS 2021-02-01
if ATTR_TMPFS in config and not isinstance(config[ATTR_TMPFS], bool):
if protocol:
_LOGGER.warning(
"Add-on config 'tmpfs' use a deprecated format, new it's only a boolean. Please report this to the maintainer of %s",
name,
)
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
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
_SCHEMA_ADDON_CONFIG = vol.Schema( SCHEMA_ADDON_CONFIG = vol.Schema(
{ {
vol.Required(ATTR_NAME): str, vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_VERSION): version_tag, vol.Required(ATTR_VERSION): vol.All(version_tag, str),
vol.Required(ATTR_SLUG): str, vol.Required(ATTR_SLUG): vol.Coerce(str),
vol.Required(ATTR_DESCRIPTON): str, vol.Required(ATTR_DESCRIPTON): vol.Coerce(str),
vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)], vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)],
vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()), vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()),
vol.Optional(ATTR_URL): vol.Url(), vol.Optional(ATTR_URL): vol.Url(),
vol.Optional(ATTR_STARTUP, default=AddonStartup.APPLICATION): vol.Coerce( vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.Coerce(AddonStartup)),
AddonStartup vol.Required(ATTR_BOOT): vol.Coerce(AddonBoot),
),
vol.Optional(ATTR_BOOT, default=AddonBoot.AUTO): vol.Coerce(AddonBoot),
vol.Optional(ATTR_INIT, default=True): vol.Boolean(), vol.Optional(ATTR_INIT, default=True): vol.Boolean(),
vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(), vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(),
vol.Optional(ATTR_STAGE, default=AddonStage.STABLE): vol.Coerce(AddonStage), vol.Optional(ATTR_STAGE, default=AddonStage.STABLE): vol.Coerce(AddonStage),
vol.Optional(ATTR_PORTS): docker_ports, vol.Optional(ATTR_PORTS): docker_ports,
vol.Optional(ATTR_PORTS_DESCRIPTION): docker_ports_description, vol.Optional(ATTR_PORTS_DESCRIPTION): docker_ports_description,
vol.Optional(ATTR_WATCHDOG): vol.Match( vol.Optional(ATTR_WATCHDOG): vol.Match(
r"^(?:https?|\[PROTO:\w+\]|tcp):\/\/\[HOST\]:(\[PORT:\d+\]|\d+).*$" r"^(?:https?|\[PROTO:\w+\]|tcp):\/\/\[HOST\]:\[PORT:\d+\].*$"
), ),
vol.Optional(ATTR_WEBUI): vol.Match( vol.Optional(ATTR_WEBUI): vol.Match(
r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$" r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"
@@ -276,32 +208,30 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_INGRESS_PORT, default=8099): vol.Any( vol.Optional(ATTR_INGRESS_PORT, default=8099): vol.Any(
network_port, vol.Equal(0) network_port, vol.Equal(0)
), ),
vol.Optional(ATTR_INGRESS_ENTRY): str, vol.Optional(ATTR_INGRESS_ENTRY): vol.Coerce(str),
vol.Optional(ATTR_INGRESS_STREAM, default=False): vol.Boolean(), vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): vol.Coerce(str),
vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): str, vol.Optional(ATTR_PANEL_TITLE): vol.Coerce(str),
vol.Optional(ATTR_PANEL_TITLE): str,
vol.Optional(ATTR_PANEL_ADMIN, default=True): vol.Boolean(), vol.Optional(ATTR_PANEL_ADMIN, default=True): vol.Boolean(),
vol.Optional(ATTR_HOMEASSISTANT): version_tag, vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(),
vol.Optional(ATTR_DEVICES): [str], vol.Optional(ATTR_DEVICES): [vol.Match(r"^(.*):(.*):([rwm]{1,3})$")],
vol.Optional(ATTR_AUTO_UART, default=False): vol.Boolean(),
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(), vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
vol.Optional(ATTR_TMPFS, default=False): vol.Boolean(), vol.Optional(ATTR_TMPFS): vol.Match(r"^size=(\d)*[kmg](,uid=\d{1,4})?(,rw)?$"),
vol.Optional(ATTR_MAP, default=list): [vol.Match(RE_VOLUME)], vol.Optional(ATTR_MAP, default=list): [vol.Match(RE_VOLUME)],
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): str}, vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)], vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(), vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(), vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(),
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(), vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
vol.Optional(ATTR_VIDEO, default=False): vol.Boolean(), vol.Optional(ATTR_VIDEO, default=False): vol.Boolean(),
vol.Optional(ATTR_GPIO, default=False): vol.Boolean(), vol.Optional(ATTR_GPIO, default=False): vol.Boolean(),
vol.Optional(ATTR_USB, default=False): vol.Boolean(), vol.Optional(ATTR_USB, default=False): vol.Boolean(),
vol.Optional(ATTR_UART, default=False): vol.Boolean(),
vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(), vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(),
vol.Optional(ATTR_KERNEL_MODULES, default=False): vol.Boolean(), vol.Optional(ATTR_KERNEL_MODULES, default=False): vol.Boolean(),
vol.Optional(ATTR_REALTIME, default=False): vol.Boolean(),
vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(), vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(),
vol.Optional(ATTR_HASSIO_ROLE, default=ROLE_DEFAULT): vol.In(ROLE_ALL), vol.Optional(ATTR_HASSIO_ROLE, default=ROLE_DEFAULT): vol.In(ROLE_ALL),
vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(), vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(),
@@ -311,44 +241,39 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(), vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(),
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)], vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
vol.Optional(ATTR_DISCOVERY): [valid_discovery_service], vol.Optional(ATTR_DISCOVERY): [valid_discovery_service],
vol.Optional(ATTR_BACKUP_EXCLUDE): [str], vol.Optional(ATTR_SNAPSHOT_EXCLUDE): [vol.Coerce(str)],
vol.Optional(ATTR_BACKUP_PRE): str, vol.Required(ATTR_OPTIONS): dict,
vol.Optional(ATTR_BACKUP_POST): str, vol.Required(ATTR_SCHEMA): vol.Any(
vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce(
AddonBackupMode
),
vol.Optional(ATTR_CODENOTARY): vol.Email(),
vol.Optional(ATTR_OPTIONS, default={}): dict,
vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
vol.Schema( vol.Schema(
{ {
str: vol.Any( vol.Coerce(str): vol.Any(
SCHEMA_ELEMENT, SCHEMA_ELEMENT,
[ [
vol.Any( vol.Any(
SCHEMA_ELEMENT, SCHEMA_ELEMENT,
{str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}, {
vol.Coerce(str): vol.Any(
SCHEMA_ELEMENT, [SCHEMA_ELEMENT]
)
},
) )
], ],
vol.Schema({str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}), vol.Schema(
{vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}
),
) )
} }
), ),
False, False,
), ),
vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_IMAGE): vol.Match(RE_DOCKER_IMAGE),
vol.Optional(ATTR_TIMEOUT, default=10): vol.All( vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
vol.Coerce(int), vol.Range(min=10, max=300) vol.Coerce(int), vol.Range(min=10, max=300)
), ),
vol.Optional(ATTR_JOURNALD, default=False): vol.Boolean(),
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
SCHEMA_ADDON_CONFIG = vol.All(
_migrate_addon_config(True), _warn_addon_config, _SCHEMA_ADDON_CONFIG
)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_BUILD_CONFIG = vol.Schema( SCHEMA_BUILD_CONFIG = vol.Schema(
@@ -357,25 +282,9 @@ SCHEMA_BUILD_CONFIG = vol.Schema(
{vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)} {vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}
), ),
vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(), vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}), vol.Optional(ATTR_ARGS, default=dict): vol.Schema(
vol.Optional(ATTR_LABELS, default=dict): vol.Schema({str: str}), {vol.Coerce(str): vol.Coerce(str)}
}, ),
extra=vol.REMOVE_EXTRA,
)
SCHEMA_TRANSLATION_CONFIGURATION = vol.Schema(
{
vol.Required(ATTR_NAME): str,
vol.Optional(ATTR_DESCRIPTON): vol.Maybe(str),
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_ADDON_TRANSLATIONS = vol.Schema(
{
vol.Optional(ATTR_CONFIGURATION): {str: SCHEMA_TRANSLATION_CONFIGURATION},
vol.Optional(ATTR_NETWORK): {str: str},
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
@@ -384,17 +293,19 @@ SCHEMA_ADDON_TRANSLATIONS = vol.Schema(
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_ADDON_USER = vol.Schema( SCHEMA_ADDON_USER = vol.Schema(
{ {
vol.Required(ATTR_VERSION): version_tag, vol.Required(ATTR_VERSION): vol.Coerce(str),
vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_IMAGE): vol.Coerce(str),
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): uuid_match, vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): uuid_match,
vol.Optional(ATTR_ACCESS_TOKEN): token, vol.Optional(ATTR_ACCESS_TOKEN): token,
vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): str, vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): vol.Coerce(
str
),
vol.Optional(ATTR_OPTIONS, default=dict): dict, vol.Optional(ATTR_OPTIONS, default=dict): dict,
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot), vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
vol.Optional(ATTR_NETWORK): docker_ports, vol.Optional(ATTR_NETWORK): docker_ports,
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(), vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(), vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG, default=False): vol.Boolean(), vol.Optional(ATTR_WATCHDOG, default=False): vol.Boolean(),
@@ -402,35 +313,285 @@ SCHEMA_ADDON_USER = vol.Schema(
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
SCHEMA_ADDON_SYSTEM = vol.All(
_migrate_addon_config(), SCHEMA_ADDON_SYSTEM = SCHEMA_ADDON_CONFIG.extend(
_SCHEMA_ADDON_CONFIG.extend( {
{ vol.Required(ATTR_LOCATON): vol.Coerce(str),
vol.Required(ATTR_LOCATON): str, vol.Required(ATTR_REPOSITORY): vol.Coerce(str),
vol.Required(ATTR_REPOSITORY): str, }
vol.Required(ATTR_TRANSLATIONS, default=dict): {
str: SCHEMA_ADDON_TRANSLATIONS
},
}
),
) )
SCHEMA_ADDONS_FILE = vol.Schema( SCHEMA_ADDONS_FILE = vol.Schema(
{ {
vol.Optional(ATTR_USER, default=dict): {str: SCHEMA_ADDON_USER}, vol.Optional(ATTR_USER, default=dict): {vol.Coerce(str): SCHEMA_ADDON_USER},
vol.Optional(ATTR_SYSTEM, default=dict): {str: SCHEMA_ADDON_SYSTEM}, vol.Optional(ATTR_SYSTEM, default=dict): {vol.Coerce(str): SCHEMA_ADDON_SYSTEM},
}, }
extra=vol.REMOVE_EXTRA,
) )
SCHEMA_ADDON_BACKUP = vol.Schema( SCHEMA_ADDON_SNAPSHOT = vol.Schema(
{ {
vol.Required(ATTR_USER): SCHEMA_ADDON_USER, vol.Required(ATTR_USER): SCHEMA_ADDON_USER,
vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM, vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM,
vol.Required(ATTR_STATE): vol.Coerce(AddonState), vol.Required(ATTR_STATE): vol.Coerce(AddonState),
vol.Required(ATTR_VERSION): version_tag, vol.Required(ATTR_VERSION): vol.Coerce(str),
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
def validate_options(coresys: CoreSys, raw_schema: Dict[str, Any]):
"""Validate schema."""
def validate(struct):
"""Create schema validator for add-ons options."""
options = {}
# read options
for key, value in struct.items():
# Ignore unknown options / remove from list
if key not in raw_schema:
_LOGGER.warning("Unknown options %s", key)
continue
typ = raw_schema[key]
try:
if isinstance(typ, list):
# nested value list
options[key] = _nested_validate_list(coresys, typ[0], value, key)
elif isinstance(typ, dict):
# nested value dict
options[key] = _nested_validate_dict(coresys, typ, value, key)
else:
# normal value
options[key] = _single_validate(coresys, typ, value, key)
except (IndexError, KeyError):
raise vol.Invalid(f"Type error for {key}") from None
_check_missing_options(raw_schema, options, "root")
return options
return validate
# pylint: disable=no-value-for-parameter
# pylint: disable=inconsistent-return-statements
def _single_validate(coresys: CoreSys, typ: str, value: Any, key: str):
"""Validate a single element."""
# if required argument
if value is None:
raise vol.Invalid(f"Missing required option '{key}'") from None
# Lookup secret
if str(value).startswith("!secret "):
secret: str = value.partition(" ")[2]
value = coresys.homeassistant.secrets.get(secret)
if value is None:
raise vol.Invalid(f"Unknown secret {secret}") from None
# parse extend data from type
match = RE_SCHEMA_ELEMENT.match(typ)
if not match:
raise vol.Invalid(f"Unknown type {typ}") from None
# prepare range
range_args = {}
for group_name in _SCHEMA_LENGTH_PARTS:
group_value = match.group(group_name)
if group_value:
range_args[group_name[2:]] = float(group_value)
if typ.startswith(V_STR) or typ.startswith(V_PASSWORD):
return vol.All(str(value), vol.Range(**range_args))(value)
elif typ.startswith(V_INT):
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
elif typ.startswith(V_FLOAT):
return vol.All(vol.Coerce(float), vol.Range(**range_args))(value)
elif typ.startswith(V_BOOL):
return vol.Boolean()(value)
elif typ.startswith(V_EMAIL):
return vol.Email()(value)
elif typ.startswith(V_URL):
return vol.Url()(value)
elif typ.startswith(V_PORT):
return network_port(value)
elif typ.startswith(V_MATCH):
return vol.Match(match.group("match"))(str(value))
elif typ.startswith(V_LIST):
return vol.In(match.group("list").split("|"))(str(value))
raise vol.Invalid(f"Fatal error for {key} type {typ}") from None
def _nested_validate_list(coresys, typ, data_list, key):
"""Validate nested items."""
options = []
# Make sure it is a list
if not isinstance(data_list, list):
raise vol.Invalid(f"Invalid list for {key}") from None
# Process list
for element in data_list:
# Nested?
if isinstance(typ, dict):
c_options = _nested_validate_dict(coresys, typ, element, key)
options.append(c_options)
else:
options.append(_single_validate(coresys, typ, element, key))
return options
def _nested_validate_dict(coresys, typ, data_dict, key):
"""Validate nested items."""
options = {}
# Make sure it is a dict
if not isinstance(data_dict, dict):
raise vol.Invalid(f"Invalid dict for {key}") from None
# Process dict
for c_key, c_value in data_dict.items():
# Ignore unknown options / remove from list
if c_key not in typ:
_LOGGER.warning("Unknown options %s", c_key)
continue
# Nested?
if isinstance(typ[c_key], list):
options[c_key] = _nested_validate_list(
coresys, typ[c_key][0], c_value, c_key
)
else:
options[c_key] = _single_validate(coresys, typ[c_key], c_value, c_key)
_check_missing_options(typ, options, key)
return options
def _check_missing_options(origin, exists, root):
"""Check if all options are exists."""
missing = set(origin) - set(exists)
for miss_opt in missing:
if isinstance(origin[miss_opt], str) and origin[miss_opt].endswith("?"):
continue
raise vol.Invalid(f"Missing option {miss_opt} in {root}") from None
def schema_ui_options(raw_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Generate UI schema."""
ui_schema: List[Dict[str, Any]] = []
# read options
for key, value in raw_schema.items():
if isinstance(value, list):
# nested value list
_nested_ui_list(ui_schema, value, key)
elif isinstance(value, dict):
# nested value dict
_nested_ui_dict(ui_schema, value, key)
else:
# normal value
_single_ui_option(ui_schema, value, key)
return ui_schema
def _single_ui_option(
ui_schema: List[Dict[str, Any]], value: str, key: str, multiple: bool = False
) -> None:
"""Validate a single element."""
ui_node: Dict[str, Union[str, bool, float, List[str]]] = {"name": key}
# If multiple
if multiple:
ui_node["multiple"] = True
# Parse extend data from type
match = RE_SCHEMA_ELEMENT.match(value)
if not match:
return
# Prepare range
for group_name in _SCHEMA_LENGTH_PARTS:
group_value = match.group(group_name)
if not group_value:
continue
if group_name[2:] == "min":
ui_node["lengthMin"] = float(group_value)
elif group_name[2:] == "max":
ui_node["lengthMax"] = float(group_value)
# If required
if value.endswith("?"):
ui_node["optional"] = True
else:
ui_node["required"] = True
# Data types
if value.startswith(V_STR):
ui_node["type"] = "string"
elif value.startswith(V_PASSWORD):
ui_node["type"] = "string"
ui_node["format"] = "password"
elif value.startswith(V_INT):
ui_node["type"] = "integer"
elif value.startswith(V_FLOAT):
ui_node["type"] = "float"
elif value.startswith(V_BOOL):
ui_node["type"] = "boolean"
elif value.startswith(V_EMAIL):
ui_node["type"] = "string"
ui_node["format"] = "email"
elif value.startswith(V_URL):
ui_node["type"] = "string"
ui_node["format"] = "url"
elif value.startswith(V_PORT):
ui_node["type"] = "integer"
elif value.startswith(V_MATCH):
ui_node["type"] = "string"
elif value.startswith(V_LIST):
ui_node["type"] = "select"
ui_node["options"] = match.group("list").split("|")
ui_schema.append(ui_node)
def _nested_ui_list(
ui_schema: List[Dict[str, Any]], option_list: List[Any], key: str
) -> None:
"""UI nested list items."""
try:
element = option_list[0]
except IndexError:
_LOGGER.error("Invalid schema %s", key)
return
if isinstance(element, dict):
_nested_ui_dict(ui_schema, element, key, multiple=True)
else:
_single_ui_option(ui_schema, element, key, multiple=True)
def _nested_ui_dict(
ui_schema: List[Dict[str, Any]],
option_dict: Dict[str, Any],
key: str,
multiple: bool = False,
) -> None:
"""UI nested dict items."""
ui_node = {"name": key, "type": "schema", "optional": True, "multiple": multiple}
nested_schema = []
for c_key, c_value in option_dict.items():
# Nested?
if isinstance(c_value, list):
_nested_ui_list(nested_schema, c_value, c_key)
else:
_single_ui_option(nested_schema, c_value, c_key)
ui_node["schema"] = nested_schema
ui_schema.append(ui_node)

View File

@@ -9,7 +9,6 @@ from ..coresys import CoreSys, CoreSysAttributes
from .addons import APIAddons from .addons import APIAddons
from .audio import APIAudio from .audio import APIAudio
from .auth import APIAuth from .auth import APIAuth
from .backups import APIBackups
from .cli import APICli from .cli import APICli
from .discovery import APIDiscovery from .discovery import APIDiscovery
from .dns import APICoreDNS from .dns import APICoreDNS
@@ -17,25 +16,23 @@ from .docker import APIDocker
from .hardware import APIHardware from .hardware import APIHardware
from .homeassistant import APIHomeAssistant from .homeassistant import APIHomeAssistant
from .host import APIHost from .host import APIHost
from .info import APIInfo
from .ingress import APIIngress from .ingress import APIIngress
from .jobs import APIJobs
from .middleware.security import SecurityMiddleware
from .multicast import APIMulticast from .multicast import APIMulticast
from .network import APINetwork from .network import APINetwork
from .observer import APIObserver from .observer import APIObserver
from .os import APIOS from .os import APIOS
from .proxy import APIProxy from .proxy import APIProxy
from .resolution import APIResoulution from .resolution import APIResoulution
from .root import APIRoot from .security import SecurityMiddleware
from .security import APISecurity
from .services import APIServices from .services import APIServices
from .store import APIStore from .snapshots import APISnapshots
from .supervisor import APISupervisor from .supervisor import APISupervisor
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
MAX_CLIENT_SIZE: int = 1024**2 * 16 MAX_CLIENT_SIZE: int = 1024 ** 2 * 16
class RestAPI(CoreSysAttributes): class RestAPI(CoreSysAttributes):
@@ -62,7 +59,6 @@ class RestAPI(CoreSysAttributes):
self._register_addons() self._register_addons()
self._register_audio() self._register_audio()
self._register_auth() self._register_auth()
self._register_backups()
self._register_cli() self._register_cli()
self._register_discovery() self._register_discovery()
self._register_dns() self._register_dns()
@@ -70,22 +66,18 @@ class RestAPI(CoreSysAttributes):
self._register_hardware() self._register_hardware()
self._register_homeassistant() self._register_homeassistant()
self._register_host() self._register_host()
self._register_root() self._register_info()
self._register_ingress() self._register_ingress()
self._register_multicast() self._register_multicast()
self._register_network() self._register_network()
self._register_observer() self._register_observer()
self._register_os() self._register_os()
self._register_jobs()
self._register_panel() self._register_panel()
self._register_proxy() self._register_proxy()
self._register_resolution() self._register_resolution()
self._register_services() self._register_services()
self._register_snapshots()
self._register_supervisor() self._register_supervisor()
self._register_store()
self._register_security()
await self.start()
def _register_host(self) -> None: def _register_host(self) -> None:
"""Register hostcontrol functions.""" """Register hostcontrol functions."""
@@ -116,7 +108,6 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/network/info", api_network.info), web.get("/network/info", api_network.info),
web.post("/network/reload", api_network.reload),
web.get( web.get(
"/network/interface/{interface}/info", api_network.interface_info "/network/interface/{interface}/info", api_network.interface_info
), ),
@@ -124,14 +115,6 @@ class RestAPI(CoreSysAttributes):
"/network/interface/{interface}/update", "/network/interface/{interface}/update",
api_network.interface_update, api_network.interface_update,
), ),
web.get(
"/network/interface/{interface}/accesspoints",
api_network.scan_accesspoints,
),
web.post(
"/network/interface/{interface}/vlan/{vlan}",
api_network.create_vlan,
),
] ]
) )
@@ -145,34 +128,6 @@ class RestAPI(CoreSysAttributes):
web.get("/os/info", api_os.info), web.get("/os/info", api_os.info),
web.post("/os/update", api_os.update), web.post("/os/update", api_os.update),
web.post("/os/config/sync", api_os.config_sync), 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),
]
)
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),
]
)
def _register_jobs(self) -> None:
"""Register Jobs functions."""
api_jobs = APIJobs()
api_jobs.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/jobs/info", api_jobs.info),
web.post("/jobs/options", api_jobs.options),
web.post("/jobs/reset", api_jobs.reset),
] ]
) )
@@ -226,24 +181,16 @@ class RestAPI(CoreSysAttributes):
[ [
web.get("/hardware/info", api_hardware.info), web.get("/hardware/info", api_hardware.info),
web.get("/hardware/audio", api_hardware.audio), web.get("/hardware/audio", api_hardware.audio),
web.post("/hardware/trigger", api_hardware.trigger),
] ]
) )
def _register_root(self) -> None: def _register_info(self) -> None:
"""Register root functions.""" """Register info functions."""
api_root = APIRoot() api_info = APIInfo()
api_root.coresys = self.coresys api_info.coresys = self.coresys
self.webapp.add_routes([web.get("/info", api_root.info)]) self.webapp.add_routes([web.get("/info", api_info.info)])
self.webapp.add_routes([web.post("/refresh_updates", api_root.refresh_updates)])
self.webapp.add_routes(
[web.get("/available_updates", api_root.available_updates)]
)
# Remove: 2023
self.webapp.add_routes(
[web.get("/supervisor/available_updates", api_root.available_updates)]
)
def _register_resolution(self) -> None: def _register_resolution(self) -> None:
"""Register info functions.""" """Register info functions."""
@@ -253,10 +200,6 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/resolution/info", api_resolution.info), web.get("/resolution/info", api_resolution.info),
web.post(
"/resolution/check/{check}/options", api_resolution.options_check
),
web.post("/resolution/check/{check}/run", api_resolution.run_check),
web.post( web.post(
"/resolution/suggestion/{suggestion}", "/resolution/suggestion/{suggestion}",
api_resolution.apply_suggestion, api_resolution.apply_suggestion,
@@ -269,7 +212,6 @@ class RestAPI(CoreSysAttributes):
"/resolution/issue/{issue}", "/resolution/issue/{issue}",
api_resolution.dismiss_issue, api_resolution.dismiss_issue,
), ),
web.post("/resolution/healthcheck", api_resolution.healthcheck),
] ]
) )
@@ -280,7 +222,6 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/auth", api_auth.auth),
web.post("/auth", api_auth.auth), web.post("/auth", api_auth.auth),
web.post("/auth/reset", api_auth.reset), web.post("/auth/reset", api_auth.reset),
web.delete("/auth/cache", api_auth.cache), web.delete("/auth/cache", api_auth.cache),
@@ -300,7 +241,6 @@ class RestAPI(CoreSysAttributes):
web.get("/supervisor/logs", api_supervisor.logs), web.get("/supervisor/logs", api_supervisor.logs),
web.post("/supervisor/update", api_supervisor.update), web.post("/supervisor/update", api_supervisor.update),
web.post("/supervisor/reload", api_supervisor.reload), web.post("/supervisor/reload", api_supervisor.reload),
web.post("/supervisor/restart", api_supervisor.restart),
web.post("/supervisor/options", api_supervisor.options), web.post("/supervisor/options", api_supervisor.options),
web.post("/supervisor/repair", api_supervisor.repair), web.post("/supervisor/repair", api_supervisor.repair),
] ]
@@ -323,22 +263,17 @@ class RestAPI(CoreSysAttributes):
web.post("/core/start", api_hass.start), web.post("/core/start", api_hass.start),
web.post("/core/check", api_hass.check), web.post("/core/check", api_hass.check),
web.post("/core/rebuild", api_hass.rebuild), web.post("/core/rebuild", api_hass.rebuild),
] # Remove with old Supervisor fallback
)
# Reroute from legacy
self.webapp.add_routes(
[
web.get("/homeassistant/info", api_hass.info), web.get("/homeassistant/info", api_hass.info),
web.get("/homeassistant/logs", api_hass.logs), web.get("/homeassistant/logs", api_hass.logs),
web.get("/homeassistant/stats", api_hass.stats), web.get("/homeassistant/stats", api_hass.stats),
web.post("/homeassistant/options", api_hass.options), web.post("/homeassistant/options", api_hass.options),
web.post("/homeassistant/update", api_hass.update),
web.post("/homeassistant/restart", api_hass.restart), web.post("/homeassistant/restart", api_hass.restart),
web.post("/homeassistant/stop", api_hass.stop), web.post("/homeassistant/stop", api_hass.stop),
web.post("/homeassistant/start", api_hass.start), web.post("/homeassistant/start", api_hass.start),
web.post("/homeassistant/update", api_hass.update),
web.post("/homeassistant/rebuild", api_hass.rebuild),
web.post("/homeassistant/check", api_hass.check), web.post("/homeassistant/check", api_hass.check),
web.post("/homeassistant/rebuild", api_hass.rebuild),
] ]
) )
@@ -355,12 +290,7 @@ class RestAPI(CoreSysAttributes):
web.post("/core/api/{path:.+}", api_proxy.api), web.post("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/{path:.+}", api_proxy.api), web.get("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/", api_proxy.api), web.get("/core/api/", api_proxy.api),
] # Remove with old Supervisor fallback
)
# Reroute from legacy
self.webapp.add_routes(
[
web.get("/homeassistant/api/websocket", api_proxy.websocket), web.get("/homeassistant/api/websocket", api_proxy.websocket),
web.get("/homeassistant/websocket", api_proxy.websocket), web.get("/homeassistant/websocket", api_proxy.websocket),
web.get("/homeassistant/api/stream", api_proxy.stream), web.get("/homeassistant/api/stream", api_proxy.stream),
@@ -378,18 +308,24 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/addons", api_addons.list), web.get("/addons", api_addons.list),
web.post("/addons/reload", api_addons.reload),
web.get("/addons/{addon}/info", api_addons.info), web.get("/addons/{addon}/info", api_addons.info),
web.post("/addons/{addon}/install", api_addons.install),
web.post("/addons/{addon}/uninstall", api_addons.uninstall), web.post("/addons/{addon}/uninstall", api_addons.uninstall),
web.post("/addons/{addon}/start", api_addons.start), web.post("/addons/{addon}/start", api_addons.start),
web.post("/addons/{addon}/stop", api_addons.stop), web.post("/addons/{addon}/stop", api_addons.stop),
web.post("/addons/{addon}/restart", api_addons.restart), web.post("/addons/{addon}/restart", api_addons.restart),
web.post("/addons/{addon}/update", api_addons.update),
web.post("/addons/{addon}/options", api_addons.options), web.post("/addons/{addon}/options", api_addons.options),
web.post( web.post(
"/addons/{addon}/options/validate", api_addons.options_validate "/addons/{addon}/options/validate", api_addons.options_validate
), ),
web.get("/addons/{addon}/options/config", api_addons.options_config),
web.post("/addons/{addon}/rebuild", api_addons.rebuild), web.post("/addons/{addon}/rebuild", api_addons.rebuild),
web.get("/addons/{addon}/logs", api_addons.logs), web.get("/addons/{addon}/logs", api_addons.logs),
web.get("/addons/{addon}/icon", api_addons.icon),
web.get("/addons/{addon}/logo", api_addons.logo),
web.get("/addons/{addon}/changelog", api_addons.changelog),
web.get("/addons/{addon}/documentation", api_addons.documentation),
web.post("/addons/{addon}/stdin", api_addons.stdin), web.post("/addons/{addon}/stdin", api_addons.stdin),
web.post("/addons/{addon}/security", api_addons.security), web.post("/addons/{addon}/security", api_addons.security),
web.get("/addons/{addon}/stats", api_addons.stats), web.get("/addons/{addon}/stats", api_addons.stats),
@@ -404,32 +340,35 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.post("/ingress/session", api_ingress.create_session), web.post("/ingress/session", api_ingress.create_session),
web.post("/ingress/validate_session", api_ingress.validate_session),
web.get("/ingress/panels", api_ingress.panels), web.get("/ingress/panels", api_ingress.panels),
web.view("/ingress/{token}/{path:.*}", api_ingress.handler), web.view("/ingress/{token}/{path:.*}", api_ingress.handler),
] ]
) )
def _register_backups(self) -> None: def _register_snapshots(self) -> None:
"""Register backups functions.""" """Register snapshots functions."""
api_backups = APIBackups() api_snapshots = APISnapshots()
api_backups.coresys = self.coresys api_snapshots.coresys = self.coresys
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/backups", api_backups.list), web.get("/snapshots", api_snapshots.list),
web.post("/backups/reload", api_backups.reload), web.post("/snapshots/reload", api_snapshots.reload),
web.post("/backups/new/full", api_backups.backup_full), web.post("/snapshots/new/full", api_snapshots.snapshot_full),
web.post("/backups/new/partial", api_backups.backup_partial), web.post("/snapshots/new/partial", api_snapshots.snapshot_partial),
web.post("/backups/new/upload", api_backups.upload), web.post("/snapshots/new/upload", api_snapshots.upload),
web.get("/backups/{slug}/info", api_backups.info), web.get("/snapshots/{snapshot}/info", api_snapshots.info),
web.delete("/backups/{slug}", api_backups.remove), web.delete("/snapshots/{snapshot}", api_snapshots.remove),
web.post("/backups/{slug}/restore/full", api_backups.restore_full),
web.post( web.post(
"/backups/{slug}/restore/partial", "/snapshots/{snapshot}/restore/full", api_snapshots.restore_full
api_backups.restore_partial,
), ),
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),
] ]
) )
@@ -500,67 +439,6 @@ class RestAPI(CoreSysAttributes):
] ]
) )
def _register_store(self) -> None:
"""Register store endpoints."""
api_store = APIStore()
api_store.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/store", api_store.store_info),
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
),
web.post(
"/store/addons/{addon}/install/{version}",
api_store.addons_addon_install,
),
web.post("/store/addons/{addon}/update", api_store.addons_addon_update),
web.post(
"/store/addons/{addon}/update/{version}",
api_store.addons_addon_update,
),
web.post("/store/reload", api_store.reload),
web.get("/store/repositories", api_store.repositories_list),
web.get(
"/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,
),
]
)
def _register_panel(self) -> None: def _register_panel(self) -> None:
"""Register panel for Home Assistant.""" """Register panel for Home Assistant."""
panel_dir = Path(__file__).parent.joinpath("panel") panel_dir = Path(__file__).parent.joinpath("panel")

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor Home Assistant RESTful API.""" """Init file for Supervisor Home Assistant RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable from typing import Any, Awaitable, Dict, List
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -52,11 +52,13 @@ from ..const import (
ATTR_INGRESS_PANEL, ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT, ATTR_INGRESS_PORT,
ATTR_INGRESS_URL, ATTR_INGRESS_URL,
ATTR_INSTALLED,
ATTR_IP_ADDRESS, ATTR_IP_ADDRESS,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
ATTR_LOGO, ATTR_LOGO,
ATTR_LONG_DESCRIPTION, ATTR_LONG_DESCRIPTION,
ATTR_MACHINE, ATTR_MACHINE,
ATTR_MAINTAINER,
ATTR_MEMORY_LIMIT, ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT, ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE, ATTR_MEMORY_USAGE,
@@ -69,20 +71,18 @@ from ..const import (
ATTR_OPTIONS, ATTR_OPTIONS,
ATTR_PRIVILEGED, ATTR_PRIVILEGED,
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_PWNED,
ATTR_RATING, ATTR_RATING,
ATTR_REPOSITORIES,
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SCHEMA, ATTR_SCHEMA,
ATTR_SERVICES, ATTR_SERVICES,
ATTR_SLUG, ATTR_SLUG,
ATTR_SOURCE,
ATTR_STAGE, ATTR_STAGE,
ATTR_STARTUP, ATTR_STARTUP,
ATTR_STATE, ATTR_STATE,
ATTR_STDIN, ATTR_STDIN,
ATTR_TRANSLATIONS,
ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_UPDATE_AVAILABLE,
ATTR_URL, ATTR_URL,
ATTR_USB, ATTR_USB,
ATTR_VALID, ATTR_VALID,
@@ -91,19 +91,22 @@ from ..const import (
ATTR_VIDEO, ATTR_VIDEO,
ATTR_WATCHDOG, ATTR_WATCHDOG,
ATTR_WEBUI, ATTR_WEBUI,
CONTENT_TYPE_BINARY,
CONTENT_TYPE_PNG,
CONTENT_TYPE_TEXT,
REQUEST_FROM, REQUEST_FROM,
AddonBoot, AddonBoot,
AddonState,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats from ..docker.stats import DockerStats
from ..exceptions import APIError, APIForbidden, PwnedError, PwnedSecret from ..exceptions import APIError
from ..validate import docker_ports from ..validate import docker_ports
from .const import ATTR_SIGNED, CONTENT_TYPE_BINARY from .utils import api_process, api_process_raw, api_validate
from .utils import api_process, api_process_raw, api_validate, json_loads
_LOGGER: logging.Logger = logging.getLogger(__name__) _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 # pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema( SCHEMA_OPTIONS = vol.Schema(
@@ -111,8 +114,8 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot), vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports), vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports),
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(), vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(), vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
} }
@@ -125,7 +128,7 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
class APIAddons(CoreSysAttributes): class APIAddons(CoreSysAttributes):
"""Handle RESTful API for add-on functions.""" """Handle RESTful API for add-on functions."""
def _extract_addon(self, request: web.Request) -> Addon: def _extract_addon(self, request: web.Request) -> AnyAddon:
"""Return addon, throw an exception it it doesn't exist.""" """Return addon, throw an exception it it doesn't exist."""
addon_slug: str = request.match_info.get("addon") addon_slug: str = request.match_info.get("addon")
@@ -139,13 +142,17 @@ class APIAddons(CoreSysAttributes):
addon = self.sys_addons.get(addon_slug) addon = self.sys_addons.get(addon_slug)
if not addon: if not addon:
raise APIError(f"Addon {addon_slug} does not exist") raise APIError(f"Addon {addon_slug} does not exist")
if not isinstance(addon, Addon) or not addon.is_installed:
raise APIError("Addon is not installed")
return addon return addon
def _extract_addon_installed(self, request: web.Request) -> Addon:
addon = self._extract_addon(request)
if not isinstance(addon, Addon) or not addon.is_installed:
raise APIError("Addon is not installed")
return addon
@api_process @api_process
async def list(self, request: web.Request) -> dict[str, Any]: async def list(self, request: web.Request) -> Dict[str, Any]:
"""Return all add-ons or repositories.""" """Return all add-ons or repositories."""
data_addons = [ data_addons = [
{ {
@@ -154,23 +161,30 @@ class APIAddons(CoreSysAttributes):
ATTR_DESCRIPTON: addon.description, ATTR_DESCRIPTON: addon.description,
ATTR_ADVANCED: addon.advanced, ATTR_ADVANCED: addon.advanced,
ATTR_STAGE: addon.stage, ATTR_STAGE: addon.stage,
ATTR_VERSION: addon.version, ATTR_VERSION: addon.latest_version,
ATTR_VERSION_LATEST: addon.latest_version, ATTR_INSTALLED: addon.version if addon.is_installed else None,
ATTR_UPDATE_AVAILABLE: addon.need_update,
ATTR_AVAILABLE: addon.available, ATTR_AVAILABLE: addon.available,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_STATE: addon.state,
ATTR_REPOSITORY: addon.repository, ATTR_REPOSITORY: addon.repository,
ATTR_BUILD: addon.need_build, ATTR_BUILD: addon.need_build,
ATTR_URL: addon.url, ATTR_URL: addon.url,
ATTR_ICON: addon.with_icon, ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo, ATTR_LOGO: addon.with_logo,
} }
for addon in self.sys_addons.installed for addon in self.sys_addons.all
] ]
return {ATTR_ADDONS: data_addons} data_repositories = [
{
ATTR_SLUG: repository.slug,
ATTR_NAME: repository.name,
ATTR_SOURCE: repository.source,
ATTR_URL: repository.url,
ATTR_MAINTAINER: repository.maintainer,
}
for repository in self.sys_store.all
]
return {ATTR_ADDONS: data_addons, ATTR_REPOSITORIES: data_repositories}
@api_process @api_process
async def reload(self, request: web.Request) -> None: async def reload(self, request: web.Request) -> None:
@@ -178,7 +192,7 @@ class APIAddons(CoreSysAttributes):
await asyncio.shield(self.sys_store.reload()) await asyncio.shield(self.sys_store.reload())
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return add-on information.""" """Return add-on information."""
addon: AnyAddon = self._extract_addon(request) addon: AnyAddon = self._extract_addon(request)
@@ -191,7 +205,9 @@ class APIAddons(CoreSysAttributes):
ATTR_LONG_DESCRIPTION: addon.long_description, ATTR_LONG_DESCRIPTION: addon.long_description,
ATTR_ADVANCED: addon.advanced, ATTR_ADVANCED: addon.advanced,
ATTR_STAGE: addon.stage, ATTR_STAGE: addon.stage,
ATTR_AUTO_UPDATE: None,
ATTR_REPOSITORY: addon.repository, ATTR_REPOSITORY: addon.repository,
ATTR_VERSION: None,
ATTR_VERSION_LATEST: addon.latest_version, ATTR_VERSION_LATEST: addon.latest_version,
ATTR_PROTECTED: addon.protected, ATTR_PROTECTED: addon.protected,
ATTR_RATING: rating_security(addon), ATTR_RATING: rating_security(addon),
@@ -202,6 +218,7 @@ class APIAddons(CoreSysAttributes):
ATTR_MACHINE: addon.supported_machine, ATTR_MACHINE: addon.supported_machine,
ATTR_HOMEASSISTANT: addon.homeassistant_version, ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_URL: addon.url, ATTR_URL: addon.url,
ATTR_STATE: AddonState.UNKNOWN,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_AVAILABLE: addon.available, ATTR_AVAILABLE: addon.available,
ATTR_BUILD: addon.need_build, ATTR_BUILD: addon.need_build,
@@ -214,60 +231,70 @@ class APIAddons(CoreSysAttributes):
ATTR_PRIVILEGED: addon.privileged, ATTR_PRIVILEGED: addon.privileged,
ATTR_FULL_ACCESS: addon.with_full_access, ATTR_FULL_ACCESS: addon.with_full_access,
ATTR_APPARMOR: addon.apparmor, ATTR_APPARMOR: addon.apparmor,
ATTR_DEVICES: _pretty_devices(addon),
ATTR_ICON: addon.with_icon, ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo, ATTR_LOGO: addon.with_logo,
ATTR_CHANGELOG: addon.with_changelog, ATTR_CHANGELOG: addon.with_changelog,
ATTR_DOCUMENTATION: addon.with_documentation, ATTR_DOCUMENTATION: addon.with_documentation,
ATTR_STDIN: addon.with_stdin, ATTR_STDIN: addon.with_stdin,
ATTR_WEBUI: None,
ATTR_HASSIO_API: addon.access_hassio_api, ATTR_HASSIO_API: addon.access_hassio_api,
ATTR_HASSIO_ROLE: addon.hassio_role, ATTR_HASSIO_ROLE: addon.hassio_role,
ATTR_AUTH_API: addon.access_auth_api, ATTR_AUTH_API: addon.access_auth_api,
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api, ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
ATTR_GPIO: addon.with_gpio, ATTR_GPIO: addon.with_gpio,
ATTR_USB: addon.with_usb, ATTR_USB: addon.with_usb,
ATTR_UART: addon.with_uart,
ATTR_KERNEL_MODULES: addon.with_kernel_modules, ATTR_KERNEL_MODULES: addon.with_kernel_modules,
ATTR_DEVICETREE: addon.with_devicetree, ATTR_DEVICETREE: addon.with_devicetree,
ATTR_UDEV: addon.with_udev, ATTR_UDEV: addon.with_udev,
ATTR_DOCKER_API: addon.access_docker_api, ATTR_DOCKER_API: addon.access_docker_api,
ATTR_VIDEO: addon.with_video, ATTR_VIDEO: addon.with_video,
ATTR_AUDIO: addon.with_audio, ATTR_AUDIO: addon.with_audio,
ATTR_AUDIO_INPUT: None,
ATTR_AUDIO_OUTPUT: None,
ATTR_STARTUP: addon.startup, ATTR_STARTUP: addon.startup,
ATTR_SERVICES: _pretty_services(addon), ATTR_SERVICES: _pretty_services(addon),
ATTR_DISCOVERY: addon.discovery, ATTR_DISCOVERY: addon.discovery,
ATTR_TRANSLATIONS: addon.translations, ATTR_IP_ADDRESS: None,
ATTR_INGRESS: addon.with_ingress, ATTR_INGRESS: addon.with_ingress,
ATTR_SIGNED: addon.signed, ATTR_INGRESS_ENTRY: None,
ATTR_STATE: addon.state, ATTR_INGRESS_URL: None,
ATTR_WEBUI: addon.webui, ATTR_INGRESS_PORT: None,
ATTR_INGRESS_ENTRY: addon.ingress_entry, ATTR_INGRESS_PANEL: None,
ATTR_INGRESS_URL: addon.ingress_url, ATTR_WATCHDOG: None,
ATTR_INGRESS_PORT: addon.ingress_port,
ATTR_INGRESS_PANEL: addon.ingress_panel,
ATTR_AUDIO_INPUT: addon.audio_input,
ATTR_AUDIO_OUTPUT: addon.audio_output,
ATTR_AUTO_UPDATE: addon.auto_update,
ATTR_IP_ADDRESS: str(addon.ip_address),
ATTR_VERSION: addon.version,
ATTR_UPDATE_AVAILABLE: addon.need_update,
ATTR_WATCHDOG: addon.watchdog,
ATTR_DEVICES: addon.static_devices
+ [device.path for device in addon.devices],
} }
if isinstance(addon, Addon) and addon.is_installed:
data.update(
{
ATTR_STATE: addon.state,
ATTR_WEBUI: addon.webui,
ATTR_INGRESS_ENTRY: addon.ingress_entry,
ATTR_INGRESS_URL: addon.ingress_url,
ATTR_INGRESS_PORT: addon.ingress_port,
ATTR_INGRESS_PANEL: addon.ingress_panel,
ATTR_AUDIO_INPUT: addon.audio_input,
ATTR_AUDIO_OUTPUT: addon.audio_output,
ATTR_AUTO_UPDATE: addon.auto_update,
ATTR_IP_ADDRESS: str(addon.ip_address),
ATTR_VERSION: addon.version,
ATTR_WATCHDOG: addon.watchdog,
}
)
return data return data
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Store user options for add-on.""" """Store user options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
# Update secrets for validation # Update secrets for validation
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
# Extend schema with add-on specific validation # Extend schema with add-on specific validation
addon_schema = SCHEMA_OPTIONS.extend( 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 # Validate/Process Body
@@ -295,62 +322,21 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def options_validate(self, request: web.Request) -> None: async def options_validate(self, request: web.Request) -> None:
"""Validate user options for add-on.""" """Validate user options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False} data = {ATTR_MESSAGE: "", ATTR_VALID: True}
options = await request.json(loads=json_loads) or addon.options
# Validate config
options_schema = addon.schema
try: try:
options_schema.validate(options) addon.schema(addon.options)
except vol.Invalid as ex: except vol.Invalid as ex:
data[ATTR_MESSAGE] = humanize_error(options, ex) data[ATTR_MESSAGE] = humanize_error(addon.options, ex)
data[ATTR_VALID] = False data[ATTR_VALID] = False
if not self.sys_security.pwned:
return data
# 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!"
return data return data
@api_process
async def options_config(self, request: web.Request) -> None:
"""Validate user options for add-on."""
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()
try:
return addon.schema.validate(addon.options)
except vol.Invalid:
raise APIError("Invalid configuration data for the add-on") from None
@api_process @api_process
async def security(self, request: web.Request) -> None: async def security(self, request: web.Request) -> None:
"""Store security options for add-on.""" """Store security options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request) body: Dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
if ATTR_PROTECTED in body: if ATTR_PROTECTED in body:
_LOGGER.warning("Changing protected flag for %s!", addon.slug) _LOGGER.warning("Changing protected flag for %s!", addon.slug)
@@ -359,9 +345,9 @@ class APIAddons(CoreSysAttributes):
addon.save_persist() addon.save_persist()
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
stats: DockerStats = await addon.stats() stats: DockerStats = await addon.stats()
@@ -376,46 +362,98 @@ class APIAddons(CoreSysAttributes):
ATTR_BLK_WRITE: stats.blk_write, ATTR_BLK_WRITE: stats.blk_write,
} }
@api_process
def install(self, request: web.Request) -> Awaitable[None]:
"""Install add-on."""
addon = self._extract_addon(request)
return asyncio.shield(addon.install())
@api_process @api_process
def uninstall(self, request: web.Request) -> Awaitable[None]: def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on.""" """Uninstall add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return asyncio.shield(addon.uninstall()) return asyncio.shield(addon.uninstall())
@api_process @api_process
def start(self, request: web.Request) -> Awaitable[None]: def start(self, request: web.Request) -> Awaitable[None]:
"""Start add-on.""" """Start add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return asyncio.shield(addon.start()) return asyncio.shield(addon.start())
@api_process @api_process
def stop(self, request: web.Request) -> Awaitable[None]: def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop add-on.""" """Stop add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return asyncio.shield(addon.stop()) return asyncio.shield(addon.stop())
@api_process
def update(self, request: web.Request) -> Awaitable[None]:
"""Update add-on."""
addon: Addon = self._extract_addon_installed(request)
return asyncio.shield(addon.update())
@api_process @api_process
def restart(self, request: web.Request) -> Awaitable[None]: def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart add-on.""" """Restart add-on."""
addon: Addon = self._extract_addon(request) addon: Addon = self._extract_addon_installed(request)
return asyncio.shield(addon.restart()) return asyncio.shield(addon.restart())
@api_process @api_process
def rebuild(self, request: web.Request) -> Awaitable[None]: def rebuild(self, request: web.Request) -> Awaitable[None]:
"""Rebuild local build add-on.""" """Rebuild local build add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return asyncio.shield(addon.rebuild()) return asyncio.shield(addon.rebuild())
@api_process_raw(CONTENT_TYPE_BINARY) @api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]: def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return logs from add-on.""" """Return logs from add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
return addon.logs() return addon.logs()
@api_process_raw(CONTENT_TYPE_PNG)
async def icon(self, request: web.Request) -> bytes:
"""Return icon from add-on."""
addon = self._extract_addon(request)
if not addon.with_icon:
raise APIError(f"No icon found for add-on {addon.slug}!")
with addon.path_icon.open("rb") as png:
return png.read()
@api_process_raw(CONTENT_TYPE_PNG)
async def logo(self, request: web.Request) -> bytes:
"""Return logo from add-on."""
addon = self._extract_addon(request)
if not addon.with_logo:
raise APIError(f"No logo found for add-on {addon.slug}!")
with addon.path_logo.open("rb") as png:
return png.read()
@api_process_raw(CONTENT_TYPE_TEXT)
async def changelog(self, request: web.Request) -> str:
"""Return changelog from add-on."""
addon = self._extract_addon(request)
if not addon.with_changelog:
raise APIError(f"No changelog found for add-on {addon.slug}!")
with addon.path_changelog.open("r") as changelog:
return changelog.read()
@api_process_raw(CONTENT_TYPE_TEXT)
async def documentation(self, request: web.Request) -> str:
"""Return documentation from add-on."""
addon = self._extract_addon(request)
if not addon.with_documentation:
raise APIError(f"No documentation found for add-on {addon.slug}!")
with addon.path_documentation.open("r") as documentation:
return documentation.read()
@api_process @api_process
async def stdin(self, request: web.Request) -> None: async def stdin(self, request: web.Request) -> None:
"""Write to stdin of add-on.""" """Write to stdin of add-on."""
addon = self._extract_addon(request) addon = self._extract_addon_installed(request)
if not addon.with_stdin: if not addon.with_stdin:
raise APIError(f"STDIN not supported the {addon.slug} add-on") raise APIError(f"STDIN not supported the {addon.slug} add-on")
@@ -423,6 +461,14 @@ class APIAddons(CoreSysAttributes):
await asyncio.shield(addon.write_stdin(data)) await asyncio.shield(addon.write_stdin(data))
def _pretty_services(addon: Addon) -> list[str]: def _pretty_devices(addon: AnyAddon) -> List[str]:
"""Return a simplified device list."""
dev_list = addon.devices
if not dev_list:
return []
return [row.split(":")[0] for row in dev_list]
def _pretty_services(addon: AnyAddon) -> List[str]:
"""Return a simplified services role list.""" """Return a simplified services role list."""
return [f"{name}:{access}" for name, access in addon.services_role.items()] return [f"{name}:{access}" for name, access in addon.services_role.items()]

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor Audio RESTful API.""" """Init file for Supervisor Audio RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable from typing import Any, Awaitable, Dict
from aiohttp import web from aiohttp import web
import attr import attr
@@ -25,16 +25,15 @@ from ..const import (
ATTR_NETWORK_RX, ATTR_NETWORK_RX,
ATTR_NETWORK_TX, ATTR_NETWORK_TX,
ATTR_OUTPUT, ATTR_OUTPUT,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
ATTR_VOLUME, ATTR_VOLUME,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..host.sound import StreamType from ..host.sound import StreamType
from ..validate import version_tag from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -56,10 +55,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( 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)}
) )
@@ -67,12 +66,11 @@ class APIAudio(CoreSysAttributes):
"""Handle RESTful API for Audio functions.""" """Handle RESTful API for Audio functions."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Audio information.""" """Return Audio information."""
return { return {
ATTR_VERSION: self.sys_plugins.audio.version, ATTR_VERSION: self.sys_plugins.audio.version,
ATTR_VERSION_LATEST: self.sys_plugins.audio.latest_version, ATTR_VERSION_LATEST: self.sys_plugins.audio.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_plugins.audio.need_update,
ATTR_HOST: str(self.sys_docker.network.audio), ATTR_HOST: str(self.sys_docker.network.audio),
ATTR_AUDIO: { ATTR_AUDIO: {
ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards], ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards],
@@ -89,7 +87,7 @@ class APIAudio(CoreSysAttributes):
} }
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_plugins.audio.stats() stats = await self.sys_plugins.audio.stats()

View File

@@ -1,6 +1,7 @@
"""Init file for Supervisor auth/SSO RESTful API.""" """Init file for Supervisor auth/SSO RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Dict
from aiohttp import BasicAuth, web from aiohttp import BasicAuth, web
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
@@ -8,25 +9,26 @@ from aiohttp.web_exceptions import HTTPUnauthorized
import voluptuous as vol import voluptuous as vol
from ..addons.addon import Addon from ..addons.addon import Addon
from ..const import ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM from ..const import (
ATTR_PASSWORD,
ATTR_USERNAME,
CONTENT_TYPE_JSON,
CONTENT_TYPE_URL,
REQUEST_FROM,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIForbidden from ..exceptions import APIForbidden
from .const import CONTENT_TYPE_JSON, CONTENT_TYPE_URL
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_PASSWORD_RESET = vol.Schema( SCHEMA_PASSWORD_RESET = vol.Schema(
{ {
vol.Required(ATTR_USERNAME): str, vol.Required(ATTR_USERNAME): vol.Coerce(str),
vol.Required(ATTR_PASSWORD): str, vol.Required(ATTR_PASSWORD): vol.Coerce(str),
} }
) )
REALM_HEADER: dict[str, str] = {
WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'
}
class APIAuth(CoreSysAttributes): class APIAuth(CoreSysAttributes):
"""Handle RESTful API for auth functions.""" """Handle RESTful API for auth functions."""
@@ -40,7 +42,7 @@ class APIAuth(CoreSysAttributes):
return self.sys_auth.check_login(addon, auth.login, auth.password) return self.sys_auth.check_login(addon, auth.login, auth.password)
def _process_dict( def _process_dict(
self, request: web.Request, addon: Addon, data: dict[str, str] self, request: web.Request, addon: Addon, data: Dict[str, str]
) -> bool: ) -> bool:
"""Process login with dict data. """Process login with dict data.
@@ -61,9 +63,7 @@ class APIAuth(CoreSysAttributes):
# BasicAuth # BasicAuth
if AUTHORIZATION in request.headers: if AUTHORIZATION in request.headers:
if not await self._process_basic(request, addon): return await self._process_basic(request, addon)
raise HTTPUnauthorized(headers=REALM_HEADER)
return True
# Json # Json
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON: if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON:
@@ -75,12 +75,14 @@ class APIAuth(CoreSysAttributes):
data = await request.post() data = await request.post()
return await self._process_dict(request, addon, data) return await self._process_dict(request, addon, data)
raise HTTPUnauthorized(headers=REALM_HEADER) raise HTTPUnauthorized(
headers={WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'}
)
@api_process @api_process
async def reset(self, request: web.Request) -> None: async def reset(self, request: web.Request) -> None:
"""Process reset password request.""" """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( await asyncio.shield(
self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD]) self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD])
) )

View File

@@ -1,226 +0,0 @@
"""Backups RESTful API."""
import asyncio
import logging
from pathlib import Path
import re
from tempfile import TemporaryDirectory
from aiohttp import web
from aiohttp.hdrs import CONTENT_DISPOSITION
import voluptuous as vol
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT
from ..const import (
ATTR_ADDONS,
ATTR_BACKUPS,
ATTR_COMPRESSED,
ATTR_CONTENT,
ATTR_DATE,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_NAME,
ATTR_PASSWORD,
ATTR_PROTECTED,
ATTR_REPOSITORIES,
ATTR_SIZE,
ATTR_SLUG,
ATTR_TYPE,
ATTR_VERSION,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
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()),
}
)
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(),
}
)
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
@api_process
async def list(self, request):
"""Return backup list."""
data_backups = []
for backup in self.sys_backups.list_backups:
data_backups.append(
{
ATTR_SLUG: backup.slug,
ATTR_NAME: backup.name,
ATTR_DATE: backup.date,
ATTR_TYPE: backup.sys_type,
ATTR_SIZE: backup.size,
ATTR_PROTECTED: backup.protected,
ATTR_COMPRESSED: backup.compressed,
ATTR_CONTENT: {
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
ATTR_ADDONS: backup.addon_list,
ATTR_FOLDERS: backup.folders,
},
}
)
if request.path == "/snapshots":
# Kept for backwards compability
return {"snapshots": data_backups}
return {ATTR_BACKUPS: data_backups}
@api_process
async def reload(self, request):
"""Reload backup list."""
await asyncio.shield(self.sys_backups.reload())
return True
@api_process
async def 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_HOMEASSISTANT: backup.homeassistant_version,
ATTR_ADDONS: data_addons,
ATTR_REPOSITORIES: backup.repositories,
ATTR_FOLDERS: backup.folders,
}
@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(**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(**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.""" """Init file for Supervisor HA cli RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any from typing import Any, Dict
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -15,7 +15,6 @@ from ..const import (
ATTR_MEMORY_USAGE, ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX, ATTR_NETWORK_RX,
ATTR_NETWORK_TX, ATTR_NETWORK_TX,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
) )
@@ -32,16 +31,15 @@ class APICli(CoreSysAttributes):
"""Handle RESTful API for HA Cli functions.""" """Handle RESTful API for HA Cli functions."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HA cli information.""" """Return HA cli information."""
return { return {
ATTR_VERSION: self.sys_plugins.cli.version, ATTR_VERSION: self.sys_plugins.cli.version,
ATTR_VERSION_LATEST: self.sys_plugins.cli.latest_version, ATTR_VERSION_LATEST: self.sys_plugins.cli.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_plugins.cli.need_update,
} }
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_plugins.cli.stats() stats = await self.sys_plugins.cli.stats()

View File

@@ -1,39 +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"
HEADER_TOKEN_OLD = "X-Hassio-Key"
HEADER_TOKEN = "X-Supervisor-Token"
ATTR_APPARMOR_VERSION = "apparmor_version"
ATTR_AGENT_VERSION = "agent_version"
ATTR_AVAILABLE_UPDATES = "available_updates"
ATTR_BOOT_TIMESTAMP = "boot_timestamp"
ATTR_BROADCAST_LLMNR = "broadcast_llmnr"
ATTR_BROADCAST_MDNS = "broadcast_mdns"
ATTR_DATA_DISK = "data_disk"
ATTR_DEVICE = "device"
ATTR_DT_SYNCHRONIZED = "dt_synchronized"
ATTR_DT_UTC = "dt_utc"
ATTR_FALLBACK = "fallback"
ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_MDNS = "mdns"
ATTR_PANEL_PATH = "panel_path"
ATTR_SIGNED = "signed"
ATTR_STARTUP_TIME = "startup_time"
ATTR_UPDATE_TYPE = "update_type"
ATTR_USE_NTP = "use_ntp"
ATTR_BY_ID = "by_id"
ATTR_SUBSYSTEM = "subsystem"
ATTR_SYSFS = "sysfs"
ATTR_DEV_PATH = "dev_path"
ATTR_ATTRIBUTES = "attributes"
ATTR_CHILDREN = "children"

View File

@@ -13,7 +13,7 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..discovery.validate import valid_discovery_service from ..discovery.validate import valid_discovery_service
from ..exceptions import APIError, APIForbidden from ..exceptions import APIError, APIForbidden
from .utils import api_process, api_validate, require_home_assistant from .utils import api_process, api_validate
SCHEMA_DISCOVERY = vol.Schema( SCHEMA_DISCOVERY = vol.Schema(
{ {
@@ -33,10 +33,15 @@ class APIDiscovery(CoreSysAttributes):
raise APIError("Discovery message not found") raise APIError("Discovery message not found")
return message return message
def _check_permission_ha(self, request):
"""Check permission for API call / Home Assistant."""
if request[REQUEST_FROM] != self.sys_homeassistant:
raise APIForbidden("Only HomeAssistant can use this API!")
@api_process @api_process
@require_home_assistant
async def list(self, request): async def list(self, request):
"""Show register services.""" """Show register services."""
self._check_permission_ha(request)
# Get available discovery # Get available discovery
discovery = [] discovery = []
@@ -74,11 +79,13 @@ class APIDiscovery(CoreSysAttributes):
return {ATTR_UUID: message.uuid} return {ATTR_UUID: message.uuid}
@api_process @api_process
@require_home_assistant
async def get_discovery(self, request): async def get_discovery(self, request):
"""Read data into a discovery message.""" """Read data into a discovery message."""
message = self._extract_message(request) message = self._extract_message(request)
# HomeAssistant?
self._check_permission_ha(request)
return { return {
ATTR_ADDON: message.addon, ATTR_ADDON: message.addon,
ATTR_SERVICE: message.service, ATTR_SERVICE: message.service,

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor DNS RESTful API.""" """Init file for Supervisor DNS RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable from typing import Any, Awaitable, Dict
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -18,25 +18,19 @@ from ..const import (
ATTR_NETWORK_RX, ATTR_NETWORK_RX,
ATTR_NETWORK_TX, ATTR_NETWORK_TX,
ATTR_SERVERS, ATTR_SERVERS,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import dns_server_list, version_tag from ..validate import dns_server_list, version_tag
from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS, CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema( SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): dns_server_list})
{
vol.Optional(ATTR_SERVERS): dns_server_list,
vol.Optional(ATTR_FALLBACK): vol.Boolean(),
}
)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
@@ -45,41 +39,29 @@ class APICoreDNS(CoreSysAttributes):
"""Handle RESTful API for DNS functions.""" """Handle RESTful API for DNS functions."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return DNS information.""" """Return DNS information."""
return { return {
ATTR_VERSION: self.sys_plugins.dns.version, ATTR_VERSION: self.sys_plugins.dns.version,
ATTR_VERSION_LATEST: self.sys_plugins.dns.latest_version, ATTR_VERSION_LATEST: self.sys_plugins.dns.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_plugins.dns.need_update,
ATTR_HOST: str(self.sys_docker.network.dns), ATTR_HOST: str(self.sys_docker.network.dns),
ATTR_SERVERS: self.sys_plugins.dns.servers, ATTR_SERVERS: self.sys_plugins.dns.servers,
ATTR_LOCALS: self.sys_plugins.dns.locals, ATTR_LOCALS: self.sys_host.network.dns_servers,
ATTR_MDNS: self.sys_plugins.dns.mdns,
ATTR_LLMNR: self.sys_plugins.dns.llmnr,
ATTR_FALLBACK: self.sys_plugins.dns.fallback,
} }
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Set DNS options.""" """Set DNS options."""
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(SCHEMA_OPTIONS, request)
restart_required = False
if ATTR_SERVERS in body: if ATTR_SERVERS in body:
self.sys_plugins.dns.servers = body[ATTR_SERVERS] self.sys_plugins.dns.servers = body[ATTR_SERVERS]
restart_required = True
if ATTR_FALLBACK in body:
self.sys_plugins.dns.fallback = body[ATTR_FALLBACK]
restart_required = True
if restart_required:
self.sys_create_task(self.sys_plugins.dns.restart()) self.sys_create_task(self.sys_plugins.dns.restart())
self.sys_plugins.dns.save_data() self.sys_plugins.dns.save_data()
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_plugins.dns.stats() stats = await self.sys_plugins.dns.stats()

View File

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

View File

@@ -1,52 +1,54 @@
"""Init file for Supervisor hardware RESTful API.""" """Init file for Supervisor hardware RESTful API."""
import asyncio
import logging import logging
from typing import Any from typing import Any, Awaitable, Dict, List
from aiohttp import web from aiohttp import web
from ..const import ATTR_AUDIO, ATTR_DEVICES, ATTR_INPUT, ATTR_NAME, ATTR_OUTPUT from ..const import (
from ..coresys import CoreSysAttributes ATTR_AUDIO,
from ..hardware.data import Device ATTR_DISK,
from .const import ( ATTR_GPIO,
ATTR_ATTRIBUTES, ATTR_INPUT,
ATTR_BY_ID, ATTR_OUTPUT,
ATTR_CHILDREN, ATTR_SERIAL,
ATTR_DEV_PATH, ATTR_USB,
ATTR_SUBSYSTEM,
ATTR_SYSFS,
) )
from ..coresys import CoreSysAttributes
from .utils import api_process from .utils import api_process
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
def device_struct(device: Device) -> dict[str, Any]:
"""Return a dict with information of a interface to be used in th API."""
return {
ATTR_NAME: device.name,
ATTR_SYSFS: device.sysfs,
ATTR_DEV_PATH: device.path,
ATTR_SUBSYSTEM: device.subsystem,
ATTR_BY_ID: device.by_id,
ATTR_ATTRIBUTES: device.attributes,
ATTR_CHILDREN: device.children,
}
class APIHardware(CoreSysAttributes): class APIHardware(CoreSysAttributes):
"""Handle RESTful API for hardware functions.""" """Handle RESTful API for hardware functions."""
@api_process @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.""" """Show hardware info."""
serial: List[str] = []
# Create Serial list with device links
for device in self.sys_hardware.serial_devices:
serial.append(device.path.as_posix())
for link in device.links:
serial.append(link.as_posix())
return { return {
ATTR_DEVICES: [ ATTR_SERIAL: serial,
device_struct(device) for device in self.sys_hardware.devices ATTR_INPUT: list(self.sys_hardware.input_devices),
] ATTR_DISK: [
device.path.as_posix() for device in self.sys_hardware.disk_devices
],
ATTR_GPIO: list(self.sys_hardware.gpio_devices),
ATTR_USB: [
device.path.as_posix() for device in self.sys_hardware.usb_devices
],
ATTR_AUDIO: self.sys_hardware.audio_devices,
} }
@api_process @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.""" """Show pulse audio profiles."""
return { return {
ATTR_AUDIO: { ATTR_AUDIO: {
@@ -60,3 +62,8 @@ class APIHardware(CoreSysAttributes):
}, },
} }
} }
@api_process
def trigger(self, request: web.Request) -> Awaitable[None]:
"""Trigger a udev device reload."""
return asyncio.shield(self.sys_hardware.udev_trigger())

View File

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

View File

@@ -11,7 +11,6 @@ from ..const import (
ATTR_DEPLOYMENT, ATTR_DEPLOYMENT,
ATTR_DESCRIPTON, ATTR_DESCRIPTON,
ATTR_DISK_FREE, ATTR_DISK_FREE,
ATTR_DISK_LIFE_TIME,
ATTR_DISK_TOTAL, ATTR_DISK_TOTAL,
ATTR_DISK_USED, ATTR_DISK_USED,
ATTR_FEATURES, ATTR_FEATURES,
@@ -21,27 +20,14 @@ from ..const import (
ATTR_OPERATING_SYSTEM, ATTR_OPERATING_SYSTEM,
ATTR_SERVICES, ATTR_SERVICES,
ATTR_STATE, ATTR_STATE,
ATTR_TIMEZONE,
)
from ..coresys import CoreSysAttributes
from .const import (
ATTR_AGENT_VERSION,
ATTR_APPARMOR_VERSION,
ATTR_BOOT_TIMESTAMP,
ATTR_BROADCAST_LLMNR,
ATTR_BROADCAST_MDNS,
ATTR_DT_SYNCHRONIZED,
ATTR_DT_UTC,
ATTR_LLMNR_HOSTNAME,
ATTR_STARTUP_TIME,
ATTR_USE_NTP,
CONTENT_TYPE_BINARY, CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
SERVICE = "service" SERVICE = "service"
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str}) SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): vol.Coerce(str)})
class APIHost(CoreSysAttributes): class APIHost(CoreSysAttributes):
@@ -51,28 +37,16 @@ class APIHost(CoreSysAttributes):
async def info(self, request): async def info(self, request):
"""Return host information.""" """Return host information."""
return { 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_CHASSIS: self.sys_host.info.chassis,
ATTR_CPE: self.sys_host.info.cpe, ATTR_CPE: self.sys_host.info.cpe,
ATTR_DEPLOYMENT: self.sys_host.info.deployment, ATTR_DEPLOYMENT: self.sys_host.info.deployment,
ATTR_DISK_FREE: self.sys_host.info.free_space, ATTR_DISK_FREE: self.sys_host.info.free_space,
ATTR_DISK_TOTAL: self.sys_host.info.total_space, ATTR_DISK_TOTAL: self.sys_host.info.total_space,
ATTR_DISK_USED: self.sys_host.info.used_space, ATTR_DISK_USED: self.sys_host.info.used_space,
ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time, ATTR_FEATURES: self.sys_host.supported_features,
ATTR_FEATURES: self.sys_host.features,
ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,
ATTR_KERNEL: self.sys_host.info.kernel, ATTR_KERNEL: self.sys_host.info.kernel,
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
ATTR_TIMEZONE: self.sys_host.info.timezone,
ATTR_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 @api_process

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

@@ -0,0 +1,50 @@
"""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_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.supported_features,
ATTR_MACHINE: self.sys_machine,
ATTR_ARCH: self.sys_arch.default,
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,17 +2,16 @@
import asyncio import asyncio
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
from typing import Any, Union from typing import Any, Dict, Union
import aiohttp import aiohttp
from aiohttp import ClientTimeout, hdrs, web from aiohttp import hdrs, web
from aiohttp.web_exceptions import ( from aiohttp.web_exceptions import (
HTTPBadGateway, HTTPBadGateway,
HTTPServiceUnavailable, HTTPServiceUnavailable,
HTTPUnauthorized, HTTPUnauthorized,
) )
from multidict import CIMultiDict, istr from multidict import CIMultiDict, istr
import voluptuous as vol
from ..addons.addon import Addon from ..addons.addon import Addon
from ..const import ( from ..const import (
@@ -22,15 +21,16 @@ from ..const import (
ATTR_PANELS, ATTR_PANELS,
ATTR_SESSION, ATTR_SESSION,
ATTR_TITLE, ATTR_TITLE,
COOKIE_INGRESS,
HEADER_TOKEN,
HEADER_TOKEN_OLD,
REQUEST_FROM,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from .const import COOKIE_INGRESS, HEADER_TOKEN, HEADER_TOKEN_OLD from .utils import api_process
from .utils import api_process, api_validate, require_home_assistant
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str})
class APIIngress(CoreSysAttributes): class APIIngress(CoreSysAttributes):
"""Ingress view to handle add-on webui routing.""" """Ingress view to handle add-on webui routing."""
@@ -47,12 +47,17 @@ class APIIngress(CoreSysAttributes):
return addon return addon
def _check_ha_access(self, request: web.Request) -> None:
if request[REQUEST_FROM] != self.sys_homeassistant:
_LOGGER.warning("Ingress is only available behind Home Assistant")
raise HTTPUnauthorized()
def _create_url(self, addon: Addon, path: str) -> str: def _create_url(self, addon: Addon, path: str) -> str:
"""Create URL to container.""" """Create URL to container."""
return f"http://{addon.ip_address}:{addon.ingress_port}/{path}" return f"http://{addon.ip_address}:{addon.ingress_port}/{path}"
@api_process @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.""" """Create a list of panel data."""
addons = {} addons = {}
for addon in self.sys_ingress.addons: for addon in self.sys_ingress.addons:
@@ -66,28 +71,18 @@ class APIIngress(CoreSysAttributes):
return {ATTR_PANELS: addons} return {ATTR_PANELS: addons}
@api_process @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.""" """Create a new session."""
self._check_ha_access(request)
session = self.sys_ingress.create_session() session = self.sys_ingress.create_session()
return {ATTR_SESSION: session} return {ATTR_SESSION: session}
@api_process
@require_home_assistant
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)
# Check Ingress Session
if not self.sys_ingress.validate_session(data[ATTR_SESSION]):
_LOGGER.warning("No valid ingress session %s", data[ATTR_SESSION])
raise HTTPUnauthorized()
@require_home_assistant
async def handler( async def handler(
self, request: web.Request self, request: web.Request
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]: ) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
"""Route data to Supervisor ingress service.""" """Route data to Supervisor ingress service."""
self._check_ha_access(request)
# Check Ingress Session # Check Ingress Session
session = request.cookies.get(COOKIE_INGRESS) session = request.cookies.get(COOKIE_INGRESS)
@@ -160,18 +155,9 @@ class APIIngress(CoreSysAttributes):
) -> Union[web.Response, web.StreamResponse]: ) -> Union[web.Response, web.StreamResponse]:
"""Ingress route for request.""" """Ingress route for request."""
url = self._create_url(addon, path) url = self._create_url(addon, path)
data = await request.read()
source_header = _init_header(request, addon) 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( async with self.sys_websession.request(
request.method, request.method,
url, url,
@@ -179,7 +165,6 @@ class APIIngress(CoreSysAttributes):
params=request.query, params=request.query,
allow_redirects=False, allow_redirects=False,
data=data, data=data,
timeout=ClientTimeout(total=None),
) as result: ) as result:
headers = _response_header(result) headers = _response_header(result)
@@ -218,7 +203,7 @@ class APIIngress(CoreSysAttributes):
def _init_header( def _init_header(
request: web.Request, addon: str request: web.Request, addon: str
) -> Union[CIMultiDict, dict[str, str]]: ) -> Union[CIMultiDict, Dict[str, str]]:
"""Create initial header.""" """Create initial header."""
headers = {} headers = {}
@@ -227,7 +212,6 @@ def _init_header(
if name in ( if name in (
hdrs.CONTENT_LENGTH, hdrs.CONTENT_LENGTH,
hdrs.CONTENT_ENCODING, hdrs.CONTENT_ENCODING,
hdrs.TRANSFER_ENCODING,
hdrs.SEC_WEBSOCKET_EXTENSIONS, hdrs.SEC_WEBSOCKET_EXTENSIONS,
hdrs.SEC_WEBSOCKET_PROTOCOL, hdrs.SEC_WEBSOCKET_PROTOCOL,
hdrs.SEC_WEBSOCKET_VERSION, hdrs.SEC_WEBSOCKET_VERSION,
@@ -246,7 +230,7 @@ def _init_header(
return headers return headers
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
"""Create response header.""" """Create response header."""
headers = {} headers = {}

View File

@@ -1,44 +0,0 @@
"""Init file for Supervisor Jobs RESTful API."""
import logging
from typing import Any
from aiohttp import web
import voluptuous as vol
from ..coresys import CoreSysAttributes
from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_OPTIONS = vol.Schema(
{vol.Optional(ATTR_IGNORE_CONDITIONS): [vol.Coerce(JobCondition)]}
)
class APIJobs(CoreSysAttributes):
"""Handle RESTful API for OS functions."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
"""Return JobManager information."""
return {
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,
}
@api_process
async def options(self, request: web.Request) -> None:
"""Set options for JobManager."""
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_IGNORE_CONDITIONS in body:
self.sys_jobs.ignore_conditions = body[ATTR_IGNORE_CONDITIONS]
self.sys_jobs.save_data()
await self.sys_resolution.evaluate.evaluate_system()
@api_process
async def reset(self, request: web.Request) -> None:
"""Reset options for JobManager."""
self.sys_jobs.reset_data()

View File

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

View File

@@ -1,207 +0,0 @@
"""Handle security part of this API."""
import logging
import re
from aiohttp.web import Request, RequestHandler, Response, middleware
from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
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__)
# fmt: off
# Block Anytime
BLACKLIST = re.compile(
r"^(?:"
r"|/homeassistant/api/hassio/.*"
r"|/core/api/hassio/.*"
r")$"
)
# Free to call or have own security concepts
NO_SECURITY_CHECK = re.compile(
r"^(?:"
r"|/homeassistant/api/.*"
r"|/homeassistant/websocket"
r"|/core/api/.*"
r"|/core/websocket"
r"|/supervisor/ping"
r")$"
)
# Observer allow API calls
OBSERVER_CHECK = re.compile(
r"^(?:"
r"|/.+/info"
r")$"
)
# Can called by every add-on
ADDONS_API_BYPASS = 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 = {
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(?:/[^/]+/(?!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".*"
),
}
# fmt: on
class SecurityMiddleware(CoreSysAttributes):
"""Security middleware functions."""
def __init__(self, coresys: CoreSys):
"""Initialize security middleware."""
self.coresys: CoreSys = coresys
@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)
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()

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor Multicast RESTful API.""" """Init file for Supervisor Multicast RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable from typing import Any, Awaitable, Dict
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -15,14 +15,13 @@ from ..const import (
ATTR_MEMORY_USAGE, ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX, ATTR_NETWORK_RX,
ATTR_NETWORK_TX, ATTR_NETWORK_TX,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import version_tag from ..validate import version_tag
from .const import CONTENT_TYPE_BINARY
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -34,16 +33,15 @@ class APIMulticast(CoreSysAttributes):
"""Handle RESTful API for Multicast functions.""" """Handle RESTful API for Multicast functions."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Multicast information.""" """Return Multicast information."""
return { return {
ATTR_VERSION: self.sys_plugins.multicast.version, ATTR_VERSION: self.sys_plugins.multicast.version,
ATTR_VERSION_LATEST: self.sys_plugins.multicast.latest_version, ATTR_VERSION_LATEST: self.sys_plugins.multicast.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_plugins.multicast.need_update,
} }
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_plugins.multicast.stats() stats = await self.sys_plugins.multicast.stats()

View File

@@ -1,280 +1,110 @@
"""REST API for network.""" """REST API for network."""
import asyncio import asyncio
from ipaddress import ip_address, ip_interface from typing import Any, Dict
from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import attr
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ATTR_ACCESSPOINTS,
ATTR_ADDRESS, ATTR_ADDRESS,
ATTR_AUTH,
ATTR_CONNECTED,
ATTR_DNS, ATTR_DNS,
ATTR_DOCKER,
ATTR_ENABLED,
ATTR_FREQUENCY,
ATTR_GATEWAY, ATTR_GATEWAY,
ATTR_HOST_INTERNET,
ATTR_ID, ATTR_ID,
ATTR_INTERFACE, ATTR_INTERFACE,
ATTR_INTERFACES, ATTR_INTERFACES,
ATTR_IPV4, ATTR_IP_ADDRESS,
ATTR_IPV6,
ATTR_MAC,
ATTR_METHOD, ATTR_METHOD,
ATTR_MODE, ATTR_METHODS,
ATTR_NAMESERVERS, ATTR_NAMESERVERS,
ATTR_PARENT,
ATTR_PRIMARY, ATTR_PRIMARY,
ATTR_PSK,
ATTR_SIGNAL,
ATTR_SSID,
ATTR_SUPERVISOR_INTERNET,
ATTR_TYPE, ATTR_TYPE,
ATTR_VLAN,
ATTR_WIFI,
DOCKER_NETWORK,
DOCKER_NETWORK_MASK,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HostNetworkNotFound from ..dbus.const import InterfaceMethodSimple
from ..host.const import AuthMethod, InterfaceType, WifiMode from ..dbus.network.interface import NetworkInterface
from ..host.network import ( from ..dbus.network.utils import int2ip
AccessPoint, from ..exceptions import APIError
Interface,
InterfaceMethod,
IpConfig,
VlanConfig,
WifiConfig,
)
from .utils import api_process, api_validate from .utils import api_process, api_validate
_SCHEMA_IP_CONFIG = vol.Schema(
{
vol.Optional(ATTR_ADDRESS): [vol.Coerce(ip_interface)],
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
vol.Optional(ATTR_GATEWAY): vol.Coerce(ip_address),
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(ip_address)],
}
)
_SCHEMA_WIFI_CONFIG = vol.Schema(
{
vol.Optional(ATTR_MODE): vol.Coerce(WifiMode),
vol.Optional(ATTR_AUTH): vol.Coerce(AuthMethod),
vol.Optional(ATTR_SSID): str,
vol.Optional(ATTR_PSK): str,
}
)
# pylint: disable=no-value-for-parameter
SCHEMA_UPDATE = vol.Schema( SCHEMA_UPDATE = vol.Schema(
{ {
vol.Optional(ATTR_IPV4): _SCHEMA_IP_CONFIG, vol.Optional(ATTR_ADDRESS): vol.Coerce(str),
vol.Optional(ATTR_IPV6): _SCHEMA_IP_CONFIG, vol.Optional(ATTR_METHOD): vol.In(ATTR_METHODS),
vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG, vol.Optional(ATTR_GATEWAY): vol.Coerce(str),
vol.Optional(ATTR_ENABLED): vol.Boolean(), vol.Optional(ATTR_DNS): [str],
} }
) )
def ipconfig_struct(config: IpConfig) -> dict[str, Any]: def interface_information(interface: NetworkInterface) -> dict:
"""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,
}
def wifi_struct(config: WifiConfig) -> dict[str, Any]:
"""Return a dict with information about wifi configuration."""
return {
ATTR_MODE: config.mode,
ATTR_AUTH: config.auth,
ATTR_SSID: config.ssid,
ATTR_SIGNAL: config.signal,
}
def vlan_struct(config: VlanConfig) -> dict[str, Any]:
"""Return a dict with information about VLAN configuration."""
return {
ATTR_ID: config.id,
ATTR_PARENT: config.interface,
}
def interface_struct(interface: Interface) -> dict[str, Any]:
"""Return a dict with information of a interface to be used in th API.""" """Return a dict with information of a interface to be used in th API."""
return { return {
ATTR_INTERFACE: interface.name, ATTR_INTERFACE: interface.name,
ATTR_IP_ADDRESS: f"{interface.ip_address}/{interface.prefix}",
ATTR_GATEWAY: interface.gateway,
ATTR_ID: interface.id,
ATTR_TYPE: interface.type, ATTR_TYPE: interface.type,
ATTR_ENABLED: interface.enabled, ATTR_NAMESERVERS: [int2ip(x) for x in interface.nameservers],
ATTR_CONNECTED: interface.connected, ATTR_METHOD: InterfaceMethodSimple.DHCP
if interface.method == "auto"
else InterfaceMethodSimple.STATIC,
ATTR_PRIMARY: interface.primary, ATTR_PRIMARY: interface.primary,
ATTR_IPV4: ipconfig_struct(interface.ipv4) if interface.ipv4 else None,
ATTR_IPV6: ipconfig_struct(interface.ipv6) if interface.ipv6 else None,
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None,
}
def accesspoint_struct(accesspoint: AccessPoint) -> dict[str, Any]:
"""Return a dict for AccessPoint."""
return {
ATTR_MODE: accesspoint.mode,
ATTR_SSID: accesspoint.ssid,
ATTR_FREQUENCY: accesspoint.frequency,
ATTR_SIGNAL: accesspoint.signal,
ATTR_MAC: accesspoint.mac,
} }
class APINetwork(CoreSysAttributes): class APINetwork(CoreSysAttributes):
"""Handle REST API for network.""" """Handle REST API for network."""
def _get_interface(self, name: str) -> Interface: @api_process
"""Get Interface by name or default.""" async def info(self, request: web.Request) -> Dict[str, Any]:
if name.lower() == "default": """Return network information."""
interfaces = {}
for interface in self.sys_host.network.interfaces:
interfaces[
self.sys_host.network.interfaces[interface].name
] = interface_information(self.sys_host.network.interfaces[interface])
return {ATTR_INTERFACES: interfaces}
@api_process
async def interface_info(self, request: web.Request) -> Dict[str, Any]:
"""Return network information for a interface."""
req_interface = request.match_info.get(ATTR_INTERFACE)
if req_interface.lower() == "default":
for interface in self.sys_host.network.interfaces: for interface in self.sys_host.network.interfaces:
if not interface.primary: if not self.sys_host.network.interfaces[interface].primary:
continue continue
return interface return interface_information(
self.sys_host.network.interfaces[interface]
)
else: else:
try: for interface in self.sys_host.network.interfaces:
return self.sys_host.network.get(name) if req_interface != self.sys_host.network.interfaces[interface].name:
except HostNetworkNotFound: continue
pass return interface_information(
self.sys_host.network.interfaces[interface]
)
raise APIError(f"Interface {name} does not exist") from None return {}
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def interface_update(self, request: web.Request) -> Dict[str, Any]:
"""Return network information."""
return {
ATTR_INTERFACES: [
interface_struct(interface)
for interface in self.sys_host.network.interfaces
],
ATTR_DOCKER: {
ATTR_INTERFACE: DOCKER_NETWORK,
ATTR_ADDRESS: str(DOCKER_NETWORK_MASK),
ATTR_GATEWAY: str(self.sys_docker.network.gateway),
ATTR_DNS: str(self.sys_docker.network.dns),
},
ATTR_HOST_INTERNET: self.sys_host.network.connectivity,
ATTR_SUPERVISOR_INTERNET: self.sys_supervisor.connectivity,
}
@api_process
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))
return interface_struct(interface)
@api_process
async def interface_update(self, request: web.Request) -> None:
"""Update the configuration of an interface.""" """Update the configuration of an interface."""
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE)) req_interface = request.match_info.get(ATTR_INTERFACE)
# Validate data if not self.sys_host.network.interfaces.get(req_interface):
body = await api_validate(SCHEMA_UPDATE, request) raise APIError(f"Interface {req_interface} does not exsist")
if not body:
args = await api_validate(SCHEMA_UPDATE, request)
if not args:
raise APIError("You need to supply at least one option to update") raise APIError("You need to supply at least one option to update")
# Apply config await asyncio.shield(
for key, config in body.items(): self.sys_host.network.interfaces[req_interface].update_settings(**args)
if key == ATTR_IPV4:
interface.ipv4 = attr.evolve(
interface.ipv4 or IpConfig(InterfaceMethod.STATIC, [], None, []),
**config,
)
elif key == ATTR_IPV6:
interface.ipv6 = attr.evolve(
interface.ipv6 or IpConfig(InterfaceMethod.STATIC, [], None, []),
**config,
)
elif key == ATTR_WIFI:
interface.wifi = attr.evolve(
interface.wifi
or WifiConfig(
WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None
),
**config,
)
elif key == ATTR_ENABLED:
interface.enabled = config
await asyncio.shield(self.sys_host.network.apply_changes(interface))
@api_process
def reload(self, request: web.Request) -> Awaitable[None]:
"""Reload network data."""
return asyncio.shield(self.sys_host.network.update())
@api_process
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))
# Only wlan is supported
if interface.type != InterfaceType.WIRELESS:
raise APIError(f"Interface {interface.name} is not a valid wireless card!")
ap_list = await self.sys_host.network.scan_wifi(interface)
return {ATTR_ACCESSPOINTS: [accesspoint_struct(ap) for ap in ap_list]}
@api_process
async def create_vlan(self, request: web.Request) -> None:
"""Create a new vlan."""
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
vlan = int(request.match_info.get(ATTR_VLAN))
# Only ethernet is supported
if interface.type != InterfaceType.ETHERNET:
raise APIError(
f"Interface {interface.name} is not a valid ethernet card for vlan!"
)
body = await api_validate(SCHEMA_UPDATE, request)
vlan_config = VlanConfig(vlan, interface.name)
ipv4_config = None
if ATTR_IPV4 in body:
ipv4_config = IpConfig(
body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
)
ipv6_config = None
if ATTR_IPV6 in body:
ipv6_config = IpConfig(
body[ATTR_IPV6].get(ATTR_METHOD, InterfaceMethod.AUTO),
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
)
vlan_interface = Interface(
"",
True,
True,
False,
InterfaceType.VLAN,
ipv4_config,
ipv6_config,
None,
vlan_config,
) )
await asyncio.shield(self.sys_host.network.apply_changes(vlan_interface))
await asyncio.shield(self.sys_host.reload())
return await asyncio.shield(self.interface_info(request))

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor Observer RESTful API.""" """Init file for Supervisor Observer RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any from typing import Any, Dict
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@@ -16,7 +16,6 @@ from ..const import (
ATTR_MEMORY_USAGE, ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX, ATTR_NETWORK_RX,
ATTR_NETWORK_TX, ATTR_NETWORK_TX,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
) )
@@ -33,17 +32,16 @@ class APIObserver(CoreSysAttributes):
"""Handle RESTful API for Observer functions.""" """Handle RESTful API for Observer functions."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HA Observer information.""" """Return HA Observer information."""
return { return {
ATTR_HOST: str(self.sys_docker.network.observer), ATTR_HOST: str(self.sys_docker.network.observer),
ATTR_VERSION: self.sys_plugins.observer.version, ATTR_VERSION: self.sys_plugins.observer.version,
ATTR_VERSION_LATEST: self.sys_plugins.observer.latest_version, ATTR_VERSION_LATEST: self.sys_plugins.observer.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_plugins.observer.need_update,
} }
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_plugins.observer.stats() stats = await self.sys_plugins.observer.stats()

View File

@@ -1,69 +1,43 @@
"""Init file for Supervisor HassOS RESTful API.""" """Init file for Supervisor HassOS RESTful API."""
import asyncio import asyncio
import logging import logging
from pathlib import Path from typing import Any, Awaitable, Dict
from typing import Any, Awaitable
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import ATTR_BOARD, ATTR_BOOT, ATTR_VERSION, ATTR_VERSION_LATEST
ATTR_BOARD,
ATTR_BOOT,
ATTR_DEVICES,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..validate import version_tag from ..validate import version_tag
from .const import ATTR_DATA_DISK, ATTR_DEVICE
from .utils import api_process, api_validate from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))})
class APIOS(CoreSysAttributes): class APIOS(CoreSysAttributes):
"""Handle RESTful API for OS functions.""" """Handle RESTful API for OS functions."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return OS information.""" """Return OS information."""
return { return {
ATTR_VERSION: self.sys_os.version, ATTR_VERSION: self.sys_hassos.version,
ATTR_VERSION_LATEST: self.sys_os.latest_version, ATTR_VERSION_LATEST: self.sys_hassos.latest_version,
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update, ATTR_BOARD: self.sys_hassos.board,
ATTR_BOARD: self.sys_os.board,
ATTR_BOOT: self.sys_dbus.rauc.boot_slot, ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used,
} }
@api_process @api_process
async def update(self, request: web.Request) -> None: async def update(self, request: web.Request) -> None:
"""Update OS.""" """Update OS."""
body = await api_validate(SCHEMA_VERSION, request) 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 @api_process
def config_sync(self, request: web.Request) -> Awaitable[None]: def config_sync(self, request: web.Request) -> Awaitable[None]:
"""Trigger config reload on OS.""" """Trigger config reload on OS."""
return asyncio.shield(self.sys_os.config_sync()) return asyncio.shield(self.sys_hassos.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: self.sys_os.datadisk.available_disks,
}

View File

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

File diff suppressed because one or more lines are too long

View File

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

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,14 +0,0 @@
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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