mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-31 11:50:21 +00:00
Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
20bb890a27 | ||
![]() |
ebf0fe8397 |
49
.devcontainer/Dockerfile
Normal file
49
.devcontainer/Dockerfile
Normal 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
|
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "Supervisor dev",
|
||||
"image": "ghcr.io/home-assistant/devcontainer:supervisor",
|
||||
"appPort": ["9123:8123", "7357:4357"],
|
||||
"postCreateCommand": "bash devcontainer_bootstrap",
|
||||
"context": "..",
|
||||
"dockerFile": "Dockerfile",
|
||||
"appPort": "9123:8123",
|
||||
"postCreateCommand": "pre-commit install",
|
||||
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
@@ -10,14 +11,8 @@
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"mounts": [ "type=volume,target=/var/lib/docker" ],
|
||||
"settings": {
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"zsh": {
|
||||
"path": "/usr/bin/zsh"
|
||||
}
|
||||
},
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
@@ -26,7 +21,7 @@
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.formatting.provider": "black",
|
||||
"python.formatting.blackArgs": ["--target-version", "py39"],
|
||||
"python.formatting.blackArgs": ["--target-version", "py38"],
|
||||
"python.formatting.blackPath": "/usr/local/bin/black",
|
||||
"python.linting.banditPath": "/usr/local/bin/bandit",
|
||||
"python.linting.flake8Path": "/usr/local/bin/flake8",
|
||||
|
65
.github/ISSUE_TEMPLATE.md
vendored
65
.github/ISSUE_TEMPLATE.md
vendored
@@ -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:
|
||||
- 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
|
||||
- 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. -->
|
||||
|
||||
### 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>
|
||||
**Supervisor logs:**
|
||||
<!--
|
||||
- Frontend -> Supervisor -> System
|
||||
- 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:**
|
||||
|
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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.
|
25
.github/ISSUE_TEMPLATE/config.yml
vendored
25
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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!
|
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -37,7 +37,6 @@
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue:
|
||||
- Link to documentation pull request:
|
||||
- Link to cli pull request:
|
||||
|
||||
## Checklist
|
||||
|
||||
|
27
.github/lock.yml
vendored
Normal file
27
.github/lock.yml
vendored
Normal 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
|
48
.github/release-drafter.yml
vendored
48
.github/release-drafter.yml
vendored
@@ -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: |
|
||||
## What's Changed
|
||||
|
||||
$CHANGES
|
||||
|
18
.github/stale.yml
vendored
Normal file
18
.github/stale.yml
vendored
Normal 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
|
374
.github/workflows/builder.yml
vendored
374
.github/workflows/builder.yml
vendored
@@ -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
|
19
.github/workflows/check_pr_labels.yml
vendored
19
.github/workflows/check_pr_labels.yml
vendored
@@ -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
|
95
.github/workflows/ci.yaml
vendored
95
.github/workflows/ci.yaml
vendored
@@ -4,13 +4,13 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- master
|
||||
pull_request: ~
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: 3.9
|
||||
DEFAULT_PYTHON: 3.8
|
||||
PRE_COMMIT_HOME: ~/.cache/pre-commit
|
||||
DEFAULT_CAS: v1.0.2
|
||||
|
||||
jobs:
|
||||
# Separate job to pre-populate the base dependency cache
|
||||
@@ -19,23 +19,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
python-version: [3.8]
|
||||
name: Prepare Python ${{ matrix.python-version }} dependencies
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}
|
||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements_tests.txt
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -64,15 +67,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -93,7 +96,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
@@ -108,15 +111,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -128,7 +131,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -152,15 +155,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -184,15 +187,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -204,7 +207,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -225,15 +228,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -245,7 +248,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -269,15 +272,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -301,15 +304,15 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -321,7 +324,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -341,23 +344,19 @@ jobs:
|
||||
needs: prepare
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
python-version: [3.8]
|
||||
name: Run tests Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
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
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -392,7 +391,7 @@ jobs:
|
||||
-o console_output_style=count \
|
||||
tests
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v3.1.0
|
||||
uses: actions/upload-artifact@v2.2.0
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: .coverage
|
||||
@@ -403,15 +402,15 @@ jobs:
|
||||
needs: pytest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.0.0
|
||||
uses: actions/setup-python@v2.1.4
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v3.0.4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
@@ -422,7 +421,7 @@ jobs:
|
||||
echo "Failed to restore Python virtual environment from cache"
|
||||
exit 1
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v2
|
||||
- name: Combine coverage results
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -430,4 +429,4 @@ jobs:
|
||||
coverage report
|
||||
coverage xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3.1.0
|
||||
uses: codecov/codecov-action@v1.0.14
|
||||
|
20
.github/workflows/lock.yml
vendored
20
.github/workflows/lock.yml
vendored
@@ -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: ""
|
35
.github/workflows/release-drafter.yml
vendored
35
.github/workflows/release-drafter.yml
vendored
@@ -2,43 +2,14 @@ name: Release Drafter
|
||||
|
||||
on:
|
||||
push:
|
||||
# branches to consider in the event; optional, defaults to all
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
runs-on: ubuntu-latest
|
||||
name: Release Drafter
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
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 }}
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
4
.github/workflows/sentry.yaml
vendored
4
.github/workflows/sentry.yaml
vendored
@@ -10,9 +10,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v3.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.1.6
|
||||
uses: getsentry/action-release@v1.1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
|
39
.github/workflows/stale.yml
vendored
39
.github/workflows/stale.yml
vendored
@@ -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.
|
@@ -1,6 +1,5 @@
|
||||
ignored:
|
||||
- DL3003
|
||||
- DL3018
|
||||
- DL3006
|
||||
- DL3013
|
||||
- DL3018
|
||||
- SC2155
|
||||
|
@@ -1,13 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
rev: 20.8b1
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
- --target-version
|
||||
- py39
|
||||
- py38
|
||||
files: ^((supervisor|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.8.3
|
||||
@@ -23,12 +23,12 @@ repos:
|
||||
- id: check-executables-have-shebangs
|
||||
stages: [manual]
|
||||
- id: check-json
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.9.3
|
||||
- repo: https://github.com/pre-commit/mirrors-isort
|
||||
rev: v4.3.21
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.32.1
|
||||
rev: v2.6.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py39-plus]
|
||||
args: [--py37-plus]
|
||||
|
21
.vcnignore
21
.vcnignore
@@ -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
17
.vscode/tasks.json
vendored
@@ -2,9 +2,9 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run Supervisor",
|
||||
"label": "Run Testenv",
|
||||
"type": "shell",
|
||||
"command": "supervisor_run",
|
||||
"command": "./scripts/test_env.sh",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
@@ -16,7 +16,7 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run Supervisor CLI",
|
||||
"label": "Run Testenv CLI",
|
||||
"type": "shell",
|
||||
"command": "docker exec -ti hassio_cli /usr/bin/cli.sh",
|
||||
"group": {
|
||||
@@ -30,9 +30,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Update Supervisor Panel",
|
||||
"label": "Update UI",
|
||||
"type": "shell",
|
||||
"command": "LOKALISE_TOKEN='${input:localiseToken}' ./scripts/update-frontend.sh",
|
||||
"command": "./scripts/update-frontend.sh",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
@@ -86,12 +86,5 @@
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "localiseToken",
|
||||
"type": "promptString",
|
||||
"description": "Paste your lokalise token to download frontend translations"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
29
Dockerfile
29
Dockerfile
@@ -1,39 +1,24 @@
|
||||
ARG BUILD_FROM
|
||||
FROM ${BUILD_FROM}
|
||||
FROM $BUILD_FROM
|
||||
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=10000 \
|
||||
SUPERVISOR_API=http://localhost
|
||||
|
||||
ARG \
|
||||
BUILD_ARCH \
|
||||
CAS_VERSION
|
||||
|
||||
# Install base
|
||||
WORKDIR /usr/src
|
||||
RUN \
|
||||
set -x \
|
||||
&& apk add --no-cache \
|
||||
apk add --no-cache \
|
||||
eudev \
|
||||
eudev-libs \
|
||||
git \
|
||||
glib \
|
||||
libffi \
|
||||
libpulse \
|
||||
musl \
|
||||
openssl \
|
||||
&& apk add --no-cache --virtual .build-dependencies \
|
||||
build-base \
|
||||
go \
|
||||
\
|
||||
&& git clone -b "v${CAS_VERSION}" --depth 1 \
|
||||
https://github.com/codenotary/cas \
|
||||
&& cd cas \
|
||||
&& make cas \
|
||||
&& mv cas /usr/bin/cas \
|
||||
\
|
||||
&& apk del .build-dependencies \
|
||||
&& rm -rf /root/go /root/.cache \
|
||||
&& rm -rf /usr/src/cas
|
||||
openssl
|
||||
|
||||
ARG BUILD_ARCH
|
||||
WORKDIR /usr/src
|
||||
|
||||
# Install requirements
|
||||
COPY requirements.txt .
|
||||
|
24
README.md
24
README.md
@@ -10,23 +10,17 @@ network settings or installing and updating software.
|
||||
|
||||
## 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
|
||||
|
||||
For small changes and bugfixes you can just follow this, but for significant changes open a RFC first.
|
||||
Development instructions can be found [here][development].
|
||||
The development of the Supervisor is not difficult but tricky.
|
||||
|
||||
## 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:
|
||||
|
||||
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
|
||||
For small bugfixes or improvements, make a PR. For significant changes open a RFC first, please. Thanks.
|
||||
|
53
azure-pipelines-release.yml
Normal file
53
azure-pipelines-release.yml
Normal 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"
|
27
azure-pipelines-wheels.yml
Normal file
27
azure-pipelines-wheels.yml
Normal 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
13
build.json
Normal 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"
|
||||
}
|
||||
}
|
22
build.yaml
22
build.yaml
@@ -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
|
Submodule home-assistant-polymer updated: e7848262ea...eec4a91ad8
11
pylintrc
11
pylintrc
@@ -2,29 +2,31 @@
|
||||
reports=no
|
||||
jobs=2
|
||||
|
||||
good-names=id,i,j,k,ex,Run,_,fp,T,os
|
||||
|
||||
extension-pkg-whitelist=
|
||||
ciso8601
|
||||
good-names=id,i,j,k,ex,Run,_,fp,T
|
||||
|
||||
# Reasons disabled:
|
||||
# format - handled by black
|
||||
# locally-disabled - it spams too much
|
||||
# duplicate-code - unavoidable
|
||||
# cyclic-import - doesn't test if both import on load
|
||||
# abstract-class-little-used - prevents from setting right foundation
|
||||
# abstract-class-not-used - is flaky, should not show up but does
|
||||
# unused-argument - generic callbacks and setup methods create a lot of warnings
|
||||
# redefined-variable-type - this is Python, we're duck typing!
|
||||
# too-many-* - are not enforced for the sake of readability
|
||||
# too-few-* - same as too-many-*
|
||||
# abstract-method - with intro of async there are always methods missing
|
||||
disable=
|
||||
format,
|
||||
abstract-class-little-used,
|
||||
abstract-method,
|
||||
cyclic-import,
|
||||
duplicate-code,
|
||||
locally-disabled,
|
||||
no-else-return,
|
||||
no-self-use,
|
||||
not-context-manager,
|
||||
redefined-variable-type,
|
||||
too-few-public-methods,
|
||||
too-many-arguments,
|
||||
too-many-branches,
|
||||
@@ -35,7 +37,6 @@ disable=
|
||||
too-many-return-statements,
|
||||
too-many-statements,
|
||||
unused-argument,
|
||||
consider-using-with
|
||||
|
||||
[EXCEPTIONS]
|
||||
overgeneral-exceptions=Exception
|
||||
|
@@ -1,25 +1,21 @@
|
||||
aiodns==3.0.0
|
||||
aiohttp==3.8.1
|
||||
async_timeout==4.0.2
|
||||
atomicwrites==1.4.0
|
||||
attrs==21.4.0
|
||||
awesomeversion==22.5.2
|
||||
brotli==1.0.9
|
||||
cchardet==2.1.7
|
||||
ciso8601==2.2.0
|
||||
colorlog==6.6.0
|
||||
aiohttp==3.6.3
|
||||
async_timeout==3.0.1
|
||||
attrs==20.2.0
|
||||
brotlipy==0.7.0
|
||||
cchardet==2.1.6
|
||||
colorlog==4.4.0
|
||||
cpe==1.2.1
|
||||
cryptography==36.0.2
|
||||
debugpy==1.6.0
|
||||
deepmerge==1.0.1
|
||||
dirhash==0.2.1
|
||||
docker==5.0.3
|
||||
gitpython==3.1.27
|
||||
jinja2==3.1.2
|
||||
pulsectl==22.3.2
|
||||
pyudev==0.23.2
|
||||
ruamel.yaml==0.17.17
|
||||
securetar==2022.2.0
|
||||
sentry-sdk==1.5.12
|
||||
voluptuous==0.13.1
|
||||
dbus-next==0.2.3
|
||||
cryptography==3.1
|
||||
debugpy==1.0.0
|
||||
docker==4.3.1
|
||||
gitpython==3.1.9
|
||||
jinja2==2.11.2
|
||||
packaging==20.4
|
||||
pulsectl==20.5.1
|
||||
pytz==2020.1
|
||||
pyudev==0.22.0
|
||||
ruamel.yaml==0.15.100
|
||||
sentry-sdk==0.18.0
|
||||
uvloop==0.14.0
|
||||
voluptuous==0.12.0
|
||||
yarl==1.5.1
|
||||
|
@@ -1,15 +1,14 @@
|
||||
black==22.3.0
|
||||
codecov==2.1.12
|
||||
coverage==6.4.1
|
||||
flake8-docstrings==1.6.0
|
||||
flake8==4.0.1
|
||||
pre-commit==2.19.0
|
||||
pydocstyle==6.1.1
|
||||
pylint==2.14.3
|
||||
black==20.8b1
|
||||
codecov==2.1.10
|
||||
coverage==5.3
|
||||
flake8-docstrings==1.5.0
|
||||
flake8==3.8.4
|
||||
pre-commit==2.7.1
|
||||
pydocstyle==5.1.1
|
||||
pylint==2.6.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-cov==3.0.0
|
||||
pytest-timeout==2.1.0
|
||||
pytest==7.1.2
|
||||
pyupgrade==2.34.0
|
||||
time-machine==2.7.0
|
||||
pytest-cov==2.10.1
|
||||
pytest-timeout==1.4.2
|
||||
pytest==6.1.1
|
||||
pyupgrade==2.7.2
|
||||
|
8
rootfs/etc/cont-init.d/udev.sh
Executable file → Normal file
8
rootfs/etc/cont-init.d/udev.sh
Executable file → Normal file
@@ -2,17 +2,9 @@
|
||||
# ==============================================================================
|
||||
# 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
|
||||
|
||||
bashio::log.info "Update udev information"
|
||||
touch /run/.old_udev
|
||||
if udevadm trigger; then
|
||||
udevadm settle || true
|
||||
else
|
||||
|
@@ -26,7 +26,7 @@ autospawn = no
|
||||
; daemon-binary = /usr/bin/pulseaudio
|
||||
; extra-arguments = --log-target=syslog
|
||||
|
||||
cookie-file = /run/pulse-cookie
|
||||
; cookie-file =
|
||||
|
||||
; enable-shm = yes
|
||||
; 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
10
rootfs/etc/services.d/supervisor/finish
Executable file → Normal file
@@ -1,11 +1,5 @@
|
||||
#!/usr/bin/env bashio
|
||||
#!/usr/bin/execlineb -S0
|
||||
# ==============================================================================
|
||||
# Take down the S6 supervision tree when Supervisor fails
|
||||
# ==============================================================================
|
||||
|
||||
if [[ "$1" -ne 100 ]] && [[ "$1" -ne 256 ]]; then
|
||||
bashio::log.warning "Halt Supervisor"
|
||||
/run/s6/basedir/bin/halt
|
||||
fi
|
||||
|
||||
bashio::log.info "Supervisor restart after closing"
|
||||
redirfd -w 2 /dev/null s6-svscanctl -t /var/run/s6/services
|
||||
|
1
rootfs/etc/services.d/supervisor/run
Executable file → Normal file
1
rootfs/etc/services.d/supervisor/run
Executable file → Normal file
@@ -3,6 +3,5 @@
|
||||
# Start Supervisor service
|
||||
# ==============================================================================
|
||||
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
|
||||
export MALLOC_CONF="background_thread:true,metadata_thp:auto"
|
||||
|
||||
exec python3 -m supervisor
|
||||
|
11
rootfs/etc/services.d/watchdog/finish
Executable file → Normal file
11
rootfs/etc/services.d/watchdog/finish
Executable file → Normal file
@@ -1,11 +1,8 @@
|
||||
#!/usr/bin/env bashio
|
||||
#!/usr/bin/execlineb -S1
|
||||
# ==============================================================================
|
||||
# Take down the S6 supervision tree when Watchdog fails
|
||||
# ==============================================================================
|
||||
if { s6-test ${1} -ne 0 }
|
||||
if { s6-test ${1} -ne 256 }
|
||||
|
||||
if [[ "$1" -ne 0 ]] && [[ "$1" -ne 256 ]]; then
|
||||
bashio::log.warning "Halt Supervisor (Wuff)"
|
||||
/run/s6/basedir/bin/halt
|
||||
fi
|
||||
|
||||
bashio::log.info "Watchdog restart after closing"
|
||||
s6-svscanctl -t /var/run/s6/services
|
||||
|
2
rootfs/etc/services.d/watchdog/run
Executable file → Normal file
2
rootfs/etc/services.d/watchdog/run
Executable file → Normal file
@@ -31,4 +31,4 @@ do
|
||||
|
||||
done
|
||||
|
||||
bashio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"
|
||||
basio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"
|
||||
|
@@ -1,4 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE03LvYuz79GTJx4uKp3w6NrSe5JZI
|
||||
iBtgzzYi0YQYtZO/r+xFpgDJEa0gLHkXtl94fpqrFiN89In83lzaszbZtA==
|
||||
-----END PUBLIC KEY-----
|
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"currentcontext": {
|
||||
"LcHost": "cas.codenotary.com",
|
||||
"LcPort": "443"
|
||||
},
|
||||
"schemaversion": 3,
|
||||
"users": null
|
||||
}
|
133
scripts/test_env.sh
Executable file
133
scripts/test_env.sh
Executable 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
|
@@ -1,6 +1,4 @@
|
||||
#!/bin/bash
|
||||
source "/etc/supervisor_scripts/common"
|
||||
|
||||
set -e
|
||||
|
||||
# Update frontend
|
||||
@@ -11,10 +9,6 @@ cd home-assistant-polymer
|
||||
nvm install
|
||||
script/bootstrap
|
||||
|
||||
# Download translations
|
||||
start_docker
|
||||
./script/translations_download
|
||||
|
||||
# build frontend
|
||||
cd hassio
|
||||
./script/build_hassio
|
||||
@@ -22,9 +16,3 @@ cd hassio
|
||||
# Copy frontend
|
||||
rm -rf ../../supervisor/api/panel/*
|
||||
cp -rf build/* ../../supervisor/api/panel/
|
||||
|
||||
# Reset frontend git
|
||||
cd ..
|
||||
git reset --hard HEAD
|
||||
|
||||
stop_docker
|
@@ -4,8 +4,9 @@ include_trailing_comma=True
|
||||
force_grid_wrap=0
|
||||
line_length=88
|
||||
indent = " "
|
||||
not_skip = __init__.py
|
||||
force_sort_within_sections = true
|
||||
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
default_section = THIRDPARTY
|
||||
forced_separate = tests
|
||||
combine_as_imports = true
|
||||
|
26
setup.py
26
setup.py
@@ -31,30 +31,24 @@ setup(
|
||||
zip_safe=False,
|
||||
platforms="any",
|
||||
packages=[
|
||||
"supervisor",
|
||||
"supervisor.docker",
|
||||
"supervisor.addons",
|
||||
"supervisor.api",
|
||||
"supervisor.backups",
|
||||
"supervisor.dbus.network",
|
||||
"supervisor.dbus.network.setting",
|
||||
"supervisor.dbus",
|
||||
"supervisor.discovery.services",
|
||||
"supervisor.dbus.payloads",
|
||||
"supervisor.dbus.network",
|
||||
"supervisor.discovery",
|
||||
"supervisor.docker",
|
||||
"supervisor.discovery.services",
|
||||
"supervisor.services",
|
||||
"supervisor.services.modules",
|
||||
"supervisor.homeassistant",
|
||||
"supervisor.host",
|
||||
"supervisor.jobs",
|
||||
"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",
|
||||
"supervisor.plugins",
|
||||
"supervisor.snapshots",
|
||||
"supervisor.store",
|
||||
],
|
||||
include_package_data=True,
|
||||
)
|
||||
|
@@ -2,25 +2,24 @@
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from supervisor import bootstrap
|
||||
|
||||
_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:
|
||||
CONTAINER_OS_STARTUP_CHECK.unlink()
|
||||
except OSError as err:
|
||||
_LOGGER.warning("Not able to remove the startup health file: %s", err)
|
||||
# pylint: disable=import-outside-toplevel
|
||||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return asyncio.get_event_loop()
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@@ -28,7 +27,7 @@ if __name__ == "__main__":
|
||||
bootstrap.initialize_logging()
|
||||
|
||||
# Init async event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = initialize_event_loop()
|
||||
|
||||
# Check if all information are available to setup Supervisor
|
||||
bootstrap.check_environment()
|
||||
@@ -39,15 +38,11 @@ if __name__ == "__main__":
|
||||
|
||||
_LOGGER.info("Initializing Supervisor setup")
|
||||
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
|
||||
loop.set_debug(coresys.config.debug)
|
||||
loop.run_until_complete(coresys.core.connect())
|
||||
|
||||
bootstrap.supervisor_debugger(coresys)
|
||||
bootstrap.migrate_system_env(coresys)
|
||||
|
||||
# Signal health startup for container
|
||||
run_os_startup_check_cleanup()
|
||||
|
||||
_LOGGER.info("Setting up Supervisor")
|
||||
loop.run_until_complete(coresys.core.setup())
|
||||
|
||||
@@ -61,4 +56,4 @@ if __name__ == "__main__":
|
||||
loop.close()
|
||||
|
||||
_LOGGER.info("Closing Supervisor")
|
||||
sys.exit(coresys.core.exit_code)
|
||||
sys.exit(0)
|
||||
|
@@ -3,14 +3,13 @@ import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import tarfile
|
||||
from typing import Optional, Union
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from ..const import AddonBoot, AddonStartup, AddonState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import (
|
||||
AddonConfigurationError,
|
||||
AddonsError,
|
||||
AddonsJobError,
|
||||
AddonsNotSupportedError,
|
||||
CoreDNSError,
|
||||
DockerAPIError,
|
||||
@@ -19,8 +18,6 @@ from ..exceptions import (
|
||||
HomeAssistantAPIError,
|
||||
HostAppArmorError,
|
||||
)
|
||||
from ..jobs.decorator import Job, JobCondition
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
from ..store.addon import AddonStore
|
||||
from ..utils import check_exception_chain
|
||||
from .addon import Addon
|
||||
@@ -38,17 +35,17 @@ class AddonManager(CoreSysAttributes):
|
||||
"""Initialize Docker base wrapper."""
|
||||
self.coresys: CoreSys = coresys
|
||||
self.data: AddonsData = AddonsData(coresys)
|
||||
self.local: dict[str, Addon] = {}
|
||||
self.store: dict[str, AddonStore] = {}
|
||||
self.local: Dict[str, Addon] = {}
|
||||
self.store: Dict[str, AddonStore] = {}
|
||||
|
||||
@property
|
||||
def all(self) -> list[AnyAddon]:
|
||||
def all(self) -> List[AnyAddon]:
|
||||
"""Return a list of all add-ons."""
|
||||
addons: dict[str, AnyAddon] = {**self.store, **self.local}
|
||||
addons: Dict[str, AnyAddon] = {**self.store, **self.local}
|
||||
return list(addons.values())
|
||||
|
||||
@property
|
||||
def installed(self) -> list[Addon]:
|
||||
def installed(self) -> List[Addon]:
|
||||
"""Return a list of all installed add-ons."""
|
||||
return list(self.local.values())
|
||||
|
||||
@@ -89,7 +86,7 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
async def boot(self, stage: AddonStartup) -> None:
|
||||
"""Boot add-ons with mode auto."""
|
||||
tasks: list[Addon] = []
|
||||
tasks: List[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
|
||||
continue
|
||||
@@ -123,7 +120,7 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
async def shutdown(self, stage: AddonStartup) -> None:
|
||||
"""Shutdown addons."""
|
||||
tasks: list[Addon] = []
|
||||
tasks: List[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.state != AddonState.STARTED or addon.startup != stage:
|
||||
continue
|
||||
@@ -143,27 +140,20 @@ class AddonManager(CoreSysAttributes):
|
||||
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, 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:
|
||||
"""Install an add-on."""
|
||||
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)
|
||||
|
||||
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:
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {slug} not supported on this platform", _LOGGER.error
|
||||
)
|
||||
_LOGGER.error("Add-on %s not supported on that platform", slug)
|
||||
raise AddonsNotSupportedError()
|
||||
|
||||
self.data.install(store)
|
||||
addon = Addon(self.coresys, slug)
|
||||
@@ -178,7 +168,7 @@ class AddonManager(CoreSysAttributes):
|
||||
await addon.install_apparmor()
|
||||
|
||||
try:
|
||||
await addon.instance.install(store.version, store.image, arch=addon.arch)
|
||||
await addon.instance.install(store.version, store.image)
|
||||
except DockerError as err:
|
||||
self.data.uninstall(addon)
|
||||
raise AddonsError() from err
|
||||
@@ -244,56 +234,40 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||
|
||||
@Job(
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def update(self, slug: str, backup: Optional[bool] = False) -> None:
|
||||
async def update(self, slug: str) -> None:
|
||||
"""Update add-on."""
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
_LOGGER.error("Add-on %s is not installed", slug)
|
||||
raise AddonsError()
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
raise AddonsError(
|
||||
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||
)
|
||||
_LOGGER.error("Add-on %s is not available inside store", slug)
|
||||
raise AddonsError()
|
||||
store = self.store[slug]
|
||||
|
||||
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
|
||||
if not store.available:
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {slug} not supported on that platform", _LOGGER.error
|
||||
)
|
||||
|
||||
if backup:
|
||||
await self.sys_backups.do_backup_partial(
|
||||
name=f"addon_{addon.slug}_{addon.version}",
|
||||
homeassistant=False,
|
||||
addons=[addon.slug],
|
||||
)
|
||||
_LOGGER.error("Add-on %s not supported on that platform", slug)
|
||||
raise AddonsNotSupportedError()
|
||||
|
||||
# Update instance
|
||||
last_state: AddonState = addon.state
|
||||
old_image = addon.image
|
||||
try:
|
||||
await addon.instance.update(store.version, store.image)
|
||||
|
||||
# Cleanup
|
||||
with suppress(DockerError):
|
||||
await addon.instance.cleanup()
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully updated", slug)
|
||||
self.data.update(store)
|
||||
|
||||
# Cleanup
|
||||
with suppress(DockerError):
|
||||
await addon.instance.cleanup(old_image=old_image)
|
||||
else:
|
||||
self.data.update(store)
|
||||
_LOGGER.info("Add-on '%s' successfully updated", slug)
|
||||
|
||||
# Setup/Fix AppArmor profile
|
||||
await addon.install_apparmor()
|
||||
@@ -302,35 +276,25 @@ class AddonManager(CoreSysAttributes):
|
||||
if last_state == AddonState.STARTED:
|
||||
await addon.start()
|
||||
|
||||
@Job(
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def rebuild(self, slug: str) -> None:
|
||||
"""Perform a rebuild of local build add-on."""
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
_LOGGER.error("Add-on %s is not installed", slug)
|
||||
raise AddonsError()
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
raise AddonsError(
|
||||
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||
)
|
||||
_LOGGER.error("Add-on %s is not available inside store", slug)
|
||||
raise AddonsError()
|
||||
store = self.store[slug]
|
||||
|
||||
# Check if a rebuild is possible now
|
||||
if addon.version != store.version:
|
||||
raise AddonsError(
|
||||
"Version changed, use Update instead Rebuild", _LOGGER.error
|
||||
)
|
||||
_LOGGER.error("Version changed, use Update instead Rebuild")
|
||||
raise AddonsError()
|
||||
if not addon.need_build:
|
||||
raise AddonsNotSupportedError(
|
||||
"Can't rebuild a image based add-on", _LOGGER.error
|
||||
)
|
||||
_LOGGER.error("Can't rebuild a image based add-on")
|
||||
raise AddonsNotSupportedError()
|
||||
|
||||
# remove docker container but not addon config
|
||||
last_state: AddonState = addon.state
|
||||
@@ -347,14 +311,6 @@ class AddonManager(CoreSysAttributes):
|
||||
if last_state == AddonState.STARTED:
|
||||
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:
|
||||
"""Restore state of an add-on."""
|
||||
if slug not in self.local:
|
||||
@@ -377,10 +333,9 @@ class AddonManager(CoreSysAttributes):
|
||||
with suppress(HomeAssistantAPIError):
|
||||
await self.sys_ingress.update_hass_panel(addon)
|
||||
|
||||
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
|
||||
async def repair(self) -> None:
|
||||
"""Repair local add-ons."""
|
||||
needs_repair: list[Addon] = []
|
||||
needs_repair: List[Addon] = []
|
||||
|
||||
# Evaluate Add-ons to repair
|
||||
for addon in self.installed:
|
||||
@@ -394,6 +349,10 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
for addon in needs_repair:
|
||||
_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):
|
||||
# Need pull a image again
|
||||
if not addon.need_build:
|
||||
@@ -421,12 +380,7 @@ class AddonManager(CoreSysAttributes):
|
||||
continue
|
||||
except DockerError as err:
|
||||
_LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err)
|
||||
self.sys_resolution.create_issue(
|
||||
IssueType.CORRUPT_DOCKER,
|
||||
ContextType.ADDON,
|
||||
reference=addon.slug,
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
self.sys_core.healthy = False
|
||||
self.sys_capture_exception(err)
|
||||
else:
|
||||
self.sys_plugins.dns.add_host(
|
||||
|
@@ -10,11 +10,9 @@ import secrets
|
||||
import shutil
|
||||
import tarfile
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Awaitable, Final, Optional
|
||||
from typing import Any, Awaitable, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
from deepmerge import Merger
|
||||
from securetar import atomic_contents_add, secure_path
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
@@ -24,8 +22,6 @@ from ..const import (
|
||||
ATTR_AUDIO_OUTPUT,
|
||||
ATTR_AUTO_UPDATE,
|
||||
ATTR_BOOT,
|
||||
ATTR_DATA,
|
||||
ATTR_EVENT,
|
||||
ATTR_IMAGE,
|
||||
ATTR_INGRESS_ENTRY,
|
||||
ATTR_INGRESS_PANEL,
|
||||
@@ -36,10 +32,8 @@ from ..const import (
|
||||
ATTR_PORTS,
|
||||
ATTR_PROTECTED,
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SLUG,
|
||||
ATTR_STATE,
|
||||
ATTR_SYSTEM,
|
||||
ATTR_TYPE,
|
||||
ATTR_USER,
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
@@ -56,21 +50,18 @@ from ..exceptions import (
|
||||
AddonConfigurationError,
|
||||
AddonsError,
|
||||
AddonsNotSupportedError,
|
||||
ConfigurationFileError,
|
||||
DockerError,
|
||||
DockerRequestError,
|
||||
HostAppArmorError,
|
||||
JsonFileError,
|
||||
)
|
||||
from ..hardware.data import Device
|
||||
from ..homeassistant.const import WSEvent, WSType
|
||||
from ..utils import check_port
|
||||
from ..utils.apparmor import adjust_profile
|
||||
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 .options import AddonOptions
|
||||
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__)
|
||||
|
||||
@@ -81,17 +72,13 @@ RE_WEBUI = re.compile(
|
||||
|
||||
RE_WATCHDOG = re.compile(
|
||||
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)
|
||||
|
||||
_OPTIONS_MERGER: Final = Merger(
|
||||
type_strategies=[(dict, ["merge"])],
|
||||
fallback_strategies=["override"],
|
||||
type_conflict_strategies=["override"],
|
||||
)
|
||||
|
||||
|
||||
class Addon(AddonModel):
|
||||
"""Hold data for add-on inside Supervisor."""
|
||||
@@ -100,34 +87,12 @@ class Addon(AddonModel):
|
||||
"""Initialize data holder."""
|
||||
super().__init__(coresys, slug)
|
||||
self.instance: DockerAddon = DockerAddon(coresys, self)
|
||||
self._state: AddonState = AddonState.UNKNOWN
|
||||
self.state: AddonState = AddonState.UNKNOWN
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return internal representation."""
|
||||
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
|
||||
def in_progress(self) -> bool:
|
||||
"""Return True if a task is in progress."""
|
||||
@@ -136,7 +101,7 @@ class Addon(AddonModel):
|
||||
async def load(self) -> None:
|
||||
"""Async initialize of object."""
|
||||
with suppress(DockerError):
|
||||
await self.instance.attach(version=self.version)
|
||||
await self.instance.attach(tag=self.version)
|
||||
|
||||
# Evaluate state
|
||||
if await self.instance.is_running():
|
||||
@@ -185,26 +150,17 @@ class Addon(AddonModel):
|
||||
return self.persist[ATTR_VERSION]
|
||||
|
||||
@property
|
||||
def need_update(self) -> bool:
|
||||
"""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]:
|
||||
def dns(self) -> List[str]:
|
||||
"""Return list of DNS name for that add-on."""
|
||||
return [f"{self.hostname}.{DNS_SUFFIX}"]
|
||||
|
||||
@property
|
||||
def options(self) -> dict[str, Any]:
|
||||
def options(self) -> Dict[str, Any]:
|
||||
"""Return options with local changes."""
|
||||
return _OPTIONS_MERGER.merge(
|
||||
deepcopy(self.data[ATTR_OPTIONS]), deepcopy(self.persist[ATTR_OPTIONS])
|
||||
)
|
||||
return {**self.data[ATTR_OPTIONS], **self.persist[ATTR_OPTIONS]}
|
||||
|
||||
@options.setter
|
||||
def options(self, value: Optional[dict[str, Any]]) -> None:
|
||||
def options(self, value: Optional[Dict[str, Any]]) -> None:
|
||||
"""Store user add-on options."""
|
||||
self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value)
|
||||
|
||||
@@ -281,12 +237,12 @@ class Addon(AddonModel):
|
||||
self.persist[ATTR_PROTECTED] = value
|
||||
|
||||
@property
|
||||
def ports(self) -> Optional[dict[str, Optional[int]]]:
|
||||
def ports(self) -> Optional[Dict[str, Optional[int]]]:
|
||||
"""Return ports of add-on."""
|
||||
return self.persist.get(ATTR_NETWORK, super().ports)
|
||||
|
||||
@ports.setter
|
||||
def ports(self, value: Optional[dict[str, Optional[int]]]) -> None:
|
||||
def ports(self, value: Optional[Dict[str, Optional[int]]]) -> None:
|
||||
"""Set custom ports of add-on."""
|
||||
if value is None:
|
||||
self.persist.pop(ATTR_NETWORK, None)
|
||||
@@ -365,7 +321,13 @@ class Addon(AddonModel):
|
||||
"""Return a pulse profile for output or None."""
|
||||
if not self.with_audio:
|
||||
return None
|
||||
return self.persist.get(ATTR_AUDIO_OUTPUT)
|
||||
|
||||
# Fallback with old audio settings
|
||||
# Remove after 210
|
||||
output_data = self.persist.get(ATTR_AUDIO_OUTPUT)
|
||||
if output_data and RE_OLD_AUDIO.fullmatch(output_data):
|
||||
return None
|
||||
return output_data
|
||||
|
||||
@audio_output.setter
|
||||
def audio_output(self, value: Optional[str]):
|
||||
@@ -378,7 +340,12 @@ class Addon(AddonModel):
|
||||
if not self.with_audio:
|
||||
return None
|
||||
|
||||
return self.persist.get(ATTR_AUDIO_INPUT)
|
||||
# Fallback with old audio settings
|
||||
# Remove after 210
|
||||
input_data = self.persist.get(ATTR_AUDIO_INPUT)
|
||||
if input_data and RE_OLD_AUDIO.fullmatch(input_data):
|
||||
return None
|
||||
return input_data
|
||||
|
||||
@audio_input.setter
|
||||
def audio_input(self, value: Optional[str]) -> None:
|
||||
@@ -420,24 +387,6 @@ class Addon(AddonModel):
|
||||
"""Return path to asound config for Docker."""
|
||||
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:
|
||||
"""Save data of add-on."""
|
||||
self.sys_addons.data.save_data()
|
||||
@@ -450,7 +399,7 @@ class Addon(AddonModel):
|
||||
application = RE_WATCHDOG.match(url)
|
||||
|
||||
# extract arguments
|
||||
t_port = int(application.group("t_port"))
|
||||
t_port = application.group("t_port")
|
||||
t_proto = application.group("t_proto")
|
||||
s_prefix = application.group("s_prefix") or ""
|
||||
s_suffix = application.group("s_suffix") or ""
|
||||
@@ -474,8 +423,8 @@ class Addon(AddonModel):
|
||||
# Make HTTP request
|
||||
try:
|
||||
url = f"{proto}://{self.ip_address}:{port}{s_suffix}"
|
||||
async with self.sys_websession.get(
|
||||
url, timeout=WATCHDOG_TIMEOUT, ssl=False
|
||||
async with self.sys_websession_ssl.get(
|
||||
url, timeout=WATCHDOG_TIMEOUT
|
||||
) as req:
|
||||
if req.status < 300:
|
||||
return True
|
||||
@@ -486,19 +435,22 @@ class Addon(AddonModel):
|
||||
|
||||
async def write_options(self) -> None:
|
||||
"""Return True if add-on options is written to data."""
|
||||
schema = self.schema
|
||||
options = self.options
|
||||
|
||||
# Update secrets for validation
|
||||
await self.sys_homeassistant.secrets.reload()
|
||||
|
||||
try:
|
||||
options = self.schema.validate(self.options)
|
||||
options = schema(options)
|
||||
write_json_file(self.path_options, options)
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.error(
|
||||
"Add-on %s has invalid options: %s",
|
||||
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)
|
||||
else:
|
||||
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
|
||||
@@ -526,7 +478,8 @@ class Addon(AddonModel):
|
||||
|
||||
# Write pulse config
|
||||
try:
|
||||
self.path_pulse.write_text(pulse_config, encoding="utf-8")
|
||||
with self.path_pulse.open("w") as config_file:
|
||||
config_file.write(pulse_config)
|
||||
except OSError as err:
|
||||
_LOGGER.error(
|
||||
"Add-on %s can't write pulse/client.config: %s", self.slug, err
|
||||
@@ -574,15 +527,11 @@ class Addon(AddonModel):
|
||||
return True
|
||||
|
||||
# merge options
|
||||
options = _OPTIONS_MERGER.merge(
|
||||
deepcopy(default_options), deepcopy(self.persist[ATTR_OPTIONS])
|
||||
)
|
||||
options = {**self.persist[ATTR_OPTIONS], **default_options}
|
||||
|
||||
# create voluptuous
|
||||
new_schema = vol.Schema(
|
||||
vol.All(
|
||||
dict, AddonOptions(self.coresys, new_raw_schema, self.name, self.slug)
|
||||
)
|
||||
vol.All(dict, validate_options(self.coresys, new_raw_schema))
|
||||
)
|
||||
|
||||
# validate
|
||||
@@ -614,7 +563,7 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.instance.run()
|
||||
except DockerRequestError as err:
|
||||
self.state = AddonState.ERROR
|
||||
self.state = AddonState.STOPPED
|
||||
raise AddonsError() from err
|
||||
except DockerError as err:
|
||||
self.state = AddonState.ERROR
|
||||
@@ -625,9 +574,8 @@ class Addon(AddonModel):
|
||||
async def stop(self) -> None:
|
||||
"""Stop add-on."""
|
||||
try:
|
||||
await self.instance.stop()
|
||||
return await self.instance.stop()
|
||||
except DockerRequestError as err:
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
except DockerError as err:
|
||||
self.state = AddonState.ERROR
|
||||
@@ -668,34 +616,16 @@ class Addon(AddonModel):
|
||||
Return a coroutine.
|
||||
"""
|
||||
if not self.with_stdin:
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
|
||||
)
|
||||
_LOGGER.error("Add-on %s does not support writing to stdin!", self.slug)
|
||||
raise AddonsNotSupportedError()
|
||||
|
||||
try:
|
||||
return await self.instance.write_stdin(data)
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
|
||||
async def _backup_command(self, command: str) -> None:
|
||||
try:
|
||||
command_return = await self.instance.run_inside(command)
|
||||
if command_return.exit_code != 0:
|
||||
_LOGGER.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()
|
||||
|
||||
async def snapshot(self, tar_file: tarfile.TarFile) -> None:
|
||||
"""Snapshot state of an add-on."""
|
||||
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
|
||||
temp_path = Path(temp)
|
||||
|
||||
@@ -716,103 +646,80 @@ class Addon(AddonModel):
|
||||
# Store local configs/state
|
||||
try:
|
||||
write_json_file(temp_path.joinpath("addon.json"), data)
|
||||
except ConfigurationFileError as err:
|
||||
raise AddonsError(
|
||||
f"Can't save meta for {self.slug}", _LOGGER.error
|
||||
) from err
|
||||
except JsonFileError as err:
|
||||
_LOGGER.error("Can't save meta for %s", self.slug)
|
||||
raise AddonsError() from err
|
||||
|
||||
# Store AppArmor Profile
|
||||
if self.sys_host.apparmor.exists(self.slug):
|
||||
profile = temp_path.joinpath("apparmor.txt")
|
||||
try:
|
||||
await self.sys_host.apparmor.backup_profile(self.slug, profile)
|
||||
self.sys_host.apparmor.backup_profile(self.slug, profile)
|
||||
except HostAppArmorError as err:
|
||||
raise AddonsError(
|
||||
"Can't backup AppArmor profile", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error("Can't backup AppArmor profile")
|
||||
raise AddonsError() from err
|
||||
|
||||
# write into tarfile
|
||||
def _write_tarfile():
|
||||
"""Write tar inside loop."""
|
||||
with tar_file as backup:
|
||||
# Backup metadata
|
||||
backup.add(temp, arcname=".")
|
||||
with tar_file as snapshot:
|
||||
# Snapshot system
|
||||
|
||||
# Backup data
|
||||
snapshot.add(temp, arcname=".")
|
||||
|
||||
# Snapshot data
|
||||
atomic_contents_add(
|
||||
backup,
|
||||
snapshot,
|
||||
self.path_data,
|
||||
excludes=self.backup_exclude,
|
||||
excludes=self.snapshot_exclude,
|
||||
arcname="data",
|
||||
)
|
||||
|
||||
if (
|
||||
is_running
|
||||
and self.backup_mode == AddonBackupMode.HOT
|
||||
and self.backup_pre is not None
|
||||
):
|
||||
await self._backup_command(self.backup_pre)
|
||||
elif is_running and self.backup_mode == AddonBackupMode.COLD:
|
||||
_LOGGER.info("Shutdown add-on %s for cold backup", self.slug)
|
||||
await self.instance.stop()
|
||||
|
||||
try:
|
||||
_LOGGER.info("Building backup for add-on %s", self.slug)
|
||||
_LOGGER.info("Building snapshot for add-on %s", self.slug)
|
||||
await self.sys_run_in_executor(_write_tarfile)
|
||||
except (tarfile.TarError, OSError) as err:
|
||||
raise AddonsError(
|
||||
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
|
||||
) from err
|
||||
finally:
|
||||
if (
|
||||
is_running
|
||||
and self.backup_mode == AddonBackupMode.HOT
|
||||
and self.backup_post is not None
|
||||
):
|
||||
await self._backup_command(self.backup_post)
|
||||
elif is_running and self.backup_mode is AddonBackupMode.COLD:
|
||||
_LOGGER.info("Starting add-on %s again", self.slug)
|
||||
await self.start()
|
||||
_LOGGER.error("Can't write tarfile %s: %s", tar_file, err)
|
||||
raise AddonsError() from err
|
||||
|
||||
_LOGGER.info("Finish backup for addon %s", self.slug)
|
||||
_LOGGER.info("Finish snapshot for addon %s", self.slug)
|
||||
|
||||
async def restore(self, tar_file: tarfile.TarFile) -> None:
|
||||
"""Restore state of an add-on."""
|
||||
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
|
||||
# extract backup
|
||||
# extract snapshot
|
||||
def _extract_tarfile():
|
||||
"""Extract tar backup."""
|
||||
with tar_file as backup:
|
||||
backup.extractall(path=Path(temp), members=secure_path(backup))
|
||||
"""Extract tar snapshot."""
|
||||
with tar_file as snapshot:
|
||||
snapshot.extractall(path=Path(temp), members=secure_path(snapshot))
|
||||
|
||||
try:
|
||||
await self.sys_run_in_executor(_extract_tarfile)
|
||||
except tarfile.TarError as err:
|
||||
raise AddonsError(
|
||||
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
|
||||
raise AddonsError() from err
|
||||
|
||||
# Read backup data
|
||||
# Read snapshot data
|
||||
try:
|
||||
data = read_json_file(Path(temp, "addon.json"))
|
||||
except ConfigurationFileError as err:
|
||||
except JsonFileError as err:
|
||||
raise AddonsError() from err
|
||||
|
||||
# Validate
|
||||
try:
|
||||
data = SCHEMA_ADDON_BACKUP(data)
|
||||
data = SCHEMA_ADDON_SNAPSHOT(data)
|
||||
except vol.Invalid as err:
|
||||
raise AddonsError(
|
||||
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
_LOGGER.error(
|
||||
"Can't validate %s, snapshot data: %s",
|
||||
self.slug,
|
||||
humanize_error(data, err),
|
||||
)
|
||||
raise AddonsError() from err
|
||||
|
||||
# If available
|
||||
if not self._available(data[ATTR_SYSTEM]):
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {self.slug} is not available for this platform",
|
||||
_LOGGER.error,
|
||||
)
|
||||
_LOGGER.error("Add-on %s is not available for this platform", self.slug)
|
||||
raise AddonsNotSupportedError()
|
||||
|
||||
# Restore local add-on information
|
||||
_LOGGER.info("Restore config for addon %s", self.slug)
|
||||
@@ -845,11 +752,7 @@ class Addon(AddonModel):
|
||||
# Restore data
|
||||
def _restore_data():
|
||||
"""Restore data."""
|
||||
temp_data = Path(temp, "data")
|
||||
if temp_data.is_dir():
|
||||
shutil.copytree(temp_data, self.path_data, symlinks=True)
|
||||
else:
|
||||
self.path_data.mkdir()
|
||||
shutil.copytree(Path(temp, "data"), self.path_data, symlinks=True)
|
||||
|
||||
_LOGGER.info("Restoring data for addon %s", self.slug)
|
||||
if self.path_data.is_dir():
|
||||
@@ -857,9 +760,8 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.sys_run_in_executor(_restore_data)
|
||||
except shutil.Error as err:
|
||||
raise AddonsError(
|
||||
f"Can't restore origin data: {err}", _LOGGER.error
|
||||
) from err
|
||||
_LOGGER.error("Can't restore origin data: %s", err)
|
||||
raise AddonsError() from err
|
||||
|
||||
# Restore AppArmor
|
||||
profile_file = Path(temp, "apparmor.txt")
|
||||
@@ -877,10 +779,3 @@ class Addon(AddonModel):
|
||||
return await self.start()
|
||||
|
||||
_LOGGER.info("Finished restore for add-on %s", self.slug)
|
||||
|
||||
def check_trust(self) -> Awaitable[None]:
|
||||
"""Calculate Addon docker content trust.
|
||||
|
||||
Return Coroutine.
|
||||
"""
|
||||
return self.instance.check_trust()
|
||||
|
@@ -2,28 +2,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from ..const import (
|
||||
ATTR_ARGS,
|
||||
ATTR_BUILD_FROM,
|
||||
ATTR_LABELS,
|
||||
ATTR_SQUASH,
|
||||
FILE_SUFFIX_CONFIGURATION,
|
||||
META_ADDON,
|
||||
)
|
||||
from ..const import ATTR_ARGS, ATTR_BUILD_FROM, ATTR_SQUASH, META_ADDON
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import ConfigurationFileError
|
||||
from ..utils.common import FileConfiguration, find_one_filetype
|
||||
from ..utils.json import JsonConfig
|
||||
from .validate import SCHEMA_BUILD_CONFIG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnyAddon
|
||||
|
||||
|
||||
class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
class AddonBuild(JsonConfig, CoreSysAttributes):
|
||||
"""Handle build options for add-ons."""
|
||||
|
||||
def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None:
|
||||
@@ -31,14 +21,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
self.coresys: CoreSys = coresys
|
||||
self.addon = addon
|
||||
|
||||
try:
|
||||
build_file = find_one_filetype(
|
||||
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)
|
||||
super().__init__(
|
||||
Path(self.addon.path_location, "build.json"), SCHEMA_BUILD_CONFIG
|
||||
)
|
||||
|
||||
def save_data(self):
|
||||
"""Ignore save function."""
|
||||
@@ -47,12 +32,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
@property
|
||||
def base_image(self) -> str:
|
||||
"""Return base image for this add-on."""
|
||||
if not self._data[ATTR_BUILD_FROM]:
|
||||
return f"ghcr.io/home-assistant/{self.sys_arch.default}-base:latest"
|
||||
|
||||
# Evaluate correct base image
|
||||
arch = self.sys_arch.match(list(self._data[ATTR_BUILD_FROM].keys()))
|
||||
return self._data[ATTR_BUILD_FROM][arch]
|
||||
return self._data[ATTR_BUILD_FROM].get(
|
||||
self.sys_arch.default, f"homeassistant/{self.sys_arch.default}-base:latest"
|
||||
)
|
||||
|
||||
@property
|
||||
def squash(self) -> bool:
|
||||
@@ -60,32 +42,17 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
return self._data[ATTR_SQUASH]
|
||||
|
||||
@property
|
||||
def additional_args(self) -> dict[str, str]:
|
||||
def additional_args(self) -> Dict[str, str]:
|
||||
"""Return additional Docker build arguments."""
|
||||
return self._data[ATTR_ARGS]
|
||||
|
||||
@property
|
||||
def additional_labels(self) -> dict[str, str]:
|
||||
"""Return additional Docker labels."""
|
||||
return self._data[ATTR_LABELS]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""Return true if the build env is valid."""
|
||||
return all(
|
||||
[
|
||||
self.addon.path_location.is_dir(),
|
||||
Path(self.addon.path_location, "Dockerfile").is_file(),
|
||||
]
|
||||
)
|
||||
|
||||
def get_docker_args(self, version: AwesomeVersion):
|
||||
def get_docker_args(self, version):
|
||||
"""Create a dict with Docker build arguments."""
|
||||
args = {
|
||||
"path": str(self.addon.path_location),
|
||||
"tag": f"{self.addon.image}:{version!s}",
|
||||
"tag": f"{self.addon.image}:{version}",
|
||||
"pull": True,
|
||||
"forcerm": not self.sys_dev,
|
||||
"forcerm": True,
|
||||
"squash": self.squash,
|
||||
"labels": {
|
||||
"io.hass.version": version,
|
||||
@@ -93,7 +60,6 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
"io.hass.type": META_ADDON,
|
||||
"io.hass.name": self._fix_label("name"),
|
||||
"io.hass.description": self._fix_label("description"),
|
||||
**self.additional_labels,
|
||||
},
|
||||
"buildargs": {
|
||||
"BUILD_FROM": self.base_image,
|
||||
|
@@ -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"
|
@@ -1,6 +1,7 @@
|
||||
"""Init file for Supervisor add-on data."""
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..const import (
|
||||
ATTR_IMAGE,
|
||||
@@ -12,14 +13,16 @@ from ..const import (
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..store.addon import AddonStore
|
||||
from ..utils.common import FileConfiguration
|
||||
from ..utils.json import JsonConfig
|
||||
from .addon import Addon
|
||||
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."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
|
@@ -1,11 +1,10 @@
|
||||
"""Init file for Supervisor add-ons."""
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Optional
|
||||
from typing import Any, Awaitable, Dict, List, Optional
|
||||
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
|
||||
from supervisor.addons.const import AddonBackupMode
|
||||
from packaging import version as pkg_version
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
ATTR_ADVANCED,
|
||||
@@ -13,9 +12,7 @@ from ..const import (
|
||||
ATTR_ARCH,
|
||||
ATTR_AUDIO,
|
||||
ATTR_AUTH_API,
|
||||
ATTR_BACKUP_EXCLUDE,
|
||||
ATTR_BACKUP_POST,
|
||||
ATTR_BACKUP_PRE,
|
||||
ATTR_AUTO_UART,
|
||||
ATTR_BOOT,
|
||||
ATTR_DESCRIPTON,
|
||||
ATTR_DEVICES,
|
||||
@@ -35,9 +32,7 @@ from ..const import (
|
||||
ATTR_HOST_PID,
|
||||
ATTR_IMAGE,
|
||||
ATTR_INGRESS,
|
||||
ATTR_INGRESS_STREAM,
|
||||
ATTR_INIT,
|
||||
ATTR_JOURNALD,
|
||||
ATTR_KERNEL_MODULES,
|
||||
ATTR_LEGACY,
|
||||
ATTR_LOCATON,
|
||||
@@ -51,18 +46,16 @@ from ..const import (
|
||||
ATTR_PORTS,
|
||||
ATTR_PORTS_DESCRIPTION,
|
||||
ATTR_PRIVILEGED,
|
||||
ATTR_REALTIME,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SERVICES,
|
||||
ATTR_SLUG,
|
||||
ATTR_SNAPSHOT_EXCLUDE,
|
||||
ATTR_STAGE,
|
||||
ATTR_STARTUP,
|
||||
ATTR_STDIN,
|
||||
ATTR_TIMEOUT,
|
||||
ATTR_TMPFS,
|
||||
ATTR_TRANSLATIONS,
|
||||
ATTR_UART,
|
||||
ATTR_UDEV,
|
||||
ATTR_URL,
|
||||
ATTR_USB,
|
||||
@@ -78,12 +71,9 @@ from ..const import (
|
||||
AddonStartup,
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..docker.const import Capabilities
|
||||
from .const import ATTR_BACKUP, ATTR_CODENOTARY
|
||||
from .options import AddonOptions, UiOptions
|
||||
from .validate import RE_SERVICE, RE_VOLUME
|
||||
from .validate import RE_SERVICE, RE_VOLUME, schema_ui_options, validate_options
|
||||
|
||||
Data = dict[str, Any]
|
||||
Data = Dict[str, Any]
|
||||
|
||||
|
||||
class AddonModel(CoreSysAttributes, ABC):
|
||||
@@ -115,7 +105,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self._available(self.data)
|
||||
|
||||
@property
|
||||
def options(self) -> dict[str, Any]:
|
||||
def options(self) -> Dict[str, Any]:
|
||||
"""Return options with local changes."""
|
||||
return self.data[ATTR_OPTIONS]
|
||||
|
||||
@@ -140,7 +130,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.slug.replace("_", "-")
|
||||
|
||||
@property
|
||||
def dns(self) -> list[str]:
|
||||
def dns(self) -> List[str]:
|
||||
"""Return list of DNS name for that add-on."""
|
||||
return []
|
||||
|
||||
@@ -184,7 +174,8 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return None
|
||||
|
||||
# Return data
|
||||
return readme.read_text(encoding="utf-8")
|
||||
with readme.open("r") as readme_file:
|
||||
return readme_file.read()
|
||||
|
||||
@property
|
||||
def repository(self) -> str:
|
||||
@@ -192,17 +183,12 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_REPOSITORY]
|
||||
|
||||
@property
|
||||
def translations(self) -> dict:
|
||||
"""Return add-on translations."""
|
||||
return self.data[ATTR_TRANSLATIONS]
|
||||
|
||||
@property
|
||||
def latest_version(self) -> AwesomeVersion:
|
||||
def latest_version(self) -> str:
|
||||
"""Return latest version of add-on."""
|
||||
return self.data[ATTR_VERSION]
|
||||
|
||||
@property
|
||||
def version(self) -> AwesomeVersion:
|
||||
def version(self) -> Optional[str]:
|
||||
"""Return version of add-on."""
|
||||
return self.data[ATTR_VERSION]
|
||||
|
||||
@@ -227,7 +213,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_STAGE]
|
||||
|
||||
@property
|
||||
def services_role(self) -> dict[str, str]:
|
||||
def services_role(self) -> Dict[str, str]:
|
||||
"""Return dict of services with rights."""
|
||||
services_list = self.data.get(ATTR_SERVICES, [])
|
||||
|
||||
@@ -240,17 +226,17 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return services
|
||||
|
||||
@property
|
||||
def discovery(self) -> list[str]:
|
||||
def discovery(self) -> List[str]:
|
||||
"""Return list of discoverable components/platforms."""
|
||||
return self.data.get(ATTR_DISCOVERY, [])
|
||||
|
||||
@property
|
||||
def ports_description(self) -> Optional[dict[str, str]]:
|
||||
def ports_description(self) -> Optional[Dict[str, str]]:
|
||||
"""Return descriptions of ports."""
|
||||
return self.data.get(ATTR_PORTS_DESCRIPTION)
|
||||
|
||||
@property
|
||||
def ports(self) -> Optional[dict[str, Optional[int]]]:
|
||||
def ports(self) -> Optional[Dict[str, Optional[int]]]:
|
||||
"""Return ports of add-on."""
|
||||
return self.data.get(ATTR_PORTS)
|
||||
|
||||
@@ -310,17 +296,22 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_HOST_DBUS]
|
||||
|
||||
@property
|
||||
def static_devices(self) -> list[Path]:
|
||||
"""Return static devices of add-on."""
|
||||
return [Path(node) for node in self.data.get(ATTR_DEVICES, [])]
|
||||
def devices(self) -> List[str]:
|
||||
"""Return devices of add-on."""
|
||||
return self.data.get(ATTR_DEVICES, [])
|
||||
|
||||
@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 self.data.get(ATTR_ENVIRONMENT)
|
||||
|
||||
@property
|
||||
def privileged(self) -> list[Capabilities]:
|
||||
def privileged(self) -> List[str]:
|
||||
"""Return list of privilege."""
|
||||
return self.data.get(ATTR_PRIVILEGED, [])
|
||||
|
||||
@@ -359,24 +350,9 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_HASSIO_ROLE]
|
||||
|
||||
@property
|
||||
def backup_exclude(self) -> list[str]:
|
||||
"""Return Exclude list for backup."""
|
||||
return self.data.get(ATTR_BACKUP_EXCLUDE, [])
|
||||
|
||||
@property
|
||||
def backup_pre(self) -> 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]
|
||||
def snapshot_exclude(self) -> List[str]:
|
||||
"""Return Exclude list for snapshot."""
|
||||
return self.data.get(ATTR_SNAPSHOT_EXCLUDE, [])
|
||||
|
||||
@property
|
||||
def default_init(self) -> bool:
|
||||
@@ -398,11 +374,6 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
"""Return True if the add-on access support ingress."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def ingress_stream(self) -> bool:
|
||||
"""Return True if post requests to ingress should be streamed."""
|
||||
return self.data[ATTR_INGRESS_STREAM]
|
||||
|
||||
@property
|
||||
def with_gpio(self) -> bool:
|
||||
"""Return True if the add-on access to GPIO interface."""
|
||||
@@ -416,7 +387,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
@property
|
||||
def with_uart(self) -> bool:
|
||||
"""Return True if we should map all UART device."""
|
||||
return self.data[ATTR_UART]
|
||||
return self.data[ATTR_AUTO_UART]
|
||||
|
||||
@property
|
||||
def with_udev(self) -> bool:
|
||||
@@ -428,11 +399,6 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
"""Return True if the add-on access to 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
|
||||
def with_full_access(self) -> bool:
|
||||
"""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 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
|
||||
def access_auth_api(self) -> bool:
|
||||
"""Return True if the add-on access to login/auth backend."""
|
||||
@@ -494,23 +455,15 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.path_documentation.exists()
|
||||
|
||||
@property
|
||||
def supported_arch(self) -> list[str]:
|
||||
def supported_arch(self) -> List[str]:
|
||||
"""Return list of supported arch."""
|
||||
return self.data[ATTR_ARCH]
|
||||
|
||||
@property
|
||||
def supported_machine(self) -> list[str]:
|
||||
def supported_machine(self) -> List[str]:
|
||||
"""Return list of supported machine."""
|
||||
return self.data.get(ATTR_MACHINE, [])
|
||||
|
||||
@property
|
||||
def arch(self) -> str:
|
||||
"""Return architecture to use for the addon's image."""
|
||||
if ATTR_IMAGE in self.data:
|
||||
return self.sys_arch.match(self.data[ATTR_ARCH])
|
||||
|
||||
return self.sys_arch.default
|
||||
|
||||
@property
|
||||
def image(self) -> Optional[str]:
|
||||
"""Generate image name from data."""
|
||||
@@ -522,7 +475,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return ATTR_IMAGE not in self.data
|
||||
|
||||
@property
|
||||
def map_volumes(self) -> dict[str, str]:
|
||||
def map_volumes(self) -> Dict[str, str]:
|
||||
"""Return a dict of {volume: policy} from add-on."""
|
||||
volumes = {}
|
||||
for volume in self.data[ATTR_MAP]:
|
||||
@@ -564,37 +517,22 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return Path(self.path_location, "apparmor.txt")
|
||||
|
||||
@property
|
||||
def schema(self) -> AddonOptions:
|
||||
"""Return Addon options validation object."""
|
||||
def schema(self) -> vol.Schema:
|
||||
"""Create a schema for add-on options."""
|
||||
raw_schema = self.data[ATTR_SCHEMA]
|
||||
if isinstance(raw_schema, bool):
|
||||
raw_schema = {}
|
||||
|
||||
return AddonOptions(self.coresys, raw_schema, self.name, self.slug)
|
||||
if isinstance(raw_schema, bool):
|
||||
return vol.Schema(dict)
|
||||
return vol.Schema(vol.All(dict, validate_options(self.coresys, raw_schema)))
|
||||
|
||||
@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."""
|
||||
raw_schema = self.data[ATTR_SCHEMA]
|
||||
|
||||
if isinstance(raw_schema, bool):
|
||||
return None
|
||||
return UiOptions(self.coresys)(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)
|
||||
return schema_ui_options(raw_schema)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Compaired add-on objects."""
|
||||
@@ -616,10 +554,15 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return False
|
||||
|
||||
# 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:
|
||||
return self.sys_homeassistant.version >= version
|
||||
except (AwesomeVersionException, TypeError):
|
||||
return pkg_version.parse(
|
||||
self.sys_homeassistant.version
|
||||
) >= pkg_version.parse(version)
|
||||
except pkg_version.InvalidVersion:
|
||||
return True
|
||||
|
||||
def _image(self, config) -> str:
|
||||
@@ -640,9 +583,9 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
"""Uninstall this add-on."""
|
||||
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."""
|
||||
return self.sys_addons.update(self.slug, backup=backup)
|
||||
return self.sys_addons.update(self.slug)
|
||||
|
||||
def rebuild(self) -> Awaitable[None]:
|
||||
"""Rebuild this add-on."""
|
||||
|
@@ -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
|
@@ -6,8 +6,18 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..const import ROLE_ADMIN, ROLE_MANAGER, SECURITY_DISABLE, SECURITY_PROFILE
|
||||
from ..docker.const import Capabilities
|
||||
from ..const import (
|
||||
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:
|
||||
from .model import AddonModel
|
||||
@@ -16,10 +26,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def rating_security(addon: AddonModel) -> int:
|
||||
"""Return 1-8 for security rating.
|
||||
"""Return 1-6 for security rating.
|
||||
|
||||
1 = not secure
|
||||
8 = high secure
|
||||
6 = high secure
|
||||
"""
|
||||
rating = 5
|
||||
|
||||
@@ -35,24 +45,17 @@ def rating_security(addon: AddonModel) -> int:
|
||||
elif addon.access_auth_api:
|
||||
rating += 1
|
||||
|
||||
# Signed
|
||||
if addon.signed:
|
||||
rating += 1
|
||||
|
||||
# Privileged options
|
||||
if (
|
||||
any(
|
||||
privilege in addon.privileged
|
||||
for privilege in (
|
||||
Capabilities.NET_ADMIN,
|
||||
Capabilities.SYS_ADMIN,
|
||||
Capabilities.SYS_RAWIO,
|
||||
Capabilities.SYS_PTRACE,
|
||||
Capabilities.SYS_MODULE,
|
||||
Capabilities.DAC_READ_SEARCH,
|
||||
)
|
||||
if any(
|
||||
privilege in addon.privileged
|
||||
for privilege in (
|
||||
PRIVILEGED_NET_ADMIN,
|
||||
PRIVILEGED_SYS_ADMIN,
|
||||
PRIVILEGED_SYS_RAWIO,
|
||||
PRIVILEGED_SYS_PTRACE,
|
||||
PRIVILEGED_SYS_MODULE,
|
||||
PRIVILEGED_DAC_READ_SEARCH,
|
||||
)
|
||||
or addon.with_kernel_modules
|
||||
):
|
||||
rating += -1
|
||||
|
||||
@@ -70,11 +73,15 @@ def rating_security(addon: AddonModel) -> int:
|
||||
if addon.host_pid:
|
||||
rating += -2
|
||||
|
||||
# Docker Access & full Access
|
||||
if addon.access_docker_api or addon.with_full_access:
|
||||
# Full Access
|
||||
if addon.with_full_access:
|
||||
rating += -2
|
||||
|
||||
# Docker Access
|
||||
if addon.access_docker_api:
|
||||
rating = 1
|
||||
|
||||
return max(min(8, rating), 1)
|
||||
return max(min(6, rating), 1)
|
||||
|
||||
|
||||
async def remove_data(folder: Path) -> None:
|
||||
|
@@ -2,13 +2,11 @@
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
from typing import Any, Dict, List, Union
|
||||
import uuid
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from supervisor.addons.const import AddonBackupMode
|
||||
|
||||
from ..const import (
|
||||
ARCH_ALL,
|
||||
ATTR_ACCESS_TOKEN,
|
||||
@@ -20,13 +18,10 @@ from ..const import (
|
||||
ATTR_AUDIO_INPUT,
|
||||
ATTR_AUDIO_OUTPUT,
|
||||
ATTR_AUTH_API,
|
||||
ATTR_AUTO_UART,
|
||||
ATTR_AUTO_UPDATE,
|
||||
ATTR_BACKUP_EXCLUDE,
|
||||
ATTR_BACKUP_POST,
|
||||
ATTR_BACKUP_PRE,
|
||||
ATTR_BOOT,
|
||||
ATTR_BUILD_FROM,
|
||||
ATTR_CONFIGURATION,
|
||||
ATTR_DESCRIPTON,
|
||||
ATTR_DEVICES,
|
||||
ATTR_DEVICETREE,
|
||||
@@ -48,12 +43,9 @@ from ..const import (
|
||||
ATTR_INGRESS_ENTRY,
|
||||
ATTR_INGRESS_PANEL,
|
||||
ATTR_INGRESS_PORT,
|
||||
ATTR_INGRESS_STREAM,
|
||||
ATTR_INGRESS_TOKEN,
|
||||
ATTR_INIT,
|
||||
ATTR_JOURNALD,
|
||||
ATTR_KERNEL_MODULES,
|
||||
ATTR_LABELS,
|
||||
ATTR_LEGACY,
|
||||
ATTR_LOCATON,
|
||||
ATTR_MACHINE,
|
||||
@@ -68,11 +60,11 @@ from ..const import (
|
||||
ATTR_PORTS_DESCRIPTION,
|
||||
ATTR_PRIVILEGED,
|
||||
ATTR_PROTECTED,
|
||||
ATTR_REALTIME,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SERVICES,
|
||||
ATTR_SLUG,
|
||||
ATTR_SNAPSHOT_EXCLUDE,
|
||||
ATTR_SQUASH,
|
||||
ATTR_STAGE,
|
||||
ATTR_STARTUP,
|
||||
@@ -81,8 +73,6 @@ from ..const import (
|
||||
ATTR_SYSTEM,
|
||||
ATTR_TIMEOUT,
|
||||
ATTR_TMPFS,
|
||||
ATTR_TRANSLATIONS,
|
||||
ATTR_UART,
|
||||
ATTR_UDEV,
|
||||
ATTR_URL,
|
||||
ATTR_USB,
|
||||
@@ -92,6 +82,7 @@ from ..const import (
|
||||
ATTR_VIDEO,
|
||||
ATTR_WATCHDOG,
|
||||
ATTR_WEBUI,
|
||||
PRIVILEGED_ALL,
|
||||
ROLE_ALL,
|
||||
ROLE_DEFAULT,
|
||||
AddonBoot,
|
||||
@@ -99,10 +90,9 @@ from ..const import (
|
||||
AddonStartup,
|
||||
AddonState,
|
||||
)
|
||||
from ..coresys import CoreSys
|
||||
from ..discovery.validate import valid_discovery_service
|
||||
from ..docker.const import Capabilities
|
||||
from ..validate import (
|
||||
docker_image,
|
||||
docker_ports,
|
||||
docker_ports_description,
|
||||
network_port,
|
||||
@@ -110,15 +100,51 @@ from ..validate import (
|
||||
uuid_match,
|
||||
version_tag,
|
||||
)
|
||||
from .const import ATTR_BACKUP, ATTR_CODENOTARY
|
||||
from .options import RE_SCHEMA_ELEMENT
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)$")
|
||||
|
||||
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(
|
||||
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$"
|
||||
)
|
||||
@@ -128,9 +154,7 @@ SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
|
||||
RE_MACHINE = re.compile(
|
||||
r"^!?(?:"
|
||||
r"|intel-nuc"
|
||||
r"|generic-x86-64"
|
||||
r"|odroid-c2"
|
||||
r"|odroid-c4"
|
||||
r"|odroid-n2"
|
||||
r"|odroid-xu"
|
||||
r"|qemuarm-64"
|
||||
@@ -148,126 +172,34 @@ RE_MACHINE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _warn_addon_config(config: dict[str, Any]):
|
||||
"""Warn about miss configs."""
|
||||
name = config.get(ATTR_NAME)
|
||||
if not name:
|
||||
raise vol.Invalid("Invalid Add-on config!")
|
||||
|
||||
if config.get(ATTR_FULL_ACCESS, False) and (
|
||||
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
|
||||
def _simple_startup(value) -> str:
|
||||
"""Define startup schema."""
|
||||
if value == "before":
|
||||
return AddonStartup.SERVICES.value
|
||||
if value == "after":
|
||||
return AddonStartup.APPLICATION.value
|
||||
return value
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
_SCHEMA_ADDON_CONFIG = vol.Schema(
|
||||
SCHEMA_ADDON_CONFIG = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_NAME): str,
|
||||
vol.Required(ATTR_VERSION): version_tag,
|
||||
vol.Required(ATTR_SLUG): str,
|
||||
vol.Required(ATTR_DESCRIPTON): str,
|
||||
vol.Required(ATTR_NAME): vol.Coerce(str),
|
||||
vol.Required(ATTR_VERSION): vol.All(version_tag, str),
|
||||
vol.Required(ATTR_SLUG): vol.Coerce(str),
|
||||
vol.Required(ATTR_DESCRIPTON): vol.Coerce(str),
|
||||
vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)],
|
||||
vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()),
|
||||
vol.Optional(ATTR_URL): vol.Url(),
|
||||
vol.Optional(ATTR_STARTUP, default=AddonStartup.APPLICATION): vol.Coerce(
|
||||
AddonStartup
|
||||
),
|
||||
vol.Optional(ATTR_BOOT, default=AddonBoot.AUTO): vol.Coerce(AddonBoot),
|
||||
vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.Coerce(AddonStartup)),
|
||||
vol.Required(ATTR_BOOT): vol.Coerce(AddonBoot),
|
||||
vol.Optional(ATTR_INIT, default=True): vol.Boolean(),
|
||||
vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_STAGE, default=AddonStage.STABLE): vol.Coerce(AddonStage),
|
||||
vol.Optional(ATTR_PORTS): docker_ports,
|
||||
vol.Optional(ATTR_PORTS_DESCRIPTION): docker_ports_description,
|
||||
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(
|
||||
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(
|
||||
network_port, vol.Equal(0)
|
||||
),
|
||||
vol.Optional(ATTR_INGRESS_ENTRY): str,
|
||||
vol.Optional(ATTR_INGRESS_STREAM, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): str,
|
||||
vol.Optional(ATTR_PANEL_TITLE): str,
|
||||
vol.Optional(ATTR_INGRESS_ENTRY): vol.Coerce(str),
|
||||
vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): vol.Coerce(str),
|
||||
vol.Optional(ATTR_PANEL_TITLE): vol.Coerce(str),
|
||||
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_PID, 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_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_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_ENVIRONMENT): {vol.Match(r"\w*"): str},
|
||||
vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)],
|
||||
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
|
||||
vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
|
||||
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
|
||||
vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_VIDEO, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_GPIO, 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_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_ROLE, default=ROLE_DEFAULT): vol.In(ROLE_ALL),
|
||||
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_SERVICES): [vol.Match(RE_SERVICE)],
|
||||
vol.Optional(ATTR_DISCOVERY): [valid_discovery_service],
|
||||
vol.Optional(ATTR_BACKUP_EXCLUDE): [str],
|
||||
vol.Optional(ATTR_BACKUP_PRE): str,
|
||||
vol.Optional(ATTR_BACKUP_POST): str,
|
||||
vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce(
|
||||
AddonBackupMode
|
||||
),
|
||||
vol.Optional(ATTR_CODENOTARY): vol.Email(),
|
||||
vol.Optional(ATTR_OPTIONS, default={}): dict,
|
||||
vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
|
||||
vol.Optional(ATTR_SNAPSHOT_EXCLUDE): [vol.Coerce(str)],
|
||||
vol.Required(ATTR_OPTIONS): dict,
|
||||
vol.Required(ATTR_SCHEMA): vol.Any(
|
||||
vol.Schema(
|
||||
{
|
||||
str: vol.Any(
|
||||
vol.Coerce(str): vol.Any(
|
||||
SCHEMA_ELEMENT,
|
||||
[
|
||||
vol.Any(
|
||||
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,
|
||||
),
|
||||
vol.Optional(ATTR_IMAGE): docker_image,
|
||||
vol.Optional(ATTR_IMAGE): vol.Match(RE_DOCKER_IMAGE),
|
||||
vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=10, max=300)
|
||||
),
|
||||
vol.Optional(ATTR_JOURNALD, default=False): vol.Boolean(),
|
||||
},
|
||||
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
|
||||
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.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}),
|
||||
vol.Optional(ATTR_LABELS, default=dict): vol.Schema({str: str}),
|
||||
},
|
||||
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},
|
||||
vol.Optional(ATTR_ARGS, default=dict): vol.Schema(
|
||||
{vol.Coerce(str): vol.Coerce(str)}
|
||||
),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
@@ -384,17 +293,19 @@ SCHEMA_ADDON_TRANSLATIONS = vol.Schema(
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_ADDON_USER = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_VERSION): version_tag,
|
||||
vol.Optional(ATTR_IMAGE): docker_image,
|
||||
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
||||
vol.Optional(ATTR_IMAGE): vol.Coerce(str),
|
||||
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): uuid_match,
|
||||
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_AUTO_UPDATE, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
||||
vol.Optional(ATTR_NETWORK): docker_ports,
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
|
||||
vol.Optional(ATTR_INGRESS_PANEL, 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,
|
||||
)
|
||||
|
||||
SCHEMA_ADDON_SYSTEM = vol.All(
|
||||
_migrate_addon_config(),
|
||||
_SCHEMA_ADDON_CONFIG.extend(
|
||||
{
|
||||
vol.Required(ATTR_LOCATON): str,
|
||||
vol.Required(ATTR_REPOSITORY): str,
|
||||
vol.Required(ATTR_TRANSLATIONS, default=dict): {
|
||||
str: SCHEMA_ADDON_TRANSLATIONS
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
SCHEMA_ADDON_SYSTEM = SCHEMA_ADDON_CONFIG.extend(
|
||||
{
|
||||
vol.Required(ATTR_LOCATON): vol.Coerce(str),
|
||||
vol.Required(ATTR_REPOSITORY): vol.Coerce(str),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_ADDONS_FILE = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_USER, default=dict): {str: SCHEMA_ADDON_USER},
|
||||
vol.Optional(ATTR_SYSTEM, default=dict): {str: SCHEMA_ADDON_SYSTEM},
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
vol.Optional(ATTR_USER, default=dict): {vol.Coerce(str): SCHEMA_ADDON_USER},
|
||||
vol.Optional(ATTR_SYSTEM, default=dict): {vol.Coerce(str): SCHEMA_ADDON_SYSTEM},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_ADDON_BACKUP = vol.Schema(
|
||||
SCHEMA_ADDON_SNAPSHOT = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_USER): SCHEMA_ADDON_USER,
|
||||
vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM,
|
||||
vol.Required(ATTR_STATE): vol.Coerce(AddonState),
|
||||
vol.Required(ATTR_VERSION): version_tag,
|
||||
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
||||
},
|
||||
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)
|
||||
|
@@ -9,7 +9,6 @@ from ..coresys import CoreSys, CoreSysAttributes
|
||||
from .addons import APIAddons
|
||||
from .audio import APIAudio
|
||||
from .auth import APIAuth
|
||||
from .backups import APIBackups
|
||||
from .cli import APICli
|
||||
from .discovery import APIDiscovery
|
||||
from .dns import APICoreDNS
|
||||
@@ -17,25 +16,23 @@ from .docker import APIDocker
|
||||
from .hardware import APIHardware
|
||||
from .homeassistant import APIHomeAssistant
|
||||
from .host import APIHost
|
||||
from .info import APIInfo
|
||||
from .ingress import APIIngress
|
||||
from .jobs import APIJobs
|
||||
from .middleware.security import SecurityMiddleware
|
||||
from .multicast import APIMulticast
|
||||
from .network import APINetwork
|
||||
from .observer import APIObserver
|
||||
from .os import APIOS
|
||||
from .proxy import APIProxy
|
||||
from .resolution import APIResoulution
|
||||
from .root import APIRoot
|
||||
from .security import APISecurity
|
||||
from .security import SecurityMiddleware
|
||||
from .services import APIServices
|
||||
from .store import APIStore
|
||||
from .snapshots import APISnapshots
|
||||
from .supervisor import APISupervisor
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MAX_CLIENT_SIZE: int = 1024**2 * 16
|
||||
MAX_CLIENT_SIZE: int = 1024 ** 2 * 16
|
||||
|
||||
|
||||
class RestAPI(CoreSysAttributes):
|
||||
@@ -62,7 +59,6 @@ class RestAPI(CoreSysAttributes):
|
||||
self._register_addons()
|
||||
self._register_audio()
|
||||
self._register_auth()
|
||||
self._register_backups()
|
||||
self._register_cli()
|
||||
self._register_discovery()
|
||||
self._register_dns()
|
||||
@@ -70,22 +66,18 @@ class RestAPI(CoreSysAttributes):
|
||||
self._register_hardware()
|
||||
self._register_homeassistant()
|
||||
self._register_host()
|
||||
self._register_root()
|
||||
self._register_info()
|
||||
self._register_ingress()
|
||||
self._register_multicast()
|
||||
self._register_network()
|
||||
self._register_observer()
|
||||
self._register_os()
|
||||
self._register_jobs()
|
||||
self._register_panel()
|
||||
self._register_proxy()
|
||||
self._register_resolution()
|
||||
self._register_services()
|
||||
self._register_snapshots()
|
||||
self._register_supervisor()
|
||||
self._register_store()
|
||||
self._register_security()
|
||||
|
||||
await self.start()
|
||||
|
||||
def _register_host(self) -> None:
|
||||
"""Register hostcontrol functions."""
|
||||
@@ -116,7 +108,6 @@ class RestAPI(CoreSysAttributes):
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
web.get("/network/info", api_network.info),
|
||||
web.post("/network/reload", api_network.reload),
|
||||
web.get(
|
||||
"/network/interface/{interface}/info", api_network.interface_info
|
||||
),
|
||||
@@ -124,14 +115,6 @@ class RestAPI(CoreSysAttributes):
|
||||
"/network/interface/{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.post("/os/update", api_os.update),
|
||||
web.post("/os/config/sync", api_os.config_sync),
|
||||
web.post("/os/datadisk/move", api_os.migrate_data),
|
||||
web.get("/os/datadisk/list", api_os.list_data),
|
||||
]
|
||||
)
|
||||
|
||||
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/audio", api_hardware.audio),
|
||||
web.post("/hardware/trigger", api_hardware.trigger),
|
||||
]
|
||||
)
|
||||
|
||||
def _register_root(self) -> None:
|
||||
"""Register root functions."""
|
||||
api_root = APIRoot()
|
||||
api_root.coresys = self.coresys
|
||||
def _register_info(self) -> None:
|
||||
"""Register info functions."""
|
||||
api_info = APIInfo()
|
||||
api_info.coresys = self.coresys
|
||||
|
||||
self.webapp.add_routes([web.get("/info", api_root.info)])
|
||||
self.webapp.add_routes([web.post("/refresh_updates", api_root.refresh_updates)])
|
||||
self.webapp.add_routes(
|
||||
[web.get("/available_updates", api_root.available_updates)]
|
||||
)
|
||||
|
||||
# Remove: 2023
|
||||
self.webapp.add_routes(
|
||||
[web.get("/supervisor/available_updates", api_root.available_updates)]
|
||||
)
|
||||
self.webapp.add_routes([web.get("/info", api_info.info)])
|
||||
|
||||
def _register_resolution(self) -> None:
|
||||
"""Register info functions."""
|
||||
@@ -253,10 +200,6 @@ class RestAPI(CoreSysAttributes):
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
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(
|
||||
"/resolution/suggestion/{suggestion}",
|
||||
api_resolution.apply_suggestion,
|
||||
@@ -269,7 +212,6 @@ class RestAPI(CoreSysAttributes):
|
||||
"/resolution/issue/{issue}",
|
||||
api_resolution.dismiss_issue,
|
||||
),
|
||||
web.post("/resolution/healthcheck", api_resolution.healthcheck),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -280,7 +222,6 @@ class RestAPI(CoreSysAttributes):
|
||||
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
web.get("/auth", api_auth.auth),
|
||||
web.post("/auth", api_auth.auth),
|
||||
web.post("/auth/reset", api_auth.reset),
|
||||
web.delete("/auth/cache", api_auth.cache),
|
||||
@@ -300,7 +241,6 @@ class RestAPI(CoreSysAttributes):
|
||||
web.get("/supervisor/logs", api_supervisor.logs),
|
||||
web.post("/supervisor/update", api_supervisor.update),
|
||||
web.post("/supervisor/reload", api_supervisor.reload),
|
||||
web.post("/supervisor/restart", api_supervisor.restart),
|
||||
web.post("/supervisor/options", api_supervisor.options),
|
||||
web.post("/supervisor/repair", api_supervisor.repair),
|
||||
]
|
||||
@@ -323,22 +263,17 @@ class RestAPI(CoreSysAttributes):
|
||||
web.post("/core/start", api_hass.start),
|
||||
web.post("/core/check", api_hass.check),
|
||||
web.post("/core/rebuild", api_hass.rebuild),
|
||||
]
|
||||
)
|
||||
|
||||
# Reroute from legacy
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
# Remove with old Supervisor fallback
|
||||
web.get("/homeassistant/info", api_hass.info),
|
||||
web.get("/homeassistant/logs", api_hass.logs),
|
||||
web.get("/homeassistant/stats", api_hass.stats),
|
||||
web.post("/homeassistant/options", api_hass.options),
|
||||
web.post("/homeassistant/update", api_hass.update),
|
||||
web.post("/homeassistant/restart", api_hass.restart),
|
||||
web.post("/homeassistant/stop", api_hass.stop),
|
||||
web.post("/homeassistant/start", api_hass.start),
|
||||
web.post("/homeassistant/update", api_hass.update),
|
||||
web.post("/homeassistant/rebuild", api_hass.rebuild),
|
||||
web.post("/homeassistant/check", api_hass.check),
|
||||
web.post("/homeassistant/rebuild", api_hass.rebuild),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -355,12 +290,7 @@ class RestAPI(CoreSysAttributes):
|
||||
web.post("/core/api/{path:.+}", api_proxy.api),
|
||||
web.get("/core/api/{path:.+}", api_proxy.api),
|
||||
web.get("/core/api/", api_proxy.api),
|
||||
]
|
||||
)
|
||||
|
||||
# Reroute from legacy
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
# Remove with old Supervisor fallback
|
||||
web.get("/homeassistant/api/websocket", api_proxy.websocket),
|
||||
web.get("/homeassistant/websocket", api_proxy.websocket),
|
||||
web.get("/homeassistant/api/stream", api_proxy.stream),
|
||||
@@ -378,18 +308,24 @@ class RestAPI(CoreSysAttributes):
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
web.get("/addons", api_addons.list),
|
||||
web.post("/addons/reload", api_addons.reload),
|
||||
web.get("/addons/{addon}/info", api_addons.info),
|
||||
web.post("/addons/{addon}/install", api_addons.install),
|
||||
web.post("/addons/{addon}/uninstall", api_addons.uninstall),
|
||||
web.post("/addons/{addon}/start", api_addons.start),
|
||||
web.post("/addons/{addon}/stop", api_addons.stop),
|
||||
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/validate", api_addons.options_validate
|
||||
),
|
||||
web.get("/addons/{addon}/options/config", api_addons.options_config),
|
||||
web.post("/addons/{addon}/rebuild", api_addons.rebuild),
|
||||
web.get("/addons/{addon}/logs", api_addons.logs),
|
||||
web.get("/addons/{addon}/icon", api_addons.icon),
|
||||
web.get("/addons/{addon}/logo", api_addons.logo),
|
||||
web.get("/addons/{addon}/changelog", api_addons.changelog),
|
||||
web.get("/addons/{addon}/documentation", api_addons.documentation),
|
||||
web.post("/addons/{addon}/stdin", api_addons.stdin),
|
||||
web.post("/addons/{addon}/security", api_addons.security),
|
||||
web.get("/addons/{addon}/stats", api_addons.stats),
|
||||
@@ -404,32 +340,35 @@ class RestAPI(CoreSysAttributes):
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
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.view("/ingress/{token}/{path:.*}", api_ingress.handler),
|
||||
]
|
||||
)
|
||||
|
||||
def _register_backups(self) -> None:
|
||||
"""Register backups functions."""
|
||||
api_backups = APIBackups()
|
||||
api_backups.coresys = self.coresys
|
||||
def _register_snapshots(self) -> None:
|
||||
"""Register snapshots functions."""
|
||||
api_snapshots = APISnapshots()
|
||||
api_snapshots.coresys = self.coresys
|
||||
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
web.get("/backups", api_backups.list),
|
||||
web.post("/backups/reload", api_backups.reload),
|
||||
web.post("/backups/new/full", api_backups.backup_full),
|
||||
web.post("/backups/new/partial", api_backups.backup_partial),
|
||||
web.post("/backups/new/upload", api_backups.upload),
|
||||
web.get("/backups/{slug}/info", api_backups.info),
|
||||
web.delete("/backups/{slug}", api_backups.remove),
|
||||
web.post("/backups/{slug}/restore/full", api_backups.restore_full),
|
||||
web.get("/snapshots", api_snapshots.list),
|
||||
web.post("/snapshots/reload", api_snapshots.reload),
|
||||
web.post("/snapshots/new/full", api_snapshots.snapshot_full),
|
||||
web.post("/snapshots/new/partial", api_snapshots.snapshot_partial),
|
||||
web.post("/snapshots/new/upload", api_snapshots.upload),
|
||||
web.get("/snapshots/{snapshot}/info", api_snapshots.info),
|
||||
web.delete("/snapshots/{snapshot}", api_snapshots.remove),
|
||||
web.post(
|
||||
"/backups/{slug}/restore/partial",
|
||||
api_backups.restore_partial,
|
||||
"/snapshots/{snapshot}/restore/full", api_snapshots.restore_full
|
||||
),
|
||||
web.get("/backups/{slug}/download", api_backups.download),
|
||||
web.post(
|
||||
"/snapshots/{snapshot}/restore/partial",
|
||||
api_snapshots.restore_partial,
|
||||
),
|
||||
web.get("/snapshots/{snapshot}/download", api_snapshots.download),
|
||||
# Old, remove at end of 2020
|
||||
web.post("/snapshots/{snapshot}/remove", api_snapshots.remove),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""Register panel for Home Assistant."""
|
||||
panel_dir = Path(__file__).parent.joinpath("panel")
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Awaitable, Dict, List
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -52,11 +52,13 @@ from ..const import (
|
||||
ATTR_INGRESS_PANEL,
|
||||
ATTR_INGRESS_PORT,
|
||||
ATTR_INGRESS_URL,
|
||||
ATTR_INSTALLED,
|
||||
ATTR_IP_ADDRESS,
|
||||
ATTR_KERNEL_MODULES,
|
||||
ATTR_LOGO,
|
||||
ATTR_LONG_DESCRIPTION,
|
||||
ATTR_MACHINE,
|
||||
ATTR_MAINTAINER,
|
||||
ATTR_MEMORY_LIMIT,
|
||||
ATTR_MEMORY_PERCENT,
|
||||
ATTR_MEMORY_USAGE,
|
||||
@@ -69,20 +71,18 @@ from ..const import (
|
||||
ATTR_OPTIONS,
|
||||
ATTR_PRIVILEGED,
|
||||
ATTR_PROTECTED,
|
||||
ATTR_PWNED,
|
||||
ATTR_RATING,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SERVICES,
|
||||
ATTR_SLUG,
|
||||
ATTR_SOURCE,
|
||||
ATTR_STAGE,
|
||||
ATTR_STARTUP,
|
||||
ATTR_STATE,
|
||||
ATTR_STDIN,
|
||||
ATTR_TRANSLATIONS,
|
||||
ATTR_UART,
|
||||
ATTR_UDEV,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_URL,
|
||||
ATTR_USB,
|
||||
ATTR_VALID,
|
||||
@@ -91,19 +91,22 @@ from ..const import (
|
||||
ATTR_VIDEO,
|
||||
ATTR_WATCHDOG,
|
||||
ATTR_WEBUI,
|
||||
CONTENT_TYPE_BINARY,
|
||||
CONTENT_TYPE_PNG,
|
||||
CONTENT_TYPE_TEXT,
|
||||
REQUEST_FROM,
|
||||
AddonBoot,
|
||||
AddonState,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..docker.stats import DockerStats
|
||||
from ..exceptions import APIError, APIForbidden, PwnedError, PwnedSecret
|
||||
from ..exceptions import APIError
|
||||
from ..validate import docker_ports
|
||||
from .const import ATTR_SIGNED, CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate, json_loads
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): str})
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_OPTIONS = vol.Schema(
|
||||
@@ -111,8 +114,8 @@ SCHEMA_OPTIONS = vol.Schema(
|
||||
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
||||
vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports),
|
||||
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
|
||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||
}
|
||||
@@ -125,7 +128,7 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
|
||||
class APIAddons(CoreSysAttributes):
|
||||
"""Handle RESTful API for add-on functions."""
|
||||
|
||||
def _extract_addon(self, request: web.Request) -> Addon:
|
||||
def _extract_addon(self, request: web.Request) -> AnyAddon:
|
||||
"""Return addon, throw an exception it it doesn't exist."""
|
||||
addon_slug: str = request.match_info.get("addon")
|
||||
|
||||
@@ -139,13 +142,17 @@ class APIAddons(CoreSysAttributes):
|
||||
addon = self.sys_addons.get(addon_slug)
|
||||
if not addon:
|
||||
raise APIError(f"Addon {addon_slug} does not exist")
|
||||
if not isinstance(addon, Addon) or not addon.is_installed:
|
||||
raise APIError("Addon is not installed")
|
||||
|
||||
return addon
|
||||
|
||||
def _extract_addon_installed(self, request: web.Request) -> Addon:
|
||||
addon = self._extract_addon(request)
|
||||
if not isinstance(addon, Addon) or not addon.is_installed:
|
||||
raise APIError("Addon is not installed")
|
||||
return addon
|
||||
|
||||
@api_process
|
||||
async def list(self, request: web.Request) -> dict[str, Any]:
|
||||
async def list(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return all add-ons or repositories."""
|
||||
data_addons = [
|
||||
{
|
||||
@@ -154,23 +161,30 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_DESCRIPTON: addon.description,
|
||||
ATTR_ADVANCED: addon.advanced,
|
||||
ATTR_STAGE: addon.stage,
|
||||
ATTR_VERSION: addon.version,
|
||||
ATTR_VERSION_LATEST: addon.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||
ATTR_VERSION: addon.latest_version,
|
||||
ATTR_INSTALLED: addon.version if addon.is_installed else None,
|
||||
ATTR_AVAILABLE: addon.available,
|
||||
ATTR_DETACHED: addon.is_detached,
|
||||
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
||||
ATTR_STATE: addon.state,
|
||||
ATTR_REPOSITORY: addon.repository,
|
||||
ATTR_BUILD: addon.need_build,
|
||||
ATTR_URL: addon.url,
|
||||
ATTR_ICON: addon.with_icon,
|
||||
ATTR_LOGO: addon.with_logo,
|
||||
}
|
||||
for addon in self.sys_addons.installed
|
||||
for addon in self.sys_addons.all
|
||||
]
|
||||
|
||||
return {ATTR_ADDONS: data_addons}
|
||||
data_repositories = [
|
||||
{
|
||||
ATTR_SLUG: repository.slug,
|
||||
ATTR_NAME: repository.name,
|
||||
ATTR_SOURCE: repository.source,
|
||||
ATTR_URL: repository.url,
|
||||
ATTR_MAINTAINER: repository.maintainer,
|
||||
}
|
||||
for repository in self.sys_store.all
|
||||
]
|
||||
return {ATTR_ADDONS: data_addons, ATTR_REPOSITORIES: data_repositories}
|
||||
|
||||
@api_process
|
||||
async def reload(self, request: web.Request) -> None:
|
||||
@@ -178,7 +192,7 @@ class APIAddons(CoreSysAttributes):
|
||||
await asyncio.shield(self.sys_store.reload())
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return add-on information."""
|
||||
addon: AnyAddon = self._extract_addon(request)
|
||||
|
||||
@@ -191,7 +205,9 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_LONG_DESCRIPTION: addon.long_description,
|
||||
ATTR_ADVANCED: addon.advanced,
|
||||
ATTR_STAGE: addon.stage,
|
||||
ATTR_AUTO_UPDATE: None,
|
||||
ATTR_REPOSITORY: addon.repository,
|
||||
ATTR_VERSION: None,
|
||||
ATTR_VERSION_LATEST: addon.latest_version,
|
||||
ATTR_PROTECTED: addon.protected,
|
||||
ATTR_RATING: rating_security(addon),
|
||||
@@ -202,6 +218,7 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_MACHINE: addon.supported_machine,
|
||||
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
||||
ATTR_URL: addon.url,
|
||||
ATTR_STATE: AddonState.UNKNOWN,
|
||||
ATTR_DETACHED: addon.is_detached,
|
||||
ATTR_AVAILABLE: addon.available,
|
||||
ATTR_BUILD: addon.need_build,
|
||||
@@ -214,60 +231,70 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_PRIVILEGED: addon.privileged,
|
||||
ATTR_FULL_ACCESS: addon.with_full_access,
|
||||
ATTR_APPARMOR: addon.apparmor,
|
||||
ATTR_DEVICES: _pretty_devices(addon),
|
||||
ATTR_ICON: addon.with_icon,
|
||||
ATTR_LOGO: addon.with_logo,
|
||||
ATTR_CHANGELOG: addon.with_changelog,
|
||||
ATTR_DOCUMENTATION: addon.with_documentation,
|
||||
ATTR_STDIN: addon.with_stdin,
|
||||
ATTR_WEBUI: None,
|
||||
ATTR_HASSIO_API: addon.access_hassio_api,
|
||||
ATTR_HASSIO_ROLE: addon.hassio_role,
|
||||
ATTR_AUTH_API: addon.access_auth_api,
|
||||
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
|
||||
ATTR_GPIO: addon.with_gpio,
|
||||
ATTR_USB: addon.with_usb,
|
||||
ATTR_UART: addon.with_uart,
|
||||
ATTR_KERNEL_MODULES: addon.with_kernel_modules,
|
||||
ATTR_DEVICETREE: addon.with_devicetree,
|
||||
ATTR_UDEV: addon.with_udev,
|
||||
ATTR_DOCKER_API: addon.access_docker_api,
|
||||
ATTR_VIDEO: addon.with_video,
|
||||
ATTR_AUDIO: addon.with_audio,
|
||||
ATTR_AUDIO_INPUT: None,
|
||||
ATTR_AUDIO_OUTPUT: None,
|
||||
ATTR_STARTUP: addon.startup,
|
||||
ATTR_SERVICES: _pretty_services(addon),
|
||||
ATTR_DISCOVERY: addon.discovery,
|
||||
ATTR_TRANSLATIONS: addon.translations,
|
||||
ATTR_IP_ADDRESS: None,
|
||||
ATTR_INGRESS: addon.with_ingress,
|
||||
ATTR_SIGNED: addon.signed,
|
||||
ATTR_STATE: addon.state,
|
||||
ATTR_WEBUI: addon.webui,
|
||||
ATTR_INGRESS_ENTRY: addon.ingress_entry,
|
||||
ATTR_INGRESS_URL: addon.ingress_url,
|
||||
ATTR_INGRESS_PORT: addon.ingress_port,
|
||||
ATTR_INGRESS_PANEL: addon.ingress_panel,
|
||||
ATTR_AUDIO_INPUT: addon.audio_input,
|
||||
ATTR_AUDIO_OUTPUT: addon.audio_output,
|
||||
ATTR_AUTO_UPDATE: addon.auto_update,
|
||||
ATTR_IP_ADDRESS: str(addon.ip_address),
|
||||
ATTR_VERSION: addon.version,
|
||||
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||
ATTR_WATCHDOG: addon.watchdog,
|
||||
ATTR_DEVICES: addon.static_devices
|
||||
+ [device.path for device in addon.devices],
|
||||
ATTR_INGRESS_ENTRY: None,
|
||||
ATTR_INGRESS_URL: None,
|
||||
ATTR_INGRESS_PORT: None,
|
||||
ATTR_INGRESS_PANEL: None,
|
||||
ATTR_WATCHDOG: None,
|
||||
}
|
||||
|
||||
if isinstance(addon, Addon) and addon.is_installed:
|
||||
data.update(
|
||||
{
|
||||
ATTR_STATE: addon.state,
|
||||
ATTR_WEBUI: addon.webui,
|
||||
ATTR_INGRESS_ENTRY: addon.ingress_entry,
|
||||
ATTR_INGRESS_URL: addon.ingress_url,
|
||||
ATTR_INGRESS_PORT: addon.ingress_port,
|
||||
ATTR_INGRESS_PANEL: addon.ingress_panel,
|
||||
ATTR_AUDIO_INPUT: addon.audio_input,
|
||||
ATTR_AUDIO_OUTPUT: addon.audio_output,
|
||||
ATTR_AUTO_UPDATE: addon.auto_update,
|
||||
ATTR_IP_ADDRESS: str(addon.ip_address),
|
||||
ATTR_VERSION: addon.version,
|
||||
ATTR_WATCHDOG: addon.watchdog,
|
||||
}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@api_process
|
||||
async def options(self, request: web.Request) -> None:
|
||||
"""Store user options for add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
|
||||
# Update secrets for validation
|
||||
await self.sys_homeassistant.secrets.reload()
|
||||
|
||||
# Extend schema with add-on specific validation
|
||||
addon_schema = SCHEMA_OPTIONS.extend(
|
||||
{vol.Optional(ATTR_OPTIONS): vol.Maybe(addon.schema)}
|
||||
{vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema)}
|
||||
)
|
||||
|
||||
# Validate/Process Body
|
||||
@@ -295,62 +322,21 @@ class APIAddons(CoreSysAttributes):
|
||||
@api_process
|
||||
async def options_validate(self, request: web.Request) -> None:
|
||||
"""Validate user options for add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False}
|
||||
|
||||
options = await request.json(loads=json_loads) or addon.options
|
||||
|
||||
# Validate config
|
||||
options_schema = addon.schema
|
||||
addon = self._extract_addon_installed(request)
|
||||
data = {ATTR_MESSAGE: "", ATTR_VALID: True}
|
||||
try:
|
||||
options_schema.validate(options)
|
||||
addon.schema(addon.options)
|
||||
except vol.Invalid as ex:
|
||||
data[ATTR_MESSAGE] = humanize_error(options, ex)
|
||||
data[ATTR_MESSAGE] = humanize_error(addon.options, ex)
|
||||
data[ATTR_VALID] = False
|
||||
|
||||
if not self.sys_security.pwned:
|
||||
return data
|
||||
|
||||
# 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
|
||||
|
||||
@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
|
||||
async def security(self, request: web.Request) -> None:
|
||||
"""Store security options for add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
body: Dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
|
||||
|
||||
if ATTR_PROTECTED in body:
|
||||
_LOGGER.warning("Changing protected flag for %s!", addon.slug)
|
||||
@@ -359,9 +345,9 @@ class APIAddons(CoreSysAttributes):
|
||||
addon.save_persist()
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
|
||||
stats: DockerStats = await addon.stats()
|
||||
|
||||
@@ -376,46 +362,98 @@ class APIAddons(CoreSysAttributes):
|
||||
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
|
||||
def uninstall(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Uninstall add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
return asyncio.shield(addon.uninstall())
|
||||
|
||||
@api_process
|
||||
def start(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Start add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
return asyncio.shield(addon.start())
|
||||
|
||||
@api_process
|
||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Stop add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
return asyncio.shield(addon.stop())
|
||||
|
||||
@api_process
|
||||
def update(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Update add-on."""
|
||||
addon: Addon = self._extract_addon_installed(request)
|
||||
return asyncio.shield(addon.update())
|
||||
|
||||
@api_process
|
||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Restart add-on."""
|
||||
addon: Addon = self._extract_addon(request)
|
||||
addon: Addon = self._extract_addon_installed(request)
|
||||
return asyncio.shield(addon.restart())
|
||||
|
||||
@api_process
|
||||
def rebuild(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Rebuild local build add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
return asyncio.shield(addon.rebuild())
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
||||
"""Return logs from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
return addon.logs()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_PNG)
|
||||
async def icon(self, request: web.Request) -> bytes:
|
||||
"""Return icon from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_icon:
|
||||
raise APIError(f"No icon found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_icon.open("rb") as png:
|
||||
return png.read()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_PNG)
|
||||
async def logo(self, request: web.Request) -> bytes:
|
||||
"""Return logo from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_logo:
|
||||
raise APIError(f"No logo found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_logo.open("rb") as png:
|
||||
return png.read()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_TEXT)
|
||||
async def changelog(self, request: web.Request) -> str:
|
||||
"""Return changelog from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_changelog:
|
||||
raise APIError(f"No changelog found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_changelog.open("r") as changelog:
|
||||
return changelog.read()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_TEXT)
|
||||
async def documentation(self, request: web.Request) -> str:
|
||||
"""Return documentation from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_documentation:
|
||||
raise APIError(f"No documentation found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_documentation.open("r") as documentation:
|
||||
return documentation.read()
|
||||
|
||||
@api_process
|
||||
async def stdin(self, request: web.Request) -> None:
|
||||
"""Write to stdin of add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
addon = self._extract_addon_installed(request)
|
||||
if not addon.with_stdin:
|
||||
raise APIError(f"STDIN not supported the {addon.slug} add-on")
|
||||
|
||||
@@ -423,6 +461,14 @@ class APIAddons(CoreSysAttributes):
|
||||
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 [f"{name}:{access}" for name, access in addon.services_role.items()]
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor Audio RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Awaitable, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import attr
|
||||
@@ -25,16 +25,15 @@ from ..const import (
|
||||
ATTR_NETWORK_RX,
|
||||
ATTR_NETWORK_TX,
|
||||
ATTR_OUTPUT,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
ATTR_VOLUME,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..host.sound import StreamType
|
||||
from ..validate import version_tag
|
||||
from .const import CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -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(
|
||||
{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."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return Audio information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_plugins.audio.version,
|
||||
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_AUDIO: {
|
||||
ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards],
|
||||
@@ -89,7 +87,7 @@ class APIAudio(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_plugins.audio.stats()
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"""Init file for Supervisor auth/SSO RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiohttp import BasicAuth, web
|
||||
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
|
||||
@@ -8,25 +9,26 @@ from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
import voluptuous as vol
|
||||
|
||||
from ..addons.addon import Addon
|
||||
from ..const import ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
|
||||
from ..const import (
|
||||
ATTR_PASSWORD,
|
||||
ATTR_USERNAME,
|
||||
CONTENT_TYPE_JSON,
|
||||
CONTENT_TYPE_URL,
|
||||
REQUEST_FROM,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIForbidden
|
||||
from .const import CONTENT_TYPE_JSON, CONTENT_TYPE_URL
|
||||
from .utils import api_process, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_PASSWORD_RESET = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_USERNAME): str,
|
||||
vol.Required(ATTR_PASSWORD): str,
|
||||
vol.Required(ATTR_USERNAME): vol.Coerce(str),
|
||||
vol.Required(ATTR_PASSWORD): vol.Coerce(str),
|
||||
}
|
||||
)
|
||||
|
||||
REALM_HEADER: dict[str, str] = {
|
||||
WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'
|
||||
}
|
||||
|
||||
|
||||
class APIAuth(CoreSysAttributes):
|
||||
"""Handle RESTful API for auth functions."""
|
||||
@@ -40,7 +42,7 @@ class APIAuth(CoreSysAttributes):
|
||||
return self.sys_auth.check_login(addon, auth.login, auth.password)
|
||||
|
||||
def _process_dict(
|
||||
self, request: web.Request, addon: Addon, data: dict[str, str]
|
||||
self, request: web.Request, addon: Addon, data: Dict[str, str]
|
||||
) -> bool:
|
||||
"""Process login with dict data.
|
||||
|
||||
@@ -61,9 +63,7 @@ class APIAuth(CoreSysAttributes):
|
||||
|
||||
# BasicAuth
|
||||
if AUTHORIZATION in request.headers:
|
||||
if not await self._process_basic(request, addon):
|
||||
raise HTTPUnauthorized(headers=REALM_HEADER)
|
||||
return True
|
||||
return await self._process_basic(request, addon)
|
||||
|
||||
# Json
|
||||
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON:
|
||||
@@ -75,12 +75,14 @@ class APIAuth(CoreSysAttributes):
|
||||
data = await request.post()
|
||||
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
|
||||
async def reset(self, request: web.Request) -> None:
|
||||
"""Process reset password request."""
|
||||
body: dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request)
|
||||
body: Dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request)
|
||||
await asyncio.shield(
|
||||
self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD])
|
||||
)
|
||||
|
@@ -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
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor HA cli RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -15,7 +15,6 @@ from ..const import (
|
||||
ATTR_MEMORY_USAGE,
|
||||
ATTR_NETWORK_RX,
|
||||
ATTR_NETWORK_TX,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
)
|
||||
@@ -32,16 +31,15 @@ class APICli(CoreSysAttributes):
|
||||
"""Handle RESTful API for HA Cli functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return HA cli information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_plugins.cli.version,
|
||||
ATTR_VERSION_LATEST: self.sys_plugins.cli.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_plugins.cli.need_update,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_plugins.cli.stats()
|
||||
|
||||
|
@@ -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"
|
@@ -13,7 +13,7 @@ from ..const import (
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..discovery.validate import valid_discovery_service
|
||||
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(
|
||||
{
|
||||
@@ -33,10 +33,15 @@ class APIDiscovery(CoreSysAttributes):
|
||||
raise APIError("Discovery message not found")
|
||||
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
|
||||
@require_home_assistant
|
||||
async def list(self, request):
|
||||
"""Show register services."""
|
||||
self._check_permission_ha(request)
|
||||
|
||||
# Get available discovery
|
||||
discovery = []
|
||||
@@ -74,11 +79,13 @@ class APIDiscovery(CoreSysAttributes):
|
||||
return {ATTR_UUID: message.uuid}
|
||||
|
||||
@api_process
|
||||
@require_home_assistant
|
||||
async def get_discovery(self, request):
|
||||
"""Read data into a discovery message."""
|
||||
message = self._extract_message(request)
|
||||
|
||||
# HomeAssistant?
|
||||
self._check_permission_ha(request)
|
||||
|
||||
return {
|
||||
ATTR_ADDON: message.addon,
|
||||
ATTR_SERVICE: message.service,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor DNS RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Awaitable, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -18,25 +18,19 @@ from ..const import (
|
||||
ATTR_NETWORK_RX,
|
||||
ATTR_NETWORK_TX,
|
||||
ATTR_SERVERS,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..validate import dns_server_list, version_tag
|
||||
from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS, CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_OPTIONS = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_SERVERS): dns_server_list,
|
||||
vol.Optional(ATTR_FALLBACK): vol.Boolean(),
|
||||
}
|
||||
)
|
||||
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): dns_server_list})
|
||||
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
||||
|
||||
@@ -45,41 +39,29 @@ class APICoreDNS(CoreSysAttributes):
|
||||
"""Handle RESTful API for DNS functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return DNS information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_plugins.dns.version,
|
||||
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_SERVERS: self.sys_plugins.dns.servers,
|
||||
ATTR_LOCALS: self.sys_plugins.dns.locals,
|
||||
ATTR_MDNS: self.sys_plugins.dns.mdns,
|
||||
ATTR_LLMNR: self.sys_plugins.dns.llmnr,
|
||||
ATTR_FALLBACK: self.sys_plugins.dns.fallback,
|
||||
ATTR_LOCALS: self.sys_host.network.dns_servers,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def options(self, request: web.Request) -> None:
|
||||
"""Set DNS options."""
|
||||
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||
restart_required = False
|
||||
|
||||
if ATTR_SERVERS in body:
|
||||
self.sys_plugins.dns.servers = body[ATTR_SERVERS]
|
||||
restart_required = True
|
||||
|
||||
if ATTR_FALLBACK in body:
|
||||
self.sys_plugins.dns.fallback = body[ATTR_FALLBACK]
|
||||
restart_required = True
|
||||
|
||||
if restart_required:
|
||||
self.sys_create_task(self.sys_plugins.dns.restart())
|
||||
|
||||
self.sys_plugins.dns.save_data()
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_plugins.dns.stats()
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -21,7 +21,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_DOCKER_REGISTRY = vol.Schema(
|
||||
{
|
||||
str: {
|
||||
vol.Coerce(str): {
|
||||
vol.Required(ATTR_USERNAME): str,
|
||||
vol.Required(ATTR_PASSWORD): str,
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class APIDocker(CoreSysAttributes):
|
||||
"""Handle RESTful API for Docker configuration."""
|
||||
|
||||
@api_process
|
||||
async def registries(self, request) -> dict[str, Any]:
|
||||
async def registries(self, request) -> Dict[str, Any]:
|
||||
"""Return the list of registries."""
|
||||
data_registries = {}
|
||||
for hostname, registry in self.sys_docker.config.registries.items():
|
||||
|
@@ -1,52 +1,54 @@
|
||||
"""Init file for Supervisor hardware RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Awaitable, Dict, List
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..const import ATTR_AUDIO, ATTR_DEVICES, ATTR_INPUT, ATTR_NAME, ATTR_OUTPUT
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..hardware.data import Device
|
||||
from .const import (
|
||||
ATTR_ATTRIBUTES,
|
||||
ATTR_BY_ID,
|
||||
ATTR_CHILDREN,
|
||||
ATTR_DEV_PATH,
|
||||
ATTR_SUBSYSTEM,
|
||||
ATTR_SYSFS,
|
||||
from ..const import (
|
||||
ATTR_AUDIO,
|
||||
ATTR_DISK,
|
||||
ATTR_GPIO,
|
||||
ATTR_INPUT,
|
||||
ATTR_OUTPUT,
|
||||
ATTR_SERIAL,
|
||||
ATTR_USB,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from .utils import api_process
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def device_struct(device: Device) -> dict[str, Any]:
|
||||
"""Return a dict with information of a interface to be used in 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):
|
||||
"""Handle RESTful API for hardware functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Show hardware info."""
|
||||
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 {
|
||||
ATTR_DEVICES: [
|
||||
device_struct(device) for device in self.sys_hardware.devices
|
||||
]
|
||||
ATTR_SERIAL: serial,
|
||||
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
|
||||
async def audio(self, request: web.Request) -> dict[str, Any]:
|
||||
async def audio(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Show pulse audio profiles."""
|
||||
return {
|
||||
ATTR_AUDIO: {
|
||||
@@ -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())
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Awaitable, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -10,7 +10,6 @@ from ..const import (
|
||||
ATTR_ARCH,
|
||||
ATTR_AUDIO_INPUT,
|
||||
ATTR_AUDIO_OUTPUT,
|
||||
ATTR_BACKUP,
|
||||
ATTR_BLK_READ,
|
||||
ATTR_BLK_WRITE,
|
||||
ATTR_BOOT,
|
||||
@@ -26,15 +25,15 @@ from ..const import (
|
||||
ATTR_PORT,
|
||||
ATTR_REFRESH_TOKEN,
|
||||
ATTR_SSL,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
ATTR_WAIT_BOOT,
|
||||
ATTR_WATCHDOG,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..validate import docker_image, network_port, version_tag
|
||||
from .const import CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -43,34 +42,29 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
SCHEMA_OPTIONS = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_BOOT): vol.Boolean(),
|
||||
vol.Optional(ATTR_IMAGE): vol.Maybe(docker_image),
|
||||
vol.Optional(ATTR_IMAGE): docker_image,
|
||||
vol.Optional(ATTR_PORT): network_port,
|
||||
vol.Optional(ATTR_SSL): vol.Boolean(),
|
||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)),
|
||||
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UPDATE = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_VERSION): version_tag,
|
||||
vol.Optional(ATTR_BACKUP): bool,
|
||||
}
|
||||
)
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
||||
|
||||
|
||||
class APIHomeAssistant(CoreSysAttributes):
|
||||
"""Handle RESTful API for Home Assistant functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return host information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_homeassistant.version,
|
||||
ATTR_VERSION_LATEST: self.sys_homeassistant.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_homeassistant.need_update,
|
||||
ATTR_MACHINE: self.sys_homeassistant.machine,
|
||||
ATTR_IP_ADDRESS: str(self.sys_homeassistant.ip_address),
|
||||
ATTR_ARCH: self.sys_homeassistant.arch,
|
||||
@@ -79,8 +73,11 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
ATTR_PORT: self.sys_homeassistant.api_port,
|
||||
ATTR_SSL: self.sys_homeassistant.api_ssl,
|
||||
ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
|
||||
ATTR_WAIT_BOOT: self.sys_homeassistant.wait_boot,
|
||||
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
|
||||
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
|
||||
# Remove end of Q3 2020
|
||||
"last_version": self.sys_homeassistant.latest_version,
|
||||
}
|
||||
|
||||
@api_process
|
||||
@@ -103,6 +100,9 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
if ATTR_WATCHDOG in body:
|
||||
self.sys_homeassistant.watchdog = body[ATTR_WATCHDOG]
|
||||
|
||||
if ATTR_WAIT_BOOT in body:
|
||||
self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT]
|
||||
|
||||
if ATTR_REFRESH_TOKEN in body:
|
||||
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
|
||||
|
||||
@@ -115,7 +115,7 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
self.sys_homeassistant.save_data()
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[Any, str]:
|
||||
async def stats(self, request: web.Request) -> Dict[Any, str]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_homeassistant.core.stats()
|
||||
if not stats:
|
||||
@@ -135,14 +135,10 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
@api_process
|
||||
async def update(self, request: web.Request) -> None:
|
||||
"""Update Home Assistant."""
|
||||
body = await api_validate(SCHEMA_UPDATE, request)
|
||||
body = await api_validate(SCHEMA_VERSION, request)
|
||||
version = body.get(ATTR_VERSION, self.sys_homeassistant.latest_version)
|
||||
|
||||
await asyncio.shield(
|
||||
self.sys_homeassistant.core.update(
|
||||
version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version),
|
||||
backup=body.get(ATTR_BACKUP),
|
||||
)
|
||||
)
|
||||
await asyncio.shield(self.sys_homeassistant.core.update(version))
|
||||
|
||||
@api_process
|
||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||
|
@@ -11,7 +11,6 @@ from ..const import (
|
||||
ATTR_DEPLOYMENT,
|
||||
ATTR_DESCRIPTON,
|
||||
ATTR_DISK_FREE,
|
||||
ATTR_DISK_LIFE_TIME,
|
||||
ATTR_DISK_TOTAL,
|
||||
ATTR_DISK_USED,
|
||||
ATTR_FEATURES,
|
||||
@@ -21,27 +20,14 @@ from ..const import (
|
||||
ATTR_OPERATING_SYSTEM,
|
||||
ATTR_SERVICES,
|
||||
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,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
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):
|
||||
@@ -51,28 +37,16 @@ class APIHost(CoreSysAttributes):
|
||||
async def info(self, request):
|
||||
"""Return host information."""
|
||||
return {
|
||||
ATTR_AGENT_VERSION: self.sys_dbus.agent.version,
|
||||
ATTR_APPARMOR_VERSION: self.sys_host.apparmor.version,
|
||||
ATTR_CHASSIS: self.sys_host.info.chassis,
|
||||
ATTR_CPE: self.sys_host.info.cpe,
|
||||
ATTR_DEPLOYMENT: self.sys_host.info.deployment,
|
||||
ATTR_DISK_FREE: self.sys_host.info.free_space,
|
||||
ATTR_DISK_TOTAL: self.sys_host.info.total_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.features,
|
||||
ATTR_FEATURES: self.sys_host.supported_features,
|
||||
ATTR_HOSTNAME: self.sys_host.info.hostname,
|
||||
ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,
|
||||
ATTR_KERNEL: self.sys_host.info.kernel,
|
||||
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
|
||||
ATTR_TIMEZONE: self.sys_host.info.timezone,
|
||||
ATTR_DT_UTC: self.sys_host.info.dt_utc,
|
||||
ATTR_DT_SYNCHRONIZED: self.sys_host.info.dt_synchronized,
|
||||
ATTR_USE_NTP: self.sys_host.info.use_ntp,
|
||||
ATTR_STARTUP_TIME: self.sys_host.info.startup_time,
|
||||
ATTR_BOOT_TIMESTAMP: self.sys_host.info.boot_timestamp,
|
||||
ATTR_BROADCAST_LLMNR: self.sys_host.info.broadcast_llmnr,
|
||||
ATTR_BROADCAST_MDNS: self.sys_host.info.broadcast_mdns,
|
||||
}
|
||||
|
||||
@api_process
|
||||
|
50
supervisor/api/info.py
Normal file
50
supervisor/api/info.py
Normal 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,
|
||||
}
|
@@ -2,17 +2,16 @@
|
||||
import asyncio
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
from typing import Any, Union
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout, hdrs, web
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp.web_exceptions import (
|
||||
HTTPBadGateway,
|
||||
HTTPServiceUnavailable,
|
||||
HTTPUnauthorized,
|
||||
)
|
||||
from multidict import CIMultiDict, istr
|
||||
import voluptuous as vol
|
||||
|
||||
from ..addons.addon import Addon
|
||||
from ..const import (
|
||||
@@ -22,15 +21,16 @@ from ..const import (
|
||||
ATTR_PANELS,
|
||||
ATTR_SESSION,
|
||||
ATTR_TITLE,
|
||||
COOKIE_INGRESS,
|
||||
HEADER_TOKEN,
|
||||
HEADER_TOKEN_OLD,
|
||||
REQUEST_FROM,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from .const import COOKIE_INGRESS, HEADER_TOKEN, HEADER_TOKEN_OLD
|
||||
from .utils import api_process, api_validate, require_home_assistant
|
||||
from .utils import api_process
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str})
|
||||
|
||||
|
||||
class APIIngress(CoreSysAttributes):
|
||||
"""Ingress view to handle add-on webui routing."""
|
||||
@@ -47,12 +47,17 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
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:
|
||||
"""Create URL to container."""
|
||||
return f"http://{addon.ip_address}:{addon.ingress_port}/{path}"
|
||||
|
||||
@api_process
|
||||
async def panels(self, request: web.Request) -> dict[str, Any]:
|
||||
async def panels(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Create a list of panel data."""
|
||||
addons = {}
|
||||
for addon in self.sys_ingress.addons:
|
||||
@@ -66,28 +71,18 @@ class APIIngress(CoreSysAttributes):
|
||||
return {ATTR_PANELS: addons}
|
||||
|
||||
@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."""
|
||||
self._check_ha_access(request)
|
||||
|
||||
session = self.sys_ingress.create_session()
|
||||
return {ATTR_SESSION: session}
|
||||
|
||||
@api_process
|
||||
@require_home_assistant
|
||||
async def validate_session(self, request: web.Request) -> dict[str, Any]:
|
||||
"""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(
|
||||
self, request: web.Request
|
||||
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
|
||||
"""Route data to Supervisor ingress service."""
|
||||
self._check_ha_access(request)
|
||||
|
||||
# Check Ingress Session
|
||||
session = request.cookies.get(COOKIE_INGRESS)
|
||||
@@ -160,18 +155,9 @@ class APIIngress(CoreSysAttributes):
|
||||
) -> Union[web.Response, web.StreamResponse]:
|
||||
"""Ingress route for request."""
|
||||
url = self._create_url(addon, path)
|
||||
data = await request.read()
|
||||
source_header = _init_header(request, addon)
|
||||
|
||||
# Passing the raw stream breaks requests for some webservers
|
||||
# since we just need it for POST requests really, for all other methods
|
||||
# we read the bytes and pass that to the request to the add-on
|
||||
# add-ons needs to add support with that in the configuration
|
||||
data = (
|
||||
request.content
|
||||
if request.method == "POST" and addon.ingress_stream
|
||||
else await request.read()
|
||||
)
|
||||
|
||||
async with self.sys_websession.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -179,7 +165,6 @@ class APIIngress(CoreSysAttributes):
|
||||
params=request.query,
|
||||
allow_redirects=False,
|
||||
data=data,
|
||||
timeout=ClientTimeout(total=None),
|
||||
) as result:
|
||||
headers = _response_header(result)
|
||||
|
||||
@@ -218,7 +203,7 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
def _init_header(
|
||||
request: web.Request, addon: str
|
||||
) -> Union[CIMultiDict, dict[str, str]]:
|
||||
) -> Union[CIMultiDict, Dict[str, str]]:
|
||||
"""Create initial header."""
|
||||
headers = {}
|
||||
|
||||
@@ -227,7 +212,6 @@ def _init_header(
|
||||
if name in (
|
||||
hdrs.CONTENT_LENGTH,
|
||||
hdrs.CONTENT_ENCODING,
|
||||
hdrs.TRANSFER_ENCODING,
|
||||
hdrs.SEC_WEBSOCKET_EXTENSIONS,
|
||||
hdrs.SEC_WEBSOCKET_PROTOCOL,
|
||||
hdrs.SEC_WEBSOCKET_VERSION,
|
||||
@@ -246,7 +230,7 @@ def _init_header(
|
||||
return headers
|
||||
|
||||
|
||||
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
|
||||
def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
|
||||
"""Create response header."""
|
||||
headers = {}
|
||||
|
||||
|
@@ -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()
|
@@ -1 +0,0 @@
|
||||
"""API middleware for aiohttp."""
|
@@ -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()
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor Multicast RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Awaitable, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -15,14 +15,13 @@ from ..const import (
|
||||
ATTR_MEMORY_USAGE,
|
||||
ATTR_NETWORK_RX,
|
||||
ATTR_NETWORK_TX,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..validate import version_tag
|
||||
from .const import CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -34,16 +33,15 @@ class APIMulticast(CoreSysAttributes):
|
||||
"""Handle RESTful API for Multicast functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return Multicast information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_plugins.multicast.version,
|
||||
ATTR_VERSION_LATEST: self.sys_plugins.multicast.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_plugins.multicast.need_update,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_plugins.multicast.stats()
|
||||
|
||||
|
@@ -1,280 +1,110 @@
|
||||
"""REST API for network."""
|
||||
import asyncio
|
||||
from ipaddress import ip_address, ip_interface
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
ATTR_ACCESSPOINTS,
|
||||
ATTR_ADDRESS,
|
||||
ATTR_AUTH,
|
||||
ATTR_CONNECTED,
|
||||
ATTR_DNS,
|
||||
ATTR_DOCKER,
|
||||
ATTR_ENABLED,
|
||||
ATTR_FREQUENCY,
|
||||
ATTR_GATEWAY,
|
||||
ATTR_HOST_INTERNET,
|
||||
ATTR_ID,
|
||||
ATTR_INTERFACE,
|
||||
ATTR_INTERFACES,
|
||||
ATTR_IPV4,
|
||||
ATTR_IPV6,
|
||||
ATTR_MAC,
|
||||
ATTR_IP_ADDRESS,
|
||||
ATTR_METHOD,
|
||||
ATTR_MODE,
|
||||
ATTR_METHODS,
|
||||
ATTR_NAMESERVERS,
|
||||
ATTR_PARENT,
|
||||
ATTR_PRIMARY,
|
||||
ATTR_PSK,
|
||||
ATTR_SIGNAL,
|
||||
ATTR_SSID,
|
||||
ATTR_SUPERVISOR_INTERNET,
|
||||
ATTR_TYPE,
|
||||
ATTR_VLAN,
|
||||
ATTR_WIFI,
|
||||
DOCKER_NETWORK,
|
||||
DOCKER_NETWORK_MASK,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError, HostNetworkNotFound
|
||||
from ..host.const import AuthMethod, InterfaceType, WifiMode
|
||||
from ..host.network import (
|
||||
AccessPoint,
|
||||
Interface,
|
||||
InterfaceMethod,
|
||||
IpConfig,
|
||||
VlanConfig,
|
||||
WifiConfig,
|
||||
)
|
||||
from ..dbus.const import InterfaceMethodSimple
|
||||
from ..dbus.network.interface import NetworkInterface
|
||||
from ..dbus.network.utils import int2ip
|
||||
from ..exceptions import APIError
|
||||
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(
|
||||
{
|
||||
vol.Optional(ATTR_IPV4): _SCHEMA_IP_CONFIG,
|
||||
vol.Optional(ATTR_IPV6): _SCHEMA_IP_CONFIG,
|
||||
vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG,
|
||||
vol.Optional(ATTR_ENABLED): vol.Boolean(),
|
||||
vol.Optional(ATTR_ADDRESS): vol.Coerce(str),
|
||||
vol.Optional(ATTR_METHOD): vol.In(ATTR_METHODS),
|
||||
vol.Optional(ATTR_GATEWAY): vol.Coerce(str),
|
||||
vol.Optional(ATTR_DNS): [str],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def ipconfig_struct(config: IpConfig) -> dict[str, Any]:
|
||||
"""Return a dict with information about ip configuration."""
|
||||
return {
|
||||
ATTR_METHOD: config.method,
|
||||
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
|
||||
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
|
||||
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
|
||||
}
|
||||
|
||||
|
||||
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]:
|
||||
def interface_information(interface: NetworkInterface) -> dict:
|
||||
"""Return a dict with information of a interface to be used in th API."""
|
||||
return {
|
||||
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_ENABLED: interface.enabled,
|
||||
ATTR_CONNECTED: interface.connected,
|
||||
ATTR_NAMESERVERS: [int2ip(x) for x in interface.nameservers],
|
||||
ATTR_METHOD: InterfaceMethodSimple.DHCP
|
||||
if interface.method == "auto"
|
||||
else InterfaceMethodSimple.STATIC,
|
||||
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):
|
||||
"""Handle REST API for network."""
|
||||
|
||||
def _get_interface(self, name: str) -> Interface:
|
||||
"""Get Interface by name or default."""
|
||||
if name.lower() == "default":
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""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:
|
||||
if not interface.primary:
|
||||
if not self.sys_host.network.interfaces[interface].primary:
|
||||
continue
|
||||
return interface
|
||||
return interface_information(
|
||||
self.sys_host.network.interfaces[interface]
|
||||
)
|
||||
|
||||
else:
|
||||
try:
|
||||
return self.sys_host.network.get(name)
|
||||
except HostNetworkNotFound:
|
||||
pass
|
||||
for interface in self.sys_host.network.interfaces:
|
||||
if req_interface != self.sys_host.network.interfaces[interface].name:
|
||||
continue
|
||||
return interface_information(
|
||||
self.sys_host.network.interfaces[interface]
|
||||
)
|
||||
|
||||
raise APIError(f"Interface {name} does not exist") from None
|
||||
return {}
|
||||
|
||||
@api_process
|
||||
async def info(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:
|
||||
async def interface_update(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""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
|
||||
body = await api_validate(SCHEMA_UPDATE, request)
|
||||
if not body:
|
||||
if not self.sys_host.network.interfaces.get(req_interface):
|
||||
raise APIError(f"Interface {req_interface} does not exsist")
|
||||
|
||||
args = await api_validate(SCHEMA_UPDATE, request)
|
||||
if not args:
|
||||
raise APIError("You need to supply at least one option to update")
|
||||
|
||||
# Apply config
|
||||
for key, config in body.items():
|
||||
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.interfaces[req_interface].update_settings(**args)
|
||||
)
|
||||
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))
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor Observer RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -16,7 +16,6 @@ from ..const import (
|
||||
ATTR_MEMORY_USAGE,
|
||||
ATTR_NETWORK_RX,
|
||||
ATTR_NETWORK_TX,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
)
|
||||
@@ -33,17 +32,16 @@ class APIObserver(CoreSysAttributes):
|
||||
"""Handle RESTful API for Observer functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return HA Observer information."""
|
||||
return {
|
||||
ATTR_HOST: str(self.sys_docker.network.observer),
|
||||
ATTR_VERSION: self.sys_plugins.observer.version,
|
||||
ATTR_VERSION_LATEST: self.sys_plugins.observer.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_plugins.observer.need_update,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_plugins.observer.stats()
|
||||
|
||||
|
@@ -1,69 +1,43 @@
|
||||
"""Init file for Supervisor HassOS RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable
|
||||
from typing import Any, Awaitable, Dict
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
ATTR_BOARD,
|
||||
ATTR_BOOT,
|
||||
ATTR_DEVICES,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
)
|
||||
from ..const import ATTR_BOARD, ATTR_BOOT, ATTR_VERSION, ATTR_VERSION_LATEST
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..validate import version_tag
|
||||
from .const import ATTR_DATA_DISK, ATTR_DEVICE
|
||||
from .utils import api_process, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
||||
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))})
|
||||
|
||||
|
||||
class APIOS(CoreSysAttributes):
|
||||
"""Handle RESTful API for OS functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return OS information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_os.version,
|
||||
ATTR_VERSION_LATEST: self.sys_os.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
|
||||
ATTR_BOARD: self.sys_os.board,
|
||||
ATTR_VERSION: self.sys_hassos.version,
|
||||
ATTR_VERSION_LATEST: self.sys_hassos.latest_version,
|
||||
ATTR_BOARD: self.sys_hassos.board,
|
||||
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
|
||||
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def update(self, request: web.Request) -> None:
|
||||
"""Update OS."""
|
||||
body = await api_validate(SCHEMA_VERSION, request)
|
||||
version = body.get(ATTR_VERSION, self.sys_os.latest_version)
|
||||
version = body.get(ATTR_VERSION, self.sys_hassos.latest_version)
|
||||
|
||||
await asyncio.shield(self.sys_os.update(version))
|
||||
await asyncio.shield(self.sys_hassos.update(version))
|
||||
|
||||
@api_process
|
||||
def config_sync(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Trigger config reload on OS."""
|
||||
return asyncio.shield(self.sys_os.config_sync())
|
||||
|
||||
@api_process
|
||||
async def migrate_data(self, request: web.Request) -> None:
|
||||
"""Trigger data disk migration on Host."""
|
||||
body = await api_validate(SCHEMA_DISK, request)
|
||||
|
||||
await asyncio.shield(self.sys_os.datadisk.migrate_disk(body[ATTR_DEVICE]))
|
||||
|
||||
@api_process
|
||||
async def list_data(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Return possible data targets."""
|
||||
return {
|
||||
ATTR_DEVICES: self.sys_os.datadisk.available_disks,
|
||||
}
|
||||
return asyncio.shield(self.sys_hassos.config_sync())
|
||||
|
@@ -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');
|
||||
el.src = '/api/hassio/app/frontend_es5/entrypoint.f8f83860.js';
|
||||
el.src = '/api/hassio/app/frontend_es5/entrypoint.c862ef13.js';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
if (/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent)) {
|
||||
loadES5();
|
||||
} else {
|
||||
try {
|
||||
new Function("import('/api/hassio/app/frontend_latest/entrypoint.b6cf778b.js')")();
|
||||
} catch (err) {
|
||||
loadES5();
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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()}();
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
***************************************************************************** */
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
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
Reference in New Issue
Block a user