Compare commits

..

2 Commits

Author SHA1 Message Date
Mike Degatano
1f7aafcfd7 Fix fixture that ensures .git presence 2025-08-07 20:51:02 +00:00
Mike Degatano
ed45651fd9 Handle git dir missing in load and pull 2025-08-07 20:42:22 +00:00
4245 changed files with 7727 additions and 15565 deletions

View File

@@ -1,7 +1,6 @@
# General files # General files
.git .git
.github .github
.gitkeep
.devcontainer .devcontainer
.vscode .vscode

View File

@@ -34,9 +34,6 @@ on:
env: env:
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.13"
COSIGN_VERSION: "v2.5.3"
CRANE_VERSION: "v0.20.7"
CRANE_SHA256: "8ef3564d264e6b5ca93f7b7f5652704c4dd29d33935aff6947dd5adefd05953e"
BUILD_NAME: supervisor BUILD_NAME: supervisor
BUILD_TYPE: supervisor BUILD_TYPE: supervisor
@@ -53,10 +50,10 @@ jobs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }} channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }} publish: ${{ steps.version.outputs.publish }}
build_wheels: ${{ steps.requirements.outputs.build_wheels }} requirements: ${{ steps.requirements.outputs.changed }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -72,25 +69,20 @@ jobs:
- name: Get changed files - name: Get changed files
id: changed_files id: changed_files
if: github.event_name != 'release' if: steps.version.outputs.publish == 'false'
uses: masesgroup/retrieve-changed-files@491e80760c0e28d36ca6240a27b1ccb8e1402c13 # v3.0.0 uses: masesgroup/retrieve-changed-files@v3.0.0
- name: Check if requirements files changed - name: Check if requirements files changed
id: requirements id: requirements
run: | run: |
# No wheels build necessary for releases if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.yaml) ]]; then
if [[ "${{ github.event_name }}" == "release" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT"
echo "build_wheels=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements\.txt|build\.yaml|\.github/workflows/builder\.yml) ]]; then
echo "build_wheels=true" >> "$GITHUB_OUTPUT"
else
echo "build_wheels=false" >> "$GITHUB_OUTPUT"
fi fi
build: build:
name: Build ${{ matrix.arch }} supervisor name: Build ${{ matrix.arch }} supervisor
needs: init needs: init
runs-on: ${{ matrix.runs-on }} runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
@@ -98,66 +90,33 @@ jobs:
strategy: strategy:
matrix: matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- runs-on: ubuntu-24.04
- runs-on: ubuntu-24.04-arm
arch: aarch64
env:
WHEELS_ABI: cp313
WHEELS_TAG: musllinux_1_2
WHEELS_APK_DEPS: "libffi-dev;openssl-dev;yaml-dev"
WHEELS_SKIP_BINARY: aiohttp
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Write env-file for wheels build - name: Write env-file
if: needs.init.outputs.build_wheels == 'true' if: needs.init.outputs.requirements == 'true'
run: | run: |
( (
# Fix out of memory issues with rust # Fix out of memory issues with rust
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
) > .env_file ) > .env_file
- name: Build and publish wheels - name: Build wheels
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'true' if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0 uses: home-assistant/wheels@2025.07.0
with: with:
abi: cp313
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
abi: ${{ env.WHEELS_ABI }} apk: "libffi-dev;openssl-dev;yaml-dev"
tag: ${{ env.WHEELS_TAG }} skip-binary: aiohttp
arch: ${{ matrix.arch }}
apk: ${{ env.WHEELS_APK_DEPS }}
skip-binary: ${{ env.WHEELS_SKIP_BINARY }}
env-file: true env-file: true
requirements: "requirements.txt" requirements: "requirements.txt"
- name: Build local wheels
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
wheels-host: ""
wheels-user: ""
wheels-key: ""
local-wheels-repo-path: "wheels/"
abi: ${{ env.WHEELS_ABI }}
tag: ${{ env.WHEELS_TAG }}
arch: ${{ matrix.arch }}
apk: ${{ env.WHEELS_APK_DEPS }}
skip-binary: ${{ env.WHEELS_SKIP_BINARY }}
env-file: true
requirements: "requirements.txt"
- name: Upload local wheels artifact
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: wheels-${{ matrix.arch }}
path: wheels
retention-days: 1
- name: Set version - name: Set version
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version@master uses: home-assistant/actions/helpers/version@master
@@ -166,15 +125,15 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install Cosign - name: Install Cosign
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 uses: sigstore/cosign-installer@v3.9.2
with: with:
cosign-release: ${{ env.COSIGN_VERSION }} cosign-release: "v2.4.3"
- name: Install dirhash and calc hash - name: Install dirhash and calc hash
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
@@ -190,7 +149,7 @@ jobs:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -200,26 +159,26 @@ jobs:
if: needs.init.outputs.publish == 'false' if: needs.init.outputs.publish == 'false'
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
# home-assistant/builder doesn't support sha pinning
- name: Build supervisor - name: Build supervisor
uses: home-assistant/builder@2025.11.0 uses: home-assistant/builder@2025.03.0
with: with:
image: ${{ matrix.arch }}
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
--${{ matrix.arch }} \ --${{ matrix.arch }} \
--target /data \ --target /data \
--cosign \ --cosign \
--generic ${{ needs.init.outputs.version }} --generic ${{ needs.init.outputs.version }}
env:
CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
version: version:
name: Update version name: Update version
needs: ["init", "run_supervisor", "retag_deprecated"] needs: ["init", "run_supervisor"]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Initialize git - name: Initialize git
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
@@ -244,19 +203,11 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Download local wheels artifact
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: wheels-amd64
path: wheels
# home-assistant/builder doesn't support sha pinning
- name: Build the Supervisor - name: Build the Supervisor
if: needs.init.outputs.publish != 'true' if: needs.init.outputs.publish != 'true'
uses: home-assistant/builder@2025.11.0 uses: home-assistant/builder@2025.03.0
with: with:
args: | args: |
--test \ --test \
@@ -339,6 +290,33 @@ jobs:
exit 1 exit 1
fi 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 - name: Create full backup
id: backup id: backup
run: | run: |
@@ -400,50 +378,3 @@ jobs:
- name: Get supervisor logs on failiure - name: Get supervisor logs on failiure
if: ${{ cancelled() || failure() }} if: ${{ cancelled() || failure() }}
run: docker logs hassio_supervisor run: docker logs hassio_supervisor
retag_deprecated:
needs: ["build", "init"]
name: Re-tag deprecated ${{ matrix.arch }} images
if: needs.init.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
packages: write
strategy:
matrix:
arch: ["armhf", "armv7", "i386"]
env:
# Last available release for deprecated architectures
FROZEN_VERSION: "2025.11.5"
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: ${{ env.COSIGN_VERSION }}
- name: Install crane
run: |
curl -sLO https://github.com/google/go-containerregistry/releases/download/${{ env.CRANE_VERSION }}/go-containerregistry_Linux_x86_64.tar.gz
echo "${{ env.CRANE_SHA256 }} go-containerregistry_Linux_x86_64.tar.gz" | sha256sum -c -
tar xzf go-containerregistry_Linux_x86_64.tar.gz crane
sudo mv crane /usr/local/bin/
- name: Re-tag deprecated image with updated version label
run: |
crane auth login ghcr.io -u ${{ github.repository_owner }} -p ${{ secrets.GITHUB_TOKEN }}
crane mutate \
--label io.hass.version=${{ needs.init.outputs.version }} \
--tag ghcr.io/home-assistant/${{ matrix.arch }}-hassio-supervisor:${{ needs.init.outputs.version }} \
ghcr.io/home-assistant/${{ matrix.arch }}-hassio-supervisor:${{ env.FROZEN_VERSION }}
- name: Sign image with Cosign
run: |
cosign sign --yes ghcr.io/home-assistant/${{ matrix.arch }}-hassio-supervisor:${{ needs.init.outputs.version }}

View File

@@ -26,15 +26,15 @@ jobs:
name: Prepare Python dependencies name: Prepare Python dependencies
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python - name: Set up Python
id: python id: python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -48,7 +48,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true lookup-only: true
@@ -68,15 +68,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -88,7 +88,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
key: | key: |
@@ -111,15 +111,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -131,7 +131,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
key: | key: |
@@ -154,7 +154,7 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Register hadolint problem matcher - name: Register hadolint problem matcher
run: | run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json" echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -169,15 +169,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -189,7 +189,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
key: | key: |
@@ -213,15 +213,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -233,7 +233,7 @@ jobs:
exit 1 exit 1
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
key: | key: |
@@ -257,15 +257,15 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -293,9 +293,9 @@ jobs:
needs: prepare needs: prepare
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
@@ -307,7 +307,7 @@ jobs:
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: >- key: >-
@@ -318,7 +318,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache" echo "Failed to restore Python virtual environment from cache"
exit 1 exit 1
- name: Restore mypy cache - name: Restore mypy cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
@@ -339,19 +339,19 @@ jobs:
name: Run tests Python ${{ needs.prepare.outputs.python-version }} name: Run tests Python ${{ needs.prepare.outputs.python-version }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 uses: sigstore/cosign-installer@v3.9.2
with: with:
cosign-release: "v2.5.3" cosign-release: "v2.4.3"
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -386,7 +386,7 @@ jobs:
-o console_output_style=count \ -o console_output_style=count \
tests tests
- name: Upload coverage artifact - name: Upload coverage artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@v4.6.2
with: with:
name: coverage name: coverage
path: .coverage path: .coverage
@@ -398,15 +398,15 @@ jobs:
needs: ["pytest", "prepare"] needs: ["pytest", "prepare"]
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }} - name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ needs.prepare.outputs.python-version }} python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@v4.2.3
with: with:
path: venv path: venv
key: | key: |
@@ -417,7 +417,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache" echo "Failed to restore Python virtual environment from cache"
exit 1 exit 1
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: coverage name: coverage
path: coverage/ path: coverage/
@@ -428,4 +428,4 @@ jobs:
coverage report coverage report
coverage xml coverage xml
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 uses: codecov/codecov-action@v5.4.3

View File

@@ -9,7 +9,7 @@ jobs:
lock: lock:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 - uses: dessant/lock-threads@v5.0.1
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
issue-inactive-days: "30" issue-inactive-days: "30"

View File

@@ -11,7 +11,7 @@ jobs:
name: Release Drafter name: Release Drafter
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -36,7 +36,7 @@ jobs:
echo "version=$datepre.$newpost" >> "$GITHUB_OUTPUT" echo "version=$datepre.$newpost" >> "$GITHUB_OUTPUT"
- name: Run Release Drafter - name: Run Release Drafter
uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0 uses: release-drafter/release-drafter@v6.1.0
with: with:
tag: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }} name: ${{ steps.version.outputs.version }}

View File

@@ -9,10 +9,10 @@ jobs:
check-authorization: check-authorization:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form) # Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task' if: github.event.issue.issue_type == 'Task'
steps: steps:
- name: Check if user is authorized - name: Check if user is authorized
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7
with: with:
script: | script: |
const issueAuthor = context.payload.issue.user.login; const issueAuthor = context.payload.issue.user.login;

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4.2.2
- name: Sentry Release - name: Sentry Release
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3.4.0 uses: getsentry/action-release@v3.2.0
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

View File

@@ -9,14 +9,13 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 - uses: actions/stale@v9.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30 days-before-stale: 30
days-before-close: 7 days-before-close: 7
stale-issue-label: "stale" stale-issue-label: "stale"
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security" exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
only-issue-types: "bug"
stale-issue-message: > stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some high number of incoming GitHub notifications, we have to clean some

View File

@@ -14,10 +14,10 @@ jobs:
latest_version: ${{ steps.latest_frontend_version.outputs.latest_tag }} latest_version: ${{ steps.latest_frontend_version.outputs.latest_tag }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4
- name: Get latest frontend release - name: Get latest frontend release
id: latest_frontend_version id: latest_frontend_version
uses: abatilo/release-info-action@32cb932219f1cee3fc4f4a298fd65ead5d35b661 # v1.3.3 uses: abatilo/release-info-action@v1.3.3
with: with:
owner: home-assistant owner: home-assistant
repo: frontend repo: frontend
@@ -49,7 +49,7 @@ jobs:
if: needs.check-version.outputs.skip != 'true' if: needs.check-version.outputs.skip != 'true'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@v4
- name: Clear www folder - name: Clear www folder
run: | run: |
rm -rf supervisor/api/panel/* rm -rf supervisor/api/panel/*
@@ -57,7 +57,7 @@ jobs:
run: | run: |
echo "${{ needs.check-version.outputs.latest_version }}" > .ha-frontend-version echo "${{ needs.check-version.outputs.latest_version }}" > .ha-frontend-version
- name: Download release assets - name: Download release assets
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12 uses: robinraju/release-downloader@v1
with: with:
repository: 'home-assistant/frontend' repository: 'home-assistant/frontend'
tag: ${{ needs.check-version.outputs.latest_version }} tag: ${{ needs.check-version.outputs.latest_version }}
@@ -68,7 +68,7 @@ jobs:
run: | run: |
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
- name: Create PR - name: Create PR
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 uses: peter-evans/create-pull-request@v7
with: with:
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}" commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
branch: autoupdate-frontend branch: autoupdate-frontend

6
.gitignore vendored
View File

@@ -24,9 +24,6 @@ var/
.installed.cfg .installed.cfg
*.egg *.egg
# Local wheels
wheels/**/*.whl
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it. # before PyInstaller builds the exe, so as to inject date/other infos into it.
@@ -103,6 +100,3 @@ ENV/
# mypy # mypy
/.mypy_cache/* /.mypy_cache/*
/.dmypy.json /.dmypy.json
# Mac
.DS_Store

View File

@@ -1 +1 @@
20250925.1 20250806.0

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.3 rev: v0.11.10
hooks: hooks:
- id: ruff - id: ruff
args: args:

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

View File

@@ -8,7 +8,9 @@ ENV \
UV_SYSTEM_PYTHON=true UV_SYSTEM_PYTHON=true
ARG \ ARG \
COSIGN_VERSION COSIGN_VERSION \
BUILD_ARCH \
QEMU_CPU
# Install base # Install base
WORKDIR /usr/src WORKDIR /usr/src
@@ -27,22 +29,18 @@ RUN \
\ \
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \ && curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign \ && chmod a+x /usr/bin/cosign \
&& pip3 install uv==0.9.18 && pip3 install uv==0.6.17
# Install requirements # Install requirements
COPY requirements.txt .
RUN \ RUN \
--mount=type=bind,source=./requirements.txt,target=/usr/src/requirements.txt \ if [ "${BUILD_ARCH}" = "i386" ]; then \
--mount=type=bind,source=./wheels,target=/usr/src/wheels \ setarch="linux32"; \
if ls /usr/src/wheels/musllinux/* >/dev/null 2>&1; then \
LOCAL_WHEELS=/usr/src/wheels/musllinux; \
echo "Using local wheels from: $LOCAL_WHEELS"; \
else \ else \
LOCAL_WHEELS=; \ setarch=""; \
echo "No local wheels found"; \ fi \
fi && \ && ${setarch} uv pip install --compile-bytecode --no-cache --no-build -r requirements.txt \
uv pip install --compile-bytecode --no-cache --no-build \ && rm -f requirements.txt
-r requirements.txt \
${LOCAL_WHEELS:+--find-links $LOCAL_WHEELS}
# Install Home Assistant Supervisor # Install Home Assistant Supervisor
COPY . supervisor COPY . supervisor

View File

@@ -1,12 +1,18 @@
image: ghcr.io/home-assistant/{arch}-hassio-supervisor image: ghcr.io/home-assistant/{arch}-hassio-supervisor
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22-2025.12.2 aarch64: ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.22
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22-2025.12.2 armhf: ghcr.io/home-assistant/armhf-base-python:3.13-alpine3.22
armv7: ghcr.io/home-assistant/armv7-base-python:3.13-alpine3.22
amd64: ghcr.io/home-assistant/amd64-base-python:3.13-alpine3.22
i386: ghcr.io/home-assistant/i386-base-python:3.13-alpine3.22
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign: cosign:
base_identity: https://github.com/home-assistant/docker-base/.* base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.* identity: https://github.com/home-assistant/supervisor/.*
args: args:
COSIGN_VERSION: 2.5.3 COSIGN_VERSION: 2.4.3
labels: labels:
io.hass.type: supervisor io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor org.opencontainers.image.title: Home Assistant Supervisor

View File

@@ -321,6 +321,8 @@ lint.ignore = [
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
"UP006", # keep type annotation style as is "UP006", # keep type annotation style as is
"UP007", # keep type annotation style as is "UP007", # keep type annotation style as is
# Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923
"UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)`
# May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"W191", "W191",

View File

@@ -1,17 +1,15 @@
aiodns==3.6.1 aiodns==3.5.0
aiodocker==0.24.0 aiohttp==3.12.15
aiohttp==3.13.2
atomicwrites-homeassistant==1.4.1 atomicwrites-homeassistant==1.4.1
attrs==25.4.0 attrs==25.3.0
awesomeversion==25.8.0 awesomeversion==25.8.0
backports.zstd==1.2.0 blockbuster==1.5.25
blockbuster==1.5.26 brotli==1.1.0
brotli==1.2.0 ciso8601==2.3.2
ciso8601==2.3.3 colorlog==6.9.0
colorlog==6.10.1
cpe==1.3.1 cpe==1.3.1
cryptography==46.0.3 cryptography==45.0.6
debugpy==1.8.19 debugpy==1.8.16
deepmerge==2.0 deepmerge==2.0
dirhash==0.5.0 dirhash==0.5.0
docker==7.1.0 docker==7.1.0
@@ -19,14 +17,14 @@ faust-cchardet==2.1.19
gitpython==3.1.45 gitpython==3.1.45
jinja2==3.1.6 jinja2==3.1.6
log-rate-limit==1.4.2 log-rate-limit==1.4.2
orjson==3.11.5 orjson==3.11.1
pulsectl==24.12.0 pulsectl==24.12.0
pyudev==0.24.4 pyudev==0.24.3
PyYAML==6.0.3 PyYAML==6.0.2
requests==2.32.5 requests==2.32.4
securetar==2025.12.0 securetar==2025.2.1
sentry-sdk==2.48.0 sentry-sdk==2.34.1
setuptools==80.9.0 setuptools==80.9.0
voluptuous==0.16.0 voluptuous==0.15.2
dbus-fast==3.1.2 dbus-fast==2.44.3
zlib-fast==0.2.1 zlib-fast==0.2.1

View File

@@ -1,16 +1,16 @@
astroid==4.0.2 astroid==3.3.11
coverage==7.13.0 coverage==7.10.2
mypy==1.19.1 mypy==1.17.1
pre-commit==4.5.1 pre-commit==4.2.0
pylint==4.0.4 pylint==3.3.7
pytest-aiohttp==1.1.0 pytest-aiohttp==1.1.0
pytest-asyncio==1.3.0 pytest-asyncio==0.25.2
pytest-cov==7.0.0 pytest-cov==6.2.1
pytest-timeout==2.4.0 pytest-timeout==2.4.0
pytest==9.0.2 pytest==8.4.1
ruff==0.14.10 ruff==0.12.7
time-machine==3.1.0 time-machine==2.17.0
types-docker==7.1.0.20251202 types-docker==7.1.0.20250705
types-pyyaml==6.0.12.20250915 types-pyyaml==6.0.12.20250516
types-requests==2.32.4.20250913 types-requests==2.32.4.20250611
urllib3==2.6.2 urllib3==2.5.0

View File

@@ -66,23 +66,10 @@ if __name__ == "__main__":
_LOGGER.info("Setting up Supervisor") _LOGGER.info("Setting up Supervisor")
loop.run_until_complete(coresys.core.setup()) loop.run_until_complete(coresys.core.setup())
# Create startup task that can be cancelled gracefully bootstrap.register_signal_handlers(loop, coresys)
startup_task = loop.create_task(coresys.core.start())
def shutdown_handler() -> None:
"""Handle shutdown signals gracefully during startup."""
if not startup_task.done():
_LOGGER.warning("Supervisor startup interrupted by shutdown signal")
startup_task.cancel()
coresys.create_task(coresys.core.stop())
bootstrap.register_signal_handlers(loop, shutdown_handler)
try: try:
loop.run_until_complete(startup_task) loop.run_until_complete(coresys.core.start())
except asyncio.CancelledError:
_LOGGER.warning("Supervisor startup cancelled")
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
# Supervisor itself is running at this point, just something didn't # Supervisor itself is running at this point, just something didn't
# start as expected. Log with traceback to get more insights for # start as expected. Log with traceback to get more insights for

View File

@@ -15,7 +15,7 @@ import secrets
import shutil import shutil
import tarfile import tarfile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, Final, cast from typing import Any, Final
import aiohttp import aiohttp
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
@@ -66,24 +66,14 @@ from ..docker.const import ContainerState
from ..docker.monitor import DockerContainerStateEvent from ..docker.monitor import DockerContainerStateEvent
from ..docker.stats import DockerStats from ..docker.stats import DockerStats
from ..exceptions import ( from ..exceptions import (
AddonBackupMetadataInvalidError, AddonConfigurationError,
AddonBuildFailedUnknownError,
AddonConfigurationInvalidError,
AddonNotRunningError,
AddonNotSupportedError,
AddonNotSupportedWriteStdinError,
AddonPortConflict,
AddonPrePostBackupCommandReturnedError,
AddonsError, AddonsError,
AddonsJobError, AddonsJobError,
AddonUnknownError, AddonsNotSupportedError,
BackupRestoreUnknownError,
ConfigurationFileError, ConfigurationFileError,
DockerBuildError,
DockerContainerPortConflict,
DockerError, DockerError,
HomeAssistantAPIError,
HostAppArmorError, HostAppArmorError,
StoreAddonNotFoundError,
) )
from ..hardware.data import Device from ..hardware.data import Device
from ..homeassistant.const import WSEvent from ..homeassistant.const import WSEvent
@@ -237,7 +227,6 @@ class Addon(AddonModel):
) )
await self._check_ingress_port() await self._check_ingress_port()
default_image = self._image(self.data) default_image = self._image(self.data)
try: try:
await self.instance.attach(version=self.version) await self.instance.attach(version=self.version)
@@ -246,7 +235,7 @@ class Addon(AddonModel):
await self.instance.check_image(self.version, default_image, self.arch) await self.instance.check_image(self.version, default_image, self.arch)
except DockerError: except DockerError:
_LOGGER.info("No %s addon Docker image %s found", self.slug, self.image) _LOGGER.info("No %s addon Docker image %s found", self.slug, self.image)
with suppress(DockerError, AddonNotSupportedError): with suppress(DockerError):
await self.instance.install(self.version, default_image, arch=self.arch) await self.instance.install(self.version, default_image, arch=self.arch)
self.persist[ATTR_IMAGE] = default_image self.persist[ATTR_IMAGE] = default_image
@@ -729,16 +718,18 @@ class Addon(AddonModel):
options = self.schema.validate(self.options) options = self.schema.validate(self.options)
await self.sys_run_in_executor(write_json_file, self.path_options, options) await self.sys_run_in_executor(write_json_file, self.path_options, options)
except vol.Invalid as ex: except vol.Invalid as ex:
raise AddonConfigurationInvalidError( _LOGGER.error(
_LOGGER.error, "Add-on %s has invalid options: %s",
addon=self.slug, self.slug,
validation_error=humanize_error(self.options, ex), humanize_error(self.options, ex),
) from None )
except ConfigurationFileError as err: except ConfigurationFileError:
_LOGGER.error("Add-on %s can't write options", self.slug) _LOGGER.error("Add-on %s can't write options", self.slug)
raise AddonUnknownError(addon=self.slug) from err else:
_LOGGER.debug("Add-on %s write options: %s", self.slug, options)
return
_LOGGER.debug("Add-on %s write options: %s", self.slug, options) raise AddonConfigurationError()
@Job( @Job(
name="addon_unload", name="addon_unload",
@@ -781,9 +772,10 @@ class Addon(AddonModel):
async def install(self) -> None: async def install(self) -> None:
"""Install and setup this addon.""" """Install and setup this addon."""
if not self.addon_store: if not self.addon_store:
raise StoreAddonNotFoundError(addon=self.slug) raise AddonsError("Missing from store, cannot install!")
await self.sys_addons.data.install(self.addon_store) await self.sys_addons.data.install(self.addon_store)
await self.load()
def setup_data(): def setup_data():
if not self.path_data.is_dir(): if not self.path_data.is_dir():
@@ -802,20 +794,9 @@ class Addon(AddonModel):
await self.instance.install( await self.instance.install(
self.latest_version, self.addon_store.image, arch=self.arch self.latest_version, self.addon_store.image, arch=self.arch
) )
except AddonsError:
await self.sys_addons.data.uninstall(self)
raise
except DockerBuildError as err:
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
await self.sys_addons.data.uninstall(self)
raise AddonBuildFailedUnknownError(addon=self.slug) from err
except DockerError as err: except DockerError as err:
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
await self.sys_addons.data.uninstall(self) await self.sys_addons.data.uninstall(self)
raise AddonUnknownError(addon=self.slug) from err raise AddonsError() from err
# Finish initialization and set up listeners
await self.load()
# Add to addon manager # Add to addon manager
self.sys_addons.local[self.slug] = self self.sys_addons.local[self.slug] = self
@@ -836,8 +817,7 @@ class Addon(AddonModel):
try: try:
await self.instance.remove(remove_image=remove_image) await self.instance.remove(remove_image=remove_image)
except DockerError as err: except DockerError as err:
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err) raise AddonsError() from err
raise AddonUnknownError(addon=self.slug) from err
self.state = AddonState.UNKNOWN self.state = AddonState.UNKNOWN
@@ -862,7 +842,8 @@ class Addon(AddonModel):
# Cleanup Ingress panel from sidebar # Cleanup Ingress panel from sidebar
if self.ingress_panel: if self.ingress_panel:
self.ingress_panel = False self.ingress_panel = False
await self.sys_ingress.update_hass_panel(self) with suppress(HomeAssistantAPIError):
await self.sys_ingress.update_hass_panel(self)
# Cleanup Ingress dynamic port assignment # Cleanup Ingress dynamic port assignment
need_ingress_token_cleanup = False need_ingress_token_cleanup = False
@@ -902,7 +883,7 @@ class Addon(AddonModel):
if it was running. Else nothing is returned. if it was running. Else nothing is returned.
""" """
if not self.addon_store: if not self.addon_store:
raise StoreAddonNotFoundError(addon=self.slug) raise AddonsError("Missing from store, cannot update!")
old_image = self.image old_image = self.image
# Cache data to prevent races with other updates to global # Cache data to prevent races with other updates to global
@@ -910,12 +891,8 @@ class Addon(AddonModel):
try: try:
await self.instance.update(store.version, store.image, arch=self.arch) await self.instance.update(store.version, store.image, arch=self.arch)
except DockerBuildError as err:
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
raise AddonBuildFailedUnknownError(addon=self.slug) from err
except DockerError as err: except DockerError as err:
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err) raise AddonsError() from err
raise AddonUnknownError(addon=self.slug) from err
# Stop the addon if running # Stop the addon if running
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}: if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
@@ -957,23 +934,12 @@ class Addon(AddonModel):
""" """
last_state: AddonState = self.state last_state: AddonState = self.state
try: try:
# remove docker container and image but not addon config # remove docker container but not addon config
try: try:
await self.instance.remove() await self.instance.remove()
except DockerError as err:
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err)
raise AddonUnknownError(addon=self.slug) from err
try:
await self.instance.install(self.version) await self.instance.install(self.version)
except DockerBuildError as err:
_LOGGER.error("Could not build image for addon %s: %s", self.slug, err)
raise AddonBuildFailedUnknownError(addon=self.slug) from err
except DockerError as err: except DockerError as err:
_LOGGER.error( raise AddonsError() from err
"Could not pull image to update addon %s: %s", self.slug, err
)
raise AddonUnknownError(addon=self.slug) from err
if self.addon_store: if self.addon_store:
await self.sys_addons.data.update(self.addon_store) await self.sys_addons.data.update(self.addon_store)
@@ -1143,16 +1109,9 @@ class Addon(AddonModel):
self._startup_event.clear() self._startup_event.clear()
try: try:
await self.instance.run() await self.instance.run()
except DockerContainerPortConflict as err:
raise AddonPortConflict(
_LOGGER.error,
name=self.slug,
port=cast(dict[str, Any], err.extra_fields)["port"],
) from err
except DockerError as err: except DockerError as err:
_LOGGER.error("Could not start container for addon %s: %s", self.slug, err)
self.state = AddonState.ERROR self.state = AddonState.ERROR
raise AddonUnknownError(addon=self.slug) from err raise AddonsError() from err
return self.sys_create_task(self._wait_for_startup()) return self.sys_create_task(self._wait_for_startup())
@@ -1167,9 +1126,8 @@ class Addon(AddonModel):
try: try:
await self.instance.stop() await self.instance.stop()
except DockerError as err: except DockerError as err:
_LOGGER.error("Could not stop container for addon %s: %s", self.slug, err)
self.state = AddonState.ERROR self.state = AddonState.ERROR
raise AddonUnknownError(addon=self.slug) from err raise AddonsError() from err
@Job( @Job(
name="addon_restart", name="addon_restart",
@@ -1202,15 +1160,9 @@ class Addon(AddonModel):
async def stats(self) -> DockerStats: async def stats(self) -> DockerStats:
"""Return stats of container.""" """Return stats of container."""
try: try:
if not await self.is_running():
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
return await self.instance.stats() return await self.instance.stats()
except DockerError as err: except DockerError as err:
_LOGGER.error( raise AddonsError() from err
"Could not get stats of container for addon %s: %s", self.slug, err
)
raise AddonUnknownError(addon=self.slug) from err
@Job( @Job(
name="addon_write_stdin", name="addon_write_stdin",
@@ -1220,18 +1172,14 @@ class Addon(AddonModel):
async def write_stdin(self, data) -> None: async def write_stdin(self, data) -> None:
"""Write data to add-on stdin.""" """Write data to add-on stdin."""
if not self.with_stdin: if not self.with_stdin:
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=self.slug) raise AddonsNotSupportedError(
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
)
try: try:
if not await self.is_running(): return await self.instance.write_stdin(data)
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
await self.instance.write_stdin(data)
except DockerError as err: except DockerError as err:
_LOGGER.error( raise AddonsError() from err
"Could not write stdin to container for addon %s: %s", self.slug, err
)
raise AddonUnknownError(addon=self.slug) from err
async def _backup_command(self, command: str) -> None: async def _backup_command(self, command: str) -> None:
try: try:
@@ -1240,14 +1188,15 @@ class Addon(AddonModel):
_LOGGER.debug( _LOGGER.debug(
"Pre-/Post backup command failed with: %s", command_return.output "Pre-/Post backup command failed with: %s", command_return.output
) )
raise AddonPrePostBackupCommandReturnedError( raise AddonsError(
_LOGGER.error, addon=self.slug, exit_code=command_return.exit_code f"Pre-/Post backup command returned error code: {command_return.exit_code}",
_LOGGER.error,
) )
except DockerError as err: except DockerError as err:
_LOGGER.error( raise AddonsError(
"Failed running pre-/post backup command %s: %s", command, err f"Failed running pre-/post backup command {command}: {str(err)}",
) _LOGGER.error,
raise AddonUnknownError(addon=self.slug) from err ) from err
@Job( @Job(
name="addon_begin_backup", name="addon_begin_backup",
@@ -1336,14 +1285,15 @@ class Addon(AddonModel):
try: try:
self.instance.export_image(temp_path.joinpath("image.tar")) self.instance.export_image(temp_path.joinpath("image.tar"))
except DockerError as err: except DockerError as err:
raise BackupRestoreUnknownError() from err raise AddonsError() from err
# Store local configs/state # Store local configs/state
try: try:
write_json_file(temp_path.joinpath("addon.json"), metadata) write_json_file(temp_path.joinpath("addon.json"), metadata)
except ConfigurationFileError as err: except ConfigurationFileError as err:
_LOGGER.error("Can't save meta for %s: %s", self.slug, err) raise AddonsError(
raise BackupRestoreUnknownError() from err f"Can't save meta for {self.slug}", _LOGGER.error
) from err
# Store AppArmor Profile # Store AppArmor Profile
if apparmor_profile: if apparmor_profile:
@@ -1353,7 +1303,9 @@ class Addon(AddonModel):
apparmor_profile, profile_backup_file apparmor_profile, profile_backup_file
) )
except HostAppArmorError as err: except HostAppArmorError as err:
raise BackupRestoreUnknownError() from err raise AddonsError(
"Can't backup AppArmor profile", _LOGGER.error
) from err
# Write tarfile # Write tarfile
with tar_file as backup: with tar_file as backup:
@@ -1407,8 +1359,7 @@ class Addon(AddonModel):
) )
_LOGGER.info("Finish backup for addon %s", self.slug) _LOGGER.info("Finish backup for addon %s", self.slug)
except (tarfile.TarError, OSError, AddFileError) as err: except (tarfile.TarError, OSError, AddFileError) as err:
_LOGGER.error("Can't write backup tarfile for addon %s: %s", self.slug, err) raise AddonsError(f"Can't write tarfile: {err}", _LOGGER.error) from err
raise BackupRestoreUnknownError() from err
finally: finally:
if was_running: if was_running:
wait_for_start = await self.end_backup() wait_for_start = await self.end_backup()
@@ -1450,24 +1401,28 @@ class Addon(AddonModel):
try: try:
tmp, data = await self.sys_run_in_executor(_extract_tarfile) tmp, data = await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err: except tarfile.TarError as err:
_LOGGER.error("Can't extract backup tarfile for %s: %s", self.slug, err) raise AddonsError(
raise BackupRestoreUnknownError() from err f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
) from err
except ConfigurationFileError as err: except ConfigurationFileError as err:
raise AddonUnknownError(addon=self.slug) from err raise AddonsError() from err
try: try:
# Validate # Validate
try: try:
data = SCHEMA_ADDON_BACKUP(data) data = SCHEMA_ADDON_BACKUP(data)
except vol.Invalid as err: except vol.Invalid as err:
raise AddonBackupMetadataInvalidError( raise AddonsError(
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
_LOGGER.error, _LOGGER.error,
addon=self.slug,
validation_error=humanize_error(data, err),
) from err ) from err
# Validate availability. Raises if not # If available
self._validate_availability(data[ATTR_SYSTEM], logger=_LOGGER.error) if not self._available(data[ATTR_SYSTEM]):
raise AddonsNotSupportedError(
f"Add-on {self.slug} is not available for this platform",
_LOGGER.error,
)
# Restore local add-on information # Restore local add-on information
_LOGGER.info("Restore config for addon %s", self.slug) _LOGGER.info("Restore config for addon %s", self.slug)
@@ -1526,10 +1481,9 @@ class Addon(AddonModel):
try: try:
await self.sys_run_in_executor(_restore_data) await self.sys_run_in_executor(_restore_data)
except shutil.Error as err: except shutil.Error as err:
_LOGGER.error( raise AddonsError(
"Can't restore origin data for %s: %s", self.slug, err f"Can't restore origin data: {err}", _LOGGER.error
) ) from err
raise BackupRestoreUnknownError() from err
# Restore AppArmor # Restore AppArmor
profile_file = Path(tmp.name, "apparmor.txt") profile_file = Path(tmp.name, "apparmor.txt")
@@ -1540,11 +1494,10 @@ class Addon(AddonModel):
) )
except HostAppArmorError as err: except HostAppArmorError as err:
_LOGGER.error( _LOGGER.error(
"Can't restore AppArmor profile for add-on %s: %s", "Can't restore AppArmor profile for add-on %s",
self.slug, self.slug,
err,
) )
raise BackupRestoreUnknownError() from err raise AddonsError() from err
finally: finally:
# Is add-on loaded # Is add-on loaded
@@ -1559,6 +1512,13 @@ class Addon(AddonModel):
_LOGGER.info("Finished restore for add-on %s", self.slug) _LOGGER.info("Finished restore for add-on %s", self.slug)
return wait_for_start return wait_for_start
def check_trust(self) -> Awaitable[None]:
"""Calculate Addon docker content trust.
Return Coroutine.
"""
return self.instance.check_trust()
@Job( @Job(
name="addon_restart_after_problem", name="addon_restart_after_problem",
throttle_period=WATCHDOG_THROTTLE_PERIOD, throttle_period=WATCHDOG_THROTTLE_PERIOD,
@@ -1601,15 +1561,7 @@ class Addon(AddonModel):
) )
break break
# Exponential backoff to spread retries over the throttle window await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
delay = WATCHDOG_RETRY_SECONDS * (1 << max(attempts - 1, 0))
_LOGGER.debug(
"Watchdog will retry addon %s in %s seconds (attempt %s)",
self.name,
delay,
attempts + 1,
)
await asyncio.sleep(delay)
async def container_state_changed(self, event: DockerContainerStateEvent) -> None: async def container_state_changed(self, event: DockerContainerStateEvent) -> None:
"""Set addon state from container state.""" """Set addon state from container state."""

View File

@@ -2,10 +2,7 @@
from __future__ import annotations from __future__ import annotations
import base64
from functools import cached_property from functools import cached_property
import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -15,31 +12,20 @@ from ..const import (
ATTR_ARGS, ATTR_ARGS,
ATTR_BUILD_FROM, ATTR_BUILD_FROM,
ATTR_LABELS, ATTR_LABELS,
ATTR_PASSWORD,
ATTR_SQUASH, ATTR_SQUASH,
ATTR_USERNAME,
FILE_SUFFIX_CONFIGURATION, FILE_SUFFIX_CONFIGURATION,
META_ADDON, META_ADDON,
SOCKET_DOCKER, SOCKET_DOCKER,
CpuArch,
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..docker.const import DOCKER_HUB, DOCKER_HUB_LEGACY
from ..docker.interface import MAP_ARCH from ..docker.interface import MAP_ARCH
from ..exceptions import ( from ..exceptions import ConfigurationFileError, HassioArchNotFound
AddonBuildArchitectureNotSupportedError,
AddonBuildDockerfileMissingError,
ConfigurationFileError,
HassioArchNotFound,
)
from ..utils.common import FileConfiguration, find_one_filetype from ..utils.common import FileConfiguration, find_one_filetype
from .validate import SCHEMA_BUILD_CONFIG from .validate import SCHEMA_BUILD_CONFIG
if TYPE_CHECKING: if TYPE_CHECKING:
from .manager import AnyAddon from .manager import AnyAddon
_LOGGER: logging.Logger = logging.getLogger(__name__)
class AddonBuild(FileConfiguration, CoreSysAttributes): class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Handle build options for add-ons.""" """Handle build options for add-ons."""
@@ -76,7 +62,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
raise RuntimeError() raise RuntimeError()
@cached_property @cached_property
def arch(self) -> CpuArch: def arch(self) -> str:
"""Return arch of the add-on.""" """Return arch of the add-on."""
return self.sys_arch.match([self.addon.arch]) return self.sys_arch.match([self.addon.arch])
@@ -120,7 +106,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}") return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
return self.addon.path_location.joinpath("Dockerfile") return self.addon.path_location.joinpath("Dockerfile")
async def is_valid(self) -> None: async def is_valid(self) -> bool:
"""Return true if the build env is valid.""" """Return true if the build env is valid."""
def build_is_valid() -> bool: def build_is_valid() -> bool:
@@ -132,58 +118,12 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
) )
try: try:
if not await self.sys_run_in_executor(build_is_valid): return await self.sys_run_in_executor(build_is_valid)
raise AddonBuildDockerfileMissingError(
_LOGGER.error, addon=self.addon.slug
)
except HassioArchNotFound: except HassioArchNotFound:
raise AddonBuildArchitectureNotSupportedError( return False
_LOGGER.error,
addon=self.addon.slug,
addon_arch_list=self.addon.supported_arch,
system_arch_list=[arch.value for arch in self.sys_arch.supported],
) from None
def get_docker_config_json(self) -> str | None:
"""Generate Docker config.json content with registry credentials for base image.
Returns a JSON string with registry credentials for the base image's registry,
or None if no matching registry is configured.
Raises:
HassioArchNotFound: If the add-on is not supported on the current architecture.
"""
# Early return before accessing base_image to avoid unnecessary arch lookup
if not self.sys_docker.config.registries:
return None
registry = self.sys_docker.config.get_registry_for_image(self.base_image)
if not registry:
return None
stored = self.sys_docker.config.registries[registry]
username = stored[ATTR_USERNAME]
password = stored[ATTR_PASSWORD]
# Docker config.json uses base64-encoded "username:password" for auth
auth_string = base64.b64encode(f"{username}:{password}".encode()).decode()
# Use the actual registry URL for the key
# Docker Hub uses "https://index.docker.io/v1/" as the key
# Support both docker.io (official) and hub.docker.com (legacy)
registry_key = (
"https://index.docker.io/v1/"
if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY)
else registry
)
config = {"auths": {registry_key: {"auth": auth_string}}}
return json.dumps(config)
def get_docker_args( def get_docker_args(
self, version: AwesomeVersion, image_tag: str, docker_config_path: Path | None self, version: AwesomeVersion, image_tag: str
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a dict with Docker run args.""" """Create a dict with Docker run args."""
dockerfile_path = self.get_dockerfile().relative_to(self.addon.path_location) dockerfile_path = self.get_dockerfile().relative_to(self.addon.path_location)
@@ -232,24 +172,12 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
self.addon.path_location self.addon.path_location
) )
volumes = {
SOCKET_DOCKER: {"bind": "/var/run/docker.sock", "mode": "rw"},
addon_extern_path: {"bind": "/addon", "mode": "ro"},
}
# Mount Docker config with registry credentials if available
if docker_config_path:
docker_config_extern_path = self.sys_config.local_to_extern_path(
docker_config_path
)
volumes[docker_config_extern_path] = {
"bind": "/root/.docker/config.json",
"mode": "ro",
}
return { return {
"command": build_cmd, "command": build_cmd,
"volumes": volumes, "volumes": {
SOCKET_DOCKER: {"bind": "/var/run/docker.sock", "mode": "rw"},
addon_extern_path: {"bind": "/addon", "mode": "ro"},
},
"working_dir": "/addon", "working_dir": "/addon",
} }

View File

@@ -9,18 +9,19 @@ from typing import Self, Union
from attr import evolve from attr import evolve
from supervisor.jobs.const import JobConcurrency
from ..const import AddonBoot, AddonStartup, AddonState from ..const import AddonBoot, AddonStartup, AddonState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ( from ..exceptions import (
AddonNotSupportedError,
AddonsError, AddonsError,
AddonsJobError, AddonsJobError,
AddonsNotSupportedError,
CoreDNSError, CoreDNSError,
DockerError, DockerError,
HassioError, HassioError,
HomeAssistantAPIError,
) )
from ..jobs import ChildJobSyncFilter
from ..jobs.const import JobConcurrency
from ..jobs.decorator import Job, JobCondition from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType from ..resolution.const import ContextType, IssueType, SuggestionType
from ..store.addon import AddonStore from ..store.addon import AddonStore
@@ -182,13 +183,8 @@ class AddonManager(CoreSysAttributes):
conditions=ADDON_UPDATE_CONDITIONS, conditions=ADDON_UPDATE_CONDITIONS,
on_condition=AddonsJobError, on_condition=AddonsJobError,
concurrency=JobConcurrency.QUEUE, concurrency=JobConcurrency.QUEUE,
child_job_syncs=[
ChildJobSyncFilter("docker_interface_install", progress_allocation=1.0)
],
) )
async def install( async def install(self, slug: str) -> None:
self, slug: str, *, validation_complete: asyncio.Event | None = None
) -> None:
"""Install an add-on.""" """Install an add-on."""
self.sys_jobs.current.reference = slug self.sys_jobs.current.reference = slug
@@ -201,10 +197,6 @@ class AddonManager(CoreSysAttributes):
store.validate_availability() store.validate_availability()
# If being run in the background, notify caller that validation has completed
if validation_complete:
validation_complete.set()
await Addon(self.coresys, slug).install() await Addon(self.coresys, slug).install()
_LOGGER.info("Add-on '%s' successfully installed", slug) _LOGGER.info("Add-on '%s' successfully installed", slug)
@@ -232,20 +224,9 @@ class AddonManager(CoreSysAttributes):
name="addon_manager_update", name="addon_manager_update",
conditions=ADDON_UPDATE_CONDITIONS, conditions=ADDON_UPDATE_CONDITIONS,
on_condition=AddonsJobError, on_condition=AddonsJobError,
# We assume for now the docker image pull is 100% of this task for progress
# allocation. But from a user perspective that isn't true. Other steps
# that take time which is not accounted for in progress include:
# partial backup, image cleanup, apparmor update, and addon restart
child_job_syncs=[
ChildJobSyncFilter("docker_interface_install", progress_allocation=1.0)
],
) )
async def update( async def update(
self, self, slug: str, backup: bool | None = False
slug: str,
backup: bool | None = False,
*,
validation_complete: asyncio.Event | None = None,
) -> asyncio.Task | None: ) -> asyncio.Task | None:
"""Update add-on. """Update add-on.
@@ -270,10 +251,6 @@ class AddonManager(CoreSysAttributes):
# Check if available, Maybe something have changed # Check if available, Maybe something have changed
store.validate_availability() store.validate_availability()
# If being run in the background, notify caller that validation has completed
if validation_complete:
validation_complete.set()
if backup: if backup:
await self.sys_backups.do_backup_partial( await self.sys_backups.do_backup_partial(
name=f"addon_{addon.slug}_{addon.version}", name=f"addon_{addon.slug}_{addon.version}",
@@ -281,10 +258,7 @@ class AddonManager(CoreSysAttributes):
addons=[addon.slug], addons=[addon.slug],
) )
task = await addon.update() return await addon.update()
_LOGGER.info("Add-on '%s' successfully updated", slug)
return task
@Job( @Job(
name="addon_manager_rebuild", name="addon_manager_rebuild",
@@ -319,7 +293,7 @@ class AddonManager(CoreSysAttributes):
"Version changed, use Update instead Rebuild", _LOGGER.error "Version changed, use Update instead Rebuild", _LOGGER.error
) )
if not force and not addon.need_build: if not force and not addon.need_build:
raise AddonNotSupportedError( raise AddonsNotSupportedError(
"Can't rebuild a image based add-on", _LOGGER.error "Can't rebuild a image based add-on", _LOGGER.error
) )
@@ -363,7 +337,8 @@ class AddonManager(CoreSysAttributes):
# Update ingress # Update ingress
if had_ingress != addon.ingress_panel: if had_ingress != addon.ingress_panel:
await self.sys_ingress.reload() await self.sys_ingress.reload()
await self.sys_ingress.update_hass_panel(addon) with suppress(HomeAssistantAPIError):
await self.sys_ingress.update_hass_panel(addon)
return wait_for_start return wait_for_start

View File

@@ -72,7 +72,6 @@ from ..const import (
ATTR_TYPE, ATTR_TYPE,
ATTR_UART, ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_ULIMITS,
ATTR_URL, ATTR_URL,
ATTR_USB, ATTR_USB,
ATTR_VERSION, ATTR_VERSION,
@@ -87,16 +86,10 @@ from ..const import (
AddonBootConfig, AddonBootConfig,
AddonStage, AddonStage,
AddonStartup, AddonStartup,
CpuArch,
) )
from ..coresys import CoreSys from ..coresys import CoreSys
from ..docker.const import Capabilities from ..docker.const import Capabilities
from ..exceptions import ( from ..exceptions import AddonsNotSupportedError
AddonNotSupportedArchitectureError,
AddonNotSupportedError,
AddonNotSupportedHomeAssistantVersionError,
AddonNotSupportedMachineTypeError,
)
from ..jobs.const import JOB_GROUP_ADDON from ..jobs.const import JOB_GROUP_ADDON
from ..jobs.job_group import JobGroup from ..jobs.job_group import JobGroup
from ..utils import version_is_new_enough from ..utils import version_is_new_enough
@@ -104,6 +97,7 @@ from .configuration import FolderMapping
from .const import ( from .const import (
ATTR_BACKUP, ATTR_BACKUP,
ATTR_BREAKING_VERSIONS, ATTR_BREAKING_VERSIONS,
ATTR_CODENOTARY,
ATTR_PATH, ATTR_PATH,
ATTR_READ_ONLY, ATTR_READ_ONLY,
AddonBackupMode, AddonBackupMode,
@@ -316,12 +310,12 @@ class AddonModel(JobGroup, ABC):
@property @property
def panel_title(self) -> str: def panel_title(self) -> str:
"""Return panel title for Ingress frame.""" """Return panel icon for Ingress frame."""
return self.data.get(ATTR_PANEL_TITLE, self.name) return self.data.get(ATTR_PANEL_TITLE, self.name)
@property @property
def panel_admin(self) -> bool: def panel_admin(self) -> str:
"""Return if panel is only available for admin users.""" """Return panel icon for Ingress frame."""
return self.data[ATTR_PANEL_ADMIN] return self.data[ATTR_PANEL_ADMIN]
@property @property
@@ -463,11 +457,6 @@ class AddonModel(JobGroup, ABC):
"""Return True if the add-on have his own udev.""" """Return True if the add-on have his own udev."""
return self.data[ATTR_UDEV] return self.data[ATTR_UDEV]
@property
def ulimits(self) -> dict[str, Any]:
"""Return ulimits configuration."""
return self.data[ATTR_ULIMITS]
@property @property
def with_kernel_modules(self) -> bool: def with_kernel_modules(self) -> bool:
"""Return True if the add-on access to kernel modules.""" """Return True if the add-on access to kernel modules."""
@@ -489,7 +478,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_DEVICETREE] return self.data[ATTR_DEVICETREE]
@property @property
def with_tmpfs(self) -> bool: def with_tmpfs(self) -> str | None:
"""Return if tmp is in memory of add-on.""" """Return if tmp is in memory of add-on."""
return self.data[ATTR_TMPFS] return self.data[ATTR_TMPFS]
@@ -509,7 +498,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_VIDEO] return self.data[ATTR_VIDEO]
@property @property
def homeassistant_version(self) -> AwesomeVersion | None: def homeassistant_version(self) -> str | None:
"""Return min Home Assistant version they needed by Add-on.""" """Return min Home Assistant version they needed by Add-on."""
return self.data.get(ATTR_HOMEASSISTANT) return self.data.get(ATTR_HOMEASSISTANT)
@@ -549,7 +538,7 @@ class AddonModel(JobGroup, ABC):
return self.data.get(ATTR_MACHINE, []) return self.data.get(ATTR_MACHINE, [])
@property @property
def arch(self) -> CpuArch: def arch(self) -> str:
"""Return architecture to use for the addon's image.""" """Return architecture to use for the addon's image."""
if ATTR_IMAGE in self.data: if ATTR_IMAGE in self.data:
return self.sys_arch.match(self.data[ATTR_ARCH]) return self.sys_arch.match(self.data[ATTR_ARCH])
@@ -632,8 +621,13 @@ class AddonModel(JobGroup, ABC):
@property @property
def signed(self) -> bool: def signed(self) -> bool:
"""Currently no signing support.""" """Return True if the image is signed."""
return False return ATTR_CODENOTARY in self.data
@property
def codenotary(self) -> str | None:
"""Return Signer email address for CAS."""
return self.data.get(ATTR_CODENOTARY)
@property @property
def breaking_versions(self) -> list[AwesomeVersion]: def breaking_versions(self) -> list[AwesomeVersion]:
@@ -686,8 +680,9 @@ class AddonModel(JobGroup, ABC):
"""Validate if addon is available for current system.""" """Validate if addon is available for current system."""
# Architecture # Architecture
if not self.sys_arch.is_supported(config[ATTR_ARCH]): if not self.sys_arch.is_supported(config[ATTR_ARCH]):
raise AddonNotSupportedArchitectureError( raise AddonsNotSupportedError(
logger, slug=self.slug, architectures=config[ATTR_ARCH] f"Add-on {self.slug} not supported on this platform, supported architectures: {', '.join(config[ATTR_ARCH])}",
logger,
) )
# Machine / Hardware # Machine / Hardware
@@ -695,8 +690,9 @@ class AddonModel(JobGroup, ABC):
if machine and ( if machine and (
f"!{self.sys_machine}" in machine or self.sys_machine not in machine f"!{self.sys_machine}" in machine or self.sys_machine not in machine
): ):
raise AddonNotSupportedMachineTypeError( raise AddonsNotSupportedError(
logger, slug=self.slug, machine_types=machine f"Add-on {self.slug} not supported on this machine, supported machine types: {', '.join(machine)}",
logger,
) )
# Home Assistant # Home Assistant
@@ -705,15 +701,16 @@ class AddonModel(JobGroup, ABC):
if version and not version_is_new_enough( if version and not version_is_new_enough(
self.sys_homeassistant.version, version self.sys_homeassistant.version, version
): ):
raise AddonNotSupportedHomeAssistantVersionError( raise AddonsNotSupportedError(
logger, slug=self.slug, version=str(version) f"Add-on {self.slug} not supported on this system, requires Home Assistant version {version} or greater",
logger,
) )
def _available(self, config) -> bool: def _available(self, config) -> bool:
"""Return True if this add-on is available on this platform.""" """Return True if this add-on is available on this platform."""
try: try:
self._validate_availability(config) self._validate_availability(config)
except AddonNotSupportedError: except AddonsNotSupportedError:
return False return False
return True return True

View File

@@ -75,7 +75,7 @@ class AddonOptions(CoreSysAttributes):
"""Create a schema for add-on options.""" """Create a schema for add-on options."""
return vol.Schema(vol.All(dict, self)) return vol.Schema(vol.All(dict, self))
def __call__(self, struct: dict[str, Any]) -> dict[str, Any]: def __call__(self, struct):
"""Create schema validator for add-ons options.""" """Create schema validator for add-ons options."""
options = {} options = {}
@@ -93,7 +93,15 @@ class AddonOptions(CoreSysAttributes):
typ = self.raw_schema[key] typ = self.raw_schema[key]
try: try:
options[key] = self._validate_element(typ, value, key) 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): except (IndexError, KeyError):
raise vol.Invalid( raise vol.Invalid(
f"Type error for option '{key}' in {self._name} ({self._slug})" f"Type error for option '{key}' in {self._name} ({self._slug})"
@@ -103,20 +111,7 @@ class AddonOptions(CoreSysAttributes):
return options return options
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
def _validate_element(self, typ: Any, value: Any, key: str) -> Any: def _single_validate(self, typ: str, value: Any, key: str):
"""Validate a value against a type specification."""
if isinstance(typ, list):
# nested value list
return self._nested_validate_list(typ[0], value, key)
elif isinstance(typ, dict):
# nested value dict
return self._nested_validate_dict(typ, value, key)
else:
# normal value
return self._single_validate(typ, value, key)
# pylint: disable=no-value-for-parameter
def _single_validate(self, typ: str, value: Any, key: str) -> Any:
"""Validate a single element.""" """Validate a single element."""
# if required argument # if required argument
if value is None: if value is None:
@@ -187,13 +182,13 @@ class AddonOptions(CoreSysAttributes):
# Device valid # Device valid
self.devices.add(device) self.devices.add(device)
return str(value) return str(device.path)
raise vol.Invalid( raise vol.Invalid(
f"Fatal error for option '{key}' with type '{typ}' in {self._name} ({self._slug})" f"Fatal error for option '{key}' with type '{typ}' in {self._name} ({self._slug})"
) from None ) from None
def _nested_validate_list(self, typ: Any, data_list: Any, key: str) -> list[Any]: def _nested_validate_list(self, typ: Any, data_list: list[Any], key: str):
"""Validate nested items.""" """Validate nested items."""
options = [] options = []
@@ -206,13 +201,17 @@ class AddonOptions(CoreSysAttributes):
# Process list # Process list
for element in data_list: for element in data_list:
# Nested? # Nested?
options.append(self._validate_element(typ, element, key)) 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 return options
def _nested_validate_dict( def _nested_validate_dict(
self, typ: dict[Any, Any], data_dict: Any, key: str self, typ: dict[Any, Any], data_dict: dict[Any, Any], key: str
) -> dict[Any, Any]: ):
"""Validate nested items.""" """Validate nested items."""
options = {} options = {}
@@ -232,7 +231,12 @@ class AddonOptions(CoreSysAttributes):
continue continue
# Nested? # Nested?
options[c_key] = self._validate_element(typ[c_key], c_value, c_key) 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) self._check_missing_options(typ, options, key)
return options return options
@@ -262,7 +266,7 @@ class UiOptions(CoreSysAttributes):
def __init__(self, coresys: CoreSys) -> None: def __init__(self, coresys: CoreSys) -> None:
"""Initialize UI option render.""" """Initialize UI option render."""
self.coresys: CoreSys = coresys self.coresys = coresys
def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]: def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Generate UI schema.""" """Generate UI schema."""
@@ -270,28 +274,18 @@ class UiOptions(CoreSysAttributes):
# read options # read options
for key, value in raw_schema.items(): for key, value in raw_schema.items():
self._ui_schema_element(ui_schema, value, key) 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 return ui_schema
def _ui_schema_element(
self,
ui_schema: list[dict[str, Any]],
value: str | list[Any] | dict[str, Any],
key: str,
multiple: bool = False,
) -> None:
if isinstance(value, list):
# nested value list
assert not multiple
self._nested_ui_list(ui_schema, value, key)
elif isinstance(value, dict):
# nested value dict
self._nested_ui_dict(ui_schema, value, key, multiple)
else:
# normal value
self._single_ui_option(ui_schema, value, key, multiple)
def _single_ui_option( def _single_ui_option(
self, self,
ui_schema: list[dict[str, Any]], ui_schema: list[dict[str, Any]],
@@ -383,7 +377,10 @@ class UiOptions(CoreSysAttributes):
_LOGGER.error("Invalid schema %s", key) _LOGGER.error("Invalid schema %s", key)
return return
self._ui_schema_element(ui_schema, element, key, multiple=True) 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( def _nested_ui_dict(
self, self,
@@ -402,7 +399,11 @@ class UiOptions(CoreSysAttributes):
nested_schema: list[dict[str, Any]] = [] nested_schema: list[dict[str, Any]] = []
for c_key, c_value in option_dict.items(): for c_key, c_value in option_dict.items():
self._ui_schema_element(nested_schema, c_value, c_key) # 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_node["schema"] = nested_schema
ui_schema.append(ui_node) ui_schema.append(ui_node)

View File

@@ -32,7 +32,6 @@ from ..const import (
ATTR_DISCOVERY, ATTR_DISCOVERY,
ATTR_DOCKER_API, ATTR_DOCKER_API,
ATTR_ENVIRONMENT, ATTR_ENVIRONMENT,
ATTR_FIELDS,
ATTR_FULL_ACCESS, ATTR_FULL_ACCESS,
ATTR_GPIO, ATTR_GPIO,
ATTR_HASSIO_API, ATTR_HASSIO_API,
@@ -88,7 +87,6 @@ from ..const import (
ATTR_TYPE, ATTR_TYPE,
ATTR_UART, ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_ULIMITS,
ATTR_URL, ATTR_URL,
ATTR_USB, ATTR_USB,
ATTR_USER, ATTR_USER,
@@ -139,19 +137,7 @@ RE_DOCKER_IMAGE_BUILD = re.compile(
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$" r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$"
) )
SCHEMA_ELEMENT = vol.Schema( SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
vol.Any(
vol.Match(RE_SCHEMA_ELEMENT),
[
# A list may not directly contain another list
vol.Any(
vol.Match(RE_SCHEMA_ELEMENT),
{str: vol.Self},
)
],
{str: vol.Self},
)
)
RE_MACHINE = re.compile( RE_MACHINE = re.compile(
r"^!?(?:" r"^!?(?:"
@@ -207,12 +193,6 @@ def _warn_addon_config(config: dict[str, Any]):
name, name,
) )
if ATTR_CODENOTARY in config:
_LOGGER.warning(
"Add-on '%s' uses deprecated 'codenotary' field in config. This field is no longer used and will be ignored. Please report this to the maintainer.",
name,
)
return config return config
@@ -286,23 +266,10 @@ def _migrate_addon_config(protocol=False):
volumes = [] volumes = []
for entry in config.get(ATTR_MAP, []): for entry in config.get(ATTR_MAP, []):
if isinstance(entry, dict): if isinstance(entry, dict):
# Validate that dict entries have required 'type' field
if ATTR_TYPE not in entry:
_LOGGER.warning(
"Add-on config has invalid map entry missing 'type' field: %s. Skipping invalid entry for %s",
entry,
name,
)
continue
volumes.append(entry) volumes.append(entry)
if isinstance(entry, str): if isinstance(entry, str):
result = RE_VOLUME.match(entry) result = RE_VOLUME.match(entry)
if not result: if not result:
_LOGGER.warning(
"Add-on config has invalid map entry: %s. Skipping invalid entry for %s",
entry,
name,
)
continue continue
volumes.append( volumes.append(
{ {
@@ -311,8 +278,8 @@ def _migrate_addon_config(protocol=False):
} }
) )
# Always update config to clear potentially malformed ones if volumes:
config[ATTR_MAP] = volumes config[ATTR_MAP] = volumes
# 2023-10 "config" became "homeassistant" so /config can be used for addon's public config # 2023-10 "config" became "homeassistant" so /config can be used for addon's public config
if any(volume[ATTR_TYPE] == MappingType.CONFIG for volume in volumes): if any(volume[ATTR_TYPE] == MappingType.CONFIG for volume in volumes):
@@ -423,26 +390,26 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce( vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce(
AddonBackupMode AddonBackupMode
), ),
vol.Optional(ATTR_CODENOTARY): vol.Email(),
vol.Optional(ATTR_OPTIONS, default={}): dict, vol.Optional(ATTR_OPTIONS, default={}): dict,
vol.Optional(ATTR_SCHEMA, default={}): vol.Any( vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
vol.Schema({str: SCHEMA_ELEMENT}), vol.Schema(
{
str: vol.Any(
SCHEMA_ELEMENT,
[
vol.Any(
SCHEMA_ELEMENT,
{str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])},
)
],
vol.Schema({str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}),
)
}
),
False, False,
), ),
vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_ULIMITS, default=dict): vol.Any(
{str: vol.Coerce(int)}, # Simple format: {name: limit}
{
str: vol.Any(
vol.Coerce(int), # Simple format for individual entries
vol.Schema(
{ # Detailed format for individual entries
vol.Required("soft"): vol.Coerce(int),
vol.Required("hard"): vol.Coerce(int),
}
),
)
},
),
vol.Optional(ATTR_TIMEOUT, default=10): vol.All( vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
vol.Coerce(int), vol.Range(min=10, max=300) vol.Coerce(int), vol.Range(min=10, max=300)
), ),
@@ -475,7 +442,6 @@ SCHEMA_TRANSLATION_CONFIGURATION = vol.Schema(
{ {
vol.Required(ATTR_NAME): str, vol.Required(ATTR_NAME): str,
vol.Optional(ATTR_DESCRIPTON): vol.Maybe(str), vol.Optional(ATTR_DESCRIPTON): vol.Maybe(str),
vol.Optional(ATTR_FIELDS): {str: vol.Self},
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )

View File

@@ -146,15 +146,6 @@ class RestAPI(CoreSysAttributes):
follow=True, follow=True,
), ),
), ),
web.get(
f"{path}/logs/latest",
partial(
self._api_host.advanced_logs,
identifier=syslog_identifier,
latest=True,
no_colors=True,
),
),
web.get( web.get(
f"{path}/logs/boots/{{bootid}}", f"{path}/logs/boots/{{bootid}}",
partial(self._api_host.advanced_logs, identifier=syslog_identifier), partial(self._api_host.advanced_logs, identifier=syslog_identifier),
@@ -207,7 +198,6 @@ class RestAPI(CoreSysAttributes):
web.post("/host/reload", api_host.reload), web.post("/host/reload", api_host.reload),
web.post("/host/options", api_host.options), web.post("/host/options", api_host.options),
web.get("/host/services", api_host.services), web.get("/host/services", api_host.services),
web.get("/host/disks/default/usage", api_host.disk_usage),
] ]
) )
@@ -449,8 +439,6 @@ class RestAPI(CoreSysAttributes):
# is known and reported to the user using the resolution center. # is known and reported to the user using the resolution center.
await async_capture_exception(err) await async_capture_exception(err)
kwargs.pop("follow", None) # Follow is not supported for Docker logs kwargs.pop("follow", None) # Follow is not supported for Docker logs
kwargs.pop("latest", None) # Latest is not supported for Docker logs
kwargs.pop("no_colors", None) # no_colors not supported for Docker logs
return await api_supervisor.logs(*args, **kwargs) return await api_supervisor.logs(*args, **kwargs)
self.webapp.add_routes( self.webapp.add_routes(
@@ -460,10 +448,6 @@ class RestAPI(CoreSysAttributes):
"/supervisor/logs/follow", "/supervisor/logs/follow",
partial(get_supervisor_logs, follow=True), partial(get_supervisor_logs, follow=True),
), ),
web.get(
"/supervisor/logs/latest",
partial(get_supervisor_logs, latest=True, no_colors=True),
),
web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs), web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs),
web.get( web.get(
"/supervisor/logs/boots/{bootid}/follow", "/supervisor/logs/boots/{bootid}/follow",
@@ -576,10 +560,6 @@ class RestAPI(CoreSysAttributes):
"/addons/{addon}/logs/follow", "/addons/{addon}/logs/follow",
partial(get_addon_logs, follow=True), partial(get_addon_logs, follow=True),
), ),
web.get(
"/addons/{addon}/logs/latest",
partial(get_addon_logs, latest=True, no_colors=True),
),
web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs), web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs),
web.get( web.get(
"/addons/{addon}/logs/boots/{bootid}/follow", "/addons/{addon}/logs/boots/{bootid}/follow",
@@ -754,10 +734,6 @@ class RestAPI(CoreSysAttributes):
"/store/addons/{addon}/documentation", "/store/addons/{addon}/documentation",
api_store.addons_addon_documentation, api_store.addons_addon_documentation,
), ),
web.get(
"/store/addons/{addon}/availability",
api_store.addons_addon_availability,
),
web.post( web.post(
"/store/addons/{addon}/install", api_store.addons_addon_install "/store/addons/{addon}/install", api_store.addons_addon_install
), ),
@@ -813,10 +789,6 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/docker/info", api_docker.info), web.get("/docker/info", api_docker.info),
web.post(
"/docker/migrate-storage-driver",
api_docker.migrate_docker_storage_driver,
),
web.post("/docker/options", api_docker.options), web.post("/docker/options", api_docker.options),
web.get("/docker/registries", api_docker.registries), web.get("/docker/registries", api_docker.registries),
web.post("/docker/registries", api_docker.create_registry), web.post("/docker/registries", api_docker.create_registry),

View File

@@ -100,9 +100,6 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats from ..docker.stats import DockerStats
from ..exceptions import ( from ..exceptions import (
AddonBootConfigCannotChangeError,
AddonConfigurationInvalidError,
AddonNotSupportedWriteStdinError,
APIAddonNotInstalled, APIAddonNotInstalled,
APIError, APIError,
APIForbidden, APIForbidden,
@@ -128,7 +125,6 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(), vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(), vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_OPTIONS): vol.Maybe(dict),
} }
) )
@@ -304,24 +300,19 @@ class APIAddons(CoreSysAttributes):
# Update secrets for validation # Update secrets for validation
await self.sys_homeassistant.secrets.reload() await self.sys_homeassistant.secrets.reload()
# Extend schema with add-on specific validation
addon_schema = SCHEMA_OPTIONS.extend(
{vol.Optional(ATTR_OPTIONS): vol.Maybe(addon.schema)}
)
# Validate/Process Body # Validate/Process Body
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(addon_schema, request, origin=[ATTR_OPTIONS])
if ATTR_OPTIONS in body: if ATTR_OPTIONS in body:
# None resets options to defaults, otherwise validate the options addon.options = body[ATTR_OPTIONS]
if body[ATTR_OPTIONS] is None:
addon.options = None
else:
try:
addon.options = addon.schema(body[ATTR_OPTIONS])
except vol.Invalid as ex:
raise AddonConfigurationInvalidError(
addon=addon.slug,
validation_error=humanize_error(body[ATTR_OPTIONS], ex),
) from None
if ATTR_BOOT in body: if ATTR_BOOT in body:
if addon.boot_config == AddonBootConfig.MANUAL_ONLY: if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
raise AddonBootConfigCannotChangeError( raise APIError(
addon=addon.slug, boot_config=addon.boot_config.value f"Addon {addon.slug} boot option is set to {addon.boot_config} so it cannot be changed"
) )
addon.boot = body[ATTR_BOOT] addon.boot = body[ATTR_BOOT]
if ATTR_AUTO_UPDATE in body: if ATTR_AUTO_UPDATE in body:
@@ -394,7 +385,7 @@ class APIAddons(CoreSysAttributes):
return data return data
@api_process @api_process
async def options_config(self, request: web.Request) -> dict[str, Any]: async def options_config(self, request: web.Request) -> None:
"""Validate user options for add-on.""" """Validate user options for add-on."""
slug: str = request.match_info["addon"] slug: str = request.match_info["addon"]
if slug != "self": if slug != "self":
@@ -439,11 +430,11 @@ class APIAddons(CoreSysAttributes):
} }
@api_process @api_process
async def uninstall(self, request: web.Request) -> None: async def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on.""" """Uninstall add-on."""
addon = self.get_addon_for_request(request) addon = self.get_addon_for_request(request)
body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request) body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request)
await asyncio.shield( return await asyncio.shield(
self.sys_addons.uninstall( self.sys_addons.uninstall(
addon.slug, remove_config=body[ATTR_REMOVE_CONFIG] addon.slug, remove_config=body[ATTR_REMOVE_CONFIG]
) )
@@ -485,7 +476,7 @@ class APIAddons(CoreSysAttributes):
"""Write to stdin of add-on.""" """Write to stdin of add-on."""
addon = self.get_addon_for_request(request) addon = self.get_addon_for_request(request)
if not addon.with_stdin: if not addon.with_stdin:
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=addon.slug) raise APIError(f"STDIN not supported the {addon.slug} add-on")
data = await request.read() data = await request.read()
await asyncio.shield(addon.write_stdin(data)) await asyncio.shield(addon.write_stdin(data))

View File

@@ -15,7 +15,7 @@ import voluptuous as vol
from ..addons.addon import Addon from ..addons.addon import Addon
from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIForbidden, AuthInvalidNonStringValueError from ..exceptions import APIForbidden
from .const import ( from .const import (
ATTR_GROUP_IDS, ATTR_GROUP_IDS,
ATTR_IS_ACTIVE, ATTR_IS_ACTIVE,
@@ -69,9 +69,7 @@ class APIAuth(CoreSysAttributes):
try: try:
_ = username.encode and password.encode # type: ignore _ = username.encode and password.encode # type: ignore
except AttributeError: except AttributeError:
raise AuthInvalidNonStringValueError( raise HTTPUnauthorized(headers=REALM_HEADER) from None
_LOGGER.error, headers=REALM_HEADER
) from None
return self.sys_auth.check_login( return self.sys_auth.check_login(
addon, cast(str, username), cast(str, password) addon, cast(str, username), cast(str, password)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
import errno import errno
from io import IOBase from io import IOBase
import logging import logging
@@ -45,9 +46,12 @@ from ..const import (
ATTR_TYPE, ATTR_TYPE,
ATTR_VERSION, ATTR_VERSION,
REQUEST_FROM, REQUEST_FROM,
BusEvent,
CoreState,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden, APINotFound from ..exceptions import APIError, APIForbidden, APINotFound
from ..jobs import JobSchedulerOptions, SupervisorJob
from ..mounts.const import MountUsage from ..mounts.const import MountUsage
from ..resolution.const import UnhealthyReason from ..resolution.const import UnhealthyReason
from .const import ( from .const import (
@@ -57,7 +61,7 @@ from .const import (
ATTR_LOCATIONS, ATTR_LOCATIONS,
CONTENT_TYPE_TAR, CONTENT_TYPE_TAR,
) )
from .utils import api_process, api_validate, background_task from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -211,7 +215,7 @@ class APIBackups(CoreSysAttributes):
await self.sys_backups.save_data() await self.sys_backups.save_data()
@api_process @api_process
async def reload(self, _: web.Request) -> bool: async def reload(self, _):
"""Reload backup list.""" """Reload backup list."""
await asyncio.shield(self.sys_backups.reload()) await asyncio.shield(self.sys_backups.reload())
return True return True
@@ -285,6 +289,41 @@ class APIBackups(CoreSysAttributes):
f"Location {LOCATION_CLOUD_BACKUP} is only available for Home Assistant" f"Location {LOCATION_CLOUD_BACKUP} is only available for Home Assistant"
) )
async def _background_backup_task(
self, backup_method: Callable, *args, **kwargs
) -> tuple[asyncio.Task, str]:
"""Start backup task in background and return task and job ID."""
event = asyncio.Event()
job, backup_task = cast(
tuple[SupervisorJob, asyncio.Task],
self.sys_jobs.schedule_job(
backup_method, JobSchedulerOptions(), *args, **kwargs
),
)
async def release_on_freeze(new_state: CoreState):
if new_state == CoreState.FREEZE:
event.set()
# Wait for system to get into freeze state before returning
# If the backup fails validation it will raise before getting there
listener = self.sys_bus.register_event(
BusEvent.SUPERVISOR_STATE_CHANGE, release_on_freeze
)
try:
event_task = self.sys_create_task(event.wait())
_, pending = await asyncio.wait(
(backup_task, event_task),
return_when=asyncio.FIRST_COMPLETED,
)
# It seems backup returned early (error or something), make sure to cancel
# the event task to avoid "Task was destroyed but it is pending!" errors.
if event_task in pending:
event_task.cancel()
return (backup_task, job.uuid)
finally:
self.sys_bus.remove_listener(listener)
@api_process @api_process
async def backup_full(self, request: web.Request): async def backup_full(self, request: web.Request):
"""Create full backup.""" """Create full backup."""
@@ -303,8 +342,8 @@ class APIBackups(CoreSysAttributes):
body[ATTR_ADDITIONAL_LOCATIONS] = locations body[ATTR_ADDITIONAL_LOCATIONS] = locations
background = body.pop(ATTR_BACKGROUND) background = body.pop(ATTR_BACKGROUND)
backup_task, job_id = await background_task( backup_task, job_id = await self._background_backup_task(
self, self.sys_backups.do_backup_full, **body self.sys_backups.do_backup_full, **body
) )
if background and not backup_task.done(): if background and not backup_task.done():
@@ -339,8 +378,8 @@ class APIBackups(CoreSysAttributes):
body[ATTR_ADDONS] = list(self.sys_addons.local) body[ATTR_ADDONS] = list(self.sys_addons.local)
background = body.pop(ATTR_BACKGROUND) background = body.pop(ATTR_BACKGROUND)
backup_task, job_id = await background_task( backup_task, job_id = await self._background_backup_task(
self, self.sys_backups.do_backup_partial, **body self.sys_backups.do_backup_partial, **body
) )
if background and not backup_task.done(): if background and not backup_task.done():
@@ -363,8 +402,8 @@ class APIBackups(CoreSysAttributes):
request, body.get(ATTR_LOCATION, backup.location) request, body.get(ATTR_LOCATION, backup.location)
) )
background = body.pop(ATTR_BACKGROUND) background = body.pop(ATTR_BACKGROUND)
restore_task, job_id = await background_task( restore_task, job_id = await self._background_backup_task(
self, self.sys_backups.do_restore_full, backup, **body self.sys_backups.do_restore_full, backup, **body
) )
if background and not restore_task.done() or await restore_task: if background and not restore_task.done() or await restore_task:
@@ -383,8 +422,8 @@ class APIBackups(CoreSysAttributes):
request, body.get(ATTR_LOCATION, backup.location) request, body.get(ATTR_LOCATION, backup.location)
) )
background = body.pop(ATTR_BACKGROUND) background = body.pop(ATTR_BACKGROUND)
restore_task, job_id = await background_task( restore_task, job_id = await self._background_backup_task(
self, self.sys_backups.do_restore_partial, backup, **body self.sys_backups.do_restore_partial, backup, **body
) )
if background and not restore_task.done() or await restore_task: if background and not restore_task.done() or await restore_task:
@@ -421,7 +460,7 @@ class APIBackups(CoreSysAttributes):
await self.sys_backups.remove(backup, locations=locations) await self.sys_backups.remove(backup, locations=locations)
@api_process @api_process
async def download(self, request: web.Request) -> web.StreamResponse: async def download(self, request: web.Request):
"""Download a backup file.""" """Download a backup file."""
backup = self._extract_slug(request) backup = self._extract_slug(request)
# Query will give us '' for /backups, convert value to None # Query will give us '' for /backups, convert value to None
@@ -451,7 +490,7 @@ class APIBackups(CoreSysAttributes):
return response return response
@api_process @api_process
async def upload(self, request: web.Request) -> dict[str, str] | bool: async def upload(self, request: web.Request):
"""Upload a backup file.""" """Upload a backup file."""
location: LOCATION_TYPE = None location: LOCATION_TYPE = None
locations: list[LOCATION_TYPE] | None = None locations: list[LOCATION_TYPE] | None = None

View File

@@ -49,7 +49,6 @@ ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_LOCAL_ONLY = "local_only" ATTR_LOCAL_ONLY = "local_only"
ATTR_LOCATION_ATTRIBUTES = "location_attributes" ATTR_LOCATION_ATTRIBUTES = "location_attributes"
ATTR_LOCATIONS = "locations" ATTR_LOCATIONS = "locations"
ATTR_MAX_DEPTH = "max_depth"
ATTR_MDNS = "mdns" ATTR_MDNS = "mdns"
ATTR_MODEL = "model" ATTR_MODEL = "model"
ATTR_MOUNTS = "mounts" ATTR_MOUNTS = "mounts"

View File

@@ -4,7 +4,6 @@ import logging
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from awesomeversion import AwesomeVersion
import voluptuous as vol import voluptuous as vol
from supervisor.resolution.const import ContextType, IssueType, SuggestionType from supervisor.resolution.const import ContextType, IssueType, SuggestionType
@@ -13,11 +12,9 @@ from ..const import (
ATTR_ENABLE_IPV6, ATTR_ENABLE_IPV6,
ATTR_HOSTNAME, ATTR_HOSTNAME,
ATTR_LOGGING, ATTR_LOGGING,
ATTR_MTU,
ATTR_PASSWORD, ATTR_PASSWORD,
ATTR_REGISTRIES, ATTR_REGISTRIES,
ATTR_STORAGE, ATTR_STORAGE,
ATTR_STORAGE_DRIVER,
ATTR_USERNAME, ATTR_USERNAME,
ATTR_VERSION, ATTR_VERSION,
) )
@@ -37,25 +34,14 @@ SCHEMA_DOCKER_REGISTRY = vol.Schema(
) )
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema( SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLE_IPV6): vol.Maybe(vol.Boolean())})
{
vol.Optional(ATTR_ENABLE_IPV6): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_MTU): vol.Maybe(vol.All(int, vol.Range(min=68, max=65535))),
}
)
SCHEMA_MIGRATE_DOCKER_STORAGE_DRIVER = vol.Schema(
{
vol.Required(ATTR_STORAGE_DRIVER): vol.In(["overlayfs"]),
}
)
class APIDocker(CoreSysAttributes): class APIDocker(CoreSysAttributes):
"""Handle RESTful API for Docker configuration.""" """Handle RESTful API for Docker configuration."""
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request):
"""Get docker info.""" """Get docker info."""
data_registries = {} data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items(): for hostname, registry in self.sys_docker.config.registries.items():
@@ -65,7 +51,6 @@ class APIDocker(CoreSysAttributes):
return { return {
ATTR_VERSION: self.sys_docker.info.version, ATTR_VERSION: self.sys_docker.info.version,
ATTR_ENABLE_IPV6: self.sys_docker.config.enable_ipv6, ATTR_ENABLE_IPV6: self.sys_docker.config.enable_ipv6,
ATTR_MTU: self.sys_docker.config.mtu,
ATTR_STORAGE: self.sys_docker.info.storage, ATTR_STORAGE: self.sys_docker.info.storage,
ATTR_LOGGING: self.sys_docker.info.logging, ATTR_LOGGING: self.sys_docker.info.logging,
ATTR_REGISTRIES: data_registries, ATTR_REGISTRIES: data_registries,
@@ -76,23 +61,12 @@ class APIDocker(CoreSysAttributes):
"""Set docker options.""" """Set docker options."""
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(SCHEMA_OPTIONS, request)
reboot_required = False
if ( if (
ATTR_ENABLE_IPV6 in body ATTR_ENABLE_IPV6 in body
and self.sys_docker.config.enable_ipv6 != body[ATTR_ENABLE_IPV6] and self.sys_docker.config.enable_ipv6 != body[ATTR_ENABLE_IPV6]
): ):
self.sys_docker.config.enable_ipv6 = body[ATTR_ENABLE_IPV6] self.sys_docker.config.enable_ipv6 = body[ATTR_ENABLE_IPV6]
reboot_required = True _LOGGER.info("Host system reboot required to apply new IPv6 configuration")
if ATTR_MTU in body and self.sys_docker.config.mtu != body[ATTR_MTU]:
self.sys_docker.config.mtu = body[ATTR_MTU]
reboot_required = True
if reboot_required:
_LOGGER.info(
"Host system reboot required to apply Docker configuration changes"
)
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.REBOOT_REQUIRED, IssueType.REBOOT_REQUIRED,
ContextType.SYSTEM, ContextType.SYSTEM,
@@ -113,7 +87,7 @@ class APIDocker(CoreSysAttributes):
return {ATTR_REGISTRIES: data_registries} return {ATTR_REGISTRIES: data_registries}
@api_process @api_process
async def create_registry(self, request: web.Request) -> None: async def create_registry(self, request: web.Request):
"""Create a new docker registry.""" """Create a new docker registry."""
body = await api_validate(SCHEMA_DOCKER_REGISTRY, request) body = await api_validate(SCHEMA_DOCKER_REGISTRY, request)
@@ -123,7 +97,7 @@ class APIDocker(CoreSysAttributes):
await self.sys_docker.config.save_data() await self.sys_docker.config.save_data()
@api_process @api_process
async def remove_registry(self, request: web.Request) -> None: async def remove_registry(self, request: web.Request):
"""Delete a docker registry.""" """Delete a docker registry."""
hostname = request.match_info.get(ATTR_HOSTNAME) hostname = request.match_info.get(ATTR_HOSTNAME)
if hostname not in self.sys_docker.config.registries: if hostname not in self.sys_docker.config.registries:
@@ -131,27 +105,3 @@ class APIDocker(CoreSysAttributes):
del self.sys_docker.config.registries[hostname] del self.sys_docker.config.registries[hostname]
await self.sys_docker.config.save_data() await self.sys_docker.config.save_data()
@api_process
async def migrate_docker_storage_driver(self, request: web.Request) -> None:
"""Migrate Docker storage driver."""
if (
not self.coresys.os.available
or not self.coresys.os.version
or self.coresys.os.version < AwesomeVersion("17.0.dev0")
):
raise APINotFound(
"Home Assistant OS 17.0 or newer required for Docker storage driver migration"
)
body = await api_validate(SCHEMA_MIGRATE_DOCKER_STORAGE_DRIVER, request)
await self.sys_dbus.agent.system.migrate_docker_storage_driver(
body[ATTR_STORAGE_DRIVER]
)
_LOGGER.info("Host system reboot required to apply Docker storage migration")
self.sys_resolution.create_issue(
IssueType.REBOOT_REQUIRED,
ContextType.SYSTEM,
suggestions=[SuggestionType.EXECUTE_REBOOT],
)

View File

@@ -18,10 +18,8 @@ from ..const import (
ATTR_BLK_WRITE, ATTR_BLK_WRITE,
ATTR_BOOT, ATTR_BOOT,
ATTR_CPU_PERCENT, ATTR_CPU_PERCENT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_IP_ADDRESS, ATTR_IP_ADDRESS,
ATTR_JOB_ID,
ATTR_MACHINE, ATTR_MACHINE,
ATTR_MEMORY_LIMIT, ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT, ATTR_MEMORY_PERCENT,
@@ -39,8 +37,8 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIDBMigrationInProgress, APIError from ..exceptions import APIDBMigrationInProgress, APIError
from ..validate import docker_image, network_port, version_tag from ..validate import docker_image, network_port, version_tag
from .const import ATTR_BACKGROUND, ATTR_FORCE, ATTR_SAFE_MODE from .const import ATTR_FORCE, ATTR_SAFE_MODE
from .utils import api_process, api_validate, background_task from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -56,7 +54,6 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE): vol.Boolean(), vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE): vol.Boolean(),
vol.Optional(ATTR_DUPLICATE_LOG_FILE): vol.Boolean(),
} }
) )
@@ -64,7 +61,6 @@ SCHEMA_UPDATE = vol.Schema(
{ {
vol.Optional(ATTR_VERSION): version_tag, vol.Optional(ATTR_VERSION): version_tag,
vol.Optional(ATTR_BACKUP): bool, vol.Optional(ATTR_BACKUP): bool,
vol.Optional(ATTR_BACKGROUND, default=False): bool,
} }
) )
@@ -114,7 +110,6 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input, ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output, ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
ATTR_BACKUPS_EXCLUDE_DATABASE: self.sys_homeassistant.backups_exclude_database, ATTR_BACKUPS_EXCLUDE_DATABASE: self.sys_homeassistant.backups_exclude_database,
ATTR_DUPLICATE_LOG_FILE: self.sys_homeassistant.duplicate_log_file,
} }
@api_process @api_process
@@ -154,13 +149,10 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_BACKUPS_EXCLUDE_DATABASE ATTR_BACKUPS_EXCLUDE_DATABASE
] ]
if ATTR_DUPLICATE_LOG_FILE in body:
self.sys_homeassistant.duplicate_log_file = body[ATTR_DUPLICATE_LOG_FILE]
await self.sys_homeassistant.save_data() await self.sys_homeassistant.save_data()
@api_process @api_process
async def stats(self, request: web.Request) -> dict[str, Any]: async def stats(self, request: web.Request) -> dict[Any, str]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_homeassistant.core.stats() stats = await self.sys_homeassistant.core.stats()
if not stats: if not stats:
@@ -178,26 +170,20 @@ class APIHomeAssistant(CoreSysAttributes):
} }
@api_process @api_process
async def update(self, request: web.Request) -> dict[str, str] | None: async def update(self, request: web.Request) -> None:
"""Update Home Assistant.""" """Update Home Assistant."""
body = await api_validate(SCHEMA_UPDATE, request) body = await api_validate(SCHEMA_UPDATE, request)
await self._check_offline_migration() await self._check_offline_migration()
background = body[ATTR_BACKGROUND] await asyncio.shield(
update_task, job_id = await background_task( self.sys_homeassistant.core.update(
self, version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version),
self.sys_homeassistant.core.update, backup=body.get(ATTR_BACKUP),
version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version), )
backup=body.get(ATTR_BACKUP),
) )
if background and not update_task.done():
return {ATTR_JOB_ID: job_id}
return await update_task
@api_process @api_process
async def stop(self, request: web.Request) -> None: async def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop Home Assistant.""" """Stop Home Assistant."""
body = await api_validate(SCHEMA_STOP, request) body = await api_validate(SCHEMA_STOP, request)
await self._check_offline_migration(force=body[ATTR_FORCE]) await self._check_offline_migration(force=body[ATTR_FORCE])

View File

@@ -1,19 +1,11 @@
"""Init file for Supervisor host RESTful API.""" """Init file for Supervisor host RESTful API."""
import asyncio import asyncio
from collections.abc import Awaitable
from contextlib import suppress from contextlib import suppress
import json
import logging import logging
from typing import Any from typing import Any
from aiohttp import ( from aiohttp import ClientConnectionResetError, ClientPayloadError, web
ClientConnectionResetError,
ClientError,
ClientPayloadError,
ClientTimeout,
web,
)
from aiohttp.hdrs import ACCEPT, RANGE from aiohttp.hdrs import ACCEPT, RANGE
import voluptuous as vol import voluptuous as vol
from voluptuous.error import CoerceInvalid from voluptuous.error import CoerceInvalid
@@ -59,7 +51,6 @@ from .const import (
ATTR_FORCE, ATTR_FORCE,
ATTR_IDENTIFIERS, ATTR_IDENTIFIERS,
ATTR_LLMNR_HOSTNAME, ATTR_LLMNR_HOSTNAME,
ATTR_MAX_DEPTH,
ATTR_STARTUP_TIME, ATTR_STARTUP_TIME,
ATTR_USE_NTP, ATTR_USE_NTP,
ATTR_VIRTUALIZATION, ATTR_VIRTUALIZATION,
@@ -100,7 +91,7 @@ class APIHost(CoreSysAttributes):
) )
@api_process @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request):
"""Return host information.""" """Return host information."""
return { return {
ATTR_AGENT_VERSION: self.sys_dbus.agent.version, ATTR_AGENT_VERSION: self.sys_dbus.agent.version,
@@ -129,7 +120,7 @@ class APIHost(CoreSysAttributes):
} }
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request):
"""Edit host settings.""" """Edit host settings."""
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(SCHEMA_OPTIONS, request)
@@ -140,7 +131,7 @@ class APIHost(CoreSysAttributes):
) )
@api_process @api_process
async def reboot(self, request: web.Request) -> None: async def reboot(self, request):
"""Reboot host.""" """Reboot host."""
body = await api_validate(SCHEMA_SHUTDOWN, request) body = await api_validate(SCHEMA_SHUTDOWN, request)
await self._check_ha_offline_migration(force=body[ATTR_FORCE]) await self._check_ha_offline_migration(force=body[ATTR_FORCE])
@@ -148,7 +139,7 @@ class APIHost(CoreSysAttributes):
return await asyncio.shield(self.sys_host.control.reboot()) return await asyncio.shield(self.sys_host.control.reboot())
@api_process @api_process
async def shutdown(self, request: web.Request) -> None: async def shutdown(self, request):
"""Poweroff host.""" """Poweroff host."""
body = await api_validate(SCHEMA_SHUTDOWN, request) body = await api_validate(SCHEMA_SHUTDOWN, request)
await self._check_ha_offline_migration(force=body[ATTR_FORCE]) await self._check_ha_offline_migration(force=body[ATTR_FORCE])
@@ -156,12 +147,12 @@ class APIHost(CoreSysAttributes):
return await asyncio.shield(self.sys_host.control.shutdown()) return await asyncio.shield(self.sys_host.control.shutdown())
@api_process @api_process
def reload(self, request: web.Request) -> Awaitable[None]: def reload(self, request):
"""Reload host data.""" """Reload host data."""
return asyncio.shield(self.sys_host.reload()) return asyncio.shield(self.sys_host.reload())
@api_process @api_process
async def services(self, request: web.Request) -> dict[str, Any]: async def services(self, request):
"""Return list of available services.""" """Return list of available services."""
services = [] services = []
for unit in self.sys_host.services: for unit in self.sys_host.services:
@@ -176,7 +167,7 @@ class APIHost(CoreSysAttributes):
return {ATTR_SERVICES: services} return {ATTR_SERVICES: services}
@api_process @api_process
async def list_boots(self, _: web.Request) -> dict[str, Any]: async def list_boots(self, _: web.Request):
"""Return a list of boot IDs.""" """Return a list of boot IDs."""
boot_ids = await self.sys_host.logs.get_boot_ids() boot_ids = await self.sys_host.logs.get_boot_ids()
return { return {
@@ -187,7 +178,7 @@ class APIHost(CoreSysAttributes):
} }
@api_process @api_process
async def list_identifiers(self, _: web.Request) -> dict[str, list[str]]: async def list_identifiers(self, _: web.Request):
"""Return a list of syslog identifiers.""" """Return a list of syslog identifiers."""
return {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()} return {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()}
@@ -202,12 +193,7 @@ class APIHost(CoreSysAttributes):
return possible_offset return possible_offset
async def advanced_logs_handler( async def advanced_logs_handler(
self, self, request: web.Request, identifier: str | None = None, follow: bool = False
request: web.Request,
identifier: str | None = None,
follow: bool = False,
latest: bool = False,
no_colors: bool = False,
) -> web.StreamResponse: ) -> web.StreamResponse:
"""Return systemd-journald logs.""" """Return systemd-journald logs."""
log_formatter = LogFormatter.PLAIN log_formatter = LogFormatter.PLAIN
@@ -226,20 +212,6 @@ class APIHost(CoreSysAttributes):
if follow: if follow:
params[PARAM_FOLLOW] = "" params[PARAM_FOLLOW] = ""
if latest:
if not identifier:
raise APIError(
"Latest logs can only be fetched for a specific identifier."
)
try:
epoch = await self._get_container_last_epoch(identifier)
params["CONTAINER_LOG_EPOCH"] = epoch
except HostLogError as err:
raise APIError(
f"Cannot determine CONTAINER_LOG_EPOCH of {identifier}, latest logs not available."
) from err
if ACCEPT in request.headers and request.headers[ACCEPT] not in [ if ACCEPT in request.headers and request.headers[ACCEPT] not in [
CONTENT_TYPE_TEXT, CONTENT_TYPE_TEXT,
CONTENT_TYPE_X_LOG, CONTENT_TYPE_X_LOG,
@@ -253,9 +225,6 @@ class APIHost(CoreSysAttributes):
if "verbose" in request.query or request.headers[ACCEPT] == CONTENT_TYPE_X_LOG: if "verbose" in request.query or request.headers[ACCEPT] == CONTENT_TYPE_X_LOG:
log_formatter = LogFormatter.VERBOSE log_formatter = LogFormatter.VERBOSE
if "no_colors" in request.query:
no_colors = True
if "lines" in request.query: if "lines" in request.query:
lines = request.query.get("lines", DEFAULT_LINES) lines = request.query.get("lines", DEFAULT_LINES)
try: try:
@@ -271,8 +240,6 @@ class APIHost(CoreSysAttributes):
lines = max(2, lines) lines = max(2, lines)
# entries=cursor[[:num_skip]:num_entries] # entries=cursor[[:num_skip]:num_entries]
range_header = f"entries=:-{lines - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else lines}" range_header = f"entries=:-{lines - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else lines}"
elif latest:
range_header = f"entries=:0:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX}"
elif RANGE in request.headers: elif RANGE in request.headers:
range_header = request.headers[RANGE] range_header = request.headers[RANGE]
else: else:
@@ -285,9 +252,7 @@ class APIHost(CoreSysAttributes):
response = web.StreamResponse() response = web.StreamResponse()
response.content_type = CONTENT_TYPE_TEXT response.content_type = CONTENT_TYPE_TEXT
headers_returned = False headers_returned = False
async for cursor, line in journal_logs_reader( async for cursor, line in journal_logs_reader(resp, log_formatter):
resp, log_formatter, no_colors
):
try: try:
if not headers_returned: if not headers_returned:
if cursor: if cursor:
@@ -320,88 +285,7 @@ class APIHost(CoreSysAttributes):
@api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT) @api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
async def advanced_logs( async def advanced_logs(
self, self, request: web.Request, identifier: str | None = None, follow: bool = False
request: web.Request,
identifier: str | None = None,
follow: bool = False,
latest: bool = False,
no_colors: bool = False,
) -> web.StreamResponse: ) -> web.StreamResponse:
"""Return systemd-journald logs. Wrapped as standard API handler.""" """Return systemd-journald logs. Wrapped as standard API handler."""
return await self.advanced_logs_handler( return await self.advanced_logs_handler(request, identifier, follow)
request, identifier, follow, latest, no_colors
)
@api_process
async def disk_usage(self, request: web.Request) -> dict[str, Any]:
"""Return a breakdown of storage usage for the system."""
max_depth = request.query.get(ATTR_MAX_DEPTH, 1)
try:
max_depth = int(max_depth)
except ValueError:
max_depth = 1
disk = self.sys_hardware.disk
total, _, free = await self.sys_run_in_executor(
disk.disk_usage, self.sys_config.path_supervisor
)
# Calculate used by subtracting free makes sure we include reserved space
# in used space reporting.
used = total - free
known_paths = await self.sys_run_in_executor(
disk.get_dir_sizes,
{
"addons_data": self.sys_config.path_addons_data,
"addons_config": self.sys_config.path_addon_configs,
"media": self.sys_config.path_media,
"share": self.sys_config.path_share,
"backup": self.sys_config.path_backup,
"ssl": self.sys_config.path_ssl,
"homeassistant": self.sys_config.path_homeassistant,
},
max_depth,
)
return {
# this can be the disk/partition ID in the future
"id": "root",
"label": "Root",
"total_bytes": total,
"used_bytes": used,
"children": [
{
"id": "system",
"label": "System",
"used_bytes": used
- sum(path["used_bytes"] for path in known_paths),
},
*known_paths,
],
}
async def _get_container_last_epoch(self, identifier: str) -> str | None:
"""Get Docker's internal log epoch of the latest log entry for the given identifier."""
try:
async with self.sys_host.logs.journald_logs(
params={"CONTAINER_NAME": identifier},
range_header="entries=:-1:2", # -1 = next to the last entry
accept=LogFormat.JSON,
timeout=ClientTimeout(total=10),
) as resp:
text = await resp.text()
except (ClientError, TimeoutError) as err:
raise HostLogError(
"Could not get last container epoch from systemd-journal-gatewayd",
_LOGGER.error,
) from err
try:
return json.loads(text.strip().split("\n")[-1])["CONTAINER_LOG_EPOCH"]
except (json.JSONDecodeError, KeyError, IndexError) as err:
raise HostLogError(
f"Failed to parse CONTAINER_LOG_EPOCH of {identifier} container, got: {text}",
_LOGGER.error,
) from err

View File

@@ -199,25 +199,21 @@ class APIIngress(CoreSysAttributes):
url = f"{url}?{request.query_string}" url = f"{url}?{request.query_string}"
# Start proxy # Start proxy
try: async with self.sys_websession.ws_connect(
_LOGGER.debug("Proxing WebSocket to %s, upstream url: %s", addon.slug, url) url,
async with self.sys_websession.ws_connect( headers=source_header,
url, protocols=req_protocols,
headers=source_header, autoclose=False,
protocols=req_protocols, autoping=False,
autoclose=False, ) as ws_client:
autoping=False, # Proxy requests
) as ws_client: await asyncio.wait(
# Proxy requests [
await asyncio.wait( self.sys_create_task(_websocket_forward(ws_server, ws_client)),
[ self.sys_create_task(_websocket_forward(ws_client, ws_server)),
self.sys_create_task(_websocket_forward(ws_server, ws_client)), ],
self.sys_create_task(_websocket_forward(ws_client, ws_server)), return_when=asyncio.FIRST_COMPLETED,
], )
return_when=asyncio.FIRST_COMPLETED,
)
except TimeoutError:
_LOGGER.warning("WebSocket proxy to %s timed out", addon.slug)
return ws_server return ws_server
@@ -253,28 +249,18 @@ class APIIngress(CoreSysAttributes):
skip_auto_headers={hdrs.CONTENT_TYPE}, skip_auto_headers={hdrs.CONTENT_TYPE},
) as result: ) as result:
headers = _response_header(result) headers = _response_header(result)
# Avoid parsing content_type in simple cases for better performance # Avoid parsing content_type in simple cases for better performance
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
content_type = (maybe_content_type.partition(";"))[0].strip() content_type = (maybe_content_type.partition(";"))[0].strip()
else: else:
content_type = result.content_type content_type = result.content_type
# Empty body responses (304, 204, HEAD, etc.) should not be streamed,
# otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk
# This also avoids setting content_type for empty responses.
if must_be_empty_body(request.method, result.status):
# If upstream contains content-type, preserve it (e.g. for HEAD requests)
if maybe_content_type:
headers[hdrs.CONTENT_TYPE] = content_type
return web.Response(
headers=headers,
status=result.status,
)
# Simple request # Simple request
if ( if (
hdrs.CONTENT_LENGTH in result.headers # empty body responses should not be streamed,
# otherwise aiohttp < 3.9.0 may generate
# an invalid "0\r\n\r\n" chunk instead of an empty response.
must_be_empty_body(request.method, result.status)
or hdrs.CONTENT_LENGTH in result.headers
and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000 and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000
): ):
# Return Response # Return Response
@@ -300,7 +286,6 @@ class APIIngress(CoreSysAttributes):
aiohttp.ClientError, aiohttp.ClientError,
aiohttp.ClientPayloadError, aiohttp.ClientPayloadError,
ConnectionResetError, ConnectionResetError,
ConnectionError,
) as err: ) as err:
_LOGGER.error("Stream error with %s: %s", url, err) _LOGGER.error("Stream error with %s: %s", url, err)
@@ -401,9 +386,9 @@ async def _websocket_forward(ws_from, ws_to):
elif msg.type == aiohttp.WSMsgType.BINARY: elif msg.type == aiohttp.WSMsgType.BINARY:
await ws_to.send_bytes(msg.data) await ws_to.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.PING: elif msg.type == aiohttp.WSMsgType.PING:
await ws_to.ping(msg.data) await ws_to.ping()
elif msg.type == aiohttp.WSMsgType.PONG: elif msg.type == aiohttp.WSMsgType.PONG:
await ws_to.pong(msg.data) await ws_to.pong()
elif ws_to.closed: elif ws_to.closed:
await ws_to.close(code=ws_to.close_code, message=msg.extra) await ws_to.close(code=ws_to.close_code, message=msg.extra)
except RuntimeError: except RuntimeError:

View File

@@ -1,12 +1,12 @@
"""Handle security part of this API.""" """Handle security part of this API."""
from collections.abc import Awaitable, Callable from collections.abc import Callable
import logging import logging
import re import re
from typing import Final from typing import Final
from urllib.parse import unquote from urllib.parse import unquote
from aiohttp.web import Request, StreamResponse, middleware from aiohttp.web import Request, Response, middleware
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
@@ -89,7 +89,7 @@ CORE_ONLY_PATHS: Final = re.compile(
) )
# Policy role add-on API access # Policy role add-on API access
ADDONS_ROLE_ACCESS: dict[str, re.Pattern[str]] = { ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
ROLE_DEFAULT: re.compile( ROLE_DEFAULT: re.compile(
r"^(?:" r"^(?:"
r"|/.+/info" r"|/.+/info"
@@ -180,9 +180,7 @@ class SecurityMiddleware(CoreSysAttributes):
return unquoted return unquoted
@middleware @middleware
async def block_bad_requests( async def block_bad_requests(self, request: Request, handler: Callable) -> Response:
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""Process request and tblock commonly known exploit attempts.""" """Process request and tblock commonly known exploit attempts."""
if FILTERS.search(self._recursive_unquote(request.path)): if FILTERS.search(self._recursive_unquote(request.path)):
_LOGGER.warning( _LOGGER.warning(
@@ -200,9 +198,7 @@ class SecurityMiddleware(CoreSysAttributes):
return await handler(request) return await handler(request)
@middleware @middleware
async def system_validation( async def system_validation(self, request: Request, handler: Callable) -> Response:
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""Check if core is ready to response.""" """Check if core is ready to response."""
if self.sys_core.state not in VALID_API_STATES: if self.sys_core.state not in VALID_API_STATES:
return api_return_error( return api_return_error(
@@ -212,9 +208,7 @@ class SecurityMiddleware(CoreSysAttributes):
return await handler(request) return await handler(request)
@middleware @middleware
async def token_validation( async def token_validation(self, request: Request, handler: Callable) -> Response:
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""Check security access of this layer.""" """Check security access of this layer."""
request_from: CoreSysAttributes | None = None request_from: CoreSysAttributes | None = None
supervisor_token = extract_supervisor_token(request) supervisor_token = extract_supervisor_token(request)
@@ -285,9 +279,7 @@ class SecurityMiddleware(CoreSysAttributes):
raise HTTPForbidden() raise HTTPForbidden()
@middleware @middleware
async def core_proxy( async def core_proxy(self, request: Request, handler: Callable) -> Response:
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""Validate user from Core API proxy.""" """Validate user from Core API proxy."""
if ( if (
request[REQUEST_FROM] != self.sys_homeassistant request[REQUEST_FROM] != self.sys_homeassistant

View File

@@ -26,9 +26,7 @@ from ..const import (
ATTR_IP6_PRIVACY, ATTR_IP6_PRIVACY,
ATTR_IPV4, ATTR_IPV4,
ATTR_IPV6, ATTR_IPV6,
ATTR_LLMNR,
ATTR_MAC, ATTR_MAC,
ATTR_MDNS,
ATTR_METHOD, ATTR_METHOD,
ATTR_MODE, ATTR_MODE,
ATTR_NAMESERVERS, ATTR_NAMESERVERS,
@@ -56,7 +54,6 @@ from ..host.configuration import (
Ip6Setting, Ip6Setting,
IpConfig, IpConfig,
IpSetting, IpSetting,
MulticastDnsMode,
VlanConfig, VlanConfig,
WifiConfig, WifiConfig,
) )
@@ -100,8 +97,6 @@ SCHEMA_UPDATE = vol.Schema(
vol.Optional(ATTR_IPV6): _SCHEMA_IPV6_CONFIG, vol.Optional(ATTR_IPV6): _SCHEMA_IPV6_CONFIG,
vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG, vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG,
vol.Optional(ATTR_ENABLED): vol.Boolean(), vol.Optional(ATTR_ENABLED): vol.Boolean(),
vol.Optional(ATTR_MDNS): vol.Coerce(MulticastDnsMode),
vol.Optional(ATTR_LLMNR): vol.Coerce(MulticastDnsMode),
} }
) )
@@ -165,8 +160,6 @@ def interface_struct(interface: Interface) -> dict[str, Any]:
else None, else None,
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None, ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None, ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None,
ATTR_MDNS: interface.mdns,
ATTR_LLMNR: interface.llmnr,
} }
@@ -267,10 +260,6 @@ class APINetwork(CoreSysAttributes):
) )
elif key == ATTR_ENABLED: elif key == ATTR_ENABLED:
interface.enabled = config interface.enabled = config
elif key == ATTR_MDNS:
interface.mdns = config
elif key == ATTR_LLMNR:
interface.llmnr = config
await asyncio.shield(self.sys_host.network.apply_changes(interface)) await asyncio.shield(self.sys_host.network.apply_changes(interface))
@@ -311,15 +300,6 @@ class APINetwork(CoreSysAttributes):
vlan_config = VlanConfig(vlan, interface.name) vlan_config = VlanConfig(vlan, interface.name)
mdns_mode = MulticastDnsMode.DEFAULT
llmnr_mode = MulticastDnsMode.DEFAULT
if ATTR_MDNS in body:
mdns_mode = body[ATTR_MDNS]
if ATTR_LLMNR in body:
llmnr_mode = body[ATTR_LLMNR]
ipv4_setting = None ipv4_setting = None
if ATTR_IPV4 in body: if ATTR_IPV4 in body:
ipv4_setting = IpSetting( ipv4_setting = IpSetting(
@@ -345,7 +325,7 @@ class APINetwork(CoreSysAttributes):
) )
vlan_interface = Interface( vlan_interface = Interface(
f"{interface.name}.{vlan}", "",
"", "",
"", "",
True, True,
@@ -358,7 +338,5 @@ class APINetwork(CoreSysAttributes):
ipv6_setting, ipv6_setting,
None, None,
vlan_config, vlan_config,
mdns=mdns_mode,
llmnr=llmnr_mode,
) )
await asyncio.shield(self.sys_host.network.create_vlan(vlan_interface)) await asyncio.shield(self.sys_host.network.apply_changes(vlan_interface))

View File

@@ -1 +1 @@
!function(){function d(d){var e=document.createElement("script");e.src=d,document.body.appendChild(e)}if(/Edge?\/(13\d|1[4-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Firefox\/(13[1-9]|1[4-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Chrom(ium|e)\/(10[5-9]|1[1-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|(Maci|X1{2}).+ Version\/(18\.([1-9]|\d{2,})|(19|[2-9]\d|\d{3,})\.\d+)([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/(1{2}[5-9]|1[2-9]\d|[2-9]\d{2}|\d{4,})\.\d+\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(18[._]([1-9]|\d{2,})|(19|[2-9]\d|\d{3,})[._]\d+)([._]\d+|)|Android:?[ /-](13\d|1[4-9]\d|[2-9]\d{2}|\d{4,})(\.\d+|)(\.\d+|)|Mobile Safari.+OPR\/([89]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(13[1-9]|1[4-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(13\d|1[4-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|SamsungBrowser\/(2[89]|[3-9]\d|\d{3,})\.\d+|Home As{2}istant\/[\d.]+ \(.+; macOS (1[3-9]|[2-9]\d|\d{3,})\.\d+(\.\d+)?\)/.test(navigator.userAgent))try{new Function("import('/api/hassio/app/frontend_latest/entrypoint.1e251476306cafd4.js')")()}catch(e){d("/api/hassio/app/frontend_es5/entrypoint.601ff5d4dddd11f9.js")}else d("/api/hassio/app/frontend_es5/entrypoint.601ff5d4dddd11f9.js")}() !function(){function d(d){var e=document.createElement("script");e.src=d,document.body.appendChild(e)}if(/Edge?\/(12[89]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Firefox\/(12[89]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Chrom(ium|e)\/(109|1[1-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|(Maci|X1{2}).+ Version\/(18\.\d+|(19|[2-9]\d|\d{3,})\.\d+)([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/(1{2}[3-9]|1[2-9]\d|[2-9]\d{2}|\d{4,})\.\d+\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(16[._]([6-9]|\d{2,})|(1[7-9]|[2-9]\d|\d{3,})[._]\d+)([._]\d+|)|Android:?[ /-](12[89]|1[3-9]\d|[2-9]\d{2}|\d{4,})(\.\d+|)(\.\d+|)|Mobile Safari.+OPR\/([89]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(13\d|1[4-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(12[89]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|SamsungBrowser\/(2[7-9]|[3-9]\d|\d{3,})\.\d+|Home As{2}istant\/[\d.]+ \(.+; macOS (1[3-9]|[2-9]\d|\d{3,})\.\d+(\.\d+)?\)/.test(navigator.userAgent))try{new Function("import('/api/hassio/app/frontend_latest/entrypoint.9b475c5882bbf15f.js')")()}catch(e){d("/api/hassio/app/frontend_es5/entrypoint.73551a66a38f3359.js")}else d("/api/hassio/app/frontend_es5/entrypoint.73551a66a38f3359.js")}()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"version":3,"file":"1057.d306824fd6aa0497.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250925.1/src/data/auth.ts","https://raw.githubusercontent.com/home-assistant/frontend/20250925.1/src/data/entity.ts","https://raw.githubusercontent.com/home-assistant/frontend/20250925.1/src/data/media-player.ts","https://raw.githubusercontent.com/home-assistant/frontend/20250925.1/src/data/tts.ts","https://raw.githubusercontent.com/home-assistant/frontend/20250925.1/src/util/brands-url.ts"],"names":["autocompleteLoginFields","schema","map","field","type","name","Object","assign","autocomplete","autofocus","getSignedPath","hass","path","callWS","UNAVAILABLE","UNKNOWN","ON","OFF","UNAVAILABLE_STATES","OFF_STATES","isUnavailableState","arrayLiteralIncludes","MediaPlayerEntityFeature","BROWSER_PLAYER","MediaClassBrowserSettings","album","icon","layout","app","show_list_images","artist","mdiAccountMusic","channel","mdiTelevisionClassic","thumbnail_ratio","composer","contributing_artist","directory","episode","game","genre","image","movie","music","playlist","podcast","season","track","tv_show","url","video","browseMediaPlayer","entityId","mediaContentId","mediaContentType","entity_id","media_content_id","media_content_type","convertTextToSpeech","data","callApi","TTS_MEDIA_SOURCE_PREFIX","isTTSMediaSource","startsWith","getProviderFromTTSMediaSource","substring","listTTSEngines","language","country","getTTSEngine","engine_id","listTTSVoices","brandsUrl","options","brand","useFallback","domain","darkOptimized","extractDomainFromBrandUrl","split","isBrandUrl","thumbnail"],"mappings":"2QAyBO,MAEMA,EAA2BC,GACtCA,EAAOC,IAAKC,IACV,GAAmB,WAAfA,EAAMC,KAAmB,OAAOD,EACpC,OAAQA,EAAME,MACZ,IAAK,WACH,OAAAC,OAAAC,OAAAD,OAAAC,OAAA,GAAYJ,GAAK,IAAEK,aAAc,WAAYC,WAAW,IAC1D,IAAK,WACH,OAAAH,OAAAC,OAAAD,OAAAC,OAAA,GAAYJ,GAAK,IAAEK,aAAc,qBACnC,IAAK,OACH,OAAAF,OAAAC,OAAAD,OAAAC,OAAA,GAAYJ,GAAK,IAAEK,aAAc,gBAAiBC,WAAW,IAC/D,QACE,OAAON,KAIFO,EAAgBA,CAC3BC,EACAC,IACwBD,EAAKE,OAAO,CAAET,KAAM,iBAAkBQ,Q,gMC3CzD,MAAME,EAAc,cACdC,EAAU,UACVC,EAAK,KACLC,EAAM,MAENC,EAAqB,CAACJ,EAAaC,GACnCI,EAAa,CAACL,EAAaC,EAASE,GAEpCG,GAAqBC,EAAAA,EAAAA,GAAqBH,IAC7BG,EAAAA,EAAAA,GAAqBF,E,+gCCuExC,IAAWG,EAAA,SAAAA,G,qnBAAAA,C,CAAA,C,IAyBX,MAAMC,EAAiB,UAWjBC,EAGT,CACFC,MAAO,CAAEC,K,mQAAgBC,OAAQ,QACjCC,IAAK,CAAEF,K,6GAAsBC,OAAQ,OAAQE,kBAAkB,GAC/DC,OAAQ,CAAEJ,KAAMK,EAAiBJ,OAAQ,OAAQE,kBAAkB,GACnEG,QAAS,CACPN,KAAMO,EACNC,gBAAiB,WACjBP,OAAQ,OACRE,kBAAkB,GAEpBM,SAAU,CACRT,K,4cACAC,OAAQ,OACRE,kBAAkB,GAEpBO,oBAAqB,CACnBV,KAAMK,EACNJ,OAAQ,OACRE,kBAAkB,GAEpBQ,UAAW,CAAEX,K,gGAAiBC,OAAQ,OAAQE,kBAAkB,GAChES,QAAS,CACPZ,KAAMO,EACNN,OAAQ,OACRO,gBAAiB,WACjBL,kBAAkB,GAEpBU,KAAM,CACJb,K,qWACAC,OAAQ,OACRO,gBAAiB,YAEnBM,MAAO,CAAEd,K,4hCAAqBC,OAAQ,OAAQE,kBAAkB,GAChEY,MAAO,CAAEf,K,sHAAgBC,OAAQ,OAAQE,kBAAkB,GAC3Da,MAAO,CACLhB,K,6GACAQ,gBAAiB,WACjBP,OAAQ,OACRE,kBAAkB,GAEpBc,MAAO,CAAEjB,K,+NAAgBG,kBAAkB,GAC3Ce,SAAU,CAAElB,K,mJAAwBC,OAAQ,OAAQE,kBAAkB,GACtEgB,QAAS,CAAEnB,K,qpBAAkBC,OAAQ,QACrCmB,OAAQ,CACNpB,KAAMO,EACNN,OAAQ,OACRO,gBAAiB,WACjBL,kBAAkB,GAEpBkB,MAAO,CAAErB,K,mLACTsB,QAAS,CACPtB,KAAMO,EACNN,OAAQ,OACRO,gBAAiB,YAEnBe,IAAK,CAAEvB,K,w5BACPwB,MAAO,CAAExB,K,2GAAgBC,OAAQ,OAAQE,kBAAkB,IAkChDsB,EAAoBA,CAC/BxC,EACAyC,EACAC,EACAC,IAEA3C,EAAKE,OAAwB,CAC3BT,KAAM,4BACNmD,UAAWH,EACXI,iBAAkBH,EAClBI,mBAAoBH,G,yLC/MjB,MAAMI,EAAsBA,CACjC/C,EACAgD,IAOGhD,EAAKiD,QAAuC,OAAQ,cAAeD,GAElEE,EAA0B,sBAEnBC,EAAoBT,GAC/BA,EAAeU,WAAWF,GAEfG,EAAiCX,GAC5CA,EAAeY,UAAUJ,IAEdK,EAAiBA,CAC5BvD,EACAwD,EACAC,IAEAzD,EAAKE,OAAO,CACVT,KAAM,kBACN+D,WACAC,YAGSC,EAAeA,CAC1B1D,EACA2D,IAEA3D,EAAKE,OAAO,CACVT,KAAM,iBACNkE,cAGSC,EAAgBA,CAC3B5D,EACA2D,EACAH,IAEAxD,EAAKE,OAAO,CACVT,KAAM,oBACNkE,YACAH,Y,kHC9CG,MAAMK,EAAaC,GACxB,oCAAoCA,EAAQC,MAAQ,UAAY,KAC9DD,EAAQE,YAAc,KAAO,KAC5BF,EAAQG,UAAUH,EAAQI,cAAgB,QAAU,KACrDJ,EAAQrE,WAQC0E,EAA6B7B,GAAgBA,EAAI8B,MAAM,KAAK,GAE5DC,EAAcC,GACzBA,EAAUlB,WAAW,oC"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
"use strict";(self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[]).push([["113"],{51383:function(t,o,e){e.r(o),e.d(o,{HaIconButtonArrowNext:function(){return c}});e(26847),e(27530);var n=e(73742),a=e(59048),i=e(7616),r=e(88479);e(81777);let s,d=t=>t;class c extends a.oi{render(){var t;return(0,a.dy)(s||(s=d` <ha-icon-button .disabled="${0}" .label="${0}" .path="${0}"></ha-icon-button> `),this.disabled,this.label||(null===(t=this.hass)||void 0===t?void 0:t.localize("ui.common.next"))||"Next",this._icon)}constructor(...t){super(...t),this.disabled=!1,this._icon="rtl"===r.E.document.dir?"M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z":"M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z"}}(0,n.__decorate)([(0,i.Cb)({attribute:!1})],c.prototype,"hass",void 0),(0,n.__decorate)([(0,i.Cb)({type:Boolean})],c.prototype,"disabled",void 0),(0,n.__decorate)([(0,i.Cb)()],c.prototype,"label",void 0),(0,n.__decorate)([(0,i.SB)()],c.prototype,"_icon",void 0),c=(0,n.__decorate)([(0,i.Mo)("ha-icon-button-arrow-next")],c)}}]);
//# sourceMappingURL=113.13cc15fa81bd492f.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"113.13cc15fa81bd492f.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250806.0/src/components/ha-icon-button-arrow-next.ts"],"names":["HaIconButtonArrowNext","LitElement","render","_this$hass","html","_t","_","this","disabled","label","hass","localize","_icon","args","mainWindow","attribute","type","Boolean"],"mappings":"4RASO,MAAMA,UAA8BC,EAAAA,GAU/BC,MAAAA,GAAyB,IAAAC,EACjC,OAAOC,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,mFAEKC,KAAKC,SACRD,KAAKE,QAAkB,QAAbN,EAAII,KAAKG,YAAI,IAAAP,OAAA,EAATA,EAAWQ,SAAS,oBAAqB,OACxDJ,KAAKK,MAGnB,C,kBAlBK,SAAAC,GAAA,KAG+BL,UAAW,OAI9BI,MACa,QAA5BE,EAAAA,EAAAA,SAAAA,I,gLAPUC,WAAW,K,uDAEXC,KAAMC,W"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
"use strict";(self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[]).push([["1303"],{29815:function(e,t,a){a.r(t),a.d(t,{HaFormConstant:function(){return h}});var s=a(73742),o=a(59048),n=a(7616);let r,l,i=e=>e;class h extends o.oi{render(){return(0,o.dy)(r||(r=i`<span class="label">${0}</span>${0}`),this.label,this.schema.value?`: ${this.schema.value}`:"")}}h.styles=(0,o.iv)(l||(l=i`:host{display:block}.label{font-weight:var(--ha-font-weight-medium)}`)),(0,s.__decorate)([(0,n.Cb)({attribute:!1})],h.prototype,"schema",void 0),(0,s.__decorate)([(0,n.Cb)()],h.prototype,"label",void 0),h=(0,s.__decorate)([(0,n.Mo)("ha-form-constant")],h)}}]);
//# sourceMappingURL=1303.586b15f0603e938e.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"1303.586b15f0603e938e.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250806.0/src/components/ha-form/ha-form-constant.ts"],"names":["HaFormConstant","LitElement","render","html","_t","_","this","label","schema","value","styles","css","_t2","attribute"],"mappings":"kPAMO,MAAMA,UAAuBC,EAAAA,GAKxBC,MAAAA,GACR,OAAOC,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,uBAAuB,WAAvB,KAAuBC,KAAKC,MAAeD,KAAKE,OAAOC,MAC5D,KAAKH,KAAKE,OAAOC,QACjB,GACR,EATWT,EAWJU,QAASC,EAAAA,EAAAA,IAAGC,IAAAA,EAAAP,CAAA,0E,2BAVPQ,WAAW,K"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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